diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 63466f762f..8acf79ada0 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -705,7 +705,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 57); + assert_eq!(migrations.len(), 58); assert_eq!(migrations[55].version, 56); assert!(migrations[55] .sql @@ -716,6 +716,11 @@ mod postgres_tests { .sql .as_str() .contains("ALTER TABLE personal_read_accounts ADD COLUMN started_at")); + assert_eq!(migrations[57].version, 58); + assert!(migrations[57] + .sql + .as_str() + .contains("ADD COLUMN through_message_id")); assert_eq!(migrations[48].version, 49); assert_eq!(migrations[49].version, 50); assert_eq!(migrations[50].version, 51); @@ -2229,6 +2234,18 @@ mod postgres_tests { .get("personal_read_accounts") .expect("schema.sql personal read accounts") .contains(started_at_column)); + // 0058 replaces the frontier's whole-channel cut with its anchor ID. + let threads_through_column = "threads_through_timestamp timestamptz \ + check (threads_through_timestamp is null or root_id = ''::bytea), "; + let through_message_column = + "through_message_id bytea check (octet_length(through_message_id) = 32), "; + let through_message = MIGRATOR + .iter() + .find(|m| m.version == 58) + .expect("personal read anchor migration") + .sql + .as_str(); + assert!(through_message.contains("DROP COLUMN threads_through_timestamp")); for (table, definition) in personal.tables { let in_schema = schema.tables.get(&table).map(|schema_definition| { if table == "personal_read_accounts" { @@ -2237,6 +2254,11 @@ mod postgres_tests { schema_definition.clone() } }); + let definition = if table == "personal_read_frontiers" { + definition.replacen(threads_through_column, through_message_column, 1) + } else { + definition + }; assert_eq!( in_schema.as_ref(), Some(&definition), diff --git a/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs index 0c578a34e2..a36c2d3c9c 100644 --- a/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs @@ -22,18 +22,19 @@ async fn arrive(pool: &PgPool, community: CommunityId, event: &nostr::Event, arr assert_eq!(updated, 1, "arrival must land on exactly one stored event"); } -/// A message authored at `authored` that arrives at `arrived`. +/// A message authored at `authored` that arrives at `arrived`. It mentions the +/// actor, so the channel's mention count is exactly its unread top-level count. async fn post( db: &Db, pool: &PgPool, community: CommunityId, channel: Uuid, + actor: &Keys, authored: u64, arrived: u64, - tags: Vec, ) -> nostr::Event { let event = EventBuilder::new(Kind::Custom(9), format!("authored {authored}")) - .tags(tags) + .tags([Tag::public_key(actor.public_key())]) .custom_created_at(nostr::Timestamp::from(authored)) .sign_with_keys(&Keys::generate()) .unwrap(); @@ -56,17 +57,7 @@ async fn reply( authored: u64, arrived: u64, ) -> nostr::Event { - let mention = Tag::public_key(actor.public_key()); - let event = post( - db, - pool, - community, - channel, - authored, - arrived, - vec![mention], - ) - .await; + let event = post(db, pool, community, channel, actor, authored, arrived).await; sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) @@ -89,6 +80,12 @@ async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSu .remove(0) } +/// The channel row's top-level state: (unread, mentions). +async fn counts(db: &Db, community: CommunityId, actor: &Keys) -> (bool, u32) { + let row = sidebar(db, community, actor).await; + (row.unread, row.mentions) +} + async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) { let outcome = db .apply_personal_read_intent(community, &actor.public_key(), &intent) @@ -107,46 +104,6 @@ fn mark_through(channel: Uuid, root: Option<&str>, message: &str) -> ReadIntent } } -/// Channel-timeline states of `messages`, in order. -async fn states( - db: &Db, - community: CommunityId, - actor: &Keys, - channel: Uuid, - messages: &[&nostr::Event], -) -> Vec { - let query = ContextQuery { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_ids: messages.iter().map(|e| e.id.to_hex()).collect(), - }; - let page = db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &[query], - ) - .await - .unwrap(); - let page = serde_json::to_value(page).unwrap(); - page["contexts"][0]["messages"] - .as_array() - .unwrap() - .iter() - .map(|m| m["status"].as_str().unwrap().to_owned()) - .collect() -} - -fn exact(count: &ReadCount) -> Option { - match count { - ReadCount::Exact { value } => Some(*value), - _ => None, - } -} - #[tokio::test] #[ignore = "requires Postgres"] async fn late_arrival_with_old_author_time_is_unread() { @@ -160,18 +117,15 @@ async fn late_arrival_with_old_author_time_is_unread() { mark_through(channel, None, &read.id.to_hex()), ) .await; + assert_eq!(counts(&db, community, &actor).await, (false, 0)); // Authored ten minutes before the read message, arriving after it was read. - let late = post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await; + let late = post(&db, &pool, community, channel, &actor, now - 600, now - 30).await; - assert_eq!( - states(&db, community, &actor, channel, &[&read, &late]).await, - ["read", "unread"] - ); - assert_eq!( - exact(&sidebar(&db, community, &actor).await.unread), - Some(1) - ); + let row = sidebar(&db, community, &actor).await; + assert_eq!((row.unread, row.mentions), (true, 1)); + assert_eq!(row.latest_id, Some(late.id.to_hex())); + assert_eq!(row.read_through_id, Some(read.id.to_hex())); } #[tokio::test] @@ -181,7 +135,7 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() { let now = earlier.created_at.as_secs(); arrive(&pool, community, &earlier, now - 60).await; // Stamped ten minutes ahead; the relay accepts up to fifteen. - let ahead = post(&db, &pool, community, channel, now + 600, now - 40, vec![]).await; + let ahead = post(&db, &pool, community, channel, &actor, now + 600, now - 40).await; apply( &db, community, @@ -189,17 +143,11 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() { mark_through(channel, None, &ahead.id.to_hex()), ) .await; + assert_eq!(counts(&db, community, &actor).await, (false, 0)); - let later = post(&db, &pool, community, channel, now, now - 30, vec![]).await; + post(&db, &pool, community, channel, &actor, now, now - 30).await; - assert_eq!( - states(&db, community, &actor, channel, &[&earlier, &ahead, &later]).await, - ["read", "read", "unread"] - ); - assert_eq!( - exact(&sidebar(&db, community, &actor).await.unread), - Some(1) - ); + assert_eq!(counts(&db, community, &actor).await, (true, 1)); } /// Marking the sidebar's own latest message must clear the badge even when the @@ -210,32 +158,19 @@ async fn mark_as_read_with_the_sidebar_anchor_clears_a_late_arrival() { let (db, pool, community, channel, actor, first) = fixture().await; let now = first.created_at.as_secs(); arrive(&pool, community, &first, now - 60).await; - post(&db, &pool, community, channel, now + 1, now - 40, vec![]).await; - post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await; - assert_eq!( - exact(&sidebar(&db, community, &actor).await.unread), - Some(3) - ); + post(&db, &pool, community, channel, &actor, now + 1, now - 40).await; + let last = post(&db, &pool, community, channel, &actor, now - 600, now - 30).await; + let row = sidebar(&db, community, &actor).await; + // The fixture message is unread too, but mentions no one. + assert_eq!((row.unread, row.mentions), (true, 2)); + assert_eq!(row.latest_id, Some(last.id.to_hex())); - let anchor = sidebar(&db, community, &actor) - .await - .latest_message_id + let anchor = row + .latest_id .expect("a channel with messages has a latest message"); - apply( - &db, - community, - &actor, - ReadIntent::MarkChannelRead { - channel_id: channel, - message_id: anchor, - }, - ) - .await; + apply(&db, community, &actor, mark_through(channel, None, &anchor)).await; - assert_eq!( - exact(&sidebar(&db, community, &actor).await.unread), - Some(0) - ); + assert_eq!(counts(&db, community, &actor).await, (false, 0)); } #[tokio::test] @@ -262,7 +197,7 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() { now - 40, ) .await; - reply( + let last = reply( &db, &pool, community, @@ -274,11 +209,17 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() { ) .await; let row = sidebar(&db, community, &actor).await; - assert_eq!(exact(&row.unread), Some(2)); - assert_eq!(row.threads.items.len(), 1); + assert_eq!( + (row.unread, row.mentions), + (false, 0), + "replies are not top-level" + ); + assert_eq!(row.threads.len(), 1); + assert_eq!(row.threads[0].mentions, 2); + assert_eq!(row.threads[0].latest_id, last.id.to_hex()); let root_id = root.id.to_hex(); - let anchor = row.threads.items[0].latest_reply_id.clone(); + let anchor = row.threads[0].latest_id.clone(); apply( &db, community, @@ -288,15 +229,15 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() { .await; let row = sidebar(&db, community, &actor).await; - assert_eq!(exact(&row.unread), Some(0)); - assert!(row.threads.items.is_empty() && row.threads.complete); + assert!(!row.unread); + assert!(row.threads.is_empty()); } /// The shallow latest probe reads the newest `MAX_CHANNEL_SCAN + 1` events by /// author time, of any kind. A late arrival with an older author time than /// that many events is counted unread, so the unread scan must supply the /// anchor or Mark as read with the sidebar anchor leaves it. -async fn mark_as_read_behind_fillers(fillers: u64) -> Option { +async fn mark_as_read_behind_fillers(fillers: u64) -> (bool, u32) { let (db, pool, community, channel, actor, first) = fixture().await; let now = first.created_at.as_secs(); arrive(&pool, community, &first, now - 60).await; @@ -318,27 +259,15 @@ async fn mark_as_read_behind_fillers(fillers: u64) -> Option { .unwrap(); arrive(&pool, community, &filler, now - 50).await; } - post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await; - assert_eq!( - exact(&sidebar(&db, community, &actor).await.unread), - Some(1) - ); + post(&db, &pool, community, channel, &actor, now - 600, now - 10).await; + assert_eq!(counts(&db, community, &actor).await, (true, 1)); let anchor = sidebar(&db, community, &actor) .await - .latest_message_id + .latest_id .expect("a channel with messages has a latest message"); - apply( - &db, - community, - &actor, - ReadIntent::MarkChannelRead { - channel_id: channel, - message_id: anchor, - }, - ) - .await; - exact(&sidebar(&db, community, &actor).await.unread) + apply(&db, community, &actor, mark_through(channel, None, &anchor)).await; + counts(&db, community, &actor).await } /// Control: with one slot to spare the late arrival is inside the probe. @@ -346,18 +275,18 @@ async fn mark_as_read_behind_fillers(fillers: u64) -> Option { #[ignore = "requires Postgres"] async fn mark_as_read_clears_a_late_arrival_inside_the_latest_probe() { let fillers = MAX_CHANNEL_SCAN as u64 - 1; - assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0)); + assert_eq!(mark_as_read_behind_fillers(fillers).await, (false, 0)); } #[tokio::test] #[ignore = "requires Postgres"] async fn mark_as_read_clears_a_late_arrival_outside_the_latest_probe() { let fillers = MAX_CHANNEL_SCAN as u64; - assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0)); + assert_eq!(mark_as_read_behind_fillers(fillers).await, (false, 0)); } /// When the newest 257 events are all uncounted, the only message is still in -/// the unread scan: the summary reports it as found, as one coherent triple. +/// the unread scan: the summary reports it as latest and unread. #[tokio::test] #[ignore = "requires Postgres"] async fn latest_message_behind_a_full_probe_of_reactions_is_found() { @@ -371,7 +300,7 @@ async fn latest_message_behind_a_full_probe_of_reactions_is_found() { .unwrap() .rows_affected(); assert_eq!(demoted, 1); - let only = post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await; + let only = post(&db, &pool, community, channel, &actor, now - 600, now - 10).await; for i in 0..MAX_CHANNEL_SCAN as u64 { let filler = EventBuilder::new(Kind::Custom(7), "+") .custom_created_at(nostr::Timestamp::from(now - 500 + i)) @@ -384,10 +313,8 @@ async fn latest_message_behind_a_full_probe_of_reactions_is_found() { } let row = sidebar(&db, community, &actor).await; - assert_eq!(row.latest_message_id, Some(only.id.to_hex())); - assert_eq!(row.latest_message_at, Some((now - 600) as i64)); - assert!(row.latest_message_complete); - assert_eq!(exact(&row.unread), Some(1)); + assert_eq!(row.latest_id, Some(only.id.to_hex())); + assert_eq!((row.unread, row.mentions), (true, 1)); } /// Store `event` as having arrived at exactly `seconds` plus `micros`. Built @@ -419,13 +346,17 @@ async fn arrive_exact( assert_eq!(stored, at, "received_at must keep microseconds"); } -/// Two messages with the same author time arriving within one second; marks -/// the one at `pick` and returns both channel states. -async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec, Option) { - let (db, pool, community, channel, actor, first) = fixture().await; - let now = first.created_at.as_secs(); - let second = post(&db, &pool, community, channel, now, now, vec![]).await; +/// Two mentioning messages with the same author time arriving within one +/// second; marks the one at `pick`, then the other. Returns the mention count +/// after the first mark, and the anchor (index) the frontier ends with. +async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (u32, usize) { + let (db, pool, community, channel, actor, older) = fixture().await; + let now = older.created_at.as_secs(); let arrived = now as i64 - 30; + // The fixture message arrives first, so either mark covers it. + arrive_exact(&pool, community, &older, arrived - 1, 0).await; + let first = post(&db, &pool, community, channel, &actor, now, now).await; + let second = post(&db, &pool, community, channel, &actor, now, now).await; arrive_exact(&pool, community, &first, arrived, micros[0]).await; arrive_exact(&pool, community, &second, arrived, micros[1]).await; let both = [&first, &second]; @@ -436,32 +367,41 @@ async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec, mark_through(channel, None, &both[pick].id.to_hex()), ) .await; - ( - states(&db, community, &actor, channel, &both).await, - exact(&sidebar(&db, community, &actor).await.unread), + let mentions = sidebar(&db, community, &actor).await.mentions; + apply( + &db, + community, + &actor, + mark_through(channel, None, &both[1 - pick].id.to_hex()), ) + .await; + let anchor = sidebar(&db, community, &actor).await.read_through_id; + let anchor = both + .iter() + .position(|e| Some(e.id.to_hex()) == anchor) + .expect("the anchor is one of the marked messages"); + (mentions, anchor) } /// Mid-second stamps, so truncating or rounding the frontier to whole seconds -/// either reads the later message or leaves the anchor unread. +/// either reads the later message or leaves the anchor unread. The later +/// arrival then becomes the anchor. #[tokio::test] #[ignore = "requires Postgres"] async fn arrivals_one_microsecond_apart_in_the_same_second_are_ordered() { - assert_eq!( - mark_within_one_second([500_000, 500_001], 0).await, - (vec!["read".to_owned(), "unread".to_owned()], Some(1)) - ); + assert_eq!(mark_within_one_second([500_000, 500_001], 0).await, (1, 1)); } /// The Order section: everything that arrived at or before the anchor is read, -/// so an identical stamp reads both, whichever is marked. +/// so an identical stamp reads both, whichever is marked. An equal arrival +/// keeps the first anchor. #[tokio::test] #[ignore = "requires Postgres"] async fn marking_either_of_two_identical_arrivals_reads_both() { for pick in [0, 1] { assert_eq!( mark_within_one_second([500_000, 500_000], pick).await, - (vec!["read".to_owned(), "read".to_owned()], Some(0)), + (0, pick), "marked index {pick}" ); } diff --git a/crates/buzz-db/src/store/personal_read/classification.rs b/crates/buzz-db/src/store/personal_read/classification.rs deleted file mode 100644 index dfc83abe35..0000000000 --- a/crates/buzz-db/src/store/personal_read/classification.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Selector eligibility and shared directed-reason rules. Aggregate SQL applies -//! the same eligibility before grouping, covered by the PostgreSQL parity test. -use super::model::{Reason, ELIGIBLE_KINDS}; - -pub(super) fn eligible( - kind: i32, - own: bool, - deleted: bool, - created_ms: i64, - cutoff_ms: i64, -) -> bool { - ELIGIBLE_KINDS.contains(&kind) && !own && !deleted && created_ms >= cutoff_ms -} - -/// Why a message is directed, before conversation membership is known. -pub(super) fn reason(channel_type: &str, actor_hex: &str, tags: &[Vec]) -> Option { - let tagged = |name: &str, matches: &dyn Fn(&str) -> bool| { - tags.iter() - .any(|tag| tag.len() >= 2 && tag[0] == name && matches(&tag[1])) - }; - if channel_type == "dm" { - Some(Reason::Direct) - } else if tagged("p", &|value| value.eq_ignore_ascii_case(actor_hex)) { - Some(Reason::Mention) - } else if tagged("broadcast", &|value| value == "1") { - Some(Reason::Broadcast) - } else { - None - } -} diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs deleted file mode 100644 index 4ecc5a2cb4..0000000000 --- a/crates/buzz-db/src/store/personal_read/context.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Explicit selectors over the same private frontier authority. No history API. -use super::{classification, model::*, participation, projection::read_account, writes}; -use crate::{observability, Db, DbError, Result}; -use buzz_core::CommunityId; -use chrono::{DateTime, Utc}; -use sqlx::{Acquire, Row}; -use std::collections::HashMap; -use uuid::Uuid; - -impl Db { - /// Resolve bounded explicit contexts/messages in a single read-only snapshot. - /// Callers must recheck admission and resource access outside this snapshot. - pub async fn personal_read_contexts( - &self, - community: CommunityId, - actor: &nostr::PublicKey, - retention_seconds: u32, - queries: &[ContextQuery], - ) -> Result { - if queries.is_empty() - || queries.len() > MAX_CONTEXTS - || queries.iter().map(|q| q.message_ids.len()).sum::() > MAX_CONTEXT_MESSAGES - || queries.iter().any(|q| { - q.message_ids - .iter() - .any(|id| writes::event_id(id).is_none()) - }) - { - return Err(DbError::InvalidData("invalid context selectors".into())); - } - let mut conn = observability::acquire_writer( - &self.pool, - observability::WriterOperation::SubscriptionHistory, - ) - .await?; - let mut tx = conn.begin().await?; - sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") - .execute(&mut *tx) - .await?; - writes::deadlines(&mut tx).await?; - let actor_bytes = actor.to_bytes(); - let account = read_account(&mut tx, community, &actor_bytes, retention_seconds).await?; - // Every frontier is floored at the account's start. - let floor = account.floor(); - let mut contexts = Vec::with_capacity(queries.len()); - // Replies whose state turns on conversation membership, by position. - let mut pending = Vec::new(); - for query in queries { - let root = - match writes::valid_target(&mut tx, community, &actor_bytes, &query.target).await { - Ok(Some(root)) => root, - Ok(None) => { - contexts.push(ContextState::Unavailable); - continue; - } - Err(DbError::InvalidData(_)) => { - contexts.push(ContextState::Unknown); - continue; - } - Err(error) => return Err(error), - }; - // A thread's effective prefix includes the channel's whole-channel cut, - // exactly as the sidebar projection counts it. - let prefix: Option> = sqlx::query_scalar( - "SELECT GREATEST( - (SELECT through_timestamp FROM personal_read_frontiers - WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=$4), - (SELECT threads_through_timestamp FROM personal_read_frontiers - WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=''::bytea - AND $4<>''::bytea))", - ) - .bind(community.as_uuid()) - .bind(actor_bytes.as_slice()) - .bind(query.target.channel_id) - .bind(&root) - .fetch_one(&mut *tx) - .await?; - let ids: Vec> = query - .message_ids - .iter() - .filter_map(|id| writes::event_id(id)) - .collect(); - let rows = sqlx::query( - "SELECT encode(e.id,'hex') AS id,e.kind,e.created_at,e.received_at, - e.deleted_at IS NOT NULL AS deleted,e.pubkey=$3 AS own, - CASE WHEN octet_length(e.tags::text)<=8192 THEN e.tags ELSE NULL END AS tags, - tm.root_event_id,tm.parent_event_id,c.channel_type::text AS channel_type - FROM events e JOIN channels c ON c.community_id=e.community_id AND c.id=e.channel_id - LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id - AND tm.event_id=e.id AND tm.event_created_at=e.created_at AND tm.channel_id=e.channel_id - WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=ANY($4)", - ).bind(community.as_uuid()).bind(query.target.channel_id) - .bind(actor_bytes.as_slice()).bind(&ids).fetch_all(&mut *tx).await?; - let by_id: HashMap = rows - .into_iter() - .map(|row| Ok((row.try_get::("id")?, row))) - .collect::>()?; - let mut messages = Vec::with_capacity(ids.len()); - for id in &query.message_ids { - let state = if let Some(row) = by_id.get(&id.to_ascii_lowercase()) { - let tags: Option = row.try_get("tags")?; - let parsed = - tags.and_then(|v| serde_json::from_value::>>(v).ok()); - if let Some(tags) = parsed { - let canonical: Option> = row.try_get("root_event_id")?; - let marked_reply = buzz_core::nip10::parse_thread_markers_from_parts( - tags.iter().map(Vec::as_slice), - ) - .resolve() - .is_some(); - let message_id = writes::event_id(id).unwrap_or_default(); - let is_reply = canonical.as_ref().is_some_and(|r| r != &message_id); - if marked_reply && canonical.is_none() { - MessageReadState::Unknown - } else if (root.is_empty() && is_reply) - || (!root.is_empty() - && (!is_reply || canonical.as_ref() != Some(&root))) - { - // The root's own timeline state is never the thread's state. - MessageReadState::Unavailable - } else { - let created: DateTime = row.try_get("created_at")?; - let received: DateTime = row.try_get("received_at")?; - let kind: i32 = row.try_get("kind")?; - if !classification::eligible( - kind, - row.try_get("own")?, - row.try_get("deleted")?, - created.timestamp_millis(), - account.cutoff_ms, - ) { - MessageReadState::NotCounted - } else if received <= prefix.map_or(floor, |p| p.max(floor)) { - MessageReadState::Read - } else { - let reason = classification::reason( - &row.try_get::("channel_type")?, - &actor.to_hex(), - &tags, - ); - // Membership outranks a broadcast and decides a - // plain reply. A DM or mention needs no lookup. - if is_reply - && !matches!(reason, Some(Reason::Direct | Reason::Mention)) - { - let parent: Option> = row.try_get("parent_event_id")?; - if let Some(parent) = parent { - pending.push(( - contexts.len(), - messages.len(), - (query.target.channel_id, parent), - )); - } - } - // Provisional for a pending reply: see `settle`. - if is_reply && reason.is_none() { - MessageReadState::Unknown - } else { - MessageReadState::Unread { reason } - } - } - } - } else { - MessageReadState::Unknown - } - } else { - MessageReadState::Unavailable - }; - messages.push(ContextMessage { - message_id: id.clone(), - state, - }); - } - contexts.push(ContextState::Available { messages }); - } - let targets: Vec<_> = pending.iter().map(|(.., key)| key.clone()).collect(); - let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; - for (context, message, key) in pending { - if let ContextState::Available { messages } = &mut contexts[context] { - settle(&mut messages[message].state, members.get(&key).copied()); - } - } - tx.commit().await?; - Ok(ContextPage { account, contexts }) - } - - /// Final bounded access check on the writer, outside a projection snapshot. - /// Open-channel access is independent of joined-sidebar membership. - pub async fn personal_read_accessible_contexts( - &self, - community: CommunityId, - actor: &nostr::PublicKey, - channels: &[Uuid], - ) -> Result> { - if channels.len() > MAX_CONTEXTS { - return Err(DbError::InvalidData("too many context channels".into())); - } - let mut conn = observability::acquire_writer( - &self.pool, - observability::WriterOperation::Authorization, - ) - .await?; - Ok(sqlx::query_scalar( - "SELECT c.id FROM channels c WHERE c.community_id=$1 AND c.id=ANY($2) - AND c.deleted_at IS NULL AND (c.visibility='open' OR EXISTS ( - SELECT 1 FROM channel_members cm WHERE cm.community_id=$1 - AND cm.channel_id=c.id AND cm.pubkey=$3 AND cm.removed_at IS NULL))", - ) - .bind(community.as_uuid()) - .bind(channels) - .bind(actor.to_bytes().as_slice()) - .fetch_all(&mut *conn) - .await?) - } -} - -/// Apply conversation membership to a pending reply's provisional state: -/// `unknown` for a plain reply, `unread` with reason `broadcast` for a -/// broadcast. An undecided lookup (`None`) leaves it as it is. -fn settle(state: &mut MessageReadState, member: Option) { - match member { - Some(true) => { - *state = MessageReadState::Unread { - reason: Some(Reason::Conversation), - } - } - // A broadcast counts outside the actor's conversations too. - Some(false) if matches!(state, MessageReadState::Unknown) => { - *state = MessageReadState::NotCounted - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn membership_settles_a_pending_reply_and_an_undecided_lookup_fabricates_nothing() { - let broadcast = || MessageReadState::Unread { - reason: Some(Reason::Broadcast), - }; - for (provisional, member, expected) in [ - (MessageReadState::Unknown, Some(true), "conversation"), - (MessageReadState::Unknown, Some(false), "not_counted"), - (MessageReadState::Unknown, None, "unknown"), - (broadcast(), Some(true), "conversation"), - (broadcast(), Some(false), "broadcast"), - (broadcast(), None, "broadcast"), - ] { - let mut state = provisional; - settle(&mut state, member); - let wire = serde_json::to_value(&state).unwrap(); - let got = wire["reason"].as_str().or(wire["status"].as_str()); - assert_eq!(got, Some(expected), "{member:?}"); - } - } -} diff --git a/crates/buzz-db/src/store/personal_read/mod.rs b/crates/buzz-db/src/store/personal_read/mod.rs index fb056c600c..bd01af0cde 100644 --- a/crates/buzz-db/src/store/personal_read/mod.rs +++ b/crates/buzz-db/src/store/personal_read/mod.rs @@ -4,8 +4,6 @@ //! through; the unread horizon alone uses author time. Only fixed context //! intents advance frontiers, never a query scan cap. -mod classification; -mod context; mod model; mod participation; mod projection; diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index 1b329b46ac..ef10d5ec39 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -28,14 +28,6 @@ pub enum ReadIntent { /// Fixed anchor; retry must not substitute the latest message. message_id: String, }, - /// Advance the channel timeline and every thread in it through one fixed - /// message's arrival time. The anchor may be a reply; ancestry is irrelevant. - MarkChannelRead { - /// Channel being marked, including all of its threads. - channel_id: Uuid, - /// Fixed anchor; retry must not substitute the latest message. - message_id: String, - }, } /// Outcome for one independent transaction, never acknowledged before commit. @@ -50,220 +42,68 @@ pub enum IntentOutcome { Invalid, } -/// The tracking boundary for the authenticated account. -#[derive(Clone, Debug, Serialize)] -pub struct ReadAccount { - /// Configured tracking duration in seconds. - pub retention_seconds: u32, +/// The tracking boundary for the authenticated account. Not on the wire. +#[derive(Clone, Debug)] +pub(super) struct ReadAccount { /// Read-time author-time cutoff (Unix milliseconds), not a discard boundary. - pub cutoff_ms: i64, + pub(super) cutoff_ms: i64, /// Arrival floor of every frontier: the account's first applied read - /// intent. `None` until then, and nothing counts as unread. Not on the wire. - #[serde(skip)] - pub started_at: Option>, -} - -impl ReadAccount { - /// The arrival every frontier is floored at: the start, or the end of time - /// for an account that has not started. - pub(super) fn floor(&self) -> DateTime { - self.started_at.unwrap_or(DateTime::::MAX_UTC) - } + /// intent. `None` until then, and nothing counts as unread. + pub(super) started_at: Option>, } /// Maximum channel summaries in one sidebar page. pub const MAX_CHANNELS: usize = 20; -/// Maximum unread-thread summaries per channel row. +/// Maximum thread rows per channel row. pub const MAX_THREAD_SUMMARIES: usize = 5; -/// Bounded event evidence per channel; exhaustion is never inferred at this cap. +/// Latest-probe event budget per channel, before eligibility filtering. pub const MAX_CHANNEL_SCAN: usize = 256; /// Unread-window work budget per channel, before eligibility/ancestry joins. pub const MAX_UNREAD_SCAN: usize = 4096; /// Conversation kinds eligible for ordinary unread state (not edits/reactions). pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003]; -/// An honest aggregate: capped evidence cannot establish exact zero. -#[derive(Clone, Debug, Serialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum ReadCount { - /// Exhausted the authoritative candidate set. - Exact { - /// Total within the tracking horizon. - value: u32, - }, - /// Incomplete evidence establishes no positive lower bound. No numeric value. - Unknown, - /// More evidence exists or ancestry/membership could not be proved. - AtLeast { - /// Proven lower bound, not a fabricated badge cap. - value: u32, - }, -} - -impl ReadCount { - pub(super) fn from_evidence(value: u32, complete: bool) -> Self { - if complete { - Self::Exact { value } - } else if value == 0 { - Self::Unknown - } else { - Self::AtLeast { value } - } - } -} - -/// One joined-channel summary, not a second conversation/history API. +/// One joined channel's timeline read state. A channel and each of its +/// threads keep independent read positions. #[derive(Debug, Serialize)] pub struct ChannelReadSummary { /// Joined channel UUID. pub channel_id: Uuid, - /// Existing channel name. - pub name: String, - /// Existing channel type. - pub channel_type: String, - /// Archived channels stay in the roster; presentation remains client-owned. - pub archived: bool, - /// Existing DM visibility preference (not an authorization decision). - pub hidden: bool, - /// Unread messages that count: every top-level message, and a reply only - /// when it has a [`Reason`]. Other replies are not unread at all. - pub unread: ReadCount, - /// The unread subset with a [`Reason`]: everything but ordinary top-level - /// messages. This is not Desktop notification eligibility: follows and - /// mutes do not change this count. - pub attention: ReadCount, - /// Last eligible nondeleted event to arrive, inside the unread horizon when - /// any is, whatever its author or read progress: marking through it reads - /// the row. - /// None proves absence only when latest_message_complete is true. - pub latest_message_id: Option, - /// Display activity: the greatest author time (Unix seconds) among eligible - /// nondeleted events, not necessarily latest_message_id's own. None exactly - /// when it is None. - pub latest_message_at: Option, - /// Whether the latest lookup found a result or exhausted channel history. - /// False means the bounded probe found none, but an unexamined tail remains. - pub latest_message_complete: bool, + /// Whether an unread top-level message was found. Thread replies count + /// only on their thread row. + pub unread: bool, + /// Unread top-level messages directed at the actor: every one in a DM, + /// otherwise those that tag the actor with `p` or carry `broadcast=1`. + pub mentions: u32, + /// The message the channel timeline was last marked through, if any. + pub read_through_id: Option, + /// Last eligible top-level message to arrive, whatever its author or read + /// progress: marking the timeline through it reads the timeline. + pub latest_id: Option, /// Threads with unread replies, newest unread reply first. - pub threads: ThreadSummaries, + pub threads: Vec, } -/// A bounded, ordered list of unread threads within one channel row. -#[derive(Debug, Serialize)] -pub struct ThreadSummaries { - /// At most MAX_THREAD_SUMMARIES, by latest_reply_at DESC then root_id ASC. - pub items: Vec, - /// True only when evidence was exhausted and no thread was omitted. - pub complete: bool, -} - -/// Unread replies in one canonical thread; every one has a [`Reason`]. No -/// conversation bytes. +/// One thread's read state within a channel row. No conversation bytes. #[derive(Debug, Serialize)] pub struct ThreadReadSummary { /// Canonical thread-root event ID. pub root_id: String, - /// Unread replies in this thread (same definition as the row count). - pub unread: ReadCount, - /// Last observed unread reply to arrive: marking through it reads the thread. - pub latest_reply_id: String, - /// Author time (Unix seconds) of latest_reply_id. - pub latest_reply_at: i64, + /// Whether an unread reply that counts was found. + pub unread: bool, + /// Unread replies that count. Every one is directed at the actor. + pub mentions: u32, + /// The message the thread was last marked through, if any. + pub read_through_id: Option, + /// Last counted reply to arrive: marking through it reads the thread. + pub latest_id: String, } /// A bounded roster page, with no cross-page snapshot or removal inference. #[derive(Debug, Serialize)] pub struct SidebarPage { - /// Effective read-state lifecycle for this response. - pub account: ReadAccount, /// Joined channels only, never every accessible public channel. pub channels: Vec, /// Exclusive UUID roster cursor. None means this roster scan exhausted. pub next_cursor: Option, } - -/// Maximum explicit contexts in one request. -pub const MAX_CONTEXTS: usize = 20; -/// Maximum explicit message selectors across the entire context request. -pub const MAX_CONTEXT_MESSAGES: usize = 100; - -/// A context and concrete messages already known through Nostr history/live reads. -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct ContextQuery { - /// Channel timeline or canonical thread, never an arbitrary filter. - pub target: ReadTarget, - /// Optional concrete message selectors; not an event history query. - #[serde(default)] - pub message_ids: Vec, -} - -/// Why an unread message is directed at the actor: the first that holds. -#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum Reason { - /// Its channel is a DM. - Direct, - /// It tags the actor with `p`. - Mention, - /// It replies to a message the actor wrote, or to one the actor also - /// replied to, in the same channel. Only live eligible messages qualify. - Conversation, - /// It carries `broadcast=1`. - Broadcast, -} - -/// Read progress and eligibility for one concrete message, not a public receipt. -#[derive(Debug, Serialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum MessageReadState { - /// Missing, inaccessible, or outside the requested context. No existence oracle. - Unavailable, - /// Evidence cannot safely establish ancestry, eligibility or membership. - Unknown, - /// Not unread: own, deleted, auxiliary, outside the horizon, or a reply - /// with no [`Reason`]. - NotCounted, - /// Covered by this context's frontier. - Read, - /// Counts, and is beyond this context's frontier. - Unread { - /// Null only for an ordinary top-level message. A broadcast reply whose - /// membership is undecided reports `broadcast`. - reason: Option, - }, -} - -/// An explicit message result, in request order. -#[derive(Debug, Serialize)] -pub struct ContextMessage { - /// Requested ID, not an independently disclosed event ID. - pub message_id: String, - /// Actor-private state within the requested context. - #[serde(flatten)] - pub state: MessageReadState, -} - -/// A context result. Denied and missing resources share an indistinguishable shape. -#[derive(Debug, Serialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum ContextState { - /// Missing or inaccessible context. - Unavailable, - /// Canonical context could not be proved. - Unknown, - /// Context authority at the response snapshot. - Available { - /// Bounded explicit selectors, in request order. - messages: Vec, - }, -} - -/// Actor-private bounded context response; no cross-request snapshot guarantee. -#[derive(Debug, Serialize)] -pub struct ContextPage { - /// Effective read-state lifecycle for this response. - pub account: ReadAccount, - /// One result per requested context, in request order. - pub contexts: Vec, -} diff --git a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs index ec65afb51d..fc1eb50b5c 100644 --- a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs @@ -121,32 +121,10 @@ impl World { .remove(0) } - /// The wire state of one message in the channel timeline or `root`'s thread. - async fn state( - &self, - channel: Uuid, - root: Option<&nostr::Event>, - message: &nostr::Event, - ) -> Value { - let page = self - .db - .personal_read_contexts( - self.community, - &self.actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &[ContextQuery { - target: ReadTarget { - channel_id: channel, - root_id: root.map(|root| root.id.to_hex()), - }, - message_ids: vec![message.id.to_hex()], - }], - ) - .await - .unwrap(); - let mut state = wire(&page)["contexts"][0]["messages"][0].take(); - state.as_object_mut().unwrap().remove("message_id"); - state + /// The row's counts and thread rows: what the sidebar says counts. + async fn counts(&self, channel: Uuid) -> Value { + let row = self.row(channel).await; + json!({"unread":row.unread,"mentions":row.mentions,"threads":wire(&row.threads)}) } fn mention(&self) -> Tag { @@ -158,26 +136,20 @@ fn wire(value: &T) -> Value { serde_json::to_value(value).unwrap() } -fn exact(value: u32) -> Value { - json!({"status":"exact","value":value}) -} - -/// Unread with a reason; `Value::Null` for an ordinary top-level message. -fn unread(reason: impl Into) -> Value { - json!({"status":"unread","reason":reason.into()}) -} - -fn status(status: &str) -> Value { - json!({ "status": status }) +/// Expected `counts`: whether a top-level message is unread, how many of those +/// are directed, and the listed thread rows. +fn counts(unread: bool, mentions: u32, threads: Vec) -> Value { + json!({"unread":unread,"mentions":mentions,"threads":threads}) } fn broadcast() -> Tag { Tag::parse(["broadcast", "1"]).unwrap() } -fn item(root: &nostr::Event, unread: u32, latest: &nostr::Event) -> Value { - json!({"root_id":root.id.to_hex(),"unread":exact(unread), - "latest_reply_id":latest.id.to_hex(),"latest_reply_at":latest.created_at.as_secs()}) +/// A listed, never-marked thread with `mentions` counted unread replies. +fn item(root: &nostr::Event, mentions: u32, latest: &nostr::Event) -> Value { + json!({"root_id":root.id.to_hex(),"unread":true,"mentions":mentions, + "read_through_id":null,"latest_id":latest.id.to_hex()}) } #[tokio::test] @@ -197,22 +169,11 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() { let nested = w .reply(c, &w.peer, &root, Some(&answer), w.now + 2, vec![]) .await; - assert_eq!(w.state(c, None, &root).await, status("not_counted")); + // Only `answer` counts. The newer reply that does not count is not the + // thread's latest. assert_eq!( - w.state(c, Some(&root), &answer).await, - unread("conversation") - ); - assert_eq!( - w.state(c, Some(&root), &nested).await, - status("not_counted") - ); - let row = w.row(c).await; - assert_eq!(wire(&row.unread), exact(1)); - assert_eq!(wire(&row.attention), exact(1)); - // The newer reply that does not count is not the thread's preview. - assert_eq!( - wire(&row.threads), - json!({"items":[item(&root, 1, &answer)],"complete":true}) + w.counts(c).await, + counts(false, 0, vec![item(&root, 1, &answer)]) ); // Joining the nested conversation makes its earlier reply count. The @@ -220,14 +181,8 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() { w.reply(c, &w.actor, &root, Some(&answer), w.now + 3, vec![]) .await; assert_eq!( - w.state(c, Some(&root), &nested).await, - unread("conversation") - ); - let row = w.row(c).await; - assert_eq!(wire(&row.unread), exact(2)); - assert_eq!( - wire(&row.threads), - json!({"items":[item(&root, 2, &nested)],"complete":true}) + w.counts(c).await, + counts(false, 0, vec![item(&root, 2, &nested)]) ); // A peer's thread. The actor replied to one of two parents, long ago. @@ -240,45 +195,24 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() { let sibling = w .reply(c, &w.peer, &root, Some(&joined), w.now + 1, vec![]) .await; - let elsewhere = w - .reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![]) + w.reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![]) .await; assert_eq!( - w.state(c, Some(&root), &sibling).await, - unread("conversation") - ); - assert_eq!( - w.state(c, Some(&root), &elsewhere).await, - status("not_counted") - ); - let row = w.row(c).await; - assert_eq!(wire(&row.unread), exact(1)); - assert_eq!( - wire(&row.threads), - json!({"items":[item(&root, 1, &sibling)],"complete":true}) + w.counts(c).await, + counts(false, 0, vec![item(&root, 1, &sibling)]) ); // A conversation the actor never joined: one unread top-level message, and // a reply that is not unread even in its own thread. let c = w.channel().await; let root = w.post(c, &w.peer, w.now, vec![]).await; - let reply = w - .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) .await; - assert_eq!(w.state(c, None, &root).await, unread(Value::Null)); - assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted")); - let row = w.row(c).await; - assert_eq!(wire(&row.unread), exact(1)); - assert_eq!(wire(&row.attention), exact(0)); - assert_eq!(wire(&row.threads), json!({"items":[],"complete":true})); + assert_eq!(w.counts(c).await, counts(true, 0, vec![])); - // A reply whose parent was never recorded is undecided, not absent. - let orphan = w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await; - assert_eq!(w.state(c, Some(&root), &orphan).await, status("unknown")); - let row = w.row(c).await; - assert_eq!(wire(&row.unread), json!({"status":"at_least","value":1})); - assert_eq!(wire(&row.attention), status("unknown")); - assert_eq!(wire(&row.threads), json!({"items":[],"complete":false})); + // A reply whose parent was never recorded is undecided: left out. + w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await; + assert_eq!(w.counts(c).await, counts(true, 0, vec![])); } #[tokio::test] @@ -288,29 +222,12 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() { let c = w.channel().await; let root = w.post(c, &w.peer, w.now, vec![]).await; let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await; - let own = w - .reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![]) - .await; - let mention = w - .reply( - c, - &w.peer, - &root, - Some(&parent), - w.now + 2, - vec![w.mention()], - ) - .await; - let shout = w - .reply( - c, - &w.peer, - &root, - Some(&parent), - w.now + 3, - vec![broadcast()], - ) + w.reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![]) .await; + for (at, tags) in [(2, vec![w.mention()]), (3, vec![broadcast()])] { + w.reply(c, &w.peer, &root, Some(&parent), w.now + at, tags) + .await; + } let both = w .reply( c, @@ -321,28 +238,16 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() { vec![broadcast(), w.mention()], ) .await; - assert_eq!(w.state(c, Some(&root), &own).await, status("not_counted")); - assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention")); - assert_eq!(w.state(c, Some(&root), &shout).await, unread("broadcast")); - assert_eq!(w.state(c, Some(&root), &both).await, unread("mention")); - // The root, the actor's sibling `parent`, and the three directed replies. - let row = w.row(c).await; - assert_eq!(wire(&row.unread), exact(5)); - assert_eq!(wire(&row.attention), exact(4)); - assert_eq!( - wire(&row.threads), - json!({"items":[item(&root, 4, &both)],"complete":true}) - ); + // The root is an undirected top-level message. The thread counts the + // actor's sibling `parent` and the three directed replies, not the + // actor's own. + let expected = counts(true, 0, vec![item(&root, 4, &both)]); + assert_eq!(w.counts(c).await, expected); - // Conversation outranks broadcast, never mention. Counts do not move. + // Joining the directed replies' conversation changes no count. w.reply(c, &w.actor, &root, Some(&parent), w.now + 5, vec![]) .await; - assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention")); - assert_eq!( - w.state(c, Some(&root), &shout).await, - unread("conversation") - ); - assert_eq!(wire(&w.row(c).await.unread), exact(5)); + assert_eq!(w.counts(c).await, expected); // In a DM every peer message is direct, tagged or not, joined or not. let dm = w.channel().await; @@ -359,33 +264,34 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() { let reply = w .reply(dm, &w.peer, &root, Some(&parent), w.now + 1, vec![]) .await; - assert_eq!(w.state(dm, None, &root).await, unread("direct")); - assert_eq!(w.state(dm, Some(&root), &reply).await, unread("direct")); - let row = w.row(dm).await; - assert_eq!(wire(&row.unread), exact(3)); - assert_eq!(wire(&row.attention), exact(3)); + assert_eq!( + w.counts(dm).await, + counts(true, 1, vec![item(&root, 2, &reply)]) + ); } #[tokio::test] #[ignore = "requires Postgres"] -async fn eligibility_then_the_read_frontier_are_reported_before_membership() { +async fn eligibility_frontier_and_membership_each_leave_replies_out() { let w = World::new().await; let c = w.channel().await; let root = w.post(c, &w.peer, w.now, vec![]).await; let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await; let reply = |author, at, tags| w.reply(c, author, &root, Some(&parent), w.now + at, tags); // Peer replies to a parent the actor neither wrote nor replied to. - let outside = reply(&w.peer, 1, vec![]).await; - let deleted = reply(&w.peer, 2, vec![]).await; + reply(&w.peer, 1, vec![]).await; + let deleted = reply(&w.peer, 2, vec![w.mention()]).await; w.delete(&deleted).await; - let own = w - .reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![]) + w.reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![]) .await; let anchor = reply(&w.peer, 4, vec![w.mention()]).await; - let later = reply(&w.peer, 5, vec![]).await; + // Undirected, outside the actor's conversations: never counts. + reply(&w.peer, 5, vec![]).await; + // `parent` (a reply to the root, which the actor answered) and `anchor` + // count: not the deleted, own or undirected replies. assert_eq!( - w.state(c, Some(&root), &outside).await, - status("not_counted") + w.counts(c).await, + counts(true, 0, vec![item(&root, 2, &anchor)]) ); assert_eq!( @@ -404,15 +310,14 @@ async fn eligibility_then_the_read_frontier_are_reported_before_membership() { .unwrap(), IntentOutcome::Applied ); - for (message, expected) in [ - (&outside, "read"), - (&deleted, "not_counted"), - (&own, "not_counted"), - (&anchor, "read"), - (&later, "not_counted"), - ] { - assert_eq!(w.state(c, Some(&root), message).await, status(expected)); - } + assert_eq!(w.counts(c).await, counts(true, 0, vec![])); + // A later directed reply is unread past the thread's anchor; the channel + // timeline was never marked. + let after = reply(&w.peer, 6, vec![w.mention()]).await; + let mut thread = item(&root, 1, &after); + thread["read_through_id"] = json!(anchor.id.to_hex()); + assert_eq!(w.counts(c).await, counts(true, 0, vec![thread])); + assert_eq!(w.row(c).await.read_through_id, None); } #[tokio::test] @@ -425,11 +330,11 @@ async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() { // The actor wrote the parent and never replied under it. let wrote = reply(&w.actor, &root, 0).await; - let to_wrote = reply(&w.peer, &wrote, 10).await; + reply(&w.peer, &wrote, 10).await; // The actor's only reply to a peer's parent. let once = reply(&w.peer, &root, 0).await; let only = reply(&w.actor, &once, 1).await; - let to_once = reply(&w.peer, &once, 11).await; + reply(&w.peer, &once, 11).await; // Two replies by the actor to a peer's parent. let twice = reply(&w.peer, &root, 0).await; let first = reply(&w.actor, &twice, 1).await; @@ -440,42 +345,33 @@ async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() { let under = reply(&w.actor, &both, 1).await; let to_both = reply(&w.peer, &both, 13).await; - // Whether each counts: `unread` in a conversation, or else `not_counted`. - let counted = || async { - let mut counted = Vec::new(); - for message in [&to_wrote, &to_once, &to_twice, &to_both] { - let state = w.state(c, Some(&root), message).await; - let yes = state == unread("conversation"); - assert!(yes || state == status("not_counted"), "{state}"); - counted.push(yes); - } - counted + // How many replies the thread counts, the latest of them `to_both` until + // it stops counting. + let w = &w; + let thread = |mentions: u32| async move { + let row = w.row(c).await; + assert!(!row.unread, "the root predates the horizon"); + assert_eq!(row.threads.len(), 1); + assert_eq!(row.threads[0].mentions, mentions); + row.threads[0].latest_id.clone() }; - let (yes, no) = (true, false); - // `once` and `twice` are peer replies to the root, which the actor has - // replied to (`wrote`, `both`), so they count as well. - assert_eq!(counted().await, [yes, yes, yes, yes]); - assert_eq!(wire(&w.row(c).await.unread), exact(6)); + // The four peer replies to the actor's conversations count. So do `once` + // and `twice`: peer replies to the root, which the actor has replied to + // (`wrote`, `both`). + assert_eq!(thread(6).await, to_both.id.to_hex()); // Each deletion changes only its own parent's conversation. w.delete(&wrote).await; - assert_eq!(counted().await, [no, yes, yes, yes]); + assert_eq!(thread(5).await, to_both.id.to_hex()); w.delete(&only).await; - assert_eq!(counted().await, [no, no, yes, yes]); + assert_eq!(thread(4).await, to_both.id.to_hex()); w.delete(&first).await; - assert_eq!(counted().await, [no, no, yes, yes]); - w.delete(&both).await; - assert_eq!(counted().await, [no, no, yes, yes]); - assert_eq!(wire(&w.row(c).await.unread), exact(2)); - w.delete(&under).await; - assert_eq!(counted().await, [no, no, yes, no]); + assert_eq!(thread(4).await, to_both.id.to_hex()); // With `wrote` and `both` gone the actor has no reply to the root either. - let row = w.row(c).await; - assert_eq!(wire(&row.unread), exact(1)); - assert_eq!( - wire(&row.threads), - json!({"items":[item(&root, 1, &to_twice)],"complete":true}) - ); + w.delete(&both).await; + assert_eq!(thread(2).await, to_both.id.to_hex()); + w.delete(&under).await; + assert_eq!(thread(1).await, to_twice.id.to_hex()); } #[tokio::test] @@ -492,32 +388,21 @@ async fn membership_is_scoped_to_the_replys_own_channel() { let in_a = w .reply(a, &w.peer, &theirs, Some(&theirs), w.now, vec![]) .await; - assert_eq!( - w.state(a, Some(&theirs), &in_a).await, - status("not_counted") - ); - assert_eq!(wire(&w.row(a).await.unread), exact(0)); + assert_eq!(w.counts(a).await, counts(false, 0, vec![])); // The actor wrote a parent in A. A peer reply in B claims it as its parent. let mine = w.post(a, &w.actor, old, vec![]).await; w.reply(b, &w.peer, &mine, Some(&mine), w.now, vec![]).await; - let row = w.row(b).await; - assert_eq!(wire(&row.unread), exact(0)); - assert_eq!(wire(&row.threads), json!({"items":[],"complete":true})); + assert_eq!(w.counts(b).await, counts(false, 0, vec![])); // The same parents count once the actor is a member in the reply's channel. w.reply(a, &w.actor, &theirs, Some(&theirs), old, vec![]) .await; let to_mine = w.reply(a, &w.peer, &mine, Some(&mine), w.now, vec![]).await; - assert_eq!( - w.state(a, Some(&theirs), &in_a).await, - unread("conversation") - ); - assert_eq!( - w.state(a, Some(&mine), &to_mine).await, - unread("conversation") - ); - assert_eq!(wire(&w.row(a).await.unread), exact(2)); + // Equal reply times: root ID order. + let mut threads = [item(&theirs, 1, &in_a), item(&mine, 1, &to_mine)]; + threads.sort_by(|x, y| x["root_id"].as_str().cmp(&y["root_id"].as_str())); + assert_eq!(w.counts(a).await, counts(false, 0, threads.to_vec())); } #[tokio::test] @@ -536,13 +421,9 @@ async fn membership_is_exact_behind_a_busy_parent() { .reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) .await; } - assert_eq!(w.state(c, Some(&root), &last).await, unread("conversation")); - let row = w.row(c).await; - assert_eq!(wire(&row.unread), exact(258)); - assert_eq!(wire(&row.attention), exact(258)); assert_eq!( - wire(&row.threads), - json!({"items":[item(&root, 258, &last)],"complete":true}) + w.counts(c).await, + counts(false, 0, vec![item(&root, 258, &last)]) ); } @@ -561,58 +442,42 @@ async fn threads_are_capped_after_replies_that_do_not_count_are_removed() { w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) .await; } - let row = w.row(c).await; - assert_eq!(wire(&row.unread), exact(1)); assert_eq!( - wire(&row.threads), - json!({"items":[item(&mine, 1, &answer)],"complete":true}) + w.counts(c).await, + counts(false, 0, vec![item(&mine, 1, &answer)]) ); } #[tokio::test] #[ignore = "requires Postgres"] -async fn the_parent_budget_leaves_replies_undecided_and_fabricates_no_absence() { +async fn the_parent_budget_leaves_undecided_replies_out() { let w = World::new().await; let c = w.channel().await; // The actor is in the first conversation. Whether it is inside the budget // depends on ID order, so only the bounds are asserted. + let mut mine = None; for i in 0..1025 { let author = if i == 0 { &w.actor } else { &w.peer }; let root = w.post(c, author, w.now, vec![]).await; - w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + let reply = w + .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) .await; - if i == 1023 { - // 1024 parents fit: 1023 peer roots, and the reply to the actor's. - // The independent SQL deadline may still withhold the answer. - let row = w.row(c).await; + if i == 0 { + mine = Some(item(&root, 1, &reply)); + } + // 1024 parents fit: 1023 peer roots, and the reply to the actor's. + // The independent SQL deadline may still withhold the answer. Past + // 1024, one reply is undecided. An undecided reply is left out. + if i >= 1023 { + let counted = w.counts(c).await; assert!( - [exact(1024), json!({"status":"at_least","value":1023})] - .contains(&wire(&row.unread)), - "{:?}", - row.unread - ); - assert!( - [exact(1), status("unknown")].contains(&wire(&row.attention)), - "{:?}", - row.attention + [vec![], vec![mine.clone().unwrap()]] + .map(|threads| counts(true, 0, threads)) + .contains(&counted), + "{i}: {counted}" ); } } - // 1025 parents do not: one reply is undecided, so nothing is exact. - let row = w.row(c).await; - assert!( - [1024, 1025] - .map(|value| json!({"status":"at_least","value":value})) - .contains(&wire(&row.unread)), - "{:?}", - row.unread - ); - assert!( - [json!({"status":"at_least","value":1}), status("unknown")].contains(&wire(&row.attention)), - "{:?}", - row.attention - ); - assert!(!row.threads.complete); } #[tokio::test] @@ -621,8 +486,7 @@ async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction( let w = World::new().await; let c = w.channel().await; let root = w.post(c, &w.peer, w.now, vec![]).await; - let reply = w - .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) .await; let mut held = w.pool.begin().await.unwrap(); // Force the real resolver to time out, then check that its outer snapshot @@ -668,5 +532,5 @@ async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction( } reader.rollback().await.unwrap(); held.rollback().await.unwrap(); - assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted")); + assert_eq!(w.counts(c).await, counts(true, 0, vec![])); } diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index f6f87d127e..136eb89148 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -4,7 +4,7 @@ use crate::{ Db, }; use buzz_core::CommunityId; -use nostr::{EventBuilder, Keys, Kind}; +use nostr::{EventBuilder, Keys, Kind, Tag}; use sqlx::PgPool; use uuid::Uuid; @@ -70,6 +70,49 @@ async fn expire(pool: &PgPool, community: CommunityId) { .unwrap(); } +async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> SidebarPage { + db.personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap() +} + +fn mark_channel(channel: Uuid, message: &nostr::Event) -> ReadIntent { + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: message.id.to_hex(), + } +} + +fn mark_thread(channel: Uuid, root: &nostr::Event, message: &nostr::Event) -> ReadIntent { + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_id: message.id.to_hex(), + } +} + +/// Address every stored community message to `actor`, so the channel's +/// mention count is exactly its unread top-level count. +async fn mention_everywhere(pool: &PgPool, community: CommunityId, actor: &Keys) { + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(serde_json::json!([["p", actor.public_key().to_hex()]])) + .execute(pool) + .await + .unwrap(); +} + async fn add_history(db: &Db, community: CommunityId, channel: Uuid, count: usize) -> nostr::Event { let author = Keys::generate(); let mut last = None; @@ -89,47 +132,24 @@ async fn add_history(db: &Db, community: CommunityId, channel: Uuid, count: usiz #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads_are_unknown() { +async fn personal_read_sidebar_marked_history_past_the_scan_cap_is_read_and_keeps_latest() { let (db, _pool, community, channel, actor, _) = fixture().await; let last = add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; - db.apply_personal_read_intent( - community, - &actor.public_key(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_id: last.id.to_hex(), - }, - ) - .await - .unwrap(); - let page = db - .personal_read_sidebar( + let outcome = db + .apply_personal_read_intent( community, &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, + &mark_channel(channel, &last), ) .await .unwrap(); - assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); - let wire = serde_json::to_value(&page).unwrap(); - assert_eq!( - wire["channels"][0]["unread"], - serde_json::json!({"status":"unknown"}) - ); - assert_eq!( - wire["channels"][0]["attention"], - serde_json::json!({"status":"unknown"}) - ); - assert_eq!( - page.channels[0].latest_message_id.as_deref(), - Some(last.id.to_hex().as_str()) - ); - assert!(page.channels[0].latest_message_complete); + assert_eq!(outcome, IntentOutcome::Applied); + // The scan never reaches the oldest 45 messages; nothing is unread. + let row = &sidebar(&db, community, &actor).await.channels[0]; + assert!(!row.unread); + assert_eq!(row.mentions, 0); + assert_eq!(row.latest_id.as_deref(), Some(last.id.to_hex().as_str())); + assert_eq!(row.read_through_id, row.latest_id); } #[tokio::test] @@ -138,50 +158,26 @@ async fn personal_read_sidebar_author_window_excludes_expired_and_late_old_messa let (db, pool, community, channel, actor, _) = fixture().await; let latest = add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; expire(&pool, community).await; - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); + let row = &sidebar(&db, community, &actor).await.channels[0]; assert!( - matches!(page.channels[0].unread, ReadCount::Exact { value: 0 }), - "an empty author-time window proves exhaustion" + !row.unread, + "an empty author-time window has nothing unread" ); - assert_eq!( - page.channels[0].latest_message_id.as_deref(), - Some(latest.id.to_hex().as_str()) - ); - assert!(page.channels[0].latest_message_complete); + assert_eq!(row.latest_id.as_deref(), Some(latest.id.to_hex().as_str())); // Acceptance time is irrelevant: a message accepted now with an author // time beyond the horizon stays out; one authored now is counted. let now = nostr::Timestamp::now().as_secs(); - for (age, unread) in [(40 * 86400, 0), (0, 1)] { + for (age, unread, mentions) in [(40 * 86400, false, 0), (0, true, 1)] { let event = EventBuilder::new(Kind::Custom(9), "accepted now") + .tags([Tag::public_key(actor.public_key())]) .custom_created_at(nostr::Timestamp::from(now - age)) .sign_with_keys(&Keys::generate()) .unwrap(); db.insert_event(community, &event, Some(channel)) .await .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!( - matches!(page.channels[0].unread, ReadCount::Exact { value } if value == unread), - "age={age}" - ); + let row = &sidebar(&db, community, &actor).await.channels[0]; + assert_eq!((row.unread, row.mentions), (unread, mentions), "age={age}"); } } @@ -191,18 +187,11 @@ async fn personal_read_sidebar_window_budget_counts_boundary_and_ineligible_tail let (db, pool, community, channel, actor, _) = fixture().await; // The fixture contributes one event, so this is exactly the evidence budget. add_history(&db, community, channel, MAX_UNREAD_SCAN - 1).await; - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!( - matches!(page.channels[0].unread, ReadCount::Exact { value } if value == MAX_UNREAD_SCAN as u32) + mention_everywhere(&pool, community, &actor).await; + let mentions = |page: SidebarPage| page.channels[0].mentions; + assert_eq!( + mentions(sidebar(&db, community, &actor).await), + MAX_UNREAD_SCAN as u32 ); let overflow = EventBuilder::new(Kind::Custom(9), "one beyond the budget") .sign_with_keys(&Keys::generate()) @@ -210,18 +199,11 @@ async fn personal_read_sidebar_window_budget_counts_boundary_and_ineligible_tail db.insert_event(community, &overflow, Some(channel)) .await .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!( - matches!(page.channels[0].unread, ReadCount::AtLeast { value } if value == MAX_UNREAD_SCAN as u32) + mention_everywhere(&pool, community, &actor).await; + // The budget bounds the count; the unexamined message is left out. + assert_eq!( + mentions(sidebar(&db, community, &actor).await), + MAX_UNREAD_SCAN as u32 ); // Expire all but 601 messages. Of these, 300 are own and 300 deleted. // Eligibility is downstream of the bounded unread window, not the old 256 cap. @@ -231,41 +213,9 @@ async fn personal_read_sidebar_window_budget_counts_boundary_and_ineligible_tail deleted_at=CASE WHEN r.n>300 AND r.n<=600 THEN now() ELSE NULL END FROM ranked r WHERE e.community_id=$1 AND e.created_at=r.created_at AND e.id=r.id") .bind(community.as_uuid()).bind(channel).bind(actor.public_key().to_bytes().as_slice()).execute(&pool).await.unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::Exact { value: 1 } - )); - // Filtering ineligible evidence must not erase the raw-window overflow. - sqlx::query( - "UPDATE events SET created_at=date_trunc('second',now()),pubkey=$2 WHERE community_id=$1 AND channel_id=$3", - ) - .bind(community.as_uuid()) - .bind(actor.public_key().to_bytes().as_slice()) - .bind(channel) - .execute(&pool) - .await - .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); + let page = sidebar(&db, community, &actor).await; + assert!(page.channels[0].unread); + assert_eq!(mentions(page), 1); } #[tokio::test] @@ -273,15 +223,7 @@ async fn personal_read_sidebar_window_budget_counts_boundary_and_ineligible_tail async fn personal_read_sidebar_is_read_only_and_does_not_wait_for_account() { let (db, pool, community, channel, actor, event) = fixture().await; unstart(&pool, community).await; - db.personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); + sidebar(&db, community, &actor).await; let count: i64 = sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") .bind(community.as_uuid()) @@ -289,33 +231,20 @@ async fn personal_read_sidebar_is_read_only_and_does_not_wait_for_account() { .await .unwrap(); assert_eq!(count, 0, "GET must not create private state"); - let mark = ReadIntent::MarkChannelRead { - channel_id: channel, - message_id: event.id.to_hex(), - }; - db.apply_personal_read_intent(community, &actor.public_key(), &mark) - .await - .unwrap(); + db.apply_personal_read_intent( + community, + &actor.public_key(), + &mark_channel(channel, &event), + ) + .await + .unwrap(); let mut held = pool.begin().await.unwrap(); sqlx::query("SELECT actor FROM personal_read_accounts WHERE community_id=$1 FOR UPDATE") .bind(community.as_uuid()) .fetch_all(&mut *held) .await .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::Exact { value: 0 } - )); + assert!(!sidebar(&db, community, &actor).await.channels[0].unread); held.rollback().await.unwrap(); } @@ -396,23 +325,11 @@ async fn personal_read_diff_alone_leaves_the_sidebar_row_unchanged() { .await .unwrap(); } - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::Exact { value: 1 } - )); + let page = sidebar(&db, community, &actor).await; + assert!(page.channels[0].unread); rows.push(serde_json::to_value(&page.channels[0]).unwrap()); } - assert_eq!(rows[0], rows[1], "not unread, not attention, not latest"); + assert_eq!(rows[0], rows[1], "not unread, not a mention, not latest"); } #[tokio::test] @@ -446,37 +363,52 @@ async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { .unwrap(); } } - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); + let latest = |page: SidebarPage| page.channels[0].latest_id.clone(); assert_eq!( - page.channels[0].latest_message_id.as_deref(), - Some(own.id.to_hex().as_str()) + latest(sidebar(&db, community, &actor).await), + Some(own.id.to_hex()) ); - assert!(page.channels[0].latest_message_complete); expire(&pool, community).await; - let page = db - .personal_read_sidebar( + assert_eq!( + latest(sidebar(&db, community, &actor).await), + Some(own.id.to_hex()) + ); +} + +/// A reply marker without canonical ancestry cannot anchor a timeline mark, so +/// it is never the channel's latest: marking through latest must succeed. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_latest_skips_a_reply_without_ancestry() { + let (db, pool, community, channel, actor, first) = fixture().await; + let orphan = EventBuilder::new(Kind::Custom(9), "reply to a missing parent") + .tags([nostr::Tag::parse(["e", &"ab".repeat(32), "", "reply"]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(first.created_at.as_secs() + 1)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &orphan, Some(channel)) + .await + .unwrap(); + let latest = |page: SidebarPage| page.channels[0].latest_id.clone(); + assert_eq!( + latest(sidebar(&db, community, &actor).await), + Some(first.id.to_hex()) + ); + // The shallow probe applies the same rule once the horizon is empty. + expire(&pool, community).await; + assert_eq!( + latest(sidebar(&db, community, &actor).await), + Some(first.id.to_hex()) + ); + let outcome = db + .apply_personal_read_intent( community, &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, + &mark_channel(channel, &first), ) .await .unwrap(); - assert_eq!( - page.channels[0].latest_message_id.as_deref(), - Some(own.id.to_hex().as_str()) - ); - assert!(page.channels[0].latest_message_complete); + assert_eq!(outcome, IntentOutcome::Applied); } #[tokio::test] @@ -497,63 +429,20 @@ async fn personal_read_latest_old_message_is_not_an_empty_channel() { .await .unwrap() .id; - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); + let page = sidebar(&db, community, &actor).await; let old = page .channels .iter() .find(|c| c.channel_id == channel) .unwrap(); - assert_eq!( - old.latest_message_id.as_deref(), - Some(event.id.to_hex().as_str()) - ); - assert!(old.latest_message_complete); - assert!(matches!(old.unread, ReadCount::Exact { value: 0 })); + assert_eq!(old.latest_id.as_deref(), Some(event.id.to_hex().as_str())); + assert!(!old.unread); let empty = page .channels .iter() .find(|c| c.channel_id == empty) .unwrap(); - assert!(empty.latest_message_id.is_none()); - assert!(empty.latest_message_complete); - // A long run of ineligible rows must instead report that latest is unknown. - sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1") - .bind(community.as_uuid()) - .execute(&pool) - .await - .unwrap(); - add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; - sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1") - .bind(community.as_uuid()) - .execute(&pool) - .await - .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - let unknown = page - .channels - .iter() - .find(|c| c.channel_id == channel) - .unwrap(); - assert!(unknown.latest_message_id.is_none()); - assert!(!unknown.latest_message_complete); + assert!(empty.latest_id.is_none()); } #[tokio::test] @@ -561,6 +450,12 @@ async fn personal_read_latest_old_message_is_not_an_empty_channel() { async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { let (db, pool, community, channel, actor, event) = fixture().await; unstart(&pool, community).await; + let read_through = || async { + sidebar(&db, community, &actor).await.channels[0] + .read_through_id + .clone() + }; + assert_eq!(read_through().await, None, "never marked"); let target = ReadTarget { channel_id: channel, root_id: None, @@ -582,10 +477,7 @@ async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { .await .unwrap(); assert_eq!(count, 0, "invalid intent rolls back account creation"); - let intent = ReadIntent::MarkThrough { - target: target.clone(), - message_id: event.id.to_hex(), - }; + let intent = mark_channel(channel, &event); for _ in 0..2 { assert_eq!( db.apply_personal_read_intent(community, &actor.public_key(), &intent) @@ -594,6 +486,7 @@ async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { IntentOutcome::Applied ); } + assert_eq!(read_through().await, Some(event.id.to_hex())); // Arrives after `event`; its earlier author time must not matter. let later = EventBuilder::new(Kind::Custom(9), "later") .custom_created_at(nostr::Timestamp::from(event.created_at.as_secs() - 10)) @@ -606,14 +499,14 @@ async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { (&later, "a later arrival advances the frontier"), (&event, "an earlier anchor applies without moving it back"), ] { - let intent = ReadIntent::MarkThrough { - target: target.clone(), - message_id: anchor.id.to_hex(), - }; assert_eq!( - db.apply_personal_read_intent(community, &actor.public_key(), &intent) - .await - .unwrap(), + db.apply_personal_read_intent( + community, + &actor.public_key(), + &mark_channel(channel, anchor) + ) + .await + .unwrap(), IntentOutcome::Applied, "{why}" ); @@ -629,6 +522,11 @@ async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { .await .unwrap(); assert!(at_later_arrival); + assert_eq!( + read_through().await, + Some(later.id.to_hex()), + "the anchor follows the frontier, not the last intent" + ); let stranger = Keys::generate(); let count: i64 = sqlx::query_scalar( "SELECT count(*) FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", @@ -736,24 +634,13 @@ async fn personal_read_counts_only_arrivals_after_the_account_starts() { unstart(&pool, community).await; let unread = |page: &SidebarPage, id: Uuid| { let row = page.channels.iter().find(|c| c.channel_id == id).unwrap(); - match row.unread { - ReadCount::Exact { value } => value, - _ => panic!("expected an exact count"), - } - }; - let sidebar = || async { - db.personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap() + (row.unread, row.mentions) }; // Before the first read intent nothing counts, not the 30-day window. - assert_eq!(unread(&sidebar().await, channel), 0); + assert_eq!( + unread(&sidebar(&db, community, &actor).await, channel), + (false, 0) + ); // The first intent, in another channel, starts the account everywhere. let other = db @@ -778,56 +665,44 @@ async fn personal_read_counts_only_arrivals_after_the_account_starts() { db.apply_personal_read_intent( community, &actor.public_key(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: other, - root_id: None, - }, - message_id: elsewhere.id.to_hex(), - }, + &mark_channel(other, &elsewhere), ) .await .unwrap(); // A channel never marked starts caught up: earlier arrivals stay read. - assert_eq!(unread(&sidebar().await, channel), 0); + assert_eq!( + unread(&sidebar(&db, community, &actor).await, channel), + (false, 0) + ); // Arrivals after the start count. let later = EventBuilder::new(Kind::Custom(9), "after the start") + .tags([Tag::public_key(actor.public_key())]) .sign_with_keys(&Keys::generate()) .unwrap(); db.insert_event(community, &later, Some(channel)) .await .unwrap(); - assert_eq!(unread(&sidebar().await, channel), 1); + assert_eq!( + unread(&sidebar(&db, community, &actor).await, channel), + (true, 1) + ); } #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_unstarted_account_is_exactly_caught_up_past_the_scan_cap() { +async fn personal_read_unstarted_account_is_caught_up_past_the_scan_cap() { let (db, pool, community, channel, actor, _) = fixture().await; unstart(&pool, community).await; let last = add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); + let page = sidebar(&db, community, &actor).await; let row = &page.channels[0]; - // The start floor covers rows the cap left unexamined: zero, not unknown. - assert!(matches!(row.unread, ReadCount::Exact { value: 0 })); - assert!(matches!(row.attention, ReadCount::Exact { value: 0 })); - assert!(row.threads.items.is_empty()); - assert!(row.threads.complete); - assert_eq!( - row.latest_message_id.as_deref(), - Some(last.id.to_hex().as_str()) - ); - assert!(row.latest_message_complete); + // The start floor covers every arrival, examined by the scan or not. + assert!(!row.unread); + assert_eq!(row.mentions, 0); + assert!(row.threads.is_empty()); + assert_eq!(row.latest_id.as_deref(), Some(last.id.to_hex().as_str())); + assert_eq!(row.read_through_id, None); } #[tokio::test] @@ -835,19 +710,28 @@ async fn personal_read_unstarted_account_is_exactly_caught_up_past_the_scan_cap( async fn personal_read_channel_and_thread_never_inherit_each_other() { let (db, pool, community, channel, actor, root) = fixture().await; let base = root.created_at.as_secs(); - // Directed, so it counts outside the actor's conversations. - let reply = EventBuilder::new(Kind::Custom(9), "unseen thread reply") - .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) - .custom_created_at(nostr::Timestamp::from(base + 10)) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &reply, Some(channel)) - .await - .unwrap(); - sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) - VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") - .bind(community.as_uuid()).bind(reply.id.as_bytes().as_slice()).bind((base+10) as f64) - .bind(channel).bind(root.id.as_bytes().as_slice()).execute(&pool).await.unwrap(); + // Directed, so they count outside the actor's conversations. + let reply_at = |at: u64| { + let (db, pool) = (db.clone(), pool.clone()); + let mention = Tag::public_key(actor.public_key()); + let root = root.id; + async move { + let reply = EventBuilder::new(Kind::Custom(9), "unseen thread reply") + .tags([mention]) + .custom_created_at(nostr::Timestamp::from(at)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reply, Some(channel)) + .await + .unwrap(); + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(reply.id.as_bytes().as_slice()).bind(at as f64) + .bind(channel).bind(root.as_bytes().as_slice()).execute(&pool).await.unwrap(); + reply + } + }; + let reply = reply_at(base + 10).await; let top = EventBuilder::new(Kind::Custom(9), "later timeline message") .custom_created_at(nostr::Timestamp::from(base + 20)) .sign_with_keys(&Keys::generate()) @@ -855,58 +739,68 @@ async fn personal_read_channel_and_thread_never_inherit_each_other() { db.insert_event(community, &top, Some(channel)) .await .unwrap(); - let channel_target = ReadTarget { - channel_id: channel, - root_id: None, + // Arrives last, and still is not the timeline's latest. + let newer = reply_at(base + 30).await; + let apply = |intent: ReadIntent| { + let db = db.clone(); + let actor = actor.public_key(); + async move { + db.apply_personal_read_intent(community, &actor, &intent) + .await + .unwrap() + } }; assert_eq!( - db.apply_personal_read_intent( - community, - &actor.public_key(), - &ReadIntent::MarkThrough { - target: channel_target.clone(), - message_id: reply.id.to_hex() - } - ) - .await - .unwrap(), - IntentOutcome::Blocked + apply(mark_channel(channel, &reply)).await, + IntentOutcome::Blocked, + "a reply is never a timeline anchor" ); - db.apply_personal_read_intent( - community, - &actor.public_key(), - &ReadIntent::MarkThrough { - target: channel_target, - message_id: top.id.to_hex(), - }, - ) - .await - .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!( - matches!(page.channels[0].unread, ReadCount::Exact { value: 1 }), - "timeline reading must leave unseen reply unread" + let row = |page: SidebarPage| serde_json::to_value(&page.channels[0]).unwrap(); + let thread = |mentions: u32, read_through: Option<&nostr::Event>| { + serde_json::json!({"root_id":root.id.to_hex(),"unread":true,"mentions":mentions, + "read_through_id":read_through.map(|e| e.id.to_hex()),"latest_id":newer.id.to_hex()}) + }; + let expected = + |read_through: Option<&nostr::Event>, unread: bool, threads: serde_json::Value| { + serde_json::json!({"channel_id":channel,"unread":unread,"mentions":0, + "read_through_id":read_through.map(|e| e.id.to_hex()), + "latest_id":top.id.to_hex(),"threads":threads}) + }; + // Unread replies show only on their thread row, never the channel's. + assert_eq!( + row(sidebar(&db, community, &actor).await), + expected(None, true, serde_json::json!([thread(2, None)])) ); + assert_eq!( + apply(mark_channel(channel, &top)).await, + IntentOutcome::Applied + ); + assert_eq!( + row(sidebar(&db, community, &actor).await), + expected(Some(&top), false, serde_json::json!([thread(2, None)])), + "timeline reading must leave unseen replies unread" + ); + // Thread marks move only the thread; an earlier anchor (the root) applies + // without moving it back. + for anchor in [&reply, &root] { + assert_eq!( + apply(mark_thread(channel, &root, anchor)).await, + IntentOutcome::Applied + ); + assert_eq!( + row(sidebar(&db, community, &actor).await), + expected( + Some(&top), + false, + serde_json::json!([thread(1, Some(&reply))]) + ) + ); + } let second_actor = Keys::generate(); db.apply_personal_read_intent( community, &second_actor.public_key(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: Some(root.id.to_hex()), - }, - message_id: reply.id.to_hex(), - }, + &mark_thread(channel, &root, &reply), ) .await .unwrap(); @@ -927,218 +821,30 @@ async fn personal_read_channel_and_thread_never_inherit_each_other() { #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers() { - let (db, pool, community, channel, actor, root) = fixture().await; - let base = root.created_at.as_secs(); - // A broadcast counts for every reader, in or out of the conversation. - let reply = EventBuilder::new(Kind::Custom(9), "thread only") - .tags([nostr::Tag::parse(["broadcast", "1"]).unwrap()]) - .custom_created_at(nostr::Timestamp::from(base + 1)) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &reply, Some(channel)) - .await - .unwrap(); - sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) - VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") - .bind(community.as_uuid()).bind(reply.id.as_bytes().as_slice()).bind((base+1) as f64) - .bind(channel).bind(root.id.as_bytes().as_slice()).execute(&pool).await.unwrap(); - let queries = vec![ - ContextQuery { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_ids: vec![root.id.to_hex(), reply.id.to_hex(), "00".repeat(32)], - }, - ContextQuery { - target: ReadTarget { - channel_id: channel, - root_id: Some(root.id.to_hex()), - }, - message_ids: vec![root.id.to_hex(), reply.id.to_hex()], - }, - ]; - let page = db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(); - let page = serde_json::to_value(page).unwrap(); - assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); - assert_eq!(page["contexts"][0]["messages"][1]["status"], "unavailable"); - assert_eq!(page["contexts"][0]["messages"][2]["status"], "unavailable"); - assert_eq!(page["contexts"][1]["messages"][0]["status"], "unavailable"); - assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); - assert_eq!(page["contexts"][1]["messages"][1]["reason"], "broadcast"); - let accounts: i64 = - sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") - .bind(community.as_uuid()) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(accounts, 1, "context GET must never create authority"); - db.apply_personal_read_intent( - community, - &actor.public_key(), - &ReadIntent::MarkThrough { - target: queries[0].target.clone(), - message_id: root.id.to_hex(), - }, - ) - .await - .unwrap(); - let page = serde_json::to_value( - db.personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!(page["contexts"][0]["messages"][0]["status"], "read"); - assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); - db.apply_personal_read_intent( - community, - &actor.public_key(), - &ReadIntent::MarkThrough { - target: queries[1].target.clone(), - message_id: reply.id.to_hex(), - }, - ) - .await - .unwrap(); - let page = serde_json::to_value( - db.personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!(page["contexts"][1]["messages"][1]["status"], "read"); - let other = Keys::generate(); - let other_page = || async { - serde_json::to_value( - db.personal_read_contexts( - community, - &other.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(), - ) - .unwrap() - }; - // Nothing counts before an account starts. - let page = other_page().await; - assert_eq!(page["contexts"][0]["messages"][0]["status"], "read"); - assert_eq!(page["contexts"][1]["messages"][1]["status"], "read"); - // Frontiers are per actor: the actor's marks never read for another. - start_before_everything(&pool, community, &other.public_key()).await; - let page = other_page().await; - assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); - assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); - assert!(db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &[] - ) - .await - .is_err()); - let oversized = vec![ContextQuery { - target: queries[0].target.clone(), - message_ids: vec![root.id.to_hex(); MAX_CONTEXT_MESSAGES + 1], - }]; - assert!(db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &oversized - ) - .await - .is_err()); - sqlx::query("UPDATE channels SET visibility='private' WHERE community_id=$1 AND id=$2") - .bind(community.as_uuid()) - .bind(channel) - .execute(&pool) - .await - .unwrap(); - let page = serde_json::to_value( - db.personal_read_contexts( - community, - &other.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!( - page["contexts"], - serde_json::json!([{"status":"unavailable"},{"status":"unavailable"}]) - ); - assert!(db - .personal_read_accessible_contexts(community, &other.public_key(), &[channel]) - .await - .unwrap() - .is_empty()); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn personal_read_context_author_horizon_unknown_ancestry_and_deletion() { +async fn personal_read_author_horizon_unresolved_ancestry_and_deletion() { let (db, pool, community, channel, actor, root) = fixture().await; + let mention = Tag::public_key(actor.public_key()); let old = EventBuilder::new(Kind::Custom(9), "directed, about to expire") - .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) + .tags([mention.clone()]) .sign_with_keys(&Keys::generate()) .unwrap(); db.insert_event(community, &old, Some(channel)) .await .unwrap(); + // Claims to reply, with no recorded ancestry: left out, though directed. let unresolved = EventBuilder::new(Kind::Custom(9), "ancestry missing") - .tags([nostr::Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap()]) + .tags([ + Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap(), + mention, + ]) .sign_with_keys(&Keys::generate()) .unwrap(); db.insert_event(community, &unresolved, Some(channel)) .await .unwrap(); - let queries = [ContextQuery { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_ids: vec![old.id.to_hex(), unresolved.id.to_hex(), root.id.to_hex()], - }]; - let page = serde_json::to_value( - db.personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); - assert_eq!(page["contexts"][0]["messages"][0]["reason"], "mention"); - assert_eq!(page["contexts"][0]["messages"][1]["status"], "unknown"); + let row = &sidebar(&db, community, &actor).await.channels[0]; + assert_eq!((row.unread, row.mentions), (true, 1)); + assert!(row.threads.is_empty()); sqlx::query( "UPDATE events SET created_at=created_at-interval '31 days' WHERE community_id=$1 AND id=$2", ) @@ -1153,112 +859,6 @@ async fn personal_read_context_author_horizon_unknown_ancestry_and_deletion() { .execute(&pool) .await .unwrap(); - let page = serde_json::to_value( - db.personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!(page["contexts"][0]["messages"][0]["status"], "not_counted"); - assert_eq!(page["contexts"][0]["messages"][2]["status"], "not_counted"); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn personal_read_covered_corruption_requires_canonical_matching_frontier() { - let (db, pool, community, channel, actor, root) = fixture().await; - let reply = EventBuilder::new(Kind::Custom(9), "canonical reply") - .custom_created_at(nostr::Timestamp::from(root.created_at.as_secs() - 1)) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &reply, Some(channel)) - .await - .unwrap(); - for (event, depth) in [(&root, 0), (&reply, 1)] { - sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) - VALUES ($1,$2,to_timestamp($3),$4,$5,$5,$6)") - .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) - .bind(event.created_at.as_secs() as f64).bind(channel) - .bind(root.id.as_bytes().as_slice()).bind(depth).execute(&pool).await.unwrap(); - } - db.apply_personal_read_intent( - community, - &actor.public_key(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_id: root.id.to_hex(), - }, - ) - .await - .unwrap(); - // Corrupt both stored payloads. Only canonical evidence plus its matching - // frontier can make their tags irrelevant, never the channel prefix alone. - sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") - .bind(community.as_uuid()) - .bind(serde_json::json!([["p", 42]])) - .execute(&pool) - .await - .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); - sqlx::query("INSERT INTO personal_read_frontiers (community_id,actor,channel_id,root_id,through_timestamp) - VALUES ($1,$2,$3,$4,now())") - .bind(community.as_uuid()).bind(actor.public_key().to_bytes().as_slice()).bind(channel) - .bind(root.id.as_bytes().as_slice()) - .execute(&pool).await.unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::Exact { value: 0 } - )); - assert!(matches!( - page.channels[0].attention, - ReadCount::Exact { value: 0 } - )); - // Removing canonical metadata must not let a channel frontier hide unknown - // ancestry/corruption, even though both frontiers cover the row. - sqlx::query("DELETE FROM thread_metadata WHERE community_id=$1 AND event_id=$2") - .bind(community.as_uuid()) - .bind(reply.id.as_bytes().as_slice()) - .execute(&pool) - .await - .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); - assert!(matches!(page.channels[0].attention, ReadCount::Unknown)); + let row = &sidebar(&db, community, &actor).await.channels[0]; + assert_eq!((row.unread, row.mentions), (false, 0)); } diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index 45dd619956..5e38d85515 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -81,22 +81,19 @@ impl Db { // The inner event LIMIT is deliberately before eligibility filtering. // This bounds rows/joins even with long deleted or self-authored runs. // Aggregate equivalent eligible evidence before transfer. Multiplicity - // preserves counts; raw scan count (not group count) proves exhaustion. - // SQL eligibility mirrors classification::eligible (PostgreSQL parity test). - // Canonical covered rows need no tags; missing ancestry stays unknown. + // preserves counts. // Validate relevant tag parts before compacting directed/ancestry facts. // PostgreSQL scalar "p" ->> 0 is "p": the type check must reject it too. // Canonical timeline roots share an empty (present) root sentinel. - // Tags are bounded before transfer; oversized/corrupt evidence stays - // unknown, never falsely top-level/unmentioned/read. + // Tags are bounded before transfer; oversized/corrupt evidence is + // skipped, never falsely top-level/unmentioned/read. // Latest comes from the unread scan, so its ID arrives no earlier than // anything counted; the shallow probe answers only when the horizon - // holds no message. Both are newest-first by author time, so the deeper - // one finds the same greatest author time whenever the probe finds any. + // holds no top-level message. Thread replies are never the timeline's + // latest: a timeline mark cannot use one as its anchor. let rows = sqlx::query( r#"WITH roster AS MATERIALIZED ( - SELECT c.id,c.name,c.channel_type::text AS channel_type, - c.archived_at IS NOT NULL AS archived, cm.hidden_at IS NOT NULL AS hidden + SELECT c.id,c.channel_type::text AS channel_type FROM channel_members cm JOIN channels c ON c.community_id=cm.community_id AND c.id=cm.channel_id WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL @@ -104,23 +101,27 @@ impl Db { AND ($9::uuid[] IS NULL OR c.id=ANY($9)) ORDER BY c.id LIMIT $4 ) - SELECT r.*, COALESCE(e.latest_message_id, latest.latest_message_id) AS latest_message_id, - COALESCE(e.latest_message_at, latest.latest_message_at) AS latest_message_at, - (COALESCE(e.latest_message_id, latest.latest_message_id) IS NOT NULL - OR latest.candidates <= $5-1) AS latest_message_complete, - e.scanned,COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r + SELECT r.*, COALESCE(e.latest_id, latest.latest_id) AS latest_id, + encode(cf.through_message_id,'hex') AS read_through_id, + COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r LEFT JOIN LATERAL ( WITH candidates AS MATERIALIZED ( - SELECT id,created_at,received_at,kind,deleted_at FROM events + SELECT id,created_at,received_at,kind,deleted_at,tags FROM events WHERE community_id=$1 AND channel_id=r.id ORDER BY created_at DESC,id LIMIT $5 ) - SELECT count(*) AS candidates, - (array_agg(encode(id,'hex') ORDER BY received_at DESC,id) - FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id, - (array_agg(extract(epoch FROM created_at)::bigint ORDER BY created_at DESC,id) - FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_at - FROM candidates + SELECT (array_agg(encode(c.id,'hex') ORDER BY c.received_at DESC,c.id) + FILTER (WHERE c.kind=ANY($6) AND c.deleted_at IS NULL + AND NOT COALESCE(tm.root_event_id<>c.id,false) + -- Same unresolved-reply test as `threaded` below. + AND NOT (tm.root_event_id IS NULL AND jsonb_typeof(c.tags)='array' + AND EXISTS (SELECT 1 FROM jsonb_array_elements(c.tags) t(tag) + WHERE jsonb_typeof(tag)='array' AND tag->>0='e' + AND tag->>3='reply' + AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$')) + ))[1] AS latest_id + FROM candidates c LEFT JOIN thread_metadata tm ON tm.community_id=$1 + AND tm.channel_id=r.id AND tm.event_created_at=c.created_at AND tm.event_id=c.id ) latest ON true LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2 AND cf.channel_id=r.id AND cf.root_id=''::bytea @@ -129,25 +130,37 @@ impl Db { SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7 ORDER BY created_at DESC,id LIMIT $8 - ), classified AS ( + ), threaded AS MATERIALIZED ( SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent, COALESCE(tm.root_event_id<>e.id,false) AS is_reply, - -- Every frontier is floored at the account's start. - COALESCE(e.received_at <= GREATEST(COALESCE($10::timestamptz,'infinity'), - CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id - THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp) - ELSE cf.through_timestamp END),false) AS covered - FROM (SELECT * FROM candidates ORDER BY created_at DESC,id LIMIT $8-1) e + -- A NIP-10 reply marker without canonical ancestry: never a + -- timeline anchor, since a mark cannot validate it. + tm.root_event_id IS NULL AND jsonb_typeof(e.tags)='array' + AND EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) t(tag) + WHERE jsonb_typeof(tag)='array' AND tag->>0='e' + AND tag->>3='reply' + AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$') + AS unresolved + FROM candidates e LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id AND tm.event_created_at=e.created_at AND tm.event_id=e.id + WHERE e.kind=ANY($6) AND e.deleted_at IS NULL + ), classified AS ( + SELECT e.*, + -- Every frontier is floored at the account's start. + COALESCE(e.received_at <= GREATEST(COALESCE($10::timestamptz,'infinity'), + CASE WHEN e.is_reply THEN tf.through_timestamp + ELSE cf.through_timestamp END),false) AS covered, + encode(tf.through_message_id,'hex') AS read_through_id + FROM threaded e LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 - AND tf.channel_id=r.id AND tf.root_id=tm.root_event_id AND tm.root_event_id<>e.id - WHERE e.kind=ANY($6) AND e.pubkey<>$2 AND e.deleted_at IS NULL + AND tf.channel_id=r.id AND tf.root_id=e.root AND e.is_reply + WHERE e.pubkey<>$2 ), grouped AS ( SELECT CASE WHEN root IS NULL THEN NULL WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root, CASE WHEN is_reply THEN encode(parent,'hex') END AS parent, - is_reply, covered, + is_reply, covered, read_through_id, -- ->>0 also selects scalar "p"/"e": reject nonarrays first. -- C collation matches Rust's ASCII case/hex rules. Reply -- markers matter only without canonical ancestry; otherwise @@ -172,29 +185,24 @@ impl Db { ORDER BY received_at DESC,id))[1] AS newest_at FROM classified WHERE NOT covered OR root IS NULL - GROUP BY 1,2,3,4,5 - ), scan AS ( - SELECT count(*) AS scanned, - (array_agg(encode(id,'hex') ORDER BY received_at DESC,id) - FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id, - max(extract(epoch FROM created_at)::bigint) - FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL) AS latest_message_at - FROM candidates + GROUP BY 1,2,3,4,5,6 ) - SELECT scan.*, (SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence FROM scan + SELECT (array_agg(encode(id,'hex') ORDER BY received_at DESC,id) + FILTER (WHERE NOT is_reply AND NOT unresolved))[1] AS latest_id, + (SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence + FROM threaded ) e ON true ORDER BY r.id"#, ).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after) - .bind((limit+1) as i64).bind((MAX_CHANNEL_SCAN+1) as i64) + .bind((limit+1) as i64).bind(MAX_CHANNEL_SCAN as i64) .bind(ELIGIBLE_KINDS.as_slice()) .bind(DateTime::from_timestamp_millis(account.cutoff_ms) .ok_or_else(|| DbError::InvalidData("invalid unread cutoff".into()))?) - .bind((MAX_UNREAD_SCAN+1) as i64) + .bind(MAX_UNREAD_SCAN as i64) .bind(only) .bind(account.started_at) .fetch_all(&mut *tx).await?; let has_more = rows.len() > limit; - // An unstarted account has read every arrival, scanned or not, so its - // counts are exactly zero whatever the scan cap left unexamined. + // An unstarted account has read every arrival, so nothing is unread. let started = account.started_at.is_some(); let mut channels = Vec::new(); let mut pending = Vec::new(); @@ -204,11 +212,9 @@ impl Db { .as_array() .ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?; let evidence = if started { evidence.as_slice() } else { &[] }; - let complete = !started || row.try_get::("scanned")? <= MAX_UNREAD_SCAN as i64; let channel_type: String = row.try_get("channel_type")?; - let mut unread = 0; - let mut attention = 0; - let mut unread_complete = complete; + let mut unread = false; + let mut mentions = 0; let mut threads: HashMap, Replies> = HashMap::new(); let mut undirected = Vec::new(); for e in evidence { @@ -217,14 +223,13 @@ impl Db { .filter(|n| *n <= MAX_UNREAD_SCAN as u64) .ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))? as u32; + // Unusable tags prove nothing: the message is left out. let Some(facts) = e["facts"].as_object() else { - unread_complete = false; continue; }; // SQL's bounded ancestry fact is parity-tested against the shared // NIP-10 parser; no raw tag payload crosses the DB boundary. if facts.get("reply_marked") == Some(&Value::Bool(true)) && e["root"].is_null() { - unread_complete = false; continue; } if e["covered"] == true { @@ -235,14 +240,13 @@ impl Db { // Roots are timeline messages; descendants belong exclusively // to their canonical thread. Never inherit the channel prefix. if e["is_reply"] != true { - unread += n; + unread = true; if directed { - attention += n; + mentions += n; } continue; } let Some(root) = e["root"].as_str().and_then(writes::event_id) else { - unread_complete = false; continue; }; let replies = Replies { @@ -255,6 +259,7 @@ impl Db { .to_owned(), ), newest_at: e["newest_at"].as_i64().ok_or_else(invalid_newest)?, + read_through_id: e["read_through_id"].as_str().map(str::to_owned), }; // A directed reply counts whatever its conversation. Any other // reply counts only in one of the actor's conversations. @@ -262,64 +267,38 @@ impl Db { count(&mut threads, root, replies); } else if let Some(parent) = e["parent"].as_str().and_then(writes::event_id) { undirected.push((root, parent, replies)); - } else { - unread_complete = false; } } - pending.push((threads, undirected, unread, attention, unread_complete)); + pending.push((threads, undirected)); channels.push(ChannelReadSummary { channel_id: row.try_get("id")?, - name: row.try_get("name")?, - channel_type, - archived: row.try_get("archived")?, - hidden: row.try_get("hidden")?, - // Counts and threads wait for conversation membership below. - unread: ReadCount::Unknown, - attention: ReadCount::Unknown, - latest_message_id: row.try_get("latest_message_id")?, - latest_message_at: row.try_get("latest_message_at")?, - latest_message_complete: row.try_get("latest_message_complete")?, - threads: ThreadSummaries { - items: Vec::new(), - complete: false, - }, + unread, + mentions, + read_through_id: row.try_get("read_through_id")?, + latest_id: row.try_get("latest_id")?, + // Threads wait for conversation membership below. + threads: Vec::new(), }); } let targets: Vec<_> = channels .iter() .zip(&pending) - .flat_map(|(channel, (_, undirected, ..))| { + .flat_map(|(channel, (_, undirected))| { undirected .iter() .map(|(_, parent, _)| (channel.channel_id, parent.clone())) }) .collect(); let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; - for (channel, (mut threads, undirected, mut unread, mut attention, mut complete)) in - channels.iter_mut().zip(pending) - { - // Relevance is decided before counts, previews and the thread cap. + for (channel, (mut threads, undirected)) in channels.iter_mut().zip(pending) { + // Relevance is decided before the thread cap. An undecided + // membership leaves the reply out. for (root, parent, replies) in undirected { - match members.get(&(channel.channel_id, parent)) { - Some(true) => count(&mut threads, root, replies), - Some(false) => {} - None => complete = false, + if members.get(&(channel.channel_id, parent)) == Some(&true) { + count(&mut threads, root, replies); } } - let mut items = Vec::with_capacity(threads.len()); - for (root, thread) in threads { - unread += thread.n; - attention += thread.n; - items.push(ThreadReadSummary { - root_id: hex::encode(root), - unread: ReadCount::from_evidence(thread.n, complete), - latest_reply_id: thread.newest.1, - latest_reply_at: thread.newest_at, - }); - } - channel.unread = ReadCount::from_evidence(unread, complete); - channel.attention = ReadCount::from_evidence(attention, complete); - channel.threads = summarize(items, complete); + channel.threads = summarize(threads); } let next_cursor = if has_more { channels.last().map(|c| c.channel_id) @@ -328,7 +307,6 @@ impl Db { }; tx.commit().await?; Ok(SidebarPage { - account, channels, next_cursor, }) @@ -343,6 +321,8 @@ struct Replies { newest: (i64, String), /// Its author seconds. newest_at: i64, + /// The thread's frontier anchor, shared by every group of the thread. + read_through_id: Option, } /// Add replies that count to their thread. @@ -369,20 +349,25 @@ fn invalid_newest() -> DbError { DbError::InvalidData("invalid thread evidence".into()) } -/// Order newest unread reply first (root ID breaks ties) and cap the list. The -/// list is complete only when evidence was exhausted and nothing was omitted. -pub(super) fn summarize( - mut items: Vec, - evidence_complete: bool, -) -> ThreadSummaries { - items.sort_unstable_by(|a, b| { - b.latest_reply_at - .cmp(&a.latest_reply_at) - .then_with(|| a.root_id.cmp(&b.root_id)) +/// Order newest unread reply first (root ID breaks ties) and cap the list. +fn summarize(threads: HashMap, Replies>) -> Vec { + let mut threads: Vec<_> = threads.into_iter().collect(); + threads.sort_unstable_by(|(a_root, a), (b_root, b)| { + b.newest_at + .cmp(&a.newest_at) + .then_with(|| a_root.cmp(b_root)) }); - let complete = evidence_complete && items.len() <= MAX_THREAD_SUMMARIES; - items.truncate(MAX_THREAD_SUMMARIES); - ThreadSummaries { items, complete } + threads + .into_iter() + .take(MAX_THREAD_SUMMARIES) + .map(|(root, thread)| ThreadReadSummary { + root_id: hex::encode(root), + unread: true, + mentions: thread.n, + read_through_id: thread.read_through_id, + latest_id: thread.newest.1, + }) + .collect() } /// Read-time horizon and the account's start, in the caller's snapshot. @@ -404,7 +389,6 @@ pub(super) async fn read_account( .await?; let cutoff: DateTime = row.try_get("cutoff")?; Ok(ReadAccount { - retention_seconds, cutoff_ms: cutoff.timestamp_millis(), started_at: row.try_get("started_at")?, }) @@ -414,26 +398,24 @@ pub(super) async fn read_account( mod tests { use super::*; - fn item(root: u8, at: i64) -> ThreadReadSummary { - ThreadReadSummary { - root_id: hex::encode([root; 32]), - unread: ReadCount::Exact { value: 1 }, - latest_reply_id: hex::encode([root; 32]), - latest_reply_at: at, + fn replies(at: i64) -> Replies { + Replies { + n: 1, + newest: (at, String::new()), + newest_at: at, + read_through_id: None, } } #[test] - fn thread_summaries_order_newest_first_break_ties_by_root_and_cap_at_five() { + fn thread_rows_order_newest_first_break_ties_by_root_and_cap_at_five() { // Literal contract boundaries deliberately do not derive from the constant. for count in [0_u8, 1, 4, 5, 6, 7] { - // Descending roots with pairwise-equal times exercise the tie-break. - let items: Vec<_> = (0..count) - .rev() - .map(|i| item(i, i64::from(i / 2))) + // Pairwise-equal times exercise the tie-break. + let threads = (0..count) + .map(|i| (vec![i; 32], replies(i64::from(i / 2)))) .collect(); - let summaries = summarize(items, true); - let roots: Vec<_> = summaries.items.iter().map(|t| t.root_id.clone()).collect(); + let roots: Vec<_> = summarize(threads).into_iter().map(|t| t.root_id).collect(); let mut expected: Vec<_> = (0..count).map(|i| (i64::from(i / 2), i)).collect(); expected.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); let expected: Vec<_> = expected @@ -442,14 +424,6 @@ mod tests { .map(|(_, i)| hex::encode([i; 32])) .collect(); assert_eq!(roots, expected, "count {count}"); - assert_eq!(summaries.complete, count <= 5, "count {count}"); } } - - #[test] - fn thread_summaries_are_incomplete_when_evidence_is() { - let summaries = summarize(vec![item(1, 1)], false); - assert_eq!(summaries.items.len(), 1); - assert!(!summaries.complete); - } } diff --git a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs index 27fdc713a0..2b4979515f 100644 --- a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs @@ -1,39 +1,37 @@ -use super::{classification, postgres_tests::fixture, *}; +use super::{postgres_tests::fixture, *}; use serde_json::json; -#[test] -fn unread_horizon_includes_its_own_cutoff() { - for (created_ms, counted) in [(999, false), (1000, true), (1001, true)] { - assert_eq!( - classification::eligible(9, false, false, created_ms, 1000), - counted - ); - } -} - #[tokio::test] #[ignore = "requires Postgres"] -async fn sidebar_sql_eligibility_matches_selector_classifier() { - let (db, pool, community, channel, actor, event) = fixture().await; - let query = [ContextQuery { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_ids: vec![event.id.to_hex()], - }]; +async fn sidebar_sql_eligibility_follows_kind_author_deletion_and_horizon() { + let (db, pool, community, _, actor, event) = fixture().await; let now = chrono::Utc::now().timestamp_millis(); let horizon = i64::from(DEFAULT_RETENTION_SECONDS) * 1000; - for kind in [9, 40002, 45001, 45003, 1, 7, 39002, 40008] { + // Addressed to the actor, so a counted message is also exactly one mention. + let tags = json!([["p", actor.public_key().to_hex()]]); + for (kind, eligible_kind) in [ + (9, true), + (40002, true), + (45001, true), + (45003, true), + (1, false), + (7, false), + (39002, false), + (40008, false), + ] { for own in [false, true] { for deleted in [false, true] { // Now, then one minute inside and one minute outside the horizon. - for age in [0, horizon - 60_000, horizon + 60_000] { + for (age, inside) in [ + (0, true), + (horizon - 60_000, true), + (horizon + 60_000, false), + ] { let created = now - age; - sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,created_at=to_timestamp($5::double precision/1000) WHERE community_id=$1") + sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,created_at=to_timestamp($5::double precision/1000),tags=$6 WHERE community_id=$1") .bind(community.as_uuid()).bind(kind) .bind(if own { actor.public_key().to_bytes() } else { event.pubkey.to_bytes() }.as_slice()) - .bind(deleted).bind(created as f64).execute(&pool).await.unwrap(); + .bind(deleted).bind(created as f64).bind(&tags).execute(&pool).await.unwrap(); let page = db .personal_read_sidebar( community, @@ -44,31 +42,11 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() { ) .await .unwrap(); - let expected = u32::from(classification::eligible( - kind, - own, - deleted, - created, - page.account.cutoff_ms, - )); - assert!( - matches!(page.channels[0].unread, ReadCount::Exact { value } if value == expected), - "kind={kind} own={own} deleted={deleted} age={age}" - ); - // The per-message selector must agree with the aggregate. - let contexts = db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &query, - ) - .await - .unwrap(); + let expected = eligible_kind && !own && !deleted && inside; + let row = &page.channels[0]; assert_eq!( - serde_json::to_value(&contexts).unwrap()["contexts"][0]["messages"][0] - ["status"], - ["not_counted", "unread"][expected as usize], + (row.unread, row.mentions), + (expected, u32::from(expected)), "kind={kind} own={own} deleted={deleted} age={age}" ); } @@ -82,27 +60,25 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() { async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() { let (db, pool, community, _, actor, _) = fixture().await; let actor_hex = actor.public_key().to_hex().to_uppercase(); - for (tags, unread, attention) in [ - (json!([]), Some(1), Some(0)), - (json!(["p"]), None, None), - (json!(["e"]), None, None), - (json!(["broadcast"]), None, None), - ( - json!([["p", actor_hex, "relay", "petname"]]), - Some(1), - Some(1), - ), - (json!([["p", "00".repeat(32)]]), Some(1), Some(0)), - (json!([["broadcast", "1", "extra"]]), Some(1), Some(1)), - (json!([["broadcast", "0"]]), Some(1), Some(0)), - (json!([["p", "00".repeat(32), 42]]), None, None), - (json!([["broadcast", "0", 42]]), None, None), - (json!([["e", "00".repeat(32), "", "root", 42]]), None, None), - (json!([["p", actor_hex], ["p", "other", 42]]), None, None), - (json!([["e", "00".repeat(32), "", "reply"]]), None, None), - (json!([["x", 42]]), Some(1), Some(0)), - (json!({"p":actor_hex}), None, None), - (json!([["p", "x".repeat(8193)]]), None, None), + // Unusable tags prove nothing: that message is left out, neither unread + // nor a mention. + for (tags, unread, mentions) in [ + (json!([]), true, 0), + (json!(["p"]), false, 0), + (json!(["e"]), false, 0), + (json!(["broadcast"]), false, 0), + (json!([["p", actor_hex, "relay", "petname"]]), true, 1), + (json!([["p", "00".repeat(32)]]), true, 0), + (json!([["broadcast", "1", "extra"]]), true, 1), + (json!([["broadcast", "0"]]), true, 0), + (json!([["p", "00".repeat(32), 42]]), false, 0), + (json!([["broadcast", "0", 42]]), false, 0), + (json!([["e", "00".repeat(32), "", "root", 42]]), false, 0), + (json!([["p", actor_hex], ["p", "other", 42]]), false, 0), + (json!([["e", "00".repeat(32), "", "reply"]]), false, 0), + (json!([["x", 42]]), true, 0), + (json!({"p":actor_hex}), false, 0), + (json!([["p", "x".repeat(8193)]]), false, 0), ] { sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") .bind(community.as_uuid()) @@ -120,19 +96,12 @@ async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() { ) .await .unwrap(); - for (actual, expected) in [ - (&page.channels[0].unread, unread), - (&page.channels[0].attention, attention), - ] { - assert!( - match (actual, expected) { - (ReadCount::Exact { value }, Some(n)) => *value == n, - (ReadCount::Unknown, None) => true, - _ => false, - }, - "tags={tags} actual={actual:?} expected={expected:?}" - ); - } + let row = &page.channels[0]; + assert_eq!( + (row.unread, row.mentions), + (unread, mentions), + "tags={tags}" + ); } } @@ -187,20 +156,14 @@ async fn sidebar_ancestry_fact_matches_shared_nip10_parser() { ) .await .unwrap(); - assert!( - if reply { - matches!(page.channels[0].unread, ReadCount::Unknown) - } else { - matches!(page.channels[0].unread, ReadCount::Exact { value: 1 }) - }, - "tags={tags:?}" - ); + // A reply marker without recorded ancestry leaves the message out. + assert_eq!(page.channels[0].unread, !reply, "tags={tags:?}"); } } #[tokio::test] #[ignore = "requires Postgres"] -async fn sidebar_directed_fact_matches_selector_classifier() { +async fn sidebar_directed_fact_follows_dm_mention_and_broadcast_rules() { let (db, pool, community, channel, actor, _) = fixture().await; let actor_hex = actor.public_key().to_hex(); let fullwidth: String = actor_hex @@ -208,18 +171,21 @@ async fn sidebar_directed_fact_matches_selector_classifier() { .map(|c| if c.is_ascii_alphabetic() { 'A' } else { c }) .collect(); assert_ne!(fullwidth, actor_hex); - let cases: Vec>> = serde_json::from_value(json!([ - [], - [["p", actor_hex]], - [["p", actor_hex.to_uppercase()]], - [["p", fullwidth]], - [["p"]], - [["broadcast", "1"]], - [["broadcast", "true"]], - [["p", "00".repeat(32)]], - [["broadcast", "0"], ["p", actor_hex.to_uppercase()]] - ])) - .unwrap(); + // Whether each tag set is directed in a stream; in a DM every one is. + let cases = [ + (json!([]), false), + (json!([["p", actor_hex]]), true), + (json!([["p", actor_hex.to_uppercase()]]), true), + (json!([["p", fullwidth]]), false), + (json!([["p"]]), false), + (json!([["broadcast", "1"]]), true), + (json!([["broadcast", "true"]]), false), + (json!([["p", "00".repeat(32)]]), false), + ( + json!([["broadcast", "0"], ["p", actor_hex.to_uppercase()]]), + true, + ), + ]; for channel_type in ["stream", "dm"] { sqlx::query( "UPDATE channels SET channel_type=$3::channel_type WHERE community_id=$1 AND id=$2", @@ -230,12 +196,11 @@ async fn sidebar_directed_fact_matches_selector_classifier() { .execute(&pool) .await .unwrap(); - for tags in &cases { - let expected = - u32::from(classification::reason(channel_type, &actor_hex, tags).is_some()); + for (tags, in_stream) in &cases { + let expected = u32::from(channel_type == "dm" || *in_stream); sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") .bind(community.as_uuid()) - .bind(json!(tags)) + .bind(tags) .execute(&pool) .await .unwrap(); @@ -249,13 +214,10 @@ async fn sidebar_directed_fact_matches_selector_classifier() { ) .await .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::Exact { value: 1 } - )); - assert!( - matches!(page.channels[0].attention, ReadCount::Exact { value } if value == expected), - "channel_type={channel_type} tags={tags:?} expected={expected}" + assert!(page.channels[0].unread); + assert_eq!( + page.channels[0].mentions, expected, + "channel_type={channel_type} tags={tags}" ); } } diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs index b8fc524859..d9ec61e042 100644 --- a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -1,4 +1,4 @@ -//! Whole-channel reads, thread summaries and targeted refresh. +//! Thread summaries, anchor validation and targeted refresh. use super::{postgres_tests::fixture, *}; use crate::{ channel::{ChannelType, ChannelVisibility}, @@ -102,105 +102,22 @@ async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent .unwrap() } -fn channel_read(channel: Uuid, message: &nostr::Event) -> ReadIntent { - ReadIntent::MarkChannelRead { - channel_id: channel, - message_id: message.id.to_hex(), - } -} - -fn exact(count: &ReadCount) -> Option { - match count { - ReadCount::Exact { value } => Some(*value), - _ => None, +fn channel_mark(channel: Uuid, message_id: String) -> ReadIntent { + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id, } } #[tokio::test] #[ignore = "requires Postgres"] -async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { - let (db, pool, community, channel, actor, root) = fixture().await; - join(&db, &pool, community, channel, &actor, &root).await; +async fn mark_through_rejects_unresolved_missing_auxiliary_and_malformed_anchors() { + let (db, _pool, community, channel, actor, root) = fixture().await; let base = root.created_at.as_secs(); - let first = reply(&db, &pool, community, channel, &root, base + 10, vec![]).await; - post(&db, community, channel, base + 20, vec![]).await; - let second = reply(&db, &pool, community, channel, &root, base + 30, vec![]).await; - - // A reply anchor is valid for the whole channel, unlike channel mark_through. - let channel_only = ReadTarget { - channel_id: channel, - root_id: None, - }; - let through_reply = ReadIntent::MarkThrough { - target: channel_only, - message_id: first.id.to_hex(), - }; - assert_eq!( - apply(&db, community, &actor, through_reply).await, - IntentOutcome::Blocked - ); - assert_eq!( - apply(&db, community, &actor, channel_read(channel, &first)).await, - IntentOutcome::Applied - ); - let row = sidebar(&db, community, &actor).await; - assert_eq!( - exact(&row.unread), - Some(2), - "top at +20 and reply at +30 remain" - ); - assert_eq!(row.threads.items.len(), 1); - assert_eq!(row.threads.items[0].latest_reply_id, second.id.to_hex()); - assert!(row.threads.complete); - - assert_eq!( - apply(&db, community, &actor, channel_read(channel, &second)).await, - IntentOutcome::Applied - ); - let row = sidebar(&db, community, &actor).await; - assert_eq!( - exact(&row.unread), - Some(0), - "every thread is covered by the cut" - ); - assert!(row.threads.items.is_empty() && row.threads.complete); - - // A reply that arrives after the cut is unread, however far backdated. - let late = reply(&db, &pool, community, channel, &root, base + 5, vec![]).await; - let newer = reply(&db, &pool, community, channel, &root, base + 40, vec![]).await; - let row = sidebar(&db, community, &actor).await; - assert_eq!(exact(&row.unread), Some(2)); - assert_eq!(row.threads.items[0].latest_reply_id, newer.id.to_hex()); - - // Contexts apply the same effective thread frontier. - let page = db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &[ContextQuery { - target: ReadTarget { - channel_id: channel, - root_id: Some(root.id.to_hex()), - }, - message_ids: vec![second.id.to_hex(), late.id.to_hex(), newer.id.to_hex()], - }], - ) - .await - .unwrap(); - let wire = serde_json::to_value(&page).unwrap(); - assert!(wire["contexts"][0].get("through_timestamp").is_none()); - assert_eq!(wire["contexts"][0]["messages"][0]["status"], "read"); - assert_eq!(wire["contexts"][0]["messages"][1]["status"], "unread"); - assert_eq!(wire["contexts"][0]["messages"][2]["status"], "unread"); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independent() { - let (db, pool, community, channel, actor, root) = fixture().await; - let base = root.created_at.as_secs(); - // Unresolved ancestry blocks ordinary marks, but not an arrival-only cut. + // Unresolved ancestry cannot be classified as top-level or reply. let orphan_parent = "ab".repeat(32); let orphan = post( &db, @@ -214,146 +131,69 @@ async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independe .apply_personal_read_intent( community, &actor.public_key(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None - }, - message_id: orphan.id.to_hex(), - }, + &channel_mark(channel, orphan.id.to_hex()), ) .await .is_err()); - assert_eq!( - apply(&db, community, &actor, channel_read(channel, &orphan)).await, - IntentOutcome::Applied - ); - - // Missing, auxiliary and malformed anchors. - let missing = ReadIntent::MarkChannelRead { - channel_id: channel, - message_id: "cd".repeat(32), - }; - assert_eq!( - apply(&db, community, &actor, missing).await, - IntentOutcome::Blocked - ); - let malformed = ReadIntent::MarkChannelRead { - channel_id: channel, - message_id: "zz".into(), - }; - assert_eq!( - apply(&db, community, &actor, malformed).await, - IntentOutcome::Invalid - ); let reaction = EventBuilder::new(Kind::Custom(7), "+") .sign_with_keys(&Keys::generate()) .unwrap(); db.insert_event(community, &reaction, Some(channel)) .await .unwrap(); - assert_eq!( - apply(&db, community, &actor, channel_read(channel, &reaction)).await, - IntentOutcome::Blocked - ); - - // Ordinary channel marks never set the whole-channel cut. - let other = Keys::generate(); - assert_eq!( - apply( - &db, - community, - &other, - ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None - }, - message_id: root.id.to_hex(), - }, - ) - .await, - IntentOutcome::Applied - ); - let cuts: Vec>> = sqlx::query_scalar( - "SELECT threads_through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", - ) - .bind(community.as_uuid()) - .bind(other.public_key().to_bytes().as_slice()) - .fetch_all(&pool) - .await - .unwrap(); - assert_eq!(cuts, vec![None]); - - // The schema admits the cut on channel rows only. - let thread_cut = sqlx::query( - "UPDATE personal_read_frontiers SET threads_through_timestamp=now() - WHERE community_id=$1 AND actor=$2", - ) - .bind(community.as_uuid()) - .bind(actor.public_key().to_bytes().as_slice()) - .execute(&pool) - .await; - assert!(thread_cut.is_ok(), "channel row accepts the cut"); - let root_id = root.id.to_hex(); - let thread = ReadTarget { - channel_id: channel, - root_id: Some(root_id), - }; - let mark = ReadIntent::MarkThrough { - target: thread, - message_id: root.id.to_hex(), - }; - assert_eq!( - apply(&db, community, &actor, mark).await, - IntentOutcome::Applied - ); - assert!(sqlx::query( - "UPDATE personal_read_frontiers SET threads_through_timestamp=now() - WHERE community_id=$1 AND actor=$2 AND root_id<>''::bytea", - ) - .bind(community.as_uuid()) - .bind(actor.public_key().to_bytes().as_slice()) - .execute(&pool) - .await - .is_err()); + for (anchor, outcome) in [ + ("cd".repeat(32), IntentOutcome::Blocked), + (reaction.id.to_hex(), IntentOutcome::Blocked), + ("zz".into(), IntentOutcome::Invalid), + ] { + assert_eq!( + apply( + &db, + community, + &actor, + channel_mark(channel, anchor.clone()) + ) + .await, + outcome, + "{anchor}" + ); + } + let row = sidebar(&db, community, &actor).await; + assert_eq!(row.read_through_id, None, "nothing was marked"); } #[tokio::test] #[ignore = "requires Postgres"] -async fn absent_whole_channel_cut_never_covers_epoch_zero_replies() { +async fn channel_frontier_never_covers_an_epoch_zero_reply() { let (db, pool, community, channel, actor, root) = fixture().await; join(&db, &pool, community, channel, &actor, &root).await; - reply(&db, &pool, community, channel, &root, 0, vec![]).await; - // A channel row exists, with no whole-channel cut: NULL must stay NULL. + let ancient = reply(&db, &pool, community, channel, &root, 0, vec![]).await; + // A channel frontier exists, with no thread frontier: absent must not + // read as epoch zero or as the channel's. apply( &db, community, &actor, - ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_id: root.id.to_hex(), - }, + channel_mark(channel, root.id.to_hex()), ) .await; - // Widen the horizon to reach the epoch, so that only a NULL cut read as - // zero could hide this reply. + // Widen the horizon to reach the epoch, so that only a misread absent + // frontier could hide this reply. let row = db .personal_read_sidebar(community, &actor.public_key(), u32::MAX, 20, None) .await .unwrap() .channels .remove(0); - assert_eq!(exact(&row.unread), Some(1), "epoch-zero reply stays unread"); - assert_eq!(row.threads.items[0].latest_reply_at, 0); + assert!(!row.unread); + assert_eq!(row.threads.len(), 1, "epoch-zero reply stays unread"); + assert_eq!(row.threads[0].mentions, 1); + assert_eq!(row.threads[0].latest_id, ancient.id.to_hex()); } #[tokio::test] #[ignore = "requires Postgres"] -async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { +async fn thread_summaries_order_cap_and_anchor() { let (db, pool, community, channel, actor, fixture_root) = fixture().await; // After the fixture root, so marking the newest root covers every root. let base = fixture_root.created_at.as_secs() + 1; @@ -368,13 +208,7 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { &db, community, &actor, - ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_id: newest_root.id.to_hex(), - }, + channel_mark(channel, newest_root.id.to_hex()), ) .await; // Threads 0 and 1 tie on newest reply time; thread 2 has one directed @@ -414,18 +248,18 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { .unwrap(); let row = sidebar(&db, community, &actor).await; - assert_eq!(exact(&row.unread), Some(8)); - assert!(!row.threads.complete, "six unread threads exceed the cap"); + // Every root is read; the eight unread replies count only on threads. + assert_eq!((row.unread, row.mentions), (false, 0)); + // The timeline's latest is its last top-level arrival, never a reply. + assert_eq!(row.latest_id, Some(newest_root.id.to_hex())); let mut tied = [roots[0].id.to_hex(), roots[1].id.to_hex()]; tied.sort(); - let order: Vec<_> = row - .threads - .items - .iter() - .map(|t| t.root_id.clone()) - .collect(); + let order = |row: &ChannelReadSummary| -> Vec<_> { + row.threads.iter().map(|t| t.root_id.clone()).collect() + }; + // Six unread threads exceed the cap of five. assert_eq!( - order, + order(&row), vec![ tied[0].clone(), tied[1].clone(), @@ -434,39 +268,33 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { roots[4].id.to_hex(), ] ); - let third = &row.threads.items[2]; - assert_eq!(exact(&third.unread), Some(3)); - assert_eq!(third.latest_reply_at, (base + 140) as i64); + let third = &row.threads[2]; + assert_eq!(third.mentions, 3); assert_eq!( - third.latest_reply_id, + third.latest_id, std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()), "equal arrivals break toward the smaller ID" ); - // The row's anchor is its last arrival; its activity is the greatest - // author time, another message's. - assert_eq!(row.latest_message_id.as_ref(), Some(&third.latest_reply_id)); - assert_eq!(row.latest_message_at, Some((base + 150) as i64)); - // Reading one listed thread through its anchor completes the list. - let first = row.threads.items[0].clone_target(channel); + // Reading one listed thread through its anchor lists the sixth. + let first = row.threads[0].clone_target(channel); assert_eq!( apply(&db, community, &actor, first).await, IntentOutcome::Applied ); let row = sidebar(&db, community, &actor).await; - assert!(row.threads.complete); - assert_eq!(row.threads.items.len(), 5); - let sum: u32 = row - .threads - .items - .iter() - .map(|t| exact(&t.unread).unwrap()) - .sum(); assert_eq!( - Some(sum), - exact(&row.unread), - "complete summaries account for every reply" + order(&row), + vec![ + tied[1].clone(), + roots[2].id.to_hex(), + roots[3].id.to_hex(), + roots[4].id.to_hex(), + roots[5].id.to_hex(), + ] ); + let mentions: Vec<_> = row.threads.iter().map(|t| t.mentions).collect(); + assert_eq!(mentions, [1, 3, 1, 1, 1]); } impl ThreadReadSummary { @@ -476,7 +304,7 @@ impl ThreadReadSummary { channel_id: channel, root_id: Some(self.root_id.clone()), }, - message_id: self.latest_reply_id.clone(), + message_id: self.latest_id.clone(), } } } @@ -500,44 +328,20 @@ async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() { let elsewhere = reply(&db, &pool, community, channel, &root, base + 20, mention()).await; let row = sidebar(&db, community, &actor).await; - assert_eq!(exact(&row.unread), Some(3), "the root and two replies"); - assert_eq!(row.threads.items.len(), 2); - let listed = &row.threads.items[1]; + // The undirected fixture root is unread; the directed diff is not counted. + assert_eq!((row.unread, row.mentions), (true, 0)); + assert_eq!(row.threads.len(), 2); + let listed = &row.threads[1]; assert_eq!(listed.root_id, diff.id.to_hex()); - assert_eq!(listed.latest_reply_id, on_diff.id.to_hex()); - - // The listed thread is a usable context; the diff stays uncounted. - let thread = ReadTarget { - channel_id: channel, - root_id: Some(listed.root_id.clone()), - }; - let timeline = ReadTarget { - channel_id: channel, - root_id: None, - }; - let queries = [(&thread, &on_diff), (&timeline, &diff)].map(|(target, message)| ContextQuery { - target: target.clone(), - message_ids: vec![message.id.to_hex()], - }); - let page = db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(); - let wire = serde_json::to_value(&page).unwrap(); - assert_eq!( - wire["contexts"][0]["messages"][0], - serde_json::json!({"message_id":on_diff.id.to_hex(),"status":"unread","reason":"mention"}) - ); - assert_eq!(wire["contexts"][1]["messages"][0]["status"], "not_counted"); + assert_eq!(listed.latest_id, on_diff.id.to_hex()); + assert_eq!(listed.mentions, 1); // The diff is still no anchor; the listed reply reads its thread alone. let through_diff = ReadIntent::MarkThrough { - target: thread, + target: ReadTarget { + channel_id: channel, + root_id: Some(listed.root_id.clone()), + }, message_id: diff.id.to_hex(), }; assert_eq!( @@ -549,49 +353,9 @@ async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() { IntentOutcome::Applied ); let row = sidebar(&db, community, &actor).await; - assert_eq!(exact(&row.unread), Some(2)); - assert_eq!(row.threads.items.len(), 1); - assert_eq!(row.threads.items[0].latest_reply_id, elsewhere.id.to_hex()); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn thread_summaries_are_incomplete_when_tag_evidence_could_hide_a_root() { - let (db, pool, community, channel, actor, root) = fixture().await; - join(&db, &pool, community, channel, &actor, &root).await; - let listed = reply( - &db, - &pool, - community, - channel, - &root, - root.created_at.as_secs() + 1, - vec![], - ) - .await; - let hidden = post( - &db, - community, - channel, - root.created_at.as_secs() + 2, - vec![], - ) - .await; - sqlx::query("UPDATE events SET tags=$3 WHERE community_id=$1 AND id=$2") - .bind(community.as_uuid()) - .bind(hidden.id.as_bytes().as_slice()) - .bind(serde_json::json!([["e", "x".repeat(9000)]])) - .execute(&pool) - .await - .unwrap(); - let row = sidebar(&db, community, &actor).await; - assert_eq!(row.threads.items.len(), 1); - assert_eq!(row.threads.items[0].latest_reply_id, listed.id.to_hex()); - assert!(!row.threads.complete); - assert!(!matches!( - row.threads.items[0].unread, - ReadCount::Exact { .. } - )); + assert_eq!((row.unread, row.mentions), (true, 0)); + assert_eq!(row.threads.len(), 1); + assert_eq!(row.threads[0].latest_id, elsewhere.id.to_hex()); } #[tokio::test] diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index 448e3f5367..9d4c4a6586 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -128,25 +128,6 @@ async fn message( .transpose() } -/// An eligible-kind message's arrival time, deleted or not, without tags. -async fn anchor_received_at( - conn: &mut PgConnection, - community: CommunityId, - channel: Uuid, - id: &[u8], -) -> Result>> { - Ok(sqlx::query_scalar( - "SELECT received_at FROM events - WHERE community_id=$1 AND channel_id=$2 AND id=$3 AND kind=ANY($4) LIMIT 1", - ) - .bind(community.as_uuid()) - .bind(channel) - .bind(id) - .bind(ELIGIBLE_KINDS.as_slice()) - .fetch_optional(conn) - .await?) -} - pub(super) async fn valid_target( conn: &mut PgConnection, community: CommunityId, @@ -177,20 +158,27 @@ pub(super) async fn valid_target( Ok(Some(root)) } +/// Advance a monotone frontier. The anchor ID follows the greatest arrival; +/// an equal arrival keeps the existing anchor. async fn frontier( conn: &mut PgConnection, community: CommunityId, actor: &[u8], target: &ReadTarget, root: &[u8], - through: DateTime, + msg: &Message, ) -> Result<()> { sqlx::query( "INSERT INTO personal_read_frontiers - (community_id, actor, channel_id, root_id, through_timestamp) VALUES ($1,$2,$3,$4,$5) + (community_id, actor, channel_id, root_id, through_timestamp, through_message_id) + VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE - SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp)", - ).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root).bind(through) + SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp), + through_message_id=CASE + WHEN EXCLUDED.through_timestamp > personal_read_frontiers.through_timestamp + THEN EXCLUDED.through_message_id ELSE personal_read_frontiers.through_message_id END", + ).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root) + .bind(msg.received_at).bind(&msg.id) .execute(&mut *conn).await?; Ok(()) } @@ -226,31 +214,7 @@ pub(super) async fn apply( { return Ok(IntentOutcome::Blocked); } - frontier(conn, community, actor, target, &root, msg.received_at).await?; - } - ReadIntent::MarkChannelRead { - channel_id, - message_id, - } => { - let Some(id) = event_id(message_id) else { - return Ok(IntentOutcome::Invalid); - }; - if !access(conn, community, actor, *channel_id).await? { - return Ok(IntentOutcome::Blocked); - } - // Only the anchor's arrival matters: ancestry cannot change which - // messages a whole-channel cut covers. - let Some(through) = anchor_received_at(conn, community, *channel_id, &id).await? else { - return Ok(IntentOutcome::Blocked); - }; - sqlx::query( - "INSERT INTO personal_read_frontiers (community_id, actor, channel_id, root_id, - through_timestamp, threads_through_timestamp) VALUES ($1,$2,$3,''::bytea,$4,$4) - ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE - SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, $4), - threads_through_timestamp=GREATEST(personal_read_frontiers.threads_through_timestamp, $4)", - ).bind(community.as_uuid()).bind(actor).bind(channel_id).bind(through) - .execute(&mut *conn).await?; + frontier(conn, community, actor, target, &root, &msg).await?; } } Ok(IntentOutcome::Applied) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5cb515a5b2..09c0e31c2b 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -6068,35 +6068,18 @@ pub(crate) mod postgres_tests { } // All accessory methods must preserve the shared admission wire contract. - // Keep each route independent so the unfixed adapter fails all three tests. + // Keep each route independent so the unfixed adapter fails both tests. #[tokio::test] #[ignore = "requires Postgres"] async fn nip_fi_buzz_v1_sidebar_wire_contract() { buzz_v1_wire_contract("GET", "/buzz/v1/me/sidebar?limit=1", b"").await; } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn nip_fi_buzz_v1_contexts_wire_contract() { - let targets = serde_json::json!([{"target":{"channel_id":uuid::Uuid::new_v4()}}]); - let encoded: String = targets - .to_string() - .bytes() - .map(|b| format!("%{b:02X}")) - .collect(); - buzz_v1_wire_contract( - "GET", - &format!("/buzz/v1/me/read-state?targets={encoded}"), - b"", - ) - .await; - } - #[tokio::test] #[ignore = "requires Postgres"] async fn nip_fi_buzz_v1_write_wire_contract() { let body = serde_json::to_vec(&serde_json::json!({"intents":[{ - "type":"mark_channel_read", "channel_id":uuid::Uuid::new_v4(), + "type":"mark_through", "target":{"channel_id":uuid::Uuid::new_v4()}, "message_id":"ab".repeat(32) }]})) .expect("serialize intent"); @@ -6145,25 +6128,13 @@ pub(crate) mod postgres_tests { assert_eq!(headers[header::CACHE_CONTROL], "private, no-store"); let value: serde_json::Value = serde_json::from_slice(&body).expect("control JSON"); if method == "POST" { + // A blocked intent applies to no channel, so no row is refreshed. assert_eq!( body.as_ref(), - br#"{"outcomes":[{"status":"blocked"}],"projection_status":"not_requested"}"# + br#"{"channels":[],"outcomes":[{"status":"blocked"}]}"# ); } else { - assert!(value["account"]["cutoff_ms"].is_i64()); - assert_eq!( - value["account"]["retention_seconds"], - state.config.buzz_v1_retention_seconds - ); - if path.contains("sidebar") { - assert_eq!(value["channels"], serde_json::json!([])); - assert_eq!(value["next_cursor"], serde_json::Value::Null); - } else { - assert_eq!( - value["contexts"], - serde_json::json!([{"status":"unavailable"}]) - ); - } + assert_eq!(value, serde_json::json!({"channels":[],"next_cursor":null})); } for case in [ diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index 01e0e4110e..b627677742 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -6,10 +6,12 @@ use axum::{ http::HeaderMap, response::Response, }; -use buzz_db::personal_read::{ReadIntent, MAX_CHANNELS, MAX_INTENTS}; +use buzz_db::personal_read::{ + ChannelReadSummary, ReadIntent, SidebarPage, MAX_CHANNELS, MAX_INTENTS, +}; use serde::Deserialize; use serde_json::{json, Value}; -use std::{sync::Arc, time::Duration}; +use std::{collections::BTreeSet, sync::Arc, time::Duration}; use uuid::Uuid; /// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid. @@ -72,26 +74,63 @@ pub(super) async fn sidebar( } } .map_err(|_| Error::unavailable())?; - auth::recheck(&state, &headers, &principal).await?; - let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect(); - let memberships = state - .db - .membership_pairs( - principal.tenant.community(), - &channels, - &[principal.actor.to_bytes().to_vec()], - ) - .await - .map_err(|_| Error::unavailable())?; - if memberships.len() != channels.len() { - return Err(Error::unavailable()); - } - auth::response(page) + auth::response(release(&state, &headers, &principal, page).await?) }) .await .map_err(|_| Error::unavailable())? } +/// Release projected rows only after admission and the actor's membership of +/// every row are rechecked outside the projection snapshot. +async fn release( + state: &AppState, + headers: &HeaderMap, + principal: &auth::Principal, + page: SidebarPage, +) -> Result { + auth::recheck(state, headers, principal).await?; + let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect(); + let memberships = state + .db + .membership_pairs( + principal.tenant.community(), + &channels, + &[principal.actor.to_bytes().to_vec()], + ) + .await + .map_err(|_| Error::unavailable())?; + if memberships.len() != channels.len() { + return Err(Error::unavailable()); + } + Ok(page) +} + +/// Updated rows for the channels a batch applied to. A channel the actor has +/// not joined has no row. +async fn refreshed( + state: &AppState, + headers: &HeaderMap, + principal: &auth::Principal, + channels: &BTreeSet, +) -> Result, Error> { + let channels: Vec<_> = channels.iter().copied().collect(); + let mut rows = Vec::with_capacity(channels.len()); + for chunk in channels.chunks(MAX_CHANNELS) { + let page = state + .db + .personal_read_sidebar_channels( + principal.tenant.community(), + &principal.actor, + state.config.buzz_v1_retention_seconds, + chunk, + ) + .await + .map_err(|_| Error::unavailable())?; + rows.extend(release(state, headers, principal, page).await?.channels); + } + Ok(rows) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Batch { @@ -114,6 +153,7 @@ pub(super) async fn write( } let deadline = tokio::time::Instant::now() + Duration::from_secs(8); let mut outcomes = Vec::with_capacity(batch.intents.len()); + let mut applied = BTreeSet::new(); for item in batch.intents { let Ok(intent) = serde_json::from_value::(item) else { outcomes.push(json!({"status":"invalid"})); @@ -121,83 +161,29 @@ pub(super) async fn write( }; // A deadline/DB failure after commit is ambiguous, not a false failure. // Earlier acknowledged commits survive all later projection/item failures. - outcomes.push( - tokio::time::timeout_at( - deadline, - write_intent(&state, &headers, &principal, &intent), - ) - .await - .unwrap_or_else(|_| json!({"status":"unknown","retryable":true})), - ); + let outcome = tokio::time::timeout_at( + deadline, + write_intent(&state, &headers, &principal, &intent), + ) + .await + .unwrap_or_else(|_| json!({"status":"unknown","retryable":true})); + if outcome["status"] == "applied" { + let ReadIntent::MarkThrough { target, .. } = &intent; + applied.insert(target.channel_id); + } + outcomes.push(outcome); } - auth::response(json!({"outcomes":outcomes,"projection_status":"not_requested"})) -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct ContextQuery { - // JSON array carried as one URL-encoded, signed query parameter. - targets: String, -} - -pub(super) async fn contexts( - State(state): State>, - headers: HeaderMap, - OriginalUri(uri): OriginalUri, - query: Result, axum::extract::rejection::QueryRejection>, -) -> Result { - use buzz_db::personal_read::{ - ContextQuery as Target, ContextState, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, - }; - tokio::time::timeout(Duration::from_secs(8), async { - if uri.to_string().len() > 16 * 1024 { - return Err(Error::invalid()); - } - let principal = auth::authorize(&state, &headers, &uri, "GET", None).await?; - let Query(query) = query.map_err(|_| Error::invalid())?; - let targets: Vec = - serde_json::from_str(&query.targets).map_err(|_| Error::invalid())?; - let valid_id = |id: &str| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit()); - if targets.is_empty() - || targets.len() > MAX_CONTEXTS - || targets.iter().map(|t| t.message_ids.len()).sum::() > MAX_CONTEXT_MESSAGES - || targets.iter().any(|t| { - t.message_ids.iter().any(|id| !valid_id(id)) - || t.target.root_id.as_deref().is_some_and(|id| !valid_id(id)) - }) - { - return Err(Error::invalid()); - } - let mut page = state - .db - .personal_read_contexts( - principal.tenant.community(), - &principal.actor, - state.config.buzz_v1_retention_seconds, - &targets, - ) - .await - .map_err(|_| Error::unavailable())?; - auth::recheck(&state, &headers, &principal).await?; - let channels: Vec<_> = targets.iter().map(|t| t.target.channel_id).collect(); - let allowed = state - .db - .personal_read_accessible_contexts( - principal.tenant.community(), - &principal.actor, - &channels, - ) - .await - .map_err(|_| Error::unavailable())?; - for (target, result) in targets.iter().zip(&mut page.contexts) { - if !allowed.contains(&target.target.channel_id) { - *result = ContextState::Unavailable; - } - } - auth::response(page) - }) + let mut body = json!({ "outcomes": outcomes }); + // Committed outcomes stand even when their rows cannot be read: omit them. + if let Ok(Ok(channels)) = tokio::time::timeout( + Duration::from_secs(8), + refreshed(&state, &headers, &principal, &applied), + ) .await - .map_err(|_| Error::unavailable())? + { + body["channels"] = json!(channels); + } + auth::response(body) } // Preserve earlier committed outcomes while distinguishing a definite denial diff --git a/crates/buzz-relay/src/api/buzz_v1/mod.rs b/crates/buzz-relay/src/api/buzz_v1/mod.rs index 63265f1c02..d462ac15aa 100644 --- a/crates/buzz-relay/src/api/buzz_v1/mod.rs +++ b/crates/buzz-relay/src/api/buzz_v1/mod.rs @@ -4,7 +4,10 @@ mod auth; mod handlers; use crate::state::AppState; -use axum::{routing::get, Router}; +use axum::{ + routing::{get, post}, + Router, +}; use std::sync::Arc; /// Public accessory prefix, shared by routing and capability discovery. @@ -14,10 +17,7 @@ pub const BASE_PATH: &str = "/buzz/v1"; pub fn router(state: Arc) -> Router { Router::new() .route("/me/sidebar", get(handlers::sidebar)) - .route( - "/me/read-state", - get(handlers::contexts).post(handlers::write), - ) + .route("/me/read-state", post(handlers::write)) .fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") }) .with_state(state) } diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index 49f3de7f55..6394272679 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -191,9 +191,20 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() { .insert_event(community, &event, Some(channel)) .await .unwrap(); + state + .db + .add_member( + community, + channel, + &other.public_key().to_bytes(), + buzz_db::channel::MemberRole::Member, + None, + ) + .await + .unwrap(); let write_path = "/buzz/v1/me/read-state"; - let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_channel_read", - "channel_id":channel,"message_id":event.id.to_hex()}]})) + let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_through", + "target":{"channel_id":channel},"message_id":event.id.to_hex()}]})) .unwrap(); let missing_hash = proof(&actor, &host, write_path, "POST", None); assert_eq!( @@ -228,32 +239,30 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() { assert_eq!(applied.0, StatusCode::OK, "{}", applied.1); assert_eq!(applied.1["outcomes"][0]["status"], "applied"); // The frontier belongs to the signer alone. - let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]); - let targets: String = targets - .to_string() - .bytes() - .map(|b| format!("%{b:02X}")) - .collect(); - let path = format!("/buzz/v1/me/read-state?targets={targets}"); + let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}"); start_before_everything(&state, community, &other).await; - for (key, status) in [(&actor, "read"), (&other, "unread")] { + for (key, unread, read_through) in [ + (&actor, false, json!(event.id.to_hex())), + (&other, true, Value::Null), + ] { let auth = proof(key, &host, &path, "GET", None); let page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; assert_eq!(page.0, StatusCode::OK, "{}", page.1); - assert_eq!(page.1["contexts"][0]["messages"][0]["status"], status); + assert_eq!(page.1["channels"][0]["unread"], unread, "{}", page.1); + assert_eq!(page.1["channels"][0]["read_through_id"], read_through); } } #[tokio::test] #[ignore = "requires Postgres"] -async fn accessory_context_get_signed_query_and_independent_batch_outcomes() { +async fn accessory_batch_outcomes_are_independent_and_return_applied_rows() { let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() .await .unwrap(); let mut state = (*fixture).clone(); Arc::make_mut(&mut state.config).buzz_v1_enabled = true; let state = Arc::new(state); - let host = format!("bff-context-{}.local", uuid::Uuid::new_v4()); + let host = format!("bff-batch-{}.local", uuid::Uuid::new_v4()); let community = state .db .ensure_configured_community(&host) @@ -261,76 +270,95 @@ async fn accessory_context_get_signed_query_and_independent_batch_outcomes() { .unwrap() .id; let actor = Keys::generate(); - let channel = state - .db - .create_channel( - community, - "context", - buzz_db::channel::ChannelType::Stream, - buzz_db::channel::ChannelVisibility::Open, - None, - &actor.public_key().to_bytes(), - None, - ) - .await - .unwrap() - .id; - let event = EventBuilder::new(Kind::Custom(9), "message selector") - .sign_with_keys(&Keys::generate()) - .unwrap(); - state - .db - .insert_event(community, &event, Some(channel)) - .await - .unwrap(); - let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]); - let encode = |value: &str| { - value - .bytes() - .map(|b| format!("%{b:02X}")) - .collect::() + let create = |name: &'static str, owner: &Keys| { + let (state, owner) = (state.clone(), owner.public_key().to_bytes()); + async move { + state + .db + .create_channel( + community, + name, + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .unwrap() + .id + } }; - let path = format!( - "/buzz/v1/me/read-state?targets={}", - encode(&targets.to_string()) - ); + let channel = create("joined", &actor).await; + // Open, so marking it applies, but never joined. + let unjoined = create("open, not joined", &Keys::generate()).await; + let post = |channel| { + let state = state.clone(); + async move { + let event = EventBuilder::new(Kind::Custom(9), "read state") + .sign_with_keys(&Keys::generate()) + .unwrap(); + state + .db + .insert_event(community, &event, Some(channel)) + .await + .unwrap(); + event.id.to_hex() + } + }; + let (event, elsewhere) = (post(channel).await, post(unjoined).await); start_before_everything(&state, community, &actor).await; + let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}"); let auth = proof(&actor, &host, &path, "GET", None); let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; assert_eq!(result.0, StatusCode::OK, "{}", result.1); - assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "unread"); - let auth = proof(&actor, &host, "/buzz/v1/me/read-state", "GET", None); - assert_eq!( - request(state.clone(), &host, &path, "GET", Some(&auth), b"") - .await - .0, - StatusCode::UNAUTHORIZED - ); - let bad_path = "/buzz/v1/me/read-state?targets=invalid"; - let auth = proof(&actor, &host, bad_path, "GET", None); - assert_eq!( - request(state.clone(), &host, bad_path, "GET", Some(&auth), b"") - .await - .0, - StatusCode::BAD_REQUEST - ); + let unread = json!({"channel_id":channel,"unread":true,"mentions":0, + "read_through_id":null,"latest_id":event,"threads":[]}); + assert_eq!(result.1["channels"], json!([unread])); let write_path = "/buzz/v1/me/read-state"; let body = serde_json::to_vec(&json!({"intents":[ {"type":"unknown"}, - {"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event.id.to_hex()}, - {"type":"mark_through","target":{"channel_id":channel},"message_id":event.id.to_hex()}, - {"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"} - ]})).unwrap(); + {"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event}, + {"type":"mark_through","target":{"channel_id":channel},"message_id":event}, + {"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"}, + {"type":"mark_through","target":{"channel_id":channel},"message_id":event}, + {"type":"mark_through","target":{"channel_id":unjoined},"message_id":elsewhere} + ]})) + .unwrap(); let auth = proof(&actor, &host, write_path, "POST", Some(&body)); let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await; assert_eq!(result.0, StatusCode::OK, "{}", result.1); assert_eq!( result.1["outcomes"], - json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},{"status":"invalid"}]) + json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"}, + {"status":"invalid"},{"status":"applied"},{"status":"applied"}]) ); - let auth = proof(&actor, &host, &path, "GET", None); - let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; - assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "read"); + // One updated row per applied channel; the blocked and the unjoined + // channels have none. + let read = json!({"channel_id":channel,"unread":false,"mentions":0, + "read_through_id":event,"latest_id":event,"threads":[]}); + assert_eq!(result.1["channels"], json!([read])); + + // The removed context read is not served, however well signed. + let targets = json!([{"target":{"channel_id":channel},"message_ids":[event]}]); + let targets: String = targets + .to_string() + .bytes() + .map(|b| format!("%{b:02X}")) + .collect(); + let path = format!("/buzz/v1/me/read-state?targets={targets}"); + let response = crate::router::build_router(state.clone()) + .oneshot( + Request::builder() + .uri(&path) + .header("host", &host) + .header("authorization", proof(&actor, &host, &path, "GET", None)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(response.status(), StatusCode::OK); } #[tokio::test] @@ -379,8 +407,11 @@ async fn accessory_write_revocation_is_terminal_before_persistence() { .insert_event(community, &event, Some(channel)) .await .unwrap(); - let intent = buzz_db::personal_read::ReadIntent::MarkChannelRead { - channel_id: channel, + let intent = buzz_db::personal_read::ReadIntent::MarkThrough { + target: buzz_db::personal_read::ReadTarget { + channel_id: channel, + root_id: None, + }, message_id: event.id.to_hex(), }; let path = "/buzz/v1/me/read-state"; @@ -456,19 +487,13 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { "enabled={enabled} known_host={known_host} path={path}" ); if enabled && known_host { - let d = &doc["buzz_v1"]; - assert_eq!(d["version"], 1); - assert_eq!(d["base_path"], "/buzz/v1"); - assert_eq!(d["retention_seconds"], 1234); - assert_eq!(d["max_channels"], buzz_db::personal_read::MAX_CHANNELS); - assert_eq!(d["max_intents"], buzz_db::personal_read::MAX_INTENTS); - assert_eq!(d["max_contexts"], buzz_db::personal_read::MAX_CONTEXTS); + // Exactly these fields: context and thread-cap limits are gone. assert_eq!( - d["max_context_messages"], - buzz_db::personal_read::MAX_CONTEXT_MESSAGES + doc["buzz_v1"], + json!({"version":1,"base_path":"/buzz/v1","retention_seconds":1234, + "max_channels":20,"max_intents":100, + "eligible_kinds":[9, 40002, 45001, 45003]}) ); - assert_eq!(d["max_thread_summaries"], 5); - assert_eq!(d["eligible_kinds"], json!([9, 40002, 45001, 45003])); } } } @@ -613,8 +638,12 @@ async fn signed_sidebar_deletion(deletion_kind: u16) { .await .unwrap(); } + // Addressed to the reader, so its mention count pins the exact number. let message = EventBuilder::new(Kind::Custom(9), "unread message to delete") - .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) + .tags([ + Tag::parse(["h", &channel.to_string()]).unwrap(), + Tag::public_key(reader.public_key()), + ]) .sign_with_keys(&author) .unwrap(); let mut tags = vec![Tag::parse(["e", &message.id.to_hex()]).unwrap()]; @@ -662,8 +691,8 @@ async fn signed_sidebar_deletion(deletion_kind: u16) { assert_eq!(channels.len(), 1, "{sidebar}"); assert_eq!(channels[0]["channel_id"], channel.to_string()); assert_eq!( - channels[0]["unread"], - json!({"status":"exact", "value":count}), + (&channels[0]["unread"], &channels[0]["mentions"]), + (&json!(count == 1), &json!(count)), "kind {deletion_kind}, after kind {}: {sidebar}", event.kind.as_u16() ); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 3ef9524887..abd3d258e3 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -94,12 +94,6 @@ pub struct BuzzV1Descriptor { pub max_channels: usize, /// Maximum independent write intents per request. pub max_intents: usize, - /// Maximum explicit contexts per read request. - pub max_contexts: usize, - /// Maximum message selectors across one context request. - pub max_context_messages: usize, - /// Maximum unread-thread summaries per sidebar channel row. - pub max_thread_summaries: usize, /// Message kinds that count as unread and as latest activity. Clients /// classify live arrivals with this set instead of keeping a copy. pub eligible_kinds: [i32; 4], @@ -389,19 +383,13 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st ); if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { if state.config.buzz_v1_enabled { - use buzz_db::personal_read::{ - ELIGIBLE_KINDS, MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS, - MAX_THREAD_SUMMARIES, - }; + use buzz_db::personal_read::{ELIGIBLE_KINDS, MAX_CHANNELS, MAX_INTENTS}; info.buzz_v1 = Some(BuzzV1Descriptor { version: 1, base_path: crate::api::buzz_v1::BASE_PATH.to_owned(), retention_seconds: state.config.buzz_v1_retention_seconds, max_channels: MAX_CHANNELS, max_intents: MAX_INTENTS, - max_contexts: MAX_CONTEXTS, - max_context_messages: MAX_CONTEXT_MESSAGES, - max_thread_summaries: MAX_THREAD_SUMMARIES, eligible_kinds: ELIGIBLE_KINDS, }); } diff --git a/docs/buzz-v1-extension-design.md b/docs/buzz-v1-extension-design.md index 51a6448b3b..eef9afb74a 100644 --- a/docs/buzz-v1-extension-design.md +++ b/docs/buzz-v1-extension-design.md @@ -3,12 +3,12 @@ Design guidance for follow-up work, not implemented wire capabilities. The current [API contract](buzz-v1-read-state.md) remains authoritative. -Thread summaries are implemented (see `threads` in the API contract). Richer +Thread rows are implemented (see `threads` in the API contract). Richer previews that carry signed-event content should be opt-in and preserve the base sidebar's work/byte budgets; fetching detail separately remains valid. Reuse canonical classification and read frontiers, not a second definition of unread. Historical-mention eligibility -or mute-aware attention requires an explicit policy contract, not an unnoticed -change to today's `attention` field. +or mute-aware mentions requires an explicit policy contract, not an unnoticed +change to today's `mentions` field. Channel-content revisions, personal-state revisions and synchronized preferences are separate from read frontiers. Future revisions must describe the same diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index befd690f29..b71720b6c9 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -12,8 +12,7 @@ application/nostr+json`, or `GET /info`) includes `buzz_v1` only when enabled: ```json {"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000, -"max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100, -"max_thread_summaries":5,"eligible_kinds":[9,40002,45001,45003]}} +"max_channels":20,"max_intents":100,"eligible_kinds":[9,40002,45001,45003]}} ``` Use the requesting origin plus this relative prefix. Discovery is a configured @@ -50,14 +49,9 @@ Unknown request fields are rejected. Never turn a transport failure into read. ## Sidebar `GET /buzz/v1/me/sidebar?limit=20&cursor=` returns -`account`, `channels`, and `next_cursor`. Omit the cursor on the first request. -Each channel includes identity/name/type, archived and hidden flags, `unread`, -`attention`, `latest_message_id` (the last eligible message to arrive: the -row's read anchor, see [Order](#order)), `latest_message_at` (the greatest -author time among eligible messages, in seconds: display activity, not -necessarily that message's own time; null exactly when the ID is null), -`latest_message_complete`, and `threads`. Only joined, nondeleted channels are -listed. Hidden/archived presentation remains client-owned. Each page has a +`channels` and `next_cursor`. Omit the cursor on the first request. Only +joined, nondeleted channels are listed; names, types, archived and hidden state +come from Nostr channel metadata, not this API. Each page has a writer-consistent snapshot; separate pages do not share a snapshot, and an unfinished traversal cannot prove channel removal. @@ -67,65 +61,61 @@ combined with `limit` or `cursor`. A requested ID absent from the result was not a joined, nondeleted, accessible sidebar row at that snapshot: remove its row. Absence says nothing else about access to an open channel. -`threads` lists unread threads in the row, newest unread reply first: - ```json -{"items":[{"root_id":"<64-hex>","unread":{"status":"exact","value":2}, - "latest_reply_id":"<64-hex>","latest_reply_at":1700000000}],"complete":true} +{"channels":[{"channel_id":"","unread":true,"mentions":1, + "read_through_id":"<64-hex>","latest_id":"<64-hex>", + "threads":[{"root_id":"<64-hex>","unread":true,"mentions":2, + "read_through_id":null,"latest_id":"<64-hex>"}]}], + "next_cursor":null} ``` -Items are canonical roots with unread replies that count (see below), ordered -by `latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is -the last such reply to arrive (equal arrivals prefer the smaller ID), so a -thread `mark_through` at it reads every reply listed; `latest_reply_at` is its -author time. Replies that do not count are filtered out before the count, the -latest reply and the cap are chosen. `unread` uses the row's -definition; every counted reply is also attention, so items carry no separate -attention count. `complete=true` means the unread window was exhausted, no -evidence had unresolved ancestry, unusable tags or undecided membership, and no -thread was omitted; then item unread counts sum to the row's unread replies. -Otherwise the list is a cut of observed evidence and counts may be lower bounds -or unknown. No message bytes are included. +A channel's timeline and each of its threads keep independent read positions; +marking one never moves another. -Counts have exactly three representations: +| Field | Channel row | Thread row | +|---|---|---| +| `unread` | an unread top-level message counts | an unread reply counts | +| `mentions` | unread top-level messages directed at you | unread replies that count | +| `read_through_id` | the message the timeline was last marked through, or null | the message the thread was last marked through, or null | +| `latest_id` | the last eligible top-level message to arrive, or null | the last counted unread reply to arrive | -```json -[{"status":"exact","value":0},{"status":"at_least","value":7},{"status":"unknown"}] -``` +A thread reply never makes the channel row unread and never becomes its +`latest_id`, including a reply carrying `broadcast=1`: it shows on its thread +row. `latest_id` is the anchor that reads its scope: "mark channel read" sends a +channel `mark_through` at the channel's `latest_id`, plus one thread +`mark_through` at each listed thread's `latest_id` to clear those too. The +channel `latest_id` counts any author and ignores read progress. Null means the +bounded scans found no top-level message, not that the channel is empty. +`read_through_id` may name a message that has since been deleted. -Only exact zero proves absence. Unknown has no numeric value. A message is -eligible when it is non-own, nondeleted, of the advertised `eligible_kinds` and -inside the horizon. The same kinds alone define latest activity, so an edit, -reaction or diff (40008) neither makes a channel unread nor moves it. Classify -live arrivals with the advertised set, not a client copy. +`threads` lists threads with unread replies that count, newest unread reply +first by author time (then `root_id`), at most 5. A thread row's `unread` is +always true in this version. No message bytes are included. -An eligible message beyond the matching context frontier counts as unread for -the first reason that holds: +A message is eligible when it is non-own, nondeleted, of the advertised +`eligible_kinds` and inside the horizon. The same kinds alone define latest +activity, so an edit, reaction or diff (40008) neither makes a channel unread +nor moves it. Classify live arrivals with the advertised set, not a client copy. -| `reason` | Holds when | -|---|---| -| `direct` | its channel is a DM | -| `mention` | it tags the actor with `p` | -| `conversation` | it is a reply, and the actor wrote its direct parent or has a reply to that same parent, in that channel | -| `broadcast` | it carries `broadcast=1` | -| null | it is top-level | +A top-level message is directed at you when its channel is a DM, it tags you +with `p`, or it carries `broadcast=1`. A reply counts only when it is directed +in the same way, or it is in one of your conversations: you wrote its direct +parent or have a reply to that same parent, in that channel. Conversation +membership uses only your live eligible messages (a deleted parent proves +nothing; a surviving reply to it still does), looks at the direct parent only +(owning the root or replying elsewhere in the thread proves nothing), and is +independent of read progress and retention. This is not Desktop notification +policy: follows and mutes do not affect these counts. -A reply with no reason does not count: it is not unread and appears in no -thread list. `unread` counts the messages that count; `attention` is the subset -with a reason. Conversation membership uses only the actor's live eligible -messages (a deleted parent proves nothing; a surviving reply to it still does), -looks at the direct parent only (owning the root or replying elsewhere in the -thread proves nothing), and is independent of read progress and retention. A -reply whose membership is undecided is left out, and the row's counts become -lower bounds or unknown. The sidebar counts a broadcast reply without asking -which of the two reasons applies. This is not Desktop notification policy: -follows and mutes do not affect these counts. +Counts are what the bounded scans could establish, and may undercount. A message +whose tags are unusable, whose ancestry is unresolved, or whose conversation +membership is undecided is left out, as are messages beyond the scan bounds +(see [Bounds](#bounds-and-deployment)). The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and is -measured in author time (`created_at`): a message counts while its author time -is at or after `account.cutoff_ms`. It filters unread/attention, not latest -activity, event storage or frontier state. Frontiers use a different clock, -relay arrival (see [Order](#order)). Three consequences: +measured in author time (`created_at`). It filters unread and mentions, not +latest activity, event storage or frontier state. Frontiers use a different +clock, relay arrival (see [Order](#order)). Three consequences: - A message accepted late with an author time beyond the horizon (an import, a backfill, a long-offline sender) is excluded under the current horizon, @@ -137,33 +127,6 @@ relay arrival (see [Order](#order)). Three consequences: frontier, and author time at or after the cutoff. A later configuration expansion can change counts without having lost progress. -Latest activity is independent of actor and frontiers. A null latest ID proves -an empty eligible history only when `latest_message_complete=true`. - -## Explicit contexts - -`GET /buzz/v1/me/read-state?targets=` accepts up to 20 -contexts and 100 total concrete message selectors. It is not event history or a -global export of frontiers. Example decoded `targets`: - -```json -[{"target":{"channel_id":"","root_id":"<64-hex-root>"}, - "message_ids":["<64-hex-event>"]}] -``` - -Omitting `root_id` selects the channel timeline. The result contains `account` -and one `contexts` entry per request entry, in order. Context status is -`available` (with `messages`), `unknown`, or `unavailable`. A thread context's -frontier includes any whole-channel cut. Message status is `read`, `not_counted`, -`unread` (with `reason`), `unknown`, or `unavailable`. Wrong-context, missing -and forbidden selectors share unavailable. Status is decided in this order: -ancestry and context; eligibility (`not_counted` for own, deleted, other kinds -and outside the horizon); the frontier (`read`, with no membership lookup); then -the reason. A reply past the frontier with no reason is `not_counted` when it is -proven outside the actor's conversations and `unknown` when membership is -undecided. A broadcast reply whose membership is undecided is `unread` with -reason `broadcast` and may report `conversation` on a later request. -Conversation bytes must still come from the existing Nostr path. ## Fixed-operand writes @@ -172,33 +135,30 @@ Conversation bytes must still come from the existing Nostr path. ```json {"intents":[ {"type":"mark_through","target":{"channel_id":""},"message_id":"<64-hex-event>"}, - {"type":"mark_through","target":{"channel_id":"","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"}, - {"type":"mark_channel_read","channel_id":"","message_id":"<64-hex-event>"} + {"type":"mark_through","target":{"channel_id":"","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"} ]} ``` -Each intent commits atomically and returns its own `applied`, `blocked` or -`invalid` outcome. An ambiguous timeout/storage failure returns +The response is `{"outcomes":[...],"channels":[...]}`. Each intent commits +atomically and returns its own `applied`, `blocked` or `invalid` outcome, in +request order. An ambiguous timeout/storage failure returns `{"status":"unknown","retryable":true}`. Earlier committed outcomes survive -later failures. `projection_status` is `not_requested`; a successful write does -not assert a client has refreshed. Retry the same operands, never substitute -latest. Keep pending intent durably on the client until its outcome is resolved. +later failures. `channels` holds the updated sidebar row of each distinct +channel with an applied intent, in the sidebar's row shape and ordered by ID; +a channel you have not joined has no row. Replace those rows rather than +re-deriving them. `channels` is omitted when the rows could not be read after +the writes committed: refresh them with `?channel_ids=`. Retry the same +operands, never substitute latest. Keep pending intent durably on the client +until its outcome is resolved. A mark-through validates a fixed message and advances its context's monotone -frontier to that message's relay arrival (see [Order](#order)). Channel and -thread frontiers never inherit in either direction. Opening a view is not -itself a reading action; client dwell/focus policy determines when to send an -actual observed anchor. Old or deleted valid anchors may advance a frontier. - -`mark_channel_read` is the one whole-channel cut: it advances the channel -timeline and every thread in that channel, including unlisted ones, through the -anchor's arrival. The anchor must be an accessible eligible-kind message in -the channel, top-level or reply, deleted or not; ancestry is not checked. A -reply is read at or below the greater of its thread frontier and this cut. An -anchor that no longer exists is `blocked`. `latest_message_id` is the anchor -that reads the whole row. A null ID with `latest_message_complete=false` does -not prove empty history; it only leaves the client without an anchor. Thread -marks and channel `mark_through` never set the cut. +frontier to that message's relay arrival (see [Order](#order)). A channel +anchor must be a top-level message; a thread anchor must be the thread's root +or one of its replies. The frontier records the anchor as `read_through_id` +when it advances; an anchor that arrived at or before the current frontier +changes nothing. Opening a view is not itself a reading action; client +dwell/focus policy determines when to send an actual observed anchor. Old or +deleted valid anchors may advance a frontier. ### Order @@ -209,11 +169,11 @@ before the anchor is read, whatever its author time. A message that arrives later is unread even when backdated, and a future-dated anchor reads nothing that arrives after it. -The order is the relay's and is not exposed: no response carries a frontier, -and no field lets a client compute what a mark will cover. Send the anchors the -user actually saw, let the relay take the greatest, and ask a context which -messages are read. Do not compare author times, IDs or local receipt order to -drop one pending anchor in favor of another. +The order is the relay's and is not exposed: a response carries the anchor +message (`read_through_id`), never its arrival time, and no field lets a client +compute what a mark will cover. Send the anchors the user actually saw and let +the relay take the greatest. Do not compare author times, IDs or local receipt +order to drop one pending anchor in favor of another. Arrival is the accepting relay process's clock, read just before the insert, at microsecond resolution. Three limits follow, none of which strands a badge: @@ -224,10 +184,11 @@ microsecond resolution. Three limits follow, none of which strands a badge: skew. - Messages with the identical stamp are read together. -`latest_message_id` is the last message to arrive among those the unread count -examined: the 4,096 most recent events by author time inside the horizon. So -marking through it reads everything counted. When the horizon holds no message, -it is the last to arrive among the channel's 256 most recent events. +A channel's `latest_id` is the last top-level message to arrive among those the +unread count examined: the 4,096 most recent events by author time inside the +horizon. So marking through it reads every top-level message counted. When the +horizon holds no top-level message, it is the last to arrive among the +channel's 256 most recent events. There is no import of earlier client read state. The relay records the account's `started_at` at its first applied read intent and never moves it. @@ -238,31 +199,32 @@ device-local. ## Bounds and deployment -- 20 sidebar rows, 100 intents, 20 contexts / 100 selectors per request. -- 64 KiB write body; 16 KiB context URL; 1 MiB serialized API response. -- 4096 raw events per channel inside the horizon plus one exhaustion sentinel, - before eligibility. -- Latest activity probes 256 events plus a sentinel; long ineligible tails may - leave latest incomplete even when unread is exact. -- Tag documents over 8192 bytes or malformed relevant tags yield uncertainty. - Compact boolean facts cross the database boundary, never raw tag payloads. +- 20 sidebar rows and 100 intents per request; 5 thread rows per channel. +- 64 KiB write body; 1 MiB serialized API response. +- 4096 raw events per channel inside the horizon, before eligibility. +- The latest fallback probes 256 events; a long ineligible or reply-only tail + can leave `latest_id` null. +- Tag documents over 8192 bytes or malformed relevant tags leave the message + out. Compact boolean facts cross the database boundary, never raw tag + payloads. - Conversation membership: at most 1024 unique parents per request, with a 500 ms savepoint budget. The lookup is exact, so its work grows with the - replies under each parent. Past either bound a reply is undecided, never - absent: counts become lower bounds or unknown and a context reports `unknown`. + replies under each parent. Past either bound a reply is undecided and left + out. - DB statement/lock deadlines and HTTP read deadlines bound work; writes use a shared eight-second intent-processing deadline after admission. Limits are containment, not a production capacity claim. -Apply migrations 0056 and 0057 (or the equivalent desired schema). 0056 -creates two empty private tables and no index on `events`, and 0057 adds the -nullable `personal_read_accounts.started_at` column. Both sidebar scans are +Apply migrations 0056 through 0058 (or the equivalent desired schema). 0056 +creates two empty private tables and no index on `events`, 0057 adds the +nullable `personal_read_accounts.started_at` column, and 0058 adds the nullable +`personal_read_frontiers.through_message_id` column and drops +`threads_through_timestamp`. Both sidebar scans are served by the existing `idx_events_community_channel_created`. No new per-message ingest write path or stored unread counters are introduced. Use existing HTTP route/status/latency metrics for `/buzz/v1/me/sidebar` and -`/buzz/v1/me/read-state`, plus database pool/statement metrics. Inspect exact / -lower-bound / unknown proportions in controlled acceptance captures; no payload, +`/buzz/v1/me/read-state`, plus database pool/statement metrics. No payload, actor, channel or frontier values should become metric labels. The measured local seed is not a DAU/concurrency or p95/p99 production acceptance result. @@ -270,7 +232,7 @@ local seed is not a DAU/concurrency or p95/p99 production acceptance result. Within `/buzz/v1`, clients must ignore unknown response object fields. Existing required fields, status variants and their meanings remain stable; additive -fields do not authorize silently changing `attention` or frontier semantics. +fields do not authorize silently changing `mentions` or frontier semantics. Breaking changes require an explicitly negotiated contract or a new API version. Requests remain strict: send new parameters or intent types only after the relay advertises the corresponding capability. Missing optional data means unsupported diff --git a/migrations/0058_personal_read_through_message.sql b/migrations/0058_personal_read_through_message.sql new file mode 100644 index 0000000000..62ff74e407 --- /dev/null +++ b/migrations/0058_personal_read_through_message.sql @@ -0,0 +1,7 @@ +-- A frontier records the message it was read through, so the sidebar can +-- report it. NULL for frontiers written before this column. Channel and thread +-- frontiers are independent: the whole-channel cut covering every thread is +-- gone. +ALTER TABLE personal_read_frontiers + ADD COLUMN through_message_id BYTEA CHECK (octet_length(through_message_id) = 32), + DROP COLUMN threads_through_timestamp; diff --git a/schema/tables/public/personal_read_frontiers.sql b/schema/tables/public/personal_read_frontiers.sql index b695a50f6f..e62eb9c8b9 100644 --- a/schema/tables/public/personal_read_frontiers.sql +++ b/schema/tables/public/personal_read_frontiers.sql @@ -4,9 +4,8 @@ CREATE TABLE personal_read_frontiers ( channel_id UUID NOT NULL, root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), through_timestamp TIMESTAMPTZ NOT NULL, - -- Whole-channel cut covering every thread; channel rows only. - threads_through_timestamp TIMESTAMPTZ - CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea), + -- The message whose arrival is through_timestamp; NULL before 0058. + through_message_id BYTEA CHECK (octet_length(through_message_id) = 32), PRIMARY KEY (community_id, actor, channel_id, root_id), FOREIGN KEY (community_id, actor) REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,