diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 11218114f6..d554e14e31 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1851,6 +1851,16 @@ pub(crate) async fn insert_event_with_thread_metadata_tx( .execute(&mut **tx) .await?; } + let root = meta.root_event_id.unwrap_or(pid); + crate::personal_read::record_reply( + tx, + community_id, + event, + meta.channel_id, + root, + received_at, + ) + .await?; } } } 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..d8b7e0dbce 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 @@ -1,12 +1,26 @@ //! Read progress follows relay arrival (`received_at`), never author time. //! Each case sets every arrival explicitly: back-to-back inserts share a clock. -use super::{postgres_tests::fixture, *}; +use super::{postgres_tests, projection::LATEST_PROBE, *}; use crate::Db; use buzz_core::CommunityId; use nostr::{EventBuilder, Keys, Kind, Tag}; use sqlx::PgPool; use uuid::Uuid; +/// The shared fixture, with the actor's membership moved an hour back so +/// arrivals set within that hour fall after joining. +async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Event) { + let fixture = postgres_tests::fixture().await; + sqlx::query( + "UPDATE channel_members SET joined_at=now()-interval '1 hour' WHERE community_id=$1", + ) + .bind(fixture.2.as_uuid()) + .execute(&fixture.1) + .await + .unwrap(); + fixture +} + /// Store `event` as having arrived at `arrived` (Unix seconds). async fn arrive(pool: &PgPool, community: CommunityId, event: &nostr::Event, arrived: u64) { let updated = sqlx::query( @@ -22,18 +36,18 @@ 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`. -async fn post( +/// A message of `kind` authored at `authored` that arrives at `arrived`. +#[allow(clippy::too_many_arguments)] +async fn post_kind( db: &Db, pool: &PgPool, community: CommunityId, channel: Uuid, + kind: u16, authored: u64, arrived: u64, - tags: Vec, ) -> nostr::Event { - let event = EventBuilder::new(Kind::Custom(9), format!("authored {authored}")) - .tags(tags) + let event = EventBuilder::new(Kind::Custom(kind), format!("authored {authored}")) .custom_created_at(nostr::Timestamp::from(authored)) .sign_with_keys(&Keys::generate()) .unwrap(); @@ -44,7 +58,18 @@ async fn post( event } -/// A reply to `root` that mentions the actor, so it counts without membership. +async fn post( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + authored: u64, + arrived: u64, +) -> nostr::Event { + post_kind(db, pool, community, channel, 9, authored, arrived).await +} + +/// A reply to `root` that mentions the actor, which makes it their thread. #[allow(clippy::too_many_arguments)] async fn reply( db: &Db, @@ -56,44 +81,27 @@ async fn reply( authored: u64, arrived: u64, ) -> nostr::Event { - let mention = Tag::public_key(actor.public_key()); - let event = post( + let event = postgres_tests::reply( db, - pool, community, channel, + root, + &Keys::generate(), authored, - arrived, - vec![mention], + vec![Tag::public_key(actor.public_key())], + false, ) .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()) - .bind(event.created_at.as_secs() as f64) - .bind(channel).bind(root.id.as_bytes().as_slice()).execute(pool).await.unwrap(); + arrive(pool, community, &event, arrived).await; event } async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSummary { - db.personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap() - .channels - .remove(0) + postgres_tests::sidebar(db, community, actor).await } async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) { - let outcome = db - .apply_personal_read_intent(community, &actor.public_key(), &intent) - .await - .unwrap(); + let outcome = postgres_tests::apply(db, community, actor, intent).await; assert_eq!(outcome, IntentOutcome::Applied); } @@ -107,6 +115,13 @@ fn mark_through(channel: Uuid, root: Option<&str>, message: &str) -> ReadIntent } } +fn mark_channel_read(channel: Uuid, message: String) -> ReadIntent { + ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: message, + } +} + /// Channel-timeline states of `messages`, in order. async fn states( db: &Db, @@ -115,36 +130,7 @@ async fn states( 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, - } + postgres_tests::status(db, community, actor, channel, None, messages).await } #[tokio::test] @@ -162,16 +148,13 @@ async fn late_arrival_with_old_author_time_is_unread() { .await; // 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, 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) - ); + assert_eq!(sidebar(&db, community, &actor).await.unread, 1); } #[tokio::test] @@ -181,7 +164,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, now + 600, now - 40).await; apply( &db, community, @@ -190,16 +173,13 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() { ) .await; - let later = post(&db, &pool, community, channel, now, now - 30, vec![]).await; + let later = post(&db, &pool, community, channel, 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!(sidebar(&db, community, &actor).await.unread, 1); } /// Marking the sidebar's own latest message must clear the badge even when the @@ -210,32 +190,17 @@ 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, now + 1, now - 40).await; + post(&db, &pool, community, channel, now - 600, now - 30).await; + assert_eq!(sidebar(&db, community, &actor).await.unread, 3); let anchor = sidebar(&db, community, &actor) .await .latest_message_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_channel_read(channel, anchor)).await; - assert_eq!( - exact(&sidebar(&db, community, &actor).await.unread), - Some(0) - ); + assert_eq!(sidebar(&db, community, &actor).await.unread, 0); } #[tokio::test] @@ -273,12 +238,21 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() { now - 30, ) .await; + // Ingest started the actor's thread row at real arrival time; move it + // back to before the backdated replies. + sqlx::query("UPDATE personal_read_frontiers SET through_timestamp=to_timestamp($3) WHERE community_id=$1 AND root_id=$2") + .bind(community.as_uuid()) + .bind(root.id.as_bytes().as_slice()) + .bind((now - 60) as f64) + .execute(&pool) + .await + .unwrap(); 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.unread_thread_count), (2, 1)); + assert_eq!(row.threads.len(), 1); let root_id = root.id.to_hex(); - let anchor = row.threads.items[0].latest_reply_id.clone(); + let anchor = row.threads[0].latest_reply_id.clone(); apply( &db, community, @@ -288,79 +262,47 @@ 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_eq!((row.unread, row.unread_thread_count), (0, 0)); + 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 { +/// The latest probe holds only the newest messages by author time. A late +/// arrival authored before all of them is still counted, so it must still be +/// the anchor, or Mark as read with the sidebar anchor leaves it unread. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_as_read_clears_a_late_arrival_behind_the_latest_probe() { let (db, pool, community, channel, actor, first) = fixture().await; let now = first.created_at.as_secs(); arrive(&pool, community, &first, now - 60).await; - apply( - &db, - community, - &actor, - mark_through(channel, None, &first.id.to_hex()), - ) - .await; - for i in 0..fillers { - // Reactions are not counted, but the probe's LIMIT sees them. - let filler = EventBuilder::new(Kind::Custom(7), "+") - .custom_created_at(nostr::Timestamp::from(now - 500 + i)) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &filler, Some(channel)) - .await - .unwrap(); - arrive(&pool, community, &filler, now - 50).await; + for i in 0..LATEST_PROBE as u64 { + post(&db, &pool, community, channel, now - 500 + i, 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) - ); - let anchor = sidebar(&db, community, &actor) .await .latest_message_id - .expect("a channel with messages has a latest message"); + .unwrap(); + apply(&db, community, &actor, mark_channel_read(channel, anchor)).await; + assert_eq!(sidebar(&db, community, &actor).await.unread, 0); + + let late = post(&db, &pool, community, channel, now - 600, now - 10).await; + let row = sidebar(&db, community, &actor).await; + assert_eq!(row.unread, 1); + assert_eq!(row.latest_message_id, Some(late.id.to_hex())); apply( &db, community, &actor, - ReadIntent::MarkChannelRead { - channel_id: channel, - message_id: anchor, - }, + mark_channel_read(channel, late.id.to_hex()), ) .await; - exact(&sidebar(&db, community, &actor).await.unread) + assert_eq!(sidebar(&db, community, &actor).await.unread, 0); } -/// Control: with one slot to spare the late arrival is inside the probe. +/// Reactions never fill the latest probe. #[tokio::test] #[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)); -} - -#[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)); -} - -/// 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. -#[tokio::test] -#[ignore = "requires Postgres"] -async fn latest_message_behind_a_full_probe_of_reactions_is_found() { +async fn latest_message_behind_newer_reactions_is_found() { let (db, pool, community, channel, actor, first) = fixture().await; let now = first.created_at.as_secs(); let demoted = sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1 AND id=$2") @@ -371,23 +313,15 @@ 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; - 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)) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &filler, Some(channel)) - .await - .unwrap(); - arrive(&pool, community, &filler, now - 50).await; + let only = post(&db, &pool, community, channel, now - 600, now - 10).await; + for i in 0..LATEST_PROBE as u64 { + post_kind(&db, &pool, community, channel, 7, now - 500 + i, now - 50).await; } 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.unread, 1); } /// Store `event` as having arrived at exactly `seconds` plus `micros`. Built @@ -421,10 +355,10 @@ async fn arrive_exact( /// 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) { +async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec, u32) { 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; + let second = post(&db, &pool, community, channel, now, now).await; let arrived = now as i64 - 30; arrive_exact(&pool, community, &first, arrived, micros[0]).await; arrive_exact(&pool, community, &second, arrived, micros[1]).await; @@ -438,7 +372,7 @@ async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec, .await; ( states(&db, community, &actor, channel, &both).await, - exact(&sidebar(&db, community, &actor).await.unread), + sidebar(&db, community, &actor).await.unread, ) } @@ -449,7 +383,7 @@ async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec, 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)) + (vec!["read".to_owned(), "unread".to_owned()], 1) ); } @@ -461,7 +395,7 @@ 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)), + (vec!["read".to_owned(), "read".to_owned()], 0), "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 index dfc83abe35..965519b8ad 100644 --- a/crates/buzz-db/src/store/personal_read/classification.rs +++ b/crates/buzz-db/src/store/personal_read/classification.rs @@ -1,16 +1,6 @@ -//! 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 -} +//! Directed-reason rules for explicit selectors. The sidebar's SQL applies the +//! same rules, covered by the PostgreSQL parity test. +use super::model::Reason; /// Why a message is directed, before conversation membership is known. pub(super) fn reason(channel_type: &str, actor_hex: &str, tags: &[Vec]) -> Option { diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs index 39a537575a..97fe7ebf0f 100644 --- a/crates/buzz-db/src/store/personal_read/context.rs +++ b/crates/buzz-db/src/store/personal_read/context.rs @@ -1,5 +1,5 @@ -//! Explicit selectors over the same private frontier authority. No history API. -use super::{classification, model::*, participation, projection::read_account, writes}; +//! Explicit selectors over the same private positions. No history API. +use super::{classification, model::*, writes}; use crate::{observability, Db, DbError, Result}; use buzz_core::CommunityId; use chrono::{DateTime, Utc}; @@ -14,7 +14,6 @@ impl Db { &self, community: CommunityId, actor: &nostr::PublicKey, - retention_seconds: u32, queries: &[ContextQuery], ) -> Result { if queries.is_empty() @@ -39,33 +38,34 @@ impl Db { .await?; writes::deadlines(&mut tx).await?; let actor_bytes = actor.to_bytes(); - let account = read_account(&mut tx, retention_seconds).await?; + let actor_hex = actor.to_hex(); 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))", + let Some(root) = + writes::valid_target(&mut tx, community, &actor_bytes, &query.target).await? + else { + contexts.push(ContextState::Unavailable); + continue; + }; + // The positions the sidebar counts against, both floored at the + // account's start and absent before it: the channel's, never + // before joining and absent for a non-member; and the thread's, + // which exists only for the actor's threads and includes any + // whole-channel cut. + let positions = sqlx::query( + "SELECT CASE WHEN m.joined_at IS NOT NULL AND s.started IS NOT NULL + THEN GREATEST(cf.through_timestamp, m.joined_at, s.started) END AS channel, + CASE WHEN tf.root_id IS NOT NULL AND s.started IS NOT NULL + THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp, s.started) + END AS thread + FROM (SELECT (SELECT started_at FROM personal_read_accounts + WHERE community_id=$1 AND actor=$2) AS started) s + LEFT JOIN channel_members m ON m.community_id=$1 AND m.channel_id=$3 + AND m.pubkey=$2 AND m.removed_at IS NULL + LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2 + AND cf.channel_id=$3 AND cf.root_id=''::bytea + LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 + AND tf.channel_id=$3 AND tf.root_id=$4 AND $4<>''::bytea", ) .bind(community.as_uuid()) .bind(actor_bytes.as_slice()) @@ -73,96 +73,87 @@ impl Db { .bind(&root) .fetch_one(&mut *tx) .await?; + let channel_position: Option> = positions.try_get("channel")?; + let thread_position: Option> = positions.try_get("thread")?; 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 + "SELECT encode(e.id,'hex') AS id,e.kind,e.received_at, + e.deleted_at IS NOT NULL AS deleted,e.pubkey=$3 AS own,e.tags, + tm.root_event_id,tm.depth,tm.broadcast,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?; + ) + .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 state = match by_id.get(&id.to_ascii_lowercase()) { + None => MessageReadState::Unavailable, + Some(row) => { 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 + let thread = row + .try_get::>, _>("root_event_id")? + .filter(|r| r != &message_id); + let on_timeline = thread.is_none() + || (row.try_get::, _>("depth")? == Some(1) + && row.try_get::, _>("broadcast")? == Some(true)); + // A thread context holds its replies, not its root; a + // broadcast reply belongs to both of its contexts. + let in_context = if root.is_empty() { + on_timeline } 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 prefix.is_some_and(|p| received <= p) { - MessageReadState::Read + thread.as_ref() == Some(&root) + }; + if !in_context { + MessageReadState::Unavailable + } else if !ELIGIBLE_KINDS.contains(&row.try_get("kind")?) + || row.try_get("own")? + || row.try_get("deleted")? + { + MessageReadState::NotCounted + } else { + // One state whichever context asks: a message + // counts against the timeline if it is on it. + let position = if on_timeline { + channel_position } 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 } - } + thread_position + }; + let received: DateTime = row.try_get("received_at")?; + let tags: Vec> = + serde_json::from_value(row.try_get("tags")?).unwrap_or_default(); + let reason = classification::reason( + &row.try_get::("channel_type")?, + &actor_hex, + &tags, + ); + match position { + None => MessageReadState::NotCounted, + Some(p) if received <= p => MessageReadState::Read, + Some(_) if on_timeline => MessageReadState::Unread { reason }, + Some(_) => MessageReadState::Unread { + reason: match reason { + Some(Reason::Direct | Reason::Mention) => reason, + _ => Some(Reason::Conversation), + }, + }, } } - } else { - MessageReadState::Unknown } - } else { - MessageReadState::Unavailable }; messages.push(ContextMessage { message_id: id.clone(), @@ -171,15 +162,8 @@ impl Db { } 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 }) + Ok(ContextPage { contexts }) } /// Final bounded access check on the writer, outside a projection snapshot. @@ -211,47 +195,3 @@ impl Db { .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/membership.rs b/crates/buzz-db/src/store/personal_read/membership.rs new file mode 100644 index 0000000000..8b2db9fb54 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/membership.rs @@ -0,0 +1,66 @@ +//! Thread membership. A thread frontier row exists exactly for the actor's +//! threads, and only ingest creates one: read intents move existing rows. +use super::model::ELIGIBLE_KINDS; +use crate::Result; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use nostr::Event; +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +/// Record who a newly stored reply makes a thread member: its author, the +/// root's author, members it mentions, and in a DM (at most nine members) +/// every member. The author's row starts at the reply, which it has read; +/// everyone else's starts just before it, so the reply is unread. Existing +/// rows are left alone. +pub(crate) async fn record_reply( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + event: &Event, + channel: Uuid, + root: &[u8], + received_at: DateTime, +) -> Result<()> { + if !ELIGIBLE_KINDS.contains(&i32::from(event.kind.as_u16())) { + return Ok(()); + } + let mentioned: Vec> = event + .tags + .public_keys() + .map(|key| key.to_bytes().to_vec()) + .collect(); + // Both inserts' fence and foreign-key checks run at the end of the + // statement, after the account rows exist. + sqlx::query( + "WITH joined AS ( + SELECT $4::bytea AS actor, $6::timestamptz AS through + UNION ALL + SELECT pubkey, $6-interval '1 microsecond' FROM events + WHERE community_id=$1 AND channel_id=$2 AND id=$3 + UNION ALL + SELECT cm.pubkey, $6-interval '1 microsecond' + 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.channel_id=$2 AND cm.removed_at IS NULL + AND (cm.pubkey=ANY($5) OR c.channel_type='dm') + ), members AS ( + SELECT DISTINCT ON (actor) actor, through FROM joined ORDER BY actor, through DESC + ), accounts AS ( + INSERT INTO personal_read_accounts (community_id, actor) + SELECT $1, actor FROM members ON CONFLICT DO NOTHING + ) + INSERT INTO personal_read_frontiers + (community_id, actor, channel_id, root_id, through_timestamp) + SELECT $1, actor, $2, $3, through FROM members + ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(root) + .bind(event.pubkey.to_bytes().as_slice()) + .bind(&mentioned) + .bind(received_at) + .execute(&mut **tx) + .await?; + Ok(()) +} diff --git a/crates/buzz-db/src/store/personal_read/mod.rs b/crates/buzz-db/src/store/personal_read/mod.rs index fb056c600c..0c517f20ed 100644 --- a/crates/buzz-db/src/store/personal_read/mod.rs +++ b/crates/buzz-db/src/store/personal_read/mod.rs @@ -1,24 +1,22 @@ //! Private, signer-owned accessory read progress, separate from NIP-RS events. //! //! A frontier is the relay arrival time of the message a context was read -//! through; the unread horizon alone uses author time. Only fixed context -//! intents advance frontiers, never a query scan cap. +//! through, and unread counts forward from it. Only fixed context intents +//! advance frontiers; ingest only creates thread membership rows. mod classification; mod context; +mod membership; mod model; -mod participation; mod projection; mod writes; +pub(crate) use membership::record_reply; pub use model::*; #[cfg(test)] mod postgres_tests; -#[cfg(test)] -mod participation_postgres_tests; - #[cfg(test)] mod projection_postgres_tests; diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index 5786881ba8..889fd5cc29 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -3,8 +3,6 @@ use uuid::Uuid; /// Maximum independent operations in one HTTP request. pub const MAX_INTENTS: usize = 100; -/// Default unread-tracking duration, not event or encrypted NIP-RS retention. -pub const DEFAULT_RETENTION_SECONDS: u32 = 30 * 24 * 60 * 60; /// A channel or canonical thread; absence of a root denotes only the channel timeline. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -49,56 +47,19 @@ 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, - /// Read-time author-time cutoff (Unix milliseconds), not a discard boundary. - pub cutoff_ms: i64, -} - /// 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. -pub const MAX_CHANNEL_SCAN: usize = 256; -/// Unread-window work budget per channel, before eligibility/ancestry joins. -pub const MAX_UNREAD_SCAN: usize = 4096; +/// Counts stop here: a count equal to the cap means at least the cap ("99+"). +pub const UNREAD_CAP: u32 = 99; +/// Ingest rejects author times further than this from relay time, so anything +/// that arrived after a position has an author time no earlier than this +/// before it. Forward scans range over author time and filter on arrival. +pub const MAX_ARRIVAL_SKEW_SECONDS: u32 = 900; /// 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. #[derive(Debug, Serialize)] pub struct ChannelReadSummary { @@ -112,57 +73,44 @@ pub struct ChannelReadSummary { 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. + /// Unread timeline messages plus unread replies in the actor's threads, + /// clamped at [`UNREAD_CAP`]. + pub unread: u32, + /// The unread subset with a [`Reason`]: everything but ordinary timeline + /// messages. Clamped at [`UNREAD_CAP`]. + pub attention: u32, + /// The actor's threads with at least one unread reply, clamped at + /// [`UNREAD_CAP`]. + pub unread_thread_count: u32, + /// Last eligible nondeleted event to arrive among the channel's newest + /// and everything counted, whatever its author: `mark_channel_read` + /// through it reads the row. 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. + /// Display activity: the greatest author time (Unix seconds) among the + /// channel's newest, not necessarily latest_message_id's own. None + /// exactly when it is. 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, - /// Threads with unread replies, newest unread reply first. - pub threads: ThreadSummaries, + /// The actor's threads with unread replies, newest unread reply first. + 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. +/// Unread replies in one of the actor's threads. 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. + /// Unread replies in this thread, clamped at [`UNREAD_CAP`]. + pub unread: u32, + /// Last unread reply to arrive: marking through it reads the thread. pub latest_reply_id: String, - /// Author time (Unix seconds) of latest_reply_id. + /// Greatest author time (Unix seconds) among unread replies, not + /// necessarily latest_reply_id's own; summaries order by it. pub latest_reply_at: i64, } /// 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. @@ -193,30 +141,27 @@ pub enum Reason { 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. + /// It is a reply in one of the actor's threads. Conversation, /// It carries `broadcast=1`. Broadcast, } /// Read progress and eligibility for one concrete message, not a public receipt. +/// A message has one state whichever of its contexts asks. #[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`]. + /// Not unread: own, deleted, auxiliary, a reply outside the actor's + /// threads, or anything before the actor's first read intent. NotCounted, - /// Covered by this context's frontier. + /// At or before the position it counts against. Read, - /// Counts, and is beyond this context's frontier. + /// Counts, and arrived after the position it counts against. Unread { - /// Null only for an ordinary top-level message. A broadcast reply whose - /// membership is undecided reports `broadcast`. + /// Null only for an ordinary timeline message. reason: Option, }, } @@ -237,8 +182,6 @@ pub struct ContextMessage { 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. @@ -249,8 +192,6 @@ pub enum ContextState { /// 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.rs b/crates/buzz-db/src/store/personal_read/participation.rs deleted file mode 100644 index 682bca3048..0000000000 --- a/crates/buzz-db/src/store/personal_read/participation.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Conversation membership from the existing thread store, bounded by time. -use super::model::ELIGIBLE_KINDS; -use crate::Result; -use buzz_core::CommunityId; -use sqlx::{Acquire, PgConnection, Row}; -use std::collections::HashMap; -use uuid::Uuid; - -// Bound multiplicative work independently of the unread evidence window. -const MAX_PARENTS: usize = 1024; - -/// Whether each `(channel, parent)` is one of the actor's conversations: the -/// actor wrote the parent or has a reply to it. Only live eligible messages -/// qualify. An absent key is undecided: past the target cap, or the statement -/// deadline expired. Membership is independent of unread retention and read -/// frontiers. -pub(super) async fn resolve( - conn: &mut PgConnection, - community: CommunityId, - actor: &[u8], - targets: &[(Uuid, Vec)], -) -> Result), bool>> { - if targets.is_empty() { - return Ok(HashMap::new()); - } - let targets = select_targets(targets); - let channels: Vec<_> = targets.iter().map(|(channel, _)| *channel).collect(); - let parents: Vec<_> = targets.iter().map(|(_, parent)| parent.clone()).collect(); - // Optional inference must not abort authoritative unread/frontier reads. - // A nested transaction is a savepoint; rollback also restores the caller's - // statement timeout. No state is written in this read-only inference. - let mut budget = conn.begin().await?; - sqlx::query("SET LOCAL statement_timeout = '500ms'") - .execute(&mut *budget) - .await?; - sqlx::query("SET LOCAL jit = off") - .execute(&mut *budget) - .await?; - // Exact: a row cap would lose a real member behind a busy parent's other - // replies. LATERAL ... LIMIT 1 keeps the work on the replies under the - // requested parents; a plain EXISTS may be hashed over the whole tenant. - let result = sqlx::query( - "SELECT t.channel_id,t.parent_id,(own.hit OR replied.hit) IS TRUE AS member - FROM unnest($3::uuid[],$4::bytea[]) t(channel_id,parent_id) - LEFT JOIN LATERAL ( - SELECT e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($5) AS hit - FROM events e WHERE e.community_id=$1 AND e.channel_id=t.channel_id AND e.id=t.parent_id - ORDER BY e.created_at DESC LIMIT 1 - ) own ON true - LEFT JOIN LATERAL ( - SELECT true AS hit FROM thread_metadata tm JOIN events e ON e.community_id=$1 - AND e.channel_id=t.channel_id AND e.created_at=tm.event_created_at AND e.id=tm.event_id - WHERE tm.community_id=$1 AND tm.channel_id=t.channel_id AND tm.parent_event_id=t.parent_id - AND e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($5) - AND own.hit IS NOT TRUE - LIMIT 1 - ) replied ON true", - ) - .bind(community.as_uuid()) - .bind(actor) - .bind(channels) - .bind(parents) - .bind(ELIGIBLE_KINDS.as_slice()) - .fetch_all(&mut *budget) - .await; - budget.rollback().await?; - let rows = match result { - Ok(rows) => rows, - Err(sqlx::Error::Database(error)) - if matches!(error.code().as_deref(), Some("57014" | "55P03")) => - { - return Ok(HashMap::new()); - } - Err(error) => return Err(error.into()), - }; - rows.into_iter() - .map(|row| { - Ok(( - (row.try_get("channel_id")?, row.try_get("parent_id")?), - row.try_get("member")?, - )) - }) - .collect() -} - -// Keep selection independent of the optional SQL deadline: a timeout must not -// hide a regression in the cardinality bound. -fn select_targets(targets: &[(Uuid, Vec)]) -> Vec<(Uuid, Vec)> { - let mut targets = targets.to_vec(); - targets.sort_unstable(); - targets.dedup(); - targets.truncate(MAX_PARENTS); - targets -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn target_selection_caps_unique_parents_independently_of_sql_timeout() { - // Literal contract boundaries deliberately do not derive from MAX_PARENTS. - for count in [0_u32, 1, 1023, 1024, 1025] { - let unique: Vec<_> = (0..count) - .map(|i| (Uuid::nil(), i.to_be_bytes().to_vec())) - .collect(); - let input: Vec<_> = unique - .iter() - .rev() - .chain(unique.iter().rev()) - .cloned() - .collect(); - let selected = select_targets(&input); - assert_eq!(selected, unique[..unique.len().min(1024)], "count {count}"); - } - } - - #[test] - fn target_selection_keeps_channel_identity() { - let first = (Uuid::from_u128(1), vec![7; 32]); - let second = (Uuid::from_u128(2), vec![7; 32]); - assert_eq!( - select_targets(&[second.clone(), first.clone(), second.clone()]), - vec![first, second] - ); - } -} 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 deleted file mode 100644 index 682cb045af..0000000000 --- a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs +++ /dev/null @@ -1,670 +0,0 @@ -//! Which replies count: the direct-parent conversation rule, its deletion and -//! channel edges, and the bounds on the membership lookup. -use super::*; -use crate::{ - channel::{ChannelType, ChannelVisibility}, - Db, -}; -use buzz_core::CommunityId; -use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; -use serde_json::{json, Value}; -use sqlx::PgPool; -use uuid::Uuid; - -const DAY: u64 = 86_400; - -/// One community with the reading actor and a peer. -struct World { - db: Db, - pool: PgPool, - community: CommunityId, - actor: Keys, - peer: Keys, - now: u64, -} - -impl World { - async fn new() -> Self { - let pool = PgPool::connect(&crate::test_support::database_url()) - .await - .unwrap(); - let db = Db::from_pool(pool.clone()); - let community = db - .ensure_configured_community(&format!("conversation-{}.local", Uuid::new_v4())) - .await - .unwrap() - .id; - Self { - db, - pool, - community, - actor: Keys::generate(), - peer: Keys::generate(), - now: Timestamp::now().as_secs(), - } - } - - /// A channel the actor has joined. - async fn channel(&self) -> Uuid { - self.db - .create_channel( - self.community, - &Uuid::new_v4().to_string(), - ChannelType::Stream, - ChannelVisibility::Open, - None, - &self.actor.public_key().to_bytes(), - None, - ) - .await - .unwrap() - .id - } - - /// A top-level message. - async fn post(&self, channel: Uuid, author: &Keys, at: u64, tags: Vec) -> nostr::Event { - let event = EventBuilder::new(Kind::Custom(9), Uuid::new_v4().to_string()) - .tags(tags) - .custom_created_at(Timestamp::from(at)) - .sign_with_keys(author) - .unwrap(); - self.db - .insert_event(self.community, &event, Some(channel)) - .await - .unwrap(); - event - } - - /// A canonical reply to `parent` in `root`'s thread. `parent` need not be - /// stored in `channel`: thread metadata records what the reply claims. - async fn reply( - &self, - channel: Uuid, - author: &Keys, - root: &nostr::Event, - parent: Option<&nostr::Event>, - at: u64, - tags: Vec, - ) -> nostr::Event { - let event = self.post(channel, author, at, tags).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,$6,1)") - .bind(self.community.as_uuid()).bind(event.id.as_bytes().as_slice()).bind(at as f64) - .bind(channel).bind(root.id.as_bytes().as_slice()) - .bind(parent.map(|parent| parent.id.as_bytes().as_slice())) - .execute(&self.pool).await.unwrap(); - event - } - - /// The storage write that author and staff deletions share. - async fn delete(&self, event: &nostr::Event) { - assert!(self - .db - .soft_delete_event_and_update_thread(self.community, event.id.as_bytes(), None, None) - .await - .unwrap()); - } - - async fn row(&self, channel: Uuid) -> ChannelReadSummary { - self.db - .personal_read_sidebar_channels( - self.community, - &self.actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &[channel], - ) - .await - .unwrap() - .channels - .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 - } - - fn mention(&self) -> Tag { - Tag::parse(["p", &self.actor.public_key().to_hex()]).unwrap() - } -} - -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 }) -} - -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()}) -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() { - let w = World::new().await; - // Every witness predates the unread horizon: membership does not expire. - let old = w.now - 40 * DAY; - - // The actor wrote the root. A peer answers it; another peer reply continues - // under that answer, where the actor has written nothing. - let c = w.channel().await; - let root = w.post(c, &w.actor, old, vec![]).await; - let answer = w - .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) - .await; - 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")); - 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}) - ); - - // Joining the nested conversation makes its earlier reply count. The - // actor's own reply adds nothing. - 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}) - ); - - // A peer's thread. The actor replied to one of two parents, long ago. - let c = w.channel().await; - let root = w.post(c, &w.peer, old, vec![]).await; - let joined = w.reply(c, &w.peer, &root, Some(&root), old, vec![]).await; - let other = w.reply(c, &w.peer, &root, Some(&root), old, vec![]).await; - w.reply(c, &w.actor, &root, Some(&joined), old, vec![]) - .await; - 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![]) - .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}) - ); - - // 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![]) - .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})); - - // 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})); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn a_directed_reply_counts_outside_the_actors_conversations() { - 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 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()], - ) - .await; - let both = w - .reply( - c, - &w.peer, - &root, - Some(&parent), - w.now + 4, - 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}) - ); - - // Conversation outranks broadcast, never mention. Counts do not move. - 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)); - - // In a DM every peer message is direct, tagged or not, joined or not. - let dm = w.channel().await; - sqlx::query("UPDATE channels SET channel_type='dm' WHERE community_id=$1 AND id=$2") - .bind(w.community.as_uuid()) - .bind(dm) - .execute(&w.pool) - .await - .unwrap(); - let root = w.post(dm, &w.peer, w.now, vec![w.mention()]).await; - let parent = w - .reply(dm, &w.peer, &root, Some(&root), w.now, vec![]) - .await; - 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)); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn eligibility_then_the_read_frontier_are_reported_before_membership() { - 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; - w.delete(&deleted).await; - let own = 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; - assert_eq!( - w.state(c, Some(&root), &outside).await, - status("not_counted") - ); - - assert_eq!( - w.db.apply_personal_read_intent( - w.community, - &w.actor.public_key(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: c, - root_id: Some(root.id.to_hex()), - }, - message_id: anchor.id.to_hex(), - }, - ) - .await - .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)); - } -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() { - let w = World::new().await; - let c = w.channel().await; - let root = w.post(c, &w.peer, w.now - 40 * DAY, vec![]).await; - let reply = |author, parent, at| w.reply(c, author, &root, Some(parent), w.now + at, vec![]); - - // 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; - // 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; - // 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; - reply(&w.actor, &twice, 2).await; - let to_twice = reply(&w.peer, &twice, 12).await; - // The actor wrote the parent and also replied under it. - let both = reply(&w.actor, &root, 0).await; - 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 - }; - 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)); - - // Each deletion changes only its own parent's conversation. - w.delete(&wrote).await; - assert_eq!(counted().await, [no, yes, yes, yes]); - w.delete(&only).await; - assert_eq!(counted().await, [no, no, yes, yes]); - 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]); - // 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}) - ); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn membership_is_scoped_to_the_replys_own_channel() { - let w = World::new().await; - let (a, b) = (w.channel().await, w.channel().await); - let old = w.now - 40 * DAY; - - // The actor replied to a peer's parent, but that reply is stored in B. - let theirs = w.post(a, &w.peer, old, vec![]).await; - w.reply(b, &w.actor, &theirs, Some(&theirs), old, vec![]) - .await; - 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)); - - // 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})); - - // 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)); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn membership_is_exact_behind_a_busy_parent() { - let w = World::new().await; - let c = w.channel().await; - let old = w.now - 40 * DAY; - let root = w.post(c, &w.peer, old, vec![]).await; - // The actor's reply is the oldest of 259 under one parent: a 256-row - // window over the parent's replies would never reach it. - w.reply(c, &w.actor, &root, Some(&root), old, vec![]).await; - let mut last = root.clone(); - for i in 0..258 { - last = w - .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}) - ); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -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. - 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 { - 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}) - ); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn the_parent_budget_leaves_replies_undecided_and_fabricates_no_absence() { - 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. - 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![]) - .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; - 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 - ); - } - } - // 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] -#[ignore = "requires Postgres"] -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![]) - .await; - let mut held = w.pool.begin().await.unwrap(); - // Force the real resolver to time out, then check that its outer snapshot - // and original statement budget remain usable. - sqlx::query("LOCK TABLE thread_metadata IN ACCESS EXCLUSIVE MODE") - .execute(&mut *held) - .await - .unwrap(); - let mut reader = w.pool.begin().await.unwrap(); - sqlx::query("SET LOCAL statement_timeout='2000ms'") - .execute(&mut *reader) - .await - .unwrap(); - for lock_timeout in ["0", "10ms"] { - sqlx::query("SELECT set_config('lock_timeout',$1,true)") - .bind(lock_timeout) - .execute(&mut *reader) - .await - .unwrap(); - sqlx::query("SET LOCAL jit=on") - .execute(&mut *reader) - .await - .unwrap(); - let result = participation::resolve( - &mut reader, - w.community, - &w.actor.public_key().to_bytes(), - &[(c, root.id.as_bytes().to_vec())], - ) - .await - .unwrap(); - assert!(result.is_empty(), "timeout provides no negative evidence"); - let setting: String = sqlx::query_scalar("SHOW statement_timeout") - .fetch_one(&mut *reader) - .await - .unwrap(); - assert_eq!(setting, "2s"); - let jit: String = sqlx::query_scalar("SHOW jit") - .fetch_one(&mut *reader) - .await - .unwrap(); - assert_eq!(jit, "on", "optional inference restores caller settings"); - } - reader.rollback().await.unwrap(); - held.rollback().await.unwrap(); - assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted")); -} 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 8339cb9a4a..705a714226 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -1,13 +1,16 @@ +//! Forward counts, read positions, and explicit selectors. use super::*; use crate::{ channel::{ChannelType, ChannelVisibility}, Db, }; -use buzz_core::CommunityId; -use nostr::{EventBuilder, Keys, Kind}; +use buzz_core::{channel::MemberRole, CommunityId}; +use nostr::{EventBuilder, Keys, Kind, Tag}; use sqlx::PgPool; use uuid::Uuid; +/// An open channel the actor created, read state started an hour ago, and one +/// message from someone else that arrived after the actor joined. pub(super) async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Event) { let pool = PgPool::connect(&crate::test_support::database_url()) .await @@ -32,276 +35,390 @@ pub(super) async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Ev .await .unwrap() .id; - let event = EventBuilder::new(Kind::Custom(9), "read this, not its sibling") - .sign_with_keys(&Keys::generate()) + start(&pool, community, &actor).await; + let event = post(&db, community, channel, &Keys::generate(), now(), vec![]).await; + (db, pool, community, channel, actor, event) +} + +/// Start `who`'s read state an hour ago, as a first intent would have. +pub(super) async fn start(pool: &PgPool, community: CommunityId, who: &Keys) { + sqlx::query( + "INSERT INTO personal_read_accounts (community_id,actor,started_at) + VALUES ($1,$2,now()-interval '1 hour')", + ) + .bind(community.as_uuid()) + .bind(who.public_key().to_bytes().as_slice()) + .execute(pool) + .await + .unwrap(); +} + +pub(super) fn now() -> u64 { + nostr::Timestamp::now().as_secs() +} + +pub(super) fn mention(who: &Keys) -> Vec { + vec![Tag::public_key(who.public_key())] +} + +/// A top-level message authored at `at`, stored through ordinary ingest. +pub(super) async fn post( + db: &Db, + community: CommunityId, + channel: Uuid, + author: &Keys, + at: u64, + tags: Vec, +) -> nostr::Event { + post_kind(db, community, channel, author, 9, at, tags).await +} + +pub(super) async fn post_kind( + db: &Db, + community: CommunityId, + channel: Uuid, + author: &Keys, + kind: u16, + at: u64, + tags: Vec, +) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(kind), format!("message at {at}")) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(at)) + .sign_with_keys(author) .unwrap(); db.insert_event(community, &event, Some(channel)) .await .unwrap(); - (db, pool, community, channel, actor, event) + event } -/// Move every community message past the default horizon by author time, the -/// only clock the unread window reads. -async fn expire(pool: &PgPool, community: CommunityId) { - sqlx::query("UPDATE events SET created_at=created_at-interval '31 days' WHERE community_id=$1") - .bind(community.as_uuid()) - .execute(pool) - .await +/// A depth-1 reply to `root` through the ingest path that records membership. +#[allow(clippy::too_many_arguments)] +pub(super) async fn reply( + db: &Db, + community: CommunityId, + channel: Uuid, + root: &nostr::Event, + author: &Keys, + at: u64, + tags: Vec, + broadcast: bool, +) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(9), format!("reply at {at}")) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(at)) + .sign_with_keys(author) .unwrap(); -} - -async fn add_history(db: &Db, community: CommunityId, channel: Uuid, count: usize) -> nostr::Event { - let author = Keys::generate(); - let mut last = None; - let base = nostr::Timestamp::now().as_secs(); - for i in 0..count as u64 { - let event = EventBuilder::new(Kind::Custom(9), format!("history {i}")) - .custom_created_at(nostr::Timestamp::from(base + i)) - .sign_with_keys(&author) - .unwrap(); - db.insert_event(community, &event, Some(channel)) - .await - .unwrap(); - last = Some(event); - } - last.unwrap() -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads_are_unknown() { - 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( + let stamp = |e: &nostr::Event| { + chrono::DateTime::from_timestamp(e.created_at.as_secs() as i64, 0).unwrap() + }; + db.insert_event_with_thread_metadata( community, - &actor.public_key(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_id: last.id.to_hex(), + &event, + Some(channel), + Some(crate::event::ThreadMetadataParams { + event_id: event.id.as_bytes(), + event_created_at: stamp(&event), + channel_id: channel, + parent_event_id: Some(root.id.as_bytes()), + parent_event_created_at: Some(stamp(root)), + root_event_id: Some(root.id.as_bytes()), + root_event_created_at: Some(stamp(root)), + depth: 1, + broadcast, + }), + ) + .await + .unwrap(); + event +} + +pub(super) async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSummary { + db.personal_read_sidebar(community, &actor.public_key(), 20, None) + .await + .unwrap() + .channels + .remove(0) +} + +pub(super) async fn apply( + db: &Db, + community: CommunityId, + actor: &Keys, + intent: ReadIntent, +) -> IntentOutcome { + db.apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap() +} + +/// Mark the channel timeline, or `root`'s thread, through `message`. +pub(super) fn mark( + channel: Uuid, + root: Option<&nostr::Event>, + message: &nostr::Event, +) -> ReadIntent { + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: root.map(|r| r.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::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); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn personal_read_sidebar_author_window_excludes_expired_and_late_old_messages() { - 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(); - assert!( - matches!(page.channels[0].unread, ReadCount::Exact { value: 0 }), - "an empty author-time window proves exhaustion" - ); - assert_eq!( - page.channels[0].latest_message_id.as_deref(), - Some(latest.id.to_hex().as_str()) - ); - assert!(page.channels[0].latest_message_complete); - // 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)] { - let event = EventBuilder::new(Kind::Custom(9), "accepted now") - .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}" - ); + message_id: message.id.to_hex(), } } -#[tokio::test] -#[ignore = "requires Postgres"] -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; +pub(super) fn channel_read(channel: Uuid, message: &nostr::Event) -> ReadIntent { + ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: message.id.to_hex(), + } +} + +/// States of `messages` in the timeline, or `root`'s thread, as wire JSON. +pub(super) async fn states( + db: &Db, + community: CommunityId, + actor: &Keys, + channel: Uuid, + root: Option<&nostr::Event>, + messages: &[&nostr::Event], +) -> Vec { + let query = ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: root.map(|r| r.id.to_hex()), + }, + message_ids: messages.iter().map(|e| e.id.to_hex()).collect(), + }; let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) + .personal_read_contexts(community, &actor.public_key(), &[query]) .await .unwrap(); - assert!( - matches!(page.channels[0].unread, ReadCount::Exact { value } if value == MAX_UNREAD_SCAN as u32) - ); - let overflow = EventBuilder::new(Kind::Custom(9), "one beyond the budget") - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &overflow, Some(channel)) + let page = serde_json::to_value(page).unwrap(); + page["contexts"][0]["messages"] + .as_array() + .unwrap() + .iter() + .map(|m| { + let mut m = m.clone(); + m.as_object_mut().unwrap().remove("message_id"); + m + }) + .collect() +} + +pub(super) async fn status( + db: &Db, + community: CommunityId, + actor: &Keys, + channel: Uuid, + root: Option<&nostr::Event>, + messages: &[&nostr::Event], +) -> Vec { + states(db, community, actor, channel, root, messages) .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) - ); - // 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. - sqlx::query("WITH ranked AS (SELECT created_at,id,row_number() OVER (ORDER BY created_at DESC,id) AS n FROM events WHERE community_id=$1 AND channel_id=$2) - UPDATE events e SET created_at=CASE WHEN r.n>601 THEN e.created_at-interval '31 days' ELSE e.created_at END, - pubkey=CASE WHEN r.n<=300 THEN $3 ELSE e.pubkey END, - 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", + .iter() + .map(|m| m["status"].as_str().unwrap().to_owned()) + .collect() +} + +/// Started accounts; ingest's membership accounts are not started. +async fn accounts(pool: &PgPool, community: CommunityId) -> i64 { + sqlx::query_scalar( + "SELECT count(*) FROM personal_read_accounts WHERE community_id=$1 AND started_at IS NOT NULL", ) - .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, - ) + .bind(community.as_uuid()) + .fetch_one(pool) .await - .unwrap(); - assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); + .unwrap() } #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_sidebar_is_read_only_and_does_not_wait_for_account() { - let (db, pool, community, channel, actor, event) = fixture().await; - db.personal_read_sidebar( +async fn counts_stop_at_the_advertised_cap_and_serialize_as_integers() { + let (db, _, community, channel, actor, _) = fixture().await; + let author = Keys::generate(); + let base = now(); + let mut last = None; + for i in 0..u64::from(UNREAD_CAP) + 20 { + last = Some(post(&db, community, channel, &author, base + i, vec![]).await); + } + let row = sidebar(&db, community, &actor).await; + assert_eq!( + row.unread, UNREAD_CAP, + "a count at the cap means at least the cap" + ); + let wire = serde_json::to_value(&row).unwrap(); + assert_eq!(wire["unread"], 99); + assert_eq!(wire["attention"], 0); + assert_eq!(wire["unread_thread_count"], 0); + for gone in ["latest_message_complete", "account", "status"] { + assert!(wire.get(gone).is_none(), "{gone}"); + } + let last = last.unwrap(); + assert_eq!(row.latest_message_id, Some(last.id.to_hex())); + assert_eq!( + apply(&db, community, &actor, mark(channel, None, &last)).await, + IntentOutcome::Applied + ); + assert_eq!(sidebar(&db, community, &actor).await.unread, 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn joining_starts_caught_up_and_counts_only_what_arrives_after() { + let (db, pool, community, channel, owner, event) = fixture().await; + assert_eq!(sidebar(&db, community, &owner).await.unread, 1); + let joiner = Keys::generate(); + start(&pool, community, &joiner).await; + let before = accounts(&pool, community).await; + db.add_member( community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, + channel, + &joiner.public_key().to_bytes(), + MemberRole::Member, None, ) .await .unwrap(); - let count: 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!(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) + let row = sidebar(&db, community, &joiner).await; + assert_eq!( + (row.unread, row.attention), + (0, 0), + "history before joining is read" + ); + assert_eq!(row.latest_message_id, Some(event.id.to_hex())); + assert_eq!( + status(&db, community, &joiner, channel, None, &[&event]).await, + ["read"] + ); + assert_eq!( + accounts(&pool, community).await, + before, + "joining writes nothing" + ); + let later = post( + &db, + community, + channel, + &Keys::generate(), + now(), + mention(&joiner), + ) + .await; + let row = sidebar(&db, community, &joiner).await; + assert_eq!((row.unread, row.attention), (1, 1)); + assert_eq!( + states(&db, community, &joiner, channel, None, &[&later]).await, + [serde_json::json!({"status":"unread","reason":"mention"})] + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn read_state_starts_caught_up_at_the_first_intent() { + let (db, pool, community, channel, _, _) = fixture().await; + let member = Keys::generate(); + db.add_member( + community, + channel, + &member.public_key().to_bytes(), + MemberRole::Member, + None, + ) + .await + .unwrap(); + sqlx::query("UPDATE channel_members SET joined_at=now()-interval '1 hour' WHERE community_id=$1 AND pubkey=$2") + .bind(community.as_uuid()) + .bind(member.public_key().to_bytes().as_slice()) + .execute(&pool) .await .unwrap(); + // Ingest gives the member a thread row, and so an account, unstarted. + let root = post(&db, community, channel, &member, now(), vec![]).await; + reply( + &db, + community, + channel, + &root, + &Keys::generate(), + now(), + vec![], + false, + ) + .await; + let before = post( + &db, + community, + channel, + &Keys::generate(), + now(), + mention(&member), + ) + .await; + let row = sidebar(&db, community, &member).await; + assert_eq!( + (row.unread, row.attention, row.unread_thread_count), + (0, 0, 0) + ); + assert_eq!(row.latest_message_id, Some(before.id.to_hex())); + assert_eq!( + status(&db, community, &member, channel, None, &[&before]).await, + ["not_counted"] + ); + + // Any first intent starts every position, caught up, at that moment. + assert_eq!( + apply(&db, community, &member, mark(channel, None, &root)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &member).await; + assert_eq!((row.unread, row.unread_thread_count), (0, 0)); + assert_eq!( + status(&db, community, &member, channel, None, &[&before]).await, + ["read"] + ); + reply( + &db, + community, + channel, + &root, + &Keys::generate(), + now(), + vec![], + false, + ) + .await; + post(&db, community, channel, &Keys::generate(), now(), vec![]).await; + let row = sidebar(&db, community, &member).await; + assert_eq!((row.unread, row.unread_thread_count), (2, 1)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_is_read_only_and_does_not_wait_for_account() { + let (db, pool, community, channel, actor, event) = fixture().await; + sidebar(&db, community, &actor).await; + assert_eq!( + accounts(&pool, community).await, + 1, + "GET must not create private state" + ); + apply(&db, community, &actor, channel_read(channel, &event)).await; 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_eq!(sidebar(&db, community, &actor).await.unread, 0); held.rollback().await.unwrap(); } #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_intent_does_not_lock_shared_conversation_rows() { +async fn intent_does_not_lock_shared_conversation_rows_or_block_thread_ingest() { let (db, pool, community, channel, actor, event) = fixture().await; sqlx::query("UPDATE channels SET ttl_seconds=86400 WHERE community_id=$1 AND id=$2") .bind(community.as_uuid()) @@ -317,28 +434,27 @@ async fn personal_read_intent_does_not_lock_shared_conversation_rows() { &mut held, community, &actor.public_key().to_bytes(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_id: event.id.to_hex(), - }, + &mark(channel, None, &event), ) .await .unwrap(); assert!(matches!(result, IntentOutcome::Applied)); - // Exercise the actual event-insert TTL trigger while private progress is - // uncommitted, then verify event deletion can update the observed row. - let incoming = EventBuilder::new(Kind::Custom(9), "concurrent ephemeral ingest") - .sign_with_keys(&Keys::generate()) - .unwrap(); + // Ingest that creates the locked actor's thread row must not wait on the + // uncommitted intent, nor must the event-insert TTL trigger. tokio::time::timeout( std::time::Duration::from_secs(2), - db.insert_event(community, &incoming, Some(channel)), + reply( + &db, + community, + channel, + &event, + &Keys::generate(), + now(), + mention(&actor), + false, + ), ) .await - .unwrap() .unwrap(); let mut legacy = pool.begin().await.unwrap(); sqlx::query("SET LOCAL lock_timeout='100ms'") @@ -359,245 +475,116 @@ async fn personal_read_intent_does_not_lock_shared_conversation_rows() { #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_diff_alone_leaves_the_sidebar_row_unchanged() { +async fn diff_alone_leaves_the_sidebar_row_unchanged() { let (db, _, community, channel, actor, _) = fixture().await; - let mut rows = Vec::new(); - for diff in [false, true] { - if diff { - // Newest in the channel and addressed to the actor: as loud as a - // diff can be. - let at = nostr::Timestamp::now().as_secs() + 5; - let event = EventBuilder::new(Kind::Custom(40008), "a diff") - .custom_created_at(nostr::Timestamp::from(at)) - .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) - .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: 1 } - )); - rows.push(serde_json::to_value(&page.channels[0]).unwrap()); - } - assert_eq!(rows[0], rows[1], "not unread, not attention, not latest"); + let before = serde_json::to_value(sidebar(&db, community, &actor).await).unwrap(); + // Newest in the channel and addressed to the actor: as loud as a diff gets. + post_kind( + &db, + community, + channel, + &Keys::generate(), + 40008, + now() + 5, + mention(&actor), + ) + .await; + let after = serde_json::to_value(sidebar(&db, community, &actor).await).unwrap(); + assert_eq!(before["unread"], 1); + assert_eq!(before, after, "not unread, not attention, not latest"); } #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { +async fn latest_includes_own_and_excludes_deleted_and_auxiliary() { let (db, pool, community, channel, actor, _) = fixture().await; - let base = nostr::Timestamp::now().as_secs(); - let own = EventBuilder::new(Kind::Custom(9), "own latest") - .custom_created_at(nostr::Timestamp::from(base + 1)) - .sign_with_keys(&actor) - .unwrap(); - db.insert_event(community, &own, Some(channel)) + let base = now(); + let own = post(&db, community, channel, &actor, base + 1, vec![]).await; + let deleted = post(&db, community, channel, &actor, base + 2, vec![]).await; + post_kind(&db, community, channel, &actor, 7, base + 4, vec![]).await; + sqlx::query("UPDATE events SET deleted_at=clock_timestamp() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(deleted.id.as_bytes().as_slice()) + .execute(&pool) .await .unwrap(); - for (offset, kind) in [(2, 9), (4, 7)] { - let event = EventBuilder::new(Kind::Custom(kind), format!("ineligible {offset}")) - .custom_created_at(nostr::Timestamp::from(base + offset)) - .sign_with_keys(&actor) - .unwrap(); - db.insert_event(community, &event, Some(channel)) - .await - .unwrap(); - if offset == 2 { - sqlx::query( - "UPDATE events SET deleted_at=clock_timestamp() WHERE community_id=$1 AND id=$2", - ) - .bind(community.as_uuid()) - .bind(event.id.as_bytes().as_slice()) - .execute(&pool) - .await - .unwrap(); - } - } + let row = sidebar(&db, community, &actor).await; + assert_eq!(row.latest_message_id, Some(own.id.to_hex())); + assert_eq!(row.latest_message_at, Some((base + 1) as i64)); + assert_eq!(row.unread, 1, "own, deleted and reaction do not count"); + // An empty channel has no latest message. + db.create_channel( + community, + "truly empty", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap(); let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) + .personal_read_sidebar(community, &actor.public_key(), 20, None) .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); - expire(&pool, community).await; - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .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); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn personal_read_latest_old_message_is_not_an_empty_channel() { - let (db, pool, community, channel, actor, event) = fixture().await; - expire(&pool, community).await; - let empty = db - .create_channel( - community, - "truly empty", - ChannelType::Stream, - ChannelVisibility::Open, - None, - &actor.public_key().to_bytes(), - None, - ) - .await - .unwrap() - .id; - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - 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 })); let empty = page .channels .iter() - .find(|c| c.channel_id == empty) + .find(|c| c.channel_id != channel) .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_message_id.is_none() && empty.latest_message_at.is_none()); } #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { +async fn position_is_monotonic_and_rejects_malformed_anchors() { let (db, pool, community, channel, actor, event) = fixture().await; - let target = ReadTarget { - channel_id: channel, - root_id: None, - }; let invalid = ReadIntent::MarkThrough { - target: target.clone(), + target: ReadTarget { + channel_id: channel, + root_id: None, + }, message_id: "not an event id".into(), }; assert_eq!( - db.apply_personal_read_intent(community, &actor.public_key(), &invalid) - .await - .unwrap(), + apply(&db, community, &actor, invalid).await, IntentOutcome::Invalid ); - let count: 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!(count, 0, "invalid intent rolls back account creation"); - let intent = ReadIntent::MarkThrough { - target: target.clone(), - message_id: event.id.to_hex(), - }; + assert_eq!( + accounts(&pool, community).await, + 1, + "invalid intent rolls back" + ); for _ in 0..2 { assert_eq!( - db.apply_personal_read_intent(community, &actor.public_key(), &intent) - .await - .unwrap(), + apply(&db, community, &actor, mark(channel, None, &event)).await, IntentOutcome::Applied ); } // 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)) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &later, Some(channel)) - .await - .unwrap(); - for (anchor, why) in [ - (&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(), - }; + let later = post( + &db, + community, + channel, + &Keys::generate(), + now() - 10, + vec![], + ) + .await; + assert_eq!(sidebar(&db, community, &actor).await.unread, 1); + for anchor in [&later, &event] { assert_eq!( - db.apply_personal_read_intent(community, &actor.public_key(), &intent) - .await - .unwrap(), - IntentOutcome::Applied, - "{why}" + apply(&db, community, &actor, mark(channel, None, anchor)).await, + IntentOutcome::Applied ); } - let at_later_arrival: bool = sqlx::query_scalar( + assert_eq!( + sidebar(&db, community, &actor).await.unread, + 0, + "never moves back" + ); + let at_later: bool = sqlx::query_scalar( "SELECT f.through_timestamp=e.received_at FROM personal_read_frontiers f, events e WHERE f.community_id=$1 AND f.actor=$2 AND e.community_id=$1 AND e.id=$3", ) @@ -607,278 +594,113 @@ async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { .fetch_one(&pool) .await .unwrap(); - assert!(at_later_arrival); - let stranger = Keys::generate(); - let count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", - ) - .bind(community.as_uuid()) - .bind(stranger.public_key().to_bytes().as_slice()) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count, 0); - let sparse: Option = - sqlx::query_scalar("SELECT to_regclass('personal_read_seen')::text") - .fetch_one(&pool) - .await - .unwrap(); - assert!(sparse.is_none()); + assert!(at_later); } #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_channel_and_thread_never_inherit_each_other() { +async fn contexts_bound_selectors_and_share_one_state_per_message() { 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(); - let top = EventBuilder::new(Kind::Custom(9), "later timeline message") - .custom_created_at(nostr::Timestamp::from(base + 20)) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &top, Some(channel)) - .await - .unwrap(); - let channel_target = ReadTarget { - channel_id: channel, - root_id: None, - }; + let base = now(); + let broadcast = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base, + vec![], + true, + ) + .await; + let thread_only = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base, + vec![], + false, + ) + .await; + let both = [&root, &broadcast, &thread_only]; + // A broadcast reply is on the timeline and in its thread; a plain reply + // is only in the thread, and the actor is not in it. 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 + status(&db, community, &actor, channel, None, &both).await, + ["unread", "unread", "unavailable"] ); - 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 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(), - }, - ) - .await - .unwrap(); - let roots: Vec> = sqlx::query_scalar( - "SELECT root_id FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", - ) - .bind(community.as_uuid()) - .bind(second_actor.public_key().to_bytes().as_slice()) - .fetch_all(&pool) - .await - .unwrap(); assert_eq!( - roots, - vec![root.id.as_bytes().to_vec()], - "thread reading never creates a channel frontier" + status(&db, community, &actor, channel, Some(&root), &both).await, + ["unavailable", "unread", "not_counted"] ); -} - -#[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, 0, "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 page = serde_json::to_value( - db.personal_read_contexts( - community, - &other.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, - ) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); - assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); + assert_eq!(sidebar(&db, community, &actor).await.unread, 2); + assert_eq!( + accounts(&pool, community).await, + 1, + "context GET creates nothing" + ); + // A broadcast reply is a valid timeline anchor. + assert_eq!( + apply(&db, community, &actor, mark(channel, None, &broadcast)).await, + IntentOutcome::Applied + ); + assert_eq!( + status(&db, community, &actor, channel, Some(&root), &both).await, + ["unavailable", "read", "not_counted"] + ); + let empty: [ContextQuery; 0] = []; assert!(db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &[] - ) + .personal_read_contexts(community, &actor.public_key(), &empty) .await .is_err()); - let oversized = vec![ContextQuery { - target: queries[0].target.clone(), + let oversized = [ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, message_ids: vec![root.id.to_hex(); MAX_CONTEXT_MESSAGES + 1], }]; assert!(db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &oversized - ) + .personal_read_contexts(community, &actor.public_key(), &oversized) .await .is_err()); + // A deleted message is not counted. + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(root.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + let other = Keys::generate(); + assert_eq!( + status(&db, community, &other, channel, None, &[&root]).await, + ["not_counted"] + ); 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( + let page = db + .personal_read_contexts( community, &other.public_key(), - DEFAULT_RETENTION_SECONDS, - &queries, + &[ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: vec![root.id.to_hex()], + }], ) .await - .unwrap(), - ) - .unwrap(); + .unwrap(); assert_eq!( - page["contexts"], - serde_json::json!([{"status":"unavailable"},{"status":"unavailable"}]) + serde_json::to_value(page).unwrap()["contexts"], + serde_json::json!([{"status":"unavailable"}]) ); assert!(db .personal_read_accessible_contexts(community, &other.public_key(), &[channel]) @@ -886,166 +708,3 @@ async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers( .unwrap() .is_empty()); } - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn personal_read_context_author_horizon_unknown_ancestry_and_deletion() { - let (db, pool, community, channel, actor, root) = fixture().await; - let old = EventBuilder::new(Kind::Custom(9), "directed, about to expire") - .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &old, Some(channel)) - .await - .unwrap(); - let unresolved = EventBuilder::new(Kind::Custom(9), "ancestry missing") - .tags([nostr::Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap()]) - .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"); - sqlx::query( - "UPDATE events SET created_at=created_at-interval '31 days' WHERE community_id=$1 AND id=$2", - ) - .bind(community.as_uuid()) - .bind(old.id.as_bytes().as_slice()) - .execute(&pool) - .await - .unwrap(); - sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") - .bind(community.as_uuid()) - .bind(root.id.as_bytes().as_slice()) - .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)); -} diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index b839ca7459..ea47e9bd67 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -1,34 +1,39 @@ -//! Bounded evidence projection. The cap limits evidence, not the definition of -//! unread: an unexamined tail yields a positive lower bound or an unknown count. +//! Forward counting from read positions. A caught-up reader examines almost +//! nothing: each count reads only what arrived after its position, and stops +//! at [`UNREAD_CAP`]. -use super::{model::*, participation, writes}; +use super::{model::*, writes}; use buzz_core::CommunityId; -use chrono::{DateTime, Utc}; -use serde_json::Value; -use sqlx::{Acquire, PgConnection, Row}; -use std::cmp::Reverse; -use std::collections::{hash_map::Entry, HashMap}; +use serde::Deserialize; +use sqlx::{Acquire, Row}; use uuid::Uuid; use crate::{observability, Db, DbError, Result}; +/// A message's place, given its `thread_metadata` row `tm`: on the timeline +/// (top-level, or a depth-1 reply broadcast to the channel, exactly as the +/// timeline query shows it), or else in its canonical thread. +const ON_TIMELINE: &str = "(tm.root_event_id IS NULL OR tm.root_event_id=e.id + OR (tm.depth=1 AND tm.broadcast))"; + +/// Whether event `e` is directed at the actor (hex `$hex`) by its tags. +const DIRECTED_TAGS: &str = "EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) t(tag) + WHERE (tag->>0='p' AND lower(tag->>1)=$hex) OR (tag->>0='broadcast' AND tag->>1='1'))"; + impl Db { - /// Read a bounded joined roster from the writer. One SQL statement projects - /// channels, event evidence and read authority at a compatible MVCC cut. + /// Read a bounded joined roster from the writer in one read-only snapshot. /// Callers must recheck admission/resource access before releasing this data. pub async fn personal_read_sidebar( &self, community: CommunityId, actor: &nostr::PublicKey, - retention_seconds: u32, limit: usize, after: Option, ) -> Result { if !(1..=MAX_CHANNELS).contains(&limit) { return Err(DbError::InvalidData("invalid sidebar limit".into())); } - self.sidebar(community, actor, retention_seconds, limit, after, None) - .await + self.sidebar(community, actor, limit, after, None).await } /// Refresh specific joined channels in one snapshot. A requested channel @@ -37,29 +42,20 @@ impl Db { &self, community: CommunityId, actor: &nostr::PublicKey, - retention_seconds: u32, channels: &[Uuid], ) -> Result { let unique: std::collections::HashSet<_> = channels.iter().collect(); if !(1..=MAX_CHANNELS).contains(&channels.len()) || unique.len() != channels.len() { return Err(DbError::InvalidData("invalid sidebar channels".into())); } - self.sidebar( - community, - actor, - retention_seconds, - channels.len(), - None, - Some(channels), - ) - .await + self.sidebar(community, actor, channels.len(), None, Some(channels)) + .await } async fn sidebar( &self, community: CommunityId, actor: &nostr::PublicKey, - retention_seconds: u32, limit: usize, after: Option, only: Option<&[Uuid]>, @@ -70,330 +66,186 @@ impl Db { ) .await?; let mut tx = conn.begin().await?; - // The horizon and frontier evidence share one read-only cut. sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") .execute(&mut *tx) .await?; writes::deadlines(&mut tx).await?; sqlx::query("SET LOCAL jit = off").execute(&mut *tx).await?; - let actor_bytes = actor.to_bytes(); - let account = read_account(&mut tx, retention_seconds).await?; - // 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. - // 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. - // 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. - let rows = sqlx::query( + // Every position is floored at the account's start (infinity before + // the actor's first intent, so nothing counts). A channel's is its + // frontier, never earlier than joining: a new member starts caught + // up. A thread row exists only for the actor's threads (see + // `membership`), and a whole-channel cut covers them all. Each count + // ranges over author time from the position less the arrival skew + // (served by idx_events_community_channel_created or + // idx_thread_metadata_window), filters on arrival, and stops at the + // cap: rows the filters reject are read but bound nothing. The latest message is the last arrival among + // the newest messages and everything counted, so marking through it + // reads every counted message whatever its author time. + let sql = format!( 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.name, c.channel_type::text AS channel_type, + c.archived_at IS NOT NULL AS archived, cm.hidden_at IS NOT NULL AS hidden, + GREATEST(cf.through_timestamp, cm.joined_at, s.started) AS position, + GREATEST(cf.threads_through_timestamp, s.started) AS threads_floor FROM channel_members cm JOIN channels c ON c.community_id=cm.community_id AND c.id=cm.channel_id + CROSS JOIN (SELECT COALESCE((SELECT started_at FROM personal_read_accounts + WHERE community_id=$1 AND actor=$2), 'infinity') AS started) s + LEFT JOIN personal_read_frontiers cf ON cf.community_id=cm.community_id + AND cf.actor=cm.pubkey AND cf.channel_id=c.id AND cf.root_id=''::bytea WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL AND c.deleted_at IS NULL AND ($3::uuid IS NULL OR c.id>$3) - AND ($9::uuid[] IS NULL OR c.id=ANY($9)) - ORDER BY c.id LIMIT $4 + AND ($4::uuid[] IS NULL OR c.id=ANY($4)) + ORDER BY c.id LIMIT $5 ) - 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.id, r.name, r.channel_type, r.archived, r.hidden, + (SELECT v.id FROM (VALUES (latest.arrival, latest.id), + (timeline.arrival, timeline.id), (threads.arrival, threads.id)) v(arrival, id) + WHERE v.id IS NOT NULL ORDER BY v.arrival DESC, v.id LIMIT 1) AS latest_message_id, + latest.at AS latest_message_at, + COALESCE(timeline.unread, 0) AS unread, COALESCE(timeline.attention, 0) AS attention, + COALESCE(threads.items, '[]') AS threads + FROM roster r LEFT JOIN LATERAL ( - WITH candidates AS MATERIALIZED ( - SELECT id,created_at,received_at,kind,deleted_at 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(id,'hex') ORDER BY received_at DESC,id))[1] AS id, + max(received_at) AS arrival, max(extract(epoch FROM created_at)::bigint) AS at + FROM (SELECT id,created_at,received_at FROM events + WHERE community_id=$1 AND channel_id=r.id AND kind=ANY($6) AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT $7) newest ) 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 LEFT JOIN LATERAL ( - WITH candidates AS MATERIALIZED ( - 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 ( - 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, - COALESCE(e.received_at <= - 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 - LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id + SELECT count(*)::int AS unread, count(*) FILTER (WHERE directed)::int AS attention, + (array_agg(encode(id,'hex') ORDER BY received_at DESC,id))[1] AS id, + max(received_at) AS arrival + FROM ( + SELECT e.id, e.received_at, r.channel_type='dm' OR {DIRECTED_TAGS} AS directed + FROM events e + LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.event_created_at=e.created_at AND tm.event_id=e.id - 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 - ), 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, - -- ->>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. - CASE WHEN octet_length(tags::text)<=8192 - AND jsonb_typeof(tags)='array' THEN - (SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array' - OR jsonb_path_exists(tag,'strict $[*] ? (@.type() != "string")')) - 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 - FROM jsonb_array_elements(tags) t(tag) - WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts, - count(*) AS n, - (extract(epoch FROM max(received_at))*1000000)::bigint AS newest_arrival, - (array_agg(encode(id,'hex') ORDER BY received_at DESC,id))[1] AS newest_id, - (array_agg(extract(epoch FROM created_at)::bigint - 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 - ) - SELECT scan.*, (SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence FROM scan - ) 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(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) + WHERE e.community_id=$1 AND e.channel_id=r.id + AND e.created_at >= r.position-$8 AND e.received_at > r.position + AND e.kind=ANY($6) AND e.pubkey<>$2 AND e.deleted_at IS NULL AND {ON_TIMELINE} + ORDER BY e.created_at LIMIT $9 + ) counted + ) timeline ON true + LEFT JOIN LATERAL ( + SELECT jsonb_agg(jsonb_build_object('root_id',encode(tf.root_id,'hex'), + 'unread',n.unread,'latest_reply_id',n.id,'latest_reply_at',n.at)) AS items, + (array_agg(n.id ORDER BY n.arrival DESC,n.id))[1] AS id, max(n.arrival) AS arrival + FROM personal_read_frontiers tf + CROSS JOIN LATERAL (SELECT GREATEST(tf.through_timestamp, r.threads_floor) AS position) p + CROSS JOIN LATERAL ( + SELECT count(*)::int AS unread, + (array_agg(encode(id,'hex') ORDER BY received_at DESC,id))[1] AS id, + max(extract(epoch FROM created_at)::bigint) AS at, + max(received_at) AS arrival + FROM ( + SELECT e.id, e.created_at, e.received_at + FROM thread_metadata tm JOIN events e ON e.community_id=$1 + AND e.created_at=tm.event_created_at AND e.id=tm.event_id + WHERE tm.community_id=$1 AND tm.root_event_id=tf.root_id + AND tm.channel_id=r.id AND tm.event_id<>tf.root_id + AND tm.event_created_at >= p.position-$8 + AND e.received_at > p.position AND e.kind=ANY($6) AND e.pubkey<>$2 + AND e.deleted_at IS NULL AND NOT (tm.depth=1 AND tm.broadcast) + ORDER BY tm.event_created_at LIMIT $9 + ) counted + ) n + WHERE tf.community_id=$1 AND tf.actor=$2 AND tf.channel_id=r.id + AND tf.root_id<>''::bytea AND n.unread > 0 + ) threads ON true + ORDER BY r.id"# + ) + .replace("$hex", "$10"); + // Only compile-time constants are interpolated. + let rows = sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(community.as_uuid()) + .bind(actor.to_bytes().as_slice()) + .bind(after) .bind(only) - .fetch_all(&mut *tx).await?; + .bind((limit + 1) as i64) + .bind(ELIGIBLE_KINDS.as_slice()) + .bind(LATEST_PROBE as i64) + .bind(skew()) + .bind(i64::from(UNREAD_CAP)) + .bind(actor.to_hex()) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; let has_more = rows.len() > limit; - let mut channels = Vec::new(); - let mut pending = Vec::new(); + let mut channels = Vec::with_capacity(limit); for row in rows.into_iter().take(limit) { - let evidence: Value = row.try_get("evidence")?; - let evidence = evidence - .as_array() - .ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?; - let complete = 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 threads: HashMap, Replies> = HashMap::new(); - let mut undirected = Vec::new(); - for e in evidence { - let n = e["n"] - .as_u64() - .filter(|n| *n <= MAX_UNREAD_SCAN as u64) - .ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))? - as u32; - 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; - } - let directed = - channel_type == "dm" || facts.get("directed") == Some(&Value::Bool(true)); - // Roots are timeline messages; descendants belong exclusively - // to their canonical thread. Never inherit the channel prefix. - if e["is_reply"] != true { - unread += n; - if directed { - attention += n; - } - continue; - } - let Some(root) = e["root"].as_str().and_then(writes::event_id) else { - unread_complete = false; - continue; - }; - let replies = Replies { - n, - newest: ( - e["newest_arrival"].as_i64().ok_or_else(invalid_newest)?, - e["newest_id"] - .as_str() - .ok_or_else(invalid_newest)? - .to_owned(), - ), - newest_at: e["newest_at"].as_i64().ok_or_else(invalid_newest)?, - }; - // A directed reply counts whatever its conversation. Any other - // reply counts only in one of the actor's conversations. - if directed { - 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)); + let threads: Vec = serde_json::from_value(row.try_get("threads")?) + .map_err(|_| DbError::InvalidData("invalid thread summaries".into()))?; + let thread_unread: u32 = threads.iter().map(|t| t.unread).sum(); + let unread = row.try_get::("unread")? as u32; + let attention = row.try_get::("attention")? as u32; channels.push(ChannelReadSummary { channel_id: row.try_get("id")?, name: row.try_get("name")?, - channel_type, + channel_type: row.try_get("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, + unread: clamp(unread + thread_unread), + attention: clamp(attention + thread_unread), + unread_thread_count: clamp(threads.len() as u32), 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, - }, + threads: summarize(threads.into_iter().map(ThreadRow::into).collect()), }); } - let targets: Vec<_> = channels - .iter() - .zip(&pending) - .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 (root, parent, replies) in undirected { - match members.get(&(channel.channel_id, parent)) { - Some(true) => count(&mut threads, root, replies), - Some(false) => {} - None => complete = false, - } - } - 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); - } let next_cursor = if has_more { channels.last().map(|c| c.channel_id) } else { None }; - tx.commit().await?; Ok(SidebarPage { - account, channels, next_cursor, }) } } -/// Uncovered replies in one canonical thread: one evidence group, or the -/// thread's counted total. -struct Replies { - n: u32, - /// Last of them to arrive: (arrival microseconds, lowercase hex ID). - newest: (i64, String), - /// Its author seconds. - newest_at: i64, +/// Newest messages, by author time, that join the counted ones as latest candidates. +pub(super) const LATEST_PROBE: usize = 32; + +pub(super) fn skew() -> chrono::Duration { + chrono::Duration::seconds(i64::from(MAX_ARRIVAL_SKEW_SECONDS)) } -/// Add replies that count to their thread. -fn count(threads: &mut HashMap, Replies>, root: Vec, replies: Replies) { - match threads.entry(root) { - Entry::Vacant(slot) => { - slot.insert(replies); - } - Entry::Occupied(mut slot) => { - let thread = slot.get_mut(); - thread.n += replies.n; - // Latest arrival first; equal arrivals break toward the smaller ID. - if (replies.newest.0, Reverse(&replies.newest.1)) - > (thread.newest.0, Reverse(&thread.newest.1)) - { - thread.newest = replies.newest; - thread.newest_at = replies.newest_at; - } +fn clamp(n: u32) -> u32 { + n.min(UNREAD_CAP) +} + +#[derive(Deserialize)] +struct ThreadRow { + root_id: String, + unread: u32, + latest_reply_id: String, + latest_reply_at: i64, +} + +impl From for ThreadReadSummary { + fn from(t: ThreadRow) -> Self { + Self { + root_id: t.root_id, + unread: clamp(t.unread), + latest_reply_id: t.latest_reply_id, + latest_reply_at: t.latest_reply_at, } } } -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 { +/// Order newest unread reply first (root ID breaks ties) and cap the list. +pub(super) fn summarize(mut items: Vec) -> Vec { items.sort_unstable_by(|a, b| { b.latest_reply_at .cmp(&a.latest_reply_at) .then_with(|| a.root_id.cmp(&b.root_id)) }); - let complete = evidence_complete && items.len() <= MAX_THREAD_SUMMARIES; items.truncate(MAX_THREAD_SUMMARIES); - ThreadSummaries { items, complete } -} - -/// Read-time horizon only: frontier state is not discarded on expiry. -pub(super) async fn read_account( - conn: &mut PgConnection, - retention_seconds: u32, -) -> Result { - let cutoff: DateTime = sqlx::query_scalar( - "SELECT date_trunc('milliseconds',transaction_timestamp()-make_interval(secs=>$1::double precision))", - ) - .bind(f64::from(retention_seconds)) - .fetch_one(conn) - .await?; - Ok(ReadAccount { - retention_seconds, - cutoff_ms: cutoff.timestamp_millis(), - }) + items } #[cfg(test)] @@ -403,7 +255,7 @@ mod tests { fn item(root: u8, at: i64) -> ThreadReadSummary { ThreadReadSummary { root_id: hex::encode([root; 32]), - unread: ReadCount::Exact { value: 1 }, + unread: 1, latest_reply_id: hex::encode([root; 32]), latest_reply_at: at, } @@ -418,8 +270,8 @@ mod tests { .rev() .map(|i| item(i, i64::from(i / 2))) .collect(); - let summaries = summarize(items, true); - let roots: Vec<_> = summaries.items.iter().map(|t| t.root_id.clone()).collect(); + let summaries = summarize(items); + let roots: Vec<_> = summaries.iter().map(|t| t.root_id.clone()).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 @@ -428,14 +280,6 @@ mod tests { .map(|(_, i)| hex::encode([i; 32])) .collect(); assert_eq!(roots, expected, "count {count}"); - assert_eq!(summaries.complete, count <= 5, "count {count}"); } } - - #[test] - fn thread_summaries_are_incomplete_when_evidence_is() { - let summaries = summarize(vec![item(1, 1)], false); - assert_eq!(summaries.items.len(), 1); - assert!(!summaries.complete); - } } diff --git a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs index 27fdc713a0..8a1436cb1f 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,19 +1,10 @@ +//! The sidebar's SQL and the per-message selector apply the same rules. use super::{classification, 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() { +async fn sidebar_sql_eligibility_matches_selector() { let (db, pool, community, channel, actor, event) = fixture().await; let query = [ContextQuery { target: ReadTarget { @@ -22,182 +13,37 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() { }, message_ids: vec![event.id.to_hex()], }]; - 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] { 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] { - 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") - .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(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .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(); - assert_eq!( - serde_json::to_value(&contexts).unwrap()["contexts"][0]["messages"][0] - ["status"], - ["not_counted", "unread"][expected as usize], - "kind={kind} own={own} deleted={deleted} age={age}" - ); - } + sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END 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).execute(&pool).await.unwrap(); + let page = db + .personal_read_sidebar(community, &actor.public_key(), 20, None) + .await + .unwrap(); + let expected = u32::from(ELIGIBLE_KINDS.contains(&kind) && !own && !deleted); + assert_eq!( + page.channels[0].unread, expected, + "kind={kind} own={own} deleted={deleted}" + ); + let contexts = db + .personal_read_contexts(community, &actor.public_key(), &query) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(&contexts).unwrap()["contexts"][0]["messages"][0] + ["status"], + ["not_counted", "unread"][expected as usize], + "kind={kind} own={own} deleted={deleted}" + ); } } } } -#[tokio::test] -#[ignore = "requires Postgres"] -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), - ] { - sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") - .bind(community.as_uuid()) - .bind(&tags) - .execute(&pool) - .await - .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .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:?}" - ); - } - } -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn sidebar_ancestry_fact_matches_shared_nip10_parser() { - let (db, pool, community, _, actor, _) = fixture().await; - let ids = [ - "a".repeat(64), - "A".repeat(64), - "aB09".repeat(16), - "a".repeat(63), - "a".repeat(65), - "g".repeat(64), - "١".repeat(64), - "A".repeat(64), - format!("{}\n", "a".repeat(64)), - ]; - let mut cases: Vec>> = Vec::new(); - for id in ids { - for marker in ["reply", "Reply", "root", "mention"] { - cases.push(vec![vec!["e".into(), id.clone(), "".into(), marker.into()]]); - } - } - cases.push(vec![vec!["e".into(), "a".repeat(64), "reply".into()]]); - cases.push(vec![ - vec!["e".into(), "g".repeat(64), "".into(), "reply".into()], - vec!["e".into(), "a".repeat(64), "".into(), "reply".into()], - ]); - cases.push(vec![ - vec!["e".into(), "a".repeat(64), "".into(), "reply".into()], - vec!["e".into(), "g".repeat(64), "".into(), "reply".into()], - ]); - for tags in cases { - let reply = - buzz_core::nip10::parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice)) - .resolve() - .is_some(); - sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") - .bind(community.as_uuid()) - .bind(json!(tags)) - .execute(&pool) - .await - .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!( - if reply { - matches!(page.channels[0].unread, ReadCount::Unknown) - } else { - matches!(page.channels[0].unread, ReadCount::Exact { value: 1 }) - }, - "tags={tags:?}" - ); - } -} - #[tokio::test] #[ignore = "requires Postgres"] async fn sidebar_directed_fact_matches_selector_classifier() { @@ -212,9 +58,11 @@ async fn sidebar_directed_fact_matches_selector_classifier() { [], [["p", actor_hex]], [["p", actor_hex.to_uppercase()]], + [["p", actor_hex, "relay", "petname"]], [["p", fullwidth]], [["p"]], [["broadcast", "1"]], + [["broadcast", "1", "extra"]], [["broadcast", "true"]], [["p", "00".repeat(32)]], [["broadcast", "0"], ["p", actor_hex.to_uppercase()]] @@ -239,23 +87,16 @@ async fn sidebar_directed_fact_matches_selector_classifier() { .execute(&pool) .await .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) + let row = db + .personal_read_sidebar(community, &actor.public_key(), 20, None) .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}" + .unwrap() + .channels + .remove(0); + assert_eq!( + (row.unread, row.attention), + (1, 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..3552d2273c 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,142 +1,314 @@ -//! Whole-channel reads, thread summaries and targeted refresh. -use super::{postgres_tests::fixture, *}; -use crate::{ - channel::{ChannelType, ChannelVisibility}, - Db, +//! Thread membership, whole-channel reads, thread summaries and targeted refresh. +use super::postgres_tests::{ + apply, channel_read, fixture, mark, mention, now, post, post_kind, reply, sidebar, start, + states, status, }; -use buzz_core::CommunityId; -use nostr::{EventBuilder, Keys, Kind, Tag}; +use super::*; +use crate::channel::{ChannelType, ChannelVisibility}; +use buzz_core::{channel::MemberRole, CommunityId}; +use nostr::Keys; use sqlx::PgPool; use uuid::Uuid; -async fn post( - db: &Db, - community: CommunityId, - channel: Uuid, - at: u64, - tags: Vec, -) -> nostr::Event { - post_as(db, community, channel, &Keys::generate(), at, tags).await -} - -async fn post_as( - db: &Db, - community: CommunityId, - channel: Uuid, - author: &Keys, - at: u64, - tags: Vec, -) -> nostr::Event { - let event = EventBuilder::new(Kind::Custom(9), format!("message at {at}")) - .tags(tags) - .custom_created_at(nostr::Timestamp::from(at)) - .sign_with_keys(author) - .unwrap(); - db.insert_event(community, &event, Some(channel)) - .await - .unwrap(); - event -} - -/// A canonical reply to the root: stored event plus its thread metadata. -async fn reply( - db: &Db, - pool: &PgPool, - community: CommunityId, - channel: Uuid, - root: &nostr::Event, - at: u64, - tags: Vec, -) -> nostr::Event { - let event = post(db, community, channel, at, tags).await; - link(pool, community, channel, root, &event).await; - event -} - -async fn link( - pool: &PgPool, - community: CommunityId, - channel: Uuid, - root: &nostr::Event, - event: &nostr::Event, -) { - 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()) - .bind(event.created_at.as_secs() as f64) - .bind(channel).bind(root.id.as_bytes().as_slice()).execute(pool).await.unwrap(); -} - -/// Put the actor in the root's conversation, so that plain replies to it -/// count. The actor's own reply is never unread. -async fn join( - db: &Db, - pool: &PgPool, - community: CommunityId, - channel: Uuid, - actor: &Keys, - root: &nostr::Event, -) { - let at = root.created_at.as_secs(); - let own = post_as(db, community, channel, actor, at, vec![]).await; - link(pool, community, channel, root, &own).await; -} - -async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSummary { - db.personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, +async fn thread_rows(pool: &PgPool, community: CommunityId, actor: &Keys) -> Vec> { + sqlx::query_scalar( + "SELECT root_id FROM personal_read_frontiers + WHERE community_id=$1 AND actor=$2 AND root_id<>''::bytea ORDER BY root_id", ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .fetch_all(pool) .await .unwrap() - .channels - .remove(0) } -async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) -> IntentOutcome { - db.apply_personal_read_intent(community, &actor.public_key(), &intent) +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replying_mentioning_and_starting_a_thread_make_members() { + let (db, pool, community, channel, actor, _) = fixture().await; + let base = now(); + let starter = Keys::generate(); + let bystander = Keys::generate(); + for who in [&starter, &bystander] { + db.add_member( + community, + channel, + &who.public_key().to_bytes(), + MemberRole::Member, + None, + ) + .await + .unwrap(); + start(&pool, community, who).await; + } + let root = post(&db, community, channel, &starter, base, vec![]).await; + assert!( + thread_rows(&pool, community, &starter).await.is_empty(), + "a root alone is no thread" + ); + + // The actor replies: a member, with its own reply read. The root's author + // becomes a member with the reply unread. + let own = reply( + &db, + community, + channel, + &root, + &actor, + base + 1, + vec![], + false, + ) + .await; + let root_row = vec![root.id.as_bytes().to_vec()]; + assert_eq!(thread_rows(&pool, community, &actor).await, root_row); + assert_eq!(thread_rows(&pool, community, &starter).await, root_row); + assert!(thread_rows(&pool, community, &bystander).await.is_empty()); + let row = sidebar(&db, community, &starter).await; + assert_eq!( + (row.unread, row.attention, row.unread_thread_count), + (1, 1, 1) + ); + assert_eq!(row.threads[0].latest_reply_id, own.id.to_hex()); + assert_eq!( + states(&db, community, &starter, channel, Some(&root), &[&own]).await, + [serde_json::json!({"status":"unread","reason":"conversation"})] + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!((row.unread_thread_count, row.threads.len()), (0, 0)); + + // Someone else replies: the actor, now a member, has it unread, beside + // the fixture's message and the root on the timeline. + let other = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base + 2, + vec![], + false, + ) + .await; + let row = sidebar(&db, community, &actor).await; + assert_eq!( + (row.unread, row.attention, row.unread_thread_count), + (3, 1, 1) + ); + assert_eq!(row.threads[0].unread, 1); + assert_eq!(row.threads[0].latest_reply_id, other.id.to_hex()); + + // A mention makes a channel member a thread member; a non-member gets nothing. + let outsider = Keys::generate(); + let mut tags = mention(&bystander); + tags.extend(mention(&outsider)); + let mentioned = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base + 3, + tags, + false, + ) + .await; + assert_eq!(thread_rows(&pool, community, &bystander).await, root_row); + assert!(thread_rows(&pool, community, &outsider).await.is_empty()); + let row = sidebar(&db, community, &bystander).await; + // The mention, and the root on the timeline. + assert_eq!( + (row.unread, row.attention, row.unread_thread_count), + (2, 1, 1) + ); + assert_eq!( + states( + &db, + community, + &bystander, + channel, + Some(&root), + &[&other, &mentioned] + ) + .await, + [ + serde_json::json!({"status":"read"}), + serde_json::json!({"status":"unread","reason":"mention"}) + ] + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn reading_a_thread_moves_a_members_row_and_never_joins() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = now(); + let first = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base, + vec![], + false, + ) + .await; + let reader = Keys::generate(); + // Non-members may mark (the outcome is the same), but get no row. + assert_eq!( + apply(&db, community, &reader, mark(channel, Some(&root), &first)).await, + IntentOutcome::Applied + ); + assert!(thread_rows(&pool, community, &reader).await.is_empty()); + let later = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base + 1, + vec![], + false, + ) + .await; + assert_eq!( + status(&db, community, &reader, channel, Some(&root), &[&later]).await, + ["not_counted"] + ); + + // A member's row moves through the anchor, which may be the root itself. + reply( + &db, + community, + channel, + &root, + &actor, + base + 2, + vec![], + false, + ) + .await; + let newest = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base + 3, + vec![], + false, + ) + .await; + assert_eq!(sidebar(&db, community, &actor).await.threads[0].unread, 1); + assert_eq!( + apply(&db, community, &actor, mark(channel, Some(&root), &root)).await, + IntentOutcome::Applied + ); + assert_eq!( + sidebar(&db, community, &actor).await.unread_thread_count, + 1, + "never backwards" + ); + assert_eq!( + apply(&db, community, &actor, mark(channel, Some(&root), &newest)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!( + (row.unread, row.unread_thread_count), + (1, 0), + "the root itself is on the timeline" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn dm_replies_make_every_member_a_thread_member() { + let (db, pool, community, _, actor, _) = fixture().await; + let peer = Keys::generate(); + let dm = db + .create_channel( + community, + "dm", + ChannelType::Dm, + ChannelVisibility::Private, + None, + &peer.public_key().to_bytes(), + None, + ) .await .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, - } + .id; + let inviter = peer.public_key().to_bytes(); + db.add_member( + community, + dm, + &actor.public_key().to_bytes(), + MemberRole::Member, + Some(&inviter), + ) + .await + .unwrap(); + let root = post(&db, community, dm, &Keys::generate(), now(), vec![]).await; + reply(&db, community, dm, &root, &peer, now(), vec![], false).await; + assert_eq!( + thread_rows(&pool, community, &actor).await, + vec![root.id.as_bytes().to_vec()] + ); + let row = db + .personal_read_sidebar_channels(community, &actor.public_key(), &[dm]) + .await + .unwrap() + .channels + .remove(0); + assert_eq!( + (row.unread, row.attention, row.unread_thread_count), + (2, 2, 1) + ); } #[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; - 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; + let (db, _, community, channel, actor, root) = fixture().await; + let base = now(); + reply(&db, community, channel, &root, &actor, base, vec![], false).await; + let first = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base + 10, + vec![], + false, + ) + .await; + post( + &db, + community, + channel, + &Keys::generate(), + base + 20, + vec![], + ) + .await; + let second = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base + 30, + vec![], + false, + ) + .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(), - }; + // A plain reply is no timeline anchor, but anchors a whole-channel read. assert_eq!( - apply(&db, community, &actor, through_reply).await, + apply(&db, community, &actor, mark(channel, None, &first)).await, IntentOutcome::Blocked ); assert_eq!( @@ -144,14 +316,9 @@ async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { 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!(row.unread, 2, "top at +20 and reply at +30 remain"); + assert_eq!(row.threads.len(), 1); + assert_eq!(row.threads[0].latest_reply_id, second.id.to_hex()); assert_eq!( apply(&db, community, &actor, channel_read(channel, &second)).await, @@ -159,444 +326,250 @@ async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { ); let row = sidebar(&db, community, &actor).await; assert_eq!( - exact(&row.unread), - Some(0), - "every thread is covered by the cut" + (row.unread, row.unread_thread_count), + (0, 0), + "every thread is covered" ); - 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; + // A reply that arrives after the cut is unread, though backdated. + let late = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base + 5, + vec![], + false, + ) + .await; + let newer = reply( + &db, + community, + channel, + &root, + &Keys::generate(), + base + 40, + vec![], + false, + ) + .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( + assert_eq!(row.unread, 2); + assert_eq!(row.threads[0].latest_reply_id, newer.id.to_hex()); + assert_eq!( + status( + &db, 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()], - }], + &actor, + channel, + Some(&root), + &[&second, &late, &newer] ) - .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"); + .await, + ["read", "unread", "unread"] + ); } #[tokio::test] #[ignore = "requires Postgres"] -async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independent() { +async fn mark_channel_read_validates_its_anchor() { 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. - let orphan_parent = "ab".repeat(32); - let orphan = post( - &db, - community, - channel, - base + 10, - vec![Tag::parse(["e", &orphan_parent, "", "reply"]).unwrap()], - ) - .await; - assert!(db - .apply_personal_read_intent( - community, - &actor.public_key(), - &ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None - }, - message_id: 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(); + for (message_id, outcome) in [ + ("cd".repeat(32), IntentOutcome::Blocked), + ("zz".to_owned(), IntentOutcome::Invalid), + ] { + let intent = ReadIntent::MarkChannelRead { + channel_id: channel, + message_id, + }; + assert_eq!(apply(&db, community, &actor, intent).await, outcome); + } + let reaction = post_kind(&db, community, channel, &Keys::generate(), 7, now(), vec![]).await; 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, + apply(&db, community, &actor, mark(channel, None, &root)).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()) + .bind(actor.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()); } #[tokio::test] #[ignore = "requires Postgres"] -async fn absent_whole_channel_cut_never_covers_epoch_zero_replies() { - 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. - apply( - &db, - community, - &actor, - ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_id: 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. - 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); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { - 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; +async fn thread_summaries_order_cap_and_count_every_unread_thread() { + let (db, _, community, channel, actor, _) = fixture().await; + let base = now(); let mut roots = Vec::new(); for i in 0..6 { - let root = post(&db, community, channel, base + i, vec![]).await; - join(&db, &pool, community, channel, &actor, &root).await; + let root = post(&db, community, channel, &actor, base + i, vec![]).await; roots.push(root); } - let newest_root = roots.last().unwrap().clone(); - apply( - &db, - community, - &actor, - ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: None, - }, - message_id: 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; thread 2 has three replies. let mut anchors = Vec::new(); for (i, root) in roots.iter().enumerate() { let at = base + 100 + [50, 50, 40, 30, 20, 10][i]; - anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await); + anchors.push( + reply( + &db, + community, + channel, + root, + &Keys::generate(), + at, + vec![], + false, + ) + .await, + ); + } + // Arriving last, with older author times: the anchor, not the display time. + let mut last = None; + for at in [base + 101, base + 102] { + last = Some( + reply( + &db, + community, + channel, + &roots[2], + &Keys::generate(), + at, + vec![], + false, + ) + .await, + ); } - reply( - &db, - &pool, - community, - channel, - &roots[2], - base + 101, - vec![Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()], - ) - .await; - let twin = reply( - &db, - &pool, - community, - channel, - &roots[2], - base + 140, - vec![], - ) - .await; - sqlx::query( - "UPDATE events SET received_at=(SELECT received_at FROM events WHERE id=$1) WHERE id=$2", - ) - .bind(twin.id.as_bytes().as_slice()) - .bind(anchors[2].id.as_bytes().as_slice()) - .execute(&pool) - .await - .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"); + // The fixture's message is one ordinary timeline unread. + assert_eq!((row.unread, row.attention), (9, 8)); + assert_eq!( + row.unread_thread_count, 6, + "counts threads beyond the listed five" + ); 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: Vec<_> = row.threads.iter().map(|t| t.root_id.clone()).collect(); 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(), + 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, - 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.threads[2].unread, 3); + assert_eq!(row.threads[2].latest_reply_id, last.unwrap().id.to_hex()); + assert_eq!(row.threads[2].latest_reply_at, (base + 140) as i64); + // The row's activity is the greatest author time among the newest messages. 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 a listed thread through its anchor promotes the sixth. + let first = &row.threads[0]; + let intent = ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: Some(first.root_id.clone()), + }, + message_id: first.latest_reply_id.clone(), + }; assert_eq!( - apply(&db, community, &actor, first).await, + apply(&db, community, &actor, intent).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!((row.unread_thread_count, row.threads.len()), (5, 5)); + // Plus the fixture's timeline message. assert_eq!( - Some(sum), - exact(&row.unread), - "complete summaries account for every reply" + row.unread, + 1 + row.threads.iter().map(|t| t.unread).sum::() ); } -impl ThreadReadSummary { - fn clone_target(&self, channel: Uuid) -> ReadIntent { - ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: channel, - root_id: Some(self.root_id.clone()), - }, - message_id: self.latest_reply_id.clone(), - } - } -} - #[tokio::test] #[ignore = "requires Postgres"] async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() { - let (db, pool, community, channel, actor, root) = fixture().await; - let base = root.created_at.as_secs(); - let mention = || vec![Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]; + let (db, _, community, channel, actor, root) = fixture().await; + let base = now(); // A diff is never unread, not even one addressed to the actor. - let diff = EventBuilder::new(Kind::Custom(40008), "a diff") - .tags(mention()) - .custom_created_at(nostr::Timestamp::from(base + 1)) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &diff, Some(channel)) - .await - .unwrap(); - let on_diff = reply(&db, &pool, community, channel, &diff, base + 10, mention()).await; - 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]; - 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"); - - // The diff is still no anchor; the listed reply reads its thread alone. - let through_diff = ReadIntent::MarkThrough { - target: thread, - message_id: diff.id.to_hex(), - }; - assert_eq!( - apply(&db, community, &actor, through_diff).await, - IntentOutcome::Blocked - ); - assert_eq!( - apply(&db, community, &actor, listed.clone_target(channel)).await, - 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( + let diff = post_kind( + &db, + community, + channel, + &Keys::generate(), + 40008, + base, + mention(&actor), + ) + .await; + let on_diff = reply( + &db, + community, + channel, + &diff, + &Keys::generate(), + base + 10, + mention(&actor), + false, + ) + .await; + let elsewhere = reply( &db, - &pool, community, channel, &root, - root.created_at.as_secs() + 1, - vec![], + &Keys::generate(), + base + 20, + mention(&actor), + false, ) .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, 3, "the root and two replies"); + 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()); + assert_eq!( + states(&db, community, &actor, channel, Some(&diff), &[&on_diff]).await, + [serde_json::json!({"status":"unread","reason":"mention"})] + ); + assert_eq!( + status(&db, community, &actor, channel, None, &[&diff]).await, + ["not_counted"] + ); + // The diff is no anchor; the listed reply reads its thread alone. + assert_eq!( + apply(&db, community, &actor, mark(channel, Some(&diff), &diff)).await, + IntentOutcome::Blocked + ); + assert_eq!( + apply(&db, community, &actor, mark(channel, Some(&diff), &on_diff)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!(row.unread, 2); + assert_eq!(row.threads.len(), 1); + assert_eq!(row.threads[0].latest_reply_id, elsewhere.id.to_hex()); } #[tokio::test] #[ignore = "requires Postgres"] -async fn targeted_sidebar_returns_only_requested_joined_channels_in_one_snapshot() { +async fn targeted_sidebar_returns_only_requested_joined_channels() { let (db, _pool, community, channel, actor, _) = fixture().await; let create = |name: &'static str, owner: Keys| { let db = db.clone(); @@ -619,12 +592,7 @@ async fn targeted_sidebar_returns_only_requested_joined_channels_in_one_snapshot let foreign = create("not joined", Keys::generate()).await; let _unrequested = create("unrequested", actor.clone()).await; let page = db - .personal_read_sidebar_channels( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &[joined, foreign, channel], - ) + .personal_read_sidebar_channels(community, &actor.public_key(), &[joined, foreign, channel]) .await .unwrap(); let mut expected = vec![channel, joined]; @@ -638,12 +606,7 @@ async fn targeted_sidebar_returns_only_requested_joined_channels_in_one_snapshot (0..21).map(|_| Uuid::new_v4()).collect(), ] { assert!(db - .personal_read_sidebar_channels( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &bad - ) + .personal_read_sidebar_channels(community, &actor.public_key(), &bad) .await .is_err()); } diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index 9947d2b7ab..a4940cb5fb 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -23,22 +23,26 @@ pub(super) async fn deadlines(conn: &mut PgConnection) -> Result<()> { Ok(()) } -/// Serialize private frontier writes, never shared conversation rows. +/// Start the account if this is the actor's first read intent, then serialize +/// private frontier writes, never shared conversation rows. NO KEY UPDATE +/// leaves ingest's foreign-key checks (which create thread rows) unblocked. pub(super) async fn lock_account( conn: &mut PgConnection, community: CommunityId, actor: &[u8], ) -> Result<()> { sqlx::query( - "INSERT INTO personal_read_accounts (community_id,actor) VALUES ($1,$2) - ON CONFLICT (community_id,actor) DO NOTHING", + "INSERT INTO personal_read_accounts (community_id,actor,started_at) VALUES ($1,$2,now()) + ON CONFLICT (community_id,actor) DO UPDATE SET started_at=now() + WHERE personal_read_accounts.started_at IS NULL", ) .bind(community.as_uuid()) .bind(actor) .execute(&mut *conn) .await?; sqlx::query( - "SELECT actor FROM personal_read_accounts WHERE community_id=$1 AND actor=$2 FOR UPDATE", + "SELECT actor FROM personal_read_accounts WHERE community_id=$1 AND actor=$2 + FOR NO KEY UPDATE", ) .bind(community.as_uuid()) .bind(actor) @@ -83,11 +87,14 @@ async fn access( struct Message { id: Vec, received_at: DateTime, - root: Option>, + /// Canonical thread root, when the message is a reply. + thread: Option>, + /// Shown on the channel timeline: not a reply, or a broadcast depth-1 reply. + on_timeline: bool, } -/// A channel event and its canonical ancestry. `kinds` bounds the lookup: an -/// anchor must be a kind that can be unread; a thread root need not be. +/// A channel event and its place. `kinds` bounds the lookup: an anchor must be +/// a kind that can be unread; a thread root need not be. async fn message( conn: &mut PgConnection, community: CommunityId, @@ -96,7 +103,7 @@ async fn message( kinds: Option<&[i32]>, ) -> Result> { let row = sqlx::query( - "SELECT e.id, e.received_at, e.tags, tm.root_event_id + "SELECT e.id, e.received_at, tm.root_event_id, tm.depth, tm.broadcast FROM events e LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id AND tm.event_created_at=e.created_at AND tm.event_id=e.id AND tm.channel_id=e.channel_id WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=$3 @@ -104,23 +111,16 @@ async fn message( LIMIT 1", ).bind(community.as_uuid()).bind(channel).bind(id).bind(kinds).fetch_optional(&mut *conn).await?; row.map(|row| { - let received_at: DateTime = row.try_get("received_at")?; let id: Vec = row.try_get("id")?; let root: Option> = row.try_get("root_event_id")?; - let tags: serde_json::Value = row.try_get("tags")?; - let tags: Vec> = serde_json::from_value(tags) - .map_err(|_| crate::DbError::InvalidData("invalid message tags".into()))?; - let markers = - buzz_core::nip10::parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice)); - if markers.resolve().is_some() && root.is_none() { - return Err(crate::DbError::InvalidData( - "unresolved message ancestry".into(), - )); - } + let thread = root.filter(|root| root != &id); + let broadcast_reply = row.try_get::, _>("depth")? == Some(1) + && row.try_get::, _>("broadcast")? == Some(true); Ok(Message { + on_timeline: thread.is_none() || broadcast_reply, + received_at: row.try_get("received_at")?, + thread, id, - received_at, - root, }) }) .transpose() @@ -163,36 +163,17 @@ pub(super) async fn valid_target( } if !root.is_empty() { // Deleted roots still own living replies, and so do roots of a kind - // that is never unread itself (a diff). Validate actual ancestry, not - // absence of metadata: a missing index row is not a top-level proof. + // that is never unread itself (a diff). let Some(msg) = message(conn, community, target.channel_id, &root, None).await? else { return Ok(None); }; - if msg.root.as_ref().is_some_and(|r| r != &msg.id) { + if msg.thread.is_some() { return Ok(None); } } Ok(Some(root)) } -async fn frontier( - conn: &mut PgConnection, - community: CommunityId, - actor: &[u8], - target: &ReadTarget, - root: &[u8], - through: DateTime, -) -> Result<()> { - sqlx::query( - "INSERT INTO personal_read_frontiers - (community_id, actor, channel_id, root_id, through_timestamp) VALUES ($1,$2,$3,$4,$5) - 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) - .execute(&mut *conn).await?; - Ok(()) -} - pub(super) async fn apply( conn: &mut PgConnection, community: CommunityId, @@ -218,13 +199,42 @@ pub(super) async fn apply( else { return Ok(IntentOutcome::Blocked); }; - let is_reply = msg.root.as_ref().is_some_and(|r| r != &msg.id); - if (root.is_empty() && is_reply) - || (!root.is_empty() && msg.id != root && msg.root.as_ref() != Some(&root)) - { - return Ok(IntentOutcome::Blocked); + if root.is_empty() { + if !msg.on_timeline { + return Ok(IntentOutcome::Blocked); + } + sqlx::query( + "INSERT INTO personal_read_frontiers + (community_id, actor, channel_id, root_id, through_timestamp) + VALUES ($1,$2,$3,''::bytea,$4) + ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE + SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, $4)", + ) + .bind(community.as_uuid()) + .bind(actor) + .bind(target.channel_id) + .bind(msg.received_at) + .execute(&mut *conn) + .await?; + } else { + if msg.id != root && msg.thread.as_ref() != Some(&root) { + return Ok(IntentOutcome::Blocked); + } + // Reading a thread never joins it: only the actor's threads + // have a row to advance. + sqlx::query( + "UPDATE personal_read_frontiers + SET through_timestamp=GREATEST(through_timestamp, $5) + WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=$4", + ) + .bind(community.as_uuid()) + .bind(actor) + .bind(target.channel_id) + .bind(&root) + .bind(msg.received_at) + .execute(&mut *conn) + .await?; } - frontier(conn, community, actor, target, &root, msg.received_at).await?; } ReadIntent::MarkChannelRead { channel_id, diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5cb515a5b2..7705edc861 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -6150,11 +6150,7 @@ pub(crate) mod postgres_tests { br#"{"outcomes":[{"status":"blocked"}],"projection_status":"not_requested"}"# ); } else { - assert!(value["account"]["cutoff_ms"].is_i64()); - assert_eq!( - value["account"]["retention_seconds"], - state.config.buzz_v1_retention_seconds - ); + assert!(value.get("account").is_none()); if path.contains("sidebar") { assert_eq!(value["channels"], serde_json::json!([])); assert_eq!(value["next_cursor"], serde_json::Value::Null); diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index 01e0e4110e..a006a45719 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -47,7 +47,6 @@ pub(super) async fn sidebar( return Err(Error::invalid()); } let community = principal.tenant.community(); - let retention = state.config.buzz_v1_retention_seconds; let page = match query.channel_ids { Some(ids) => { let ids = parse_channel_ids(&ids) @@ -55,7 +54,7 @@ pub(super) async fn sidebar( .ok_or_else(Error::invalid)?; state .db - .personal_read_sidebar_channels(community, &principal.actor, retention, &ids) + .personal_read_sidebar_channels(community, &principal.actor, &ids) .await } None => { @@ -64,7 +63,6 @@ pub(super) async fn sidebar( .personal_read_sidebar( community, &principal.actor, - retention, query.limit.unwrap_or(MAX_CHANNELS), query.cursor, ) @@ -170,12 +168,7 @@ pub(super) async fn contexts( } let mut page = state .db - .personal_read_contexts( - principal.tenant.community(), - &principal.actor, - state.config.buzz_v1_retention_seconds, - &targets, - ) + .personal_read_contexts(principal.tenant.community(), &principal.actor, &targets) .await .map_err(|_| Error::unavailable())?; auth::recheck(&state, &headers, &principal).await?; 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 58ed929bec..e19614d0d3 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -28,6 +28,19 @@ fn proof(key: &Keys, host: &str, path: &str, method: &str, body: Option<&[u8]>) ) } +/// Start `key`'s read state an hour ago, as a first intent would have. +async fn start(state: &crate::state::AppState, community: buzz_core::CommunityId, key: &Keys) { + sqlx::query( + "INSERT INTO personal_read_accounts (community_id,actor,started_at) + VALUES ($1,$2,now()-interval '1 hour')", + ) + .bind(community.as_uuid()) + .bind(key.public_key().to_bytes().as_slice()) + .execute(state.db.pool()) + .await + .unwrap(); +} + async fn request( state: Arc, host: &str, @@ -165,6 +178,18 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() { .await .unwrap() .id; + state + .db + .add_member( + community, + channel, + &other.public_key().to_bytes(), + buzz_db::channel::MemberRole::Member, + None, + ) + .await + .unwrap(); + start(&state, community, &other).await; let event = EventBuilder::new(Kind::Custom(9), "read by one signer only") .sign_with_keys(&Keys::generate()) .unwrap(); @@ -256,6 +281,7 @@ async fn accessory_context_get_signed_query_and_independent_batch_outcomes() { .await .unwrap() .id; + start(&state, community, &actor).await; let event = EventBuilder::new(Kind::Custom(9), "message selector") .sign_with_keys(&Keys::generate()) .unwrap(); @@ -407,7 +433,6 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { let mut state = (*fixture).clone(); let config = Arc::make_mut(&mut state.config); config.buzz_v1_enabled = enabled; - config.buzz_v1_retention_seconds = 1234; let state = Arc::new(state); for known_host in [true, false] { let request_host = if known_host { @@ -439,7 +464,8 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { 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["unread_cap"], 99); + assert!(d.get("retention_seconds").is_none()); 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); @@ -593,6 +619,7 @@ async fn signed_sidebar_deletion(deletion_kind: u16) { .await .unwrap(); } + start(&state, community, &reader).await; let message = EventBuilder::new(Kind::Custom(9), "unread message to delete") .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) .sign_with_keys(&author) @@ -632,7 +659,7 @@ async fn signed_sidebar_deletion(deletion_kind: u16) { assert_eq!(result["accepted"], true, "{result}"); assert_eq!(result["event_id"], event.id.to_hex(), "{result}"); - // This distinct member never writes a frontier. Reads alone must not + // This distinct member never marks anything. Reads alone must not // erase unread state; only the accepted signed deletion changes it. let auth = proof(&reader, &host, &path, "GET", None); let (status, sidebar) = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; @@ -642,7 +669,7 @@ async fn signed_sidebar_deletion(deletion_kind: u16) { assert_eq!(channels[0]["channel_id"], channel.to_string()); assert_eq!( channels[0]["unread"], - json!({"status":"exact", "value":count}), + json!(count), "kind {deletion_kind}, after kind {}: {sidebar}", event.kind.as_u16() ); diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e7fc70334b..e9b18853bd 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -183,8 +183,6 @@ pub struct Config { pub require_auth_token: bool, /// Opt-in private accessory API; disabled until explicitly deployed. pub buzz_v1_enabled: bool, - /// Author-time unread tracking duration, independent of NIP-RS retention. - pub buzz_v1_retention_seconds: u32, /// Comma-separated list of allowed CORS origins. /// If empty, permissive CORS is used (dev mode). /// Example: "tauri://localhost,http://localhost:3000" @@ -1337,14 +1335,6 @@ impl Config { } let buzz_v1_enabled = std::env::var("BUZZ_V1_ENABLED").is_ok_and(|v| v == "true"); - // Read only when enabled: a disabled relay ignores every v1 setting. - let buzz_v1_retention_seconds = match std::env::var("BUZZ_V1_RETENTION_SECONDS") { - Ok(v) if buzz_v1_enabled => v.parse().ok().filter(|s| *s > 0).ok_or_else(|| { - ConfigError::InvalidValue("BUZZ_V1_RETENTION_SECONDS must be positive".into()) - })?, - _ => buzz_db::personal_read::DEFAULT_RETENTION_SECONDS, - }; - Ok(Self { bind_addr, database_url, @@ -1365,7 +1355,6 @@ impl Config { auth, require_auth_token, buzz_v1_enabled, - buzz_v1_retention_seconds, cors_origins, relay_private_key, uds_path, @@ -1926,33 +1915,6 @@ mod tests { assert!(matches!(admin.auth, crate::config::AdminAuth::Nip98)); } - #[test] - fn buzz_v1_retention_is_validated_only_when_enabled() { - let _guards = env_guards(); - const KEYS: [&str; 2] = ["BUZZ_V1_ENABLED", "BUZZ_V1_RETENTION_SECONDS"]; - let previous = KEYS.map(std::env::var_os); - std::env::set_var("BUZZ_V1_RETENTION_SECONDS", "0"); - std::env::remove_var("BUZZ_V1_ENABLED"); - let disabled = Config::from_env(); - std::env::set_var("BUZZ_V1_ENABLED", "true"); - let enabled = Config::from_env(); - for (key, value) in KEYS.into_iter().zip(previous) { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - assert!( - disabled.is_ok(), - "a disabled relay ignores it: {disabled:?}" - ); - assert!(matches!( - enabled, - Err(ConfigError::InvalidValue(ref message)) - if message.contains("BUZZ_V1_RETENTION_SECONDS") - )); - } - #[test] fn malformed_relay_owner_pubkey_is_a_startup_error_not_warn_and_ignore() { let _guards = env_guards(); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 3ef9524887..a13d70c509 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -88,8 +88,8 @@ pub struct BuzzV1Descriptor { pub version: u32, /// Relay-relative API prefix; callers retain the requesting origin. pub base_path: String, - /// Author-time unread horizon; does not expire messages or frontiers. - pub retention_seconds: u32, + /// Unread counts stop here: a count equal to the cap means at least it. + pub unread_cap: u32, /// Maximum joined channels per sidebar page. pub max_channels: usize, /// Maximum independent write intents per request. @@ -391,12 +391,12 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st 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, + MAX_THREAD_SUMMARIES, UNREAD_CAP, }; 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, + unread_cap: UNREAD_CAP, max_channels: MAX_CHANNELS, max_intents: MAX_INTENTS, max_contexts: MAX_CONTEXTS, diff --git a/migrations/0056_personal_read_state.sql b/migrations/0056_personal_read_state.sql index 41889e8890..aa06f16489 100644 --- a/migrations/0056_personal_read_state.sql +++ b/migrations/0056_personal_read_state.sql @@ -5,9 +5,13 @@ -- covers that thread, without inheritance. -- threads_through_timestamp is the only cross-context cut: an explicit -- whole-channel read that also covers every thread in that channel. +-- started_at is the actor's first read intent and the floor of every +-- position: until then nothing counts, and from then the actor starts caught +-- up. Ingest creates accounts for thread membership without starting them. CREATE TABLE personal_read_accounts ( community_id UUID NOT NULL REFERENCES communities(id), actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), + started_at TIMESTAMPTZ, PRIMARY KEY (community_id, actor) ); diff --git a/schema/schema.sql b/schema/schema.sql index d111488505..a54f877671 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -195,11 +195,15 @@ CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) -- Private accessory read progress. Never included in Nostr event queries. -- A frontier is the relay arrival time (events.received_at) of the message a --- context was read through; the unread cutoff alone is signed event time. An --- empty root_id denotes a channel frontier. +-- context was read through, never signed event time. An empty root_id denotes +-- a channel frontier. +-- started_at is the actor's first read intent and the floor of every +-- position: until then nothing counts, and from then the actor starts caught +-- up. Ingest creates accounts for thread membership without starting them. CREATE TABLE personal_read_accounts ( community_id UUID NOT NULL REFERENCES communities(id), actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), + started_at TIMESTAMPTZ, PRIMARY KEY (community_id, actor) );