diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 9766bb0ab7..a7376a1e6a 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -705,10 +705,9 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 58); - // 0058 is reserved by the independently reviewed trigger retirement. - assert_eq!(migrations[57].version, 59); - assert!(migrations[57].sql.as_str().contains("buzz.push_enabled")); + assert_eq!(migrations.len(), 59); + assert_eq!(migrations[58].version, 59); + assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[55].version, 56); assert!(migrations[55] .sql @@ -719,6 +718,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); @@ -2232,6 +2236,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" { @@ -2240,6 +2256,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..448d2491cb 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,72 @@ 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. -pub const MAX_THREAD_SUMMARIES: usize = 5; -/// Bounded event evidence per channel; exhaustion is never inferred at this cap. +/// Maximum thread rows per channel row. A POST can return rows for +/// `MAX_INTENTS` channels, and they must fit the 1 MiB response limit. +pub const MAX_THREAD_SUMMARIES: usize = 25; +/// 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 anchor of the timeline's frontier: the marked message that arrived + /// last. None until the timeline is marked. + 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: directed at the actor, or in one of the + /// actor's conversations. + pub mentions: u32, + /// The anchor of the thread's frontier: the marked message that arrived + /// last. None until the thread is marked. + 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..5a3d3a0aca 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)]) ); } @@ -552,67 +433,52 @@ async fn threads_are_capped_after_replies_that_do_not_count_are_removed() { let w = World::new().await; let c = w.channel().await; let old = w.now - 40 * DAY; - // The actor's thread has the oldest unread reply. Five unjoined threads - // are newer and would fill the list if the cap came first. + // The actor's thread has the oldest unread reply. A cap's worth of + // unjoined threads are newer and would fill the list if the cap came + // first. let mine = w.post(c, &w.actor, old, vec![]).await; let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await; - for i in 1..=5 { + for i in 1..=MAX_THREAD_SUMMARIES as u64 { let root = w.post(c, &w.peer, old, vec![]).await; 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 +487,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 +533,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..38e6e02600 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -1,5 +1,5 @@ //! Bounded evidence projection. The cap limits evidence, not the definition of -//! unread: an unexamined tail yields a positive lower bound or an unknown count. +//! unread: an unexamined tail goes uncounted, so counts may undercount. use super::{model::*, participation, writes}; use buzz_core::CommunityId; @@ -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,29 +130,40 @@ 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: neither + -- counted nor a timeline anchor, since a mark cannot validate + -- it. Parity-tested against the shared NIP-10 parser. + 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 AND NOT e.unresolved ), 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 - -- metadata, not tag spelling, owns the context. + -- C collation matches Rust's ASCII case rules. CASE WHEN octet_length(tags::text)<=8192 AND jsonb_typeof(tags)='array' THEN (SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array' @@ -159,10 +171,7 @@ impl Db { THEN NULL ELSE jsonb_build_object( 'directed',COALESCE(bool_or( (tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex')) - OR (tag->>0='broadcast' AND tag->>1='1')),false), - 'reply_marked',root IS NULL AND COALESCE(bool_or(tag->>0='e' - AND tag->>3='reply' - AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'),false)) END + OR (tag->>0='broadcast' AND tag->>1='1')),false)) END FROM jsonb_array_elements(tags) t(tag) WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts, count(*) AS n, @@ -172,29 +181,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 +208,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,16 +219,10 @@ 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 { continue; } @@ -235,14 +231,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 +250,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 +258,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 +298,6 @@ impl Db { }; tx.commit().await?; Ok(SidebarPage { - account, channels, next_cursor, }) @@ -343,6 +312,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 +340,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 +380,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,42 +389,32 @@ 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_twenty_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))) + for count in [0_u8, 1, 24, 25, 26, 27] { + // 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 .into_iter() - .take(5) + .take(25) .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..3dc5c131b3 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,12 +156,12 @@ 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 }) - }, + // A reply marker without recorded ancestry leaves the message out. + assert_eq!(page.channels[0].unread, !reply, "tags={tags:?}"); + // ...and it is never the timeline's anchor. + assert_eq!( + page.channels[0].latest_id.is_some(), + !reply, "tags={tags:?}" ); } @@ -200,7 +169,7 @@ async fn sidebar_ancestry_fact_matches_shared_nip10_parser() { #[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 +177,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 +202,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 +220,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..2bb86aab8d 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,151 +131,76 @@ 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; + // One more thread than the cap. + let n = MAX_THREAD_SUMMARIES as u64 + 1; let mut roots = Vec::new(); - for i in 0..6 { + for i in 0..n { let root = post(&db, community, channel, base + i, vec![]).await; join(&db, &pool, community, channel, &actor, &root).await; roots.push(root); @@ -368,20 +210,16 @@ 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 - // reply and two that arrive last, together. + // Threads 0 and 1 tie on newest reply time and the rest follow, newest + // first. Thread 2 has one directed reply and two that arrive last, + // together. + let offset = |i: u64| if i < 2 { n * 10 } else { (n - i) * 10 }; let mut anchors = Vec::new(); - for (i, root) in roots.iter().enumerate() { - let at = base + 100 + [50, 50, 40, 30, 20, 10][i]; + for (i, root) in (0..).zip(&roots) { + let at = base + 100 + offset(i); anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await); } reply( @@ -400,7 +238,7 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { community, channel, &roots[2], - base + 140, + base + 100 + offset(2), vec![], ) .await; @@ -414,59 +252,42 @@ 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() + }; + let ids = |roots: &[nostr::Event]| roots.iter().map(|r| r.id.to_hex()).collect::>(); + let last = roots.len() - 1; + // The oldest unread thread is past the cap. + assert_eq!(order(&row), [tied.to_vec(), ids(&roots[2..last])].concat()); + let third = &row.threads[2]; + assert_eq!(third.mentions, 3); assert_eq!( - order, - vec![ - tied[0].clone(), - tied[1].clone(), - roots[2].id.to_hex(), - roots[3].id.to_hex(), - 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); - 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 oldest. + 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()], ids(&roots[2..])].concat() ); + let mentions: Vec<_> = row.threads.iter().map(|t| t.mentions).collect(); + let mut expected = vec![1; MAX_THREAD_SUMMARIES]; + expected[1] = 3; + assert_eq!(mentions, expected); } impl ThreadReadSummary { @@ -476,7 +297,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 +321,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 +346,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/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index 08434a253d..dd65d3dad6 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -161,9 +161,12 @@ pub(super) async fn recheck( Ok(()) } +/// Largest serialized API response; a larger one is a 503. +pub(super) const MAX_RESPONSE_BYTES: usize = 1024 * 1024; + pub(super) fn response(value: impl serde::Serialize) -> Result { let bytes = serde_json::to_vec(&value).map_err(|_| Error::unavailable())?; - if bytes.len() > 1024 * 1024 { + if bytes.len() > MAX_RESPONSE_BYTES { return Err(Error::unavailable()); } Ok(( diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index 01e0e4110e..f3dbf09565 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,27 @@ 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) - }) - .await - .map_err(|_| Error::unavailable())? + let mut body = json!({ "outcomes": outcomes }); + // Committed outcomes stand even when their rows cannot be read in the + // remaining budget: omit them. + if let Ok(Ok(channels)) = + tokio::time::timeout_at(deadline, refreshed(&state, &headers, &principal, &applied)).await + { + body["channels"] = json!(channels); + } + auth::response(body) } // Preserve earlier committed outcomes while distinguishing a definite denial @@ -224,3 +208,39 @@ pub(super) async fn write_intent( Err(_) => json!({"status":"unknown","retryable":true}), } } + +#[cfg(test)] +mod tests { + use super::*; + use buzz_db::personal_read::{ThreadReadSummary, MAX_THREAD_SUMMARIES}; + + /// A POST that applied to `MAX_INTENTS` channels, each with a full thread + /// list, must still fit: an oversized body is a 503 that hides committed + /// outcomes. + #[test] + fn largest_write_response_fits_the_response_limit() { + let id = || Some("f".repeat(64)); + let channels: Vec<_> = (0..MAX_INTENTS) + .map(|_| ChannelReadSummary { + channel_id: Uuid::max(), + unread: true, + mentions: u32::MAX, + read_through_id: id(), + latest_id: id(), + threads: (0..MAX_THREAD_SUMMARIES) + .map(|_| ThreadReadSummary { + root_id: "f".repeat(64), + unread: true, + mentions: u32::MAX, + read_through_id: id(), + latest_id: "f".repeat(64), + }) + .collect(), + }) + .collect(); + let outcomes = vec![json!({"status":"unknown","retryable":true}); MAX_INTENTS]; + let body = json!({ "outcomes": outcomes, "channels": channels }); + let bytes = serde_json::to_vec(&body).unwrap().len(); + assert!(bytes <= auth::MAX_RESPONSE_BYTES, "{bytes} bytes"); + } +} 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/desktop/src-tauri/tests/csp.rs b/desktop/src-tauri/tests/csp.rs deleted file mode 100644 index 3497c36863..0000000000 --- a/desktop/src-tauri/tests/csp.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`. -//! -//! The CSP is only enforced on assets Tauri itself serves, so neither -//! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under -//! `vite preview`) can catch a policy that breaks the app. These tests pin the -//! non-obvious sources the frontend actually needs, so a future tightening -//! fails here instead of in a signed build. -//! -//! Kept as an integration test so the policy can be checked without the app -//! crate having to declare a test-only module. - -use std::collections::HashMap; - -const TAURI_CONF: &str = include_str!("../tauri.conf.json"); - -fn csp_directives() -> HashMap> { - let conf: serde_json::Value = - serde_json::from_str(TAURI_CONF).expect("tauri.conf.json is valid JSON"); - let csp = conf["app"]["security"]["csp"] - .as_str() - .expect("app.security.csp is set as a policy string"); - - csp.split(';') - .filter_map(|directive| { - let mut parts = directive.split_whitespace(); - let name = parts.next()?; - Some((name.to_owned(), parts.map(str::to_owned).collect())) - }) - .collect() -} - -fn sources(directive: &str) -> Vec { - csp_directives() - .remove(directive) - .unwrap_or_else(|| panic!("csp is missing the {directive} directive")) -} - -#[test] -fn script_src_allows_wasm_instantiation() { - // Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for - // every code block; MediaPipe selfie segmentation does the same. Without - // this token both silently degrade — highlighting drops to plain text and - // animated avatars keep their background. - assert!(sources("script-src").contains(&"'wasm-unsafe-eval'".to_owned())); -} - -/// The `MEDIAPIPE_WASM_BASE` literal the frontend hands to `FilesetResolver`. -fn mediapipe_wasm_base() -> String { - const CAPTURE: &str = include_str!("../../src/features/profile/lib/animatedAvatarCapture.ts"); - - let after = CAPTURE - .split_once("const MEDIAPIPE_WASM_BASE =") - .expect("animatedAvatarCapture.ts declares MEDIAPIPE_WASM_BASE") - .1; - let url = after - .split_once('"') - .expect("MEDIAPIPE_WASM_BASE is a double-quoted string literal") - .1; - url.split_once('"') - .expect("MEDIAPIPE_WASM_BASE literal is terminated") - .0 - .to_owned() -} - -/// The npm scope the MediaPipe loader must come from. A CSP source ending in -/// `/` is a path *prefix* — paths can't be wildcarded — so this admits any -/// `@mediapipe` package while excluding the rest of what jsDelivr serves. -const MEDIAPIPE_SCOPE: &str = "https://cdn.jsdelivr.net/npm/@mediapipe/"; - -#[test] -fn script_src_scopes_the_mediapipe_loader() { - // `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js` - // via a `