diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 6b86b1c1f..5b99d20c0 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -223,6 +223,17 @@ pub const KIND_NIP29_GROUP_MEMBERS: u32 = 39002; /// NIP-29: Addressable group roles definition. pub const KIND_NIP29_GROUP_ROLES: u32 = 39003; +// Channel-window overlays (relay-signed, synthesized at query time, never +// stored). Appended to bridge `/query` responses for `top_level` window +// requests — see docs/bridge-channel-window.md. +/// Thread summary overlay: `e`/`d` tag = root event id, content = +/// `{reply_count, descendant_count, last_reply_at, participants}`. +pub const KIND_THREAD_SUMMARY: u32 = 39005; +/// Window bounds overlay: `d` tag = `:`, +/// content = `{has_more, next_cursor}`. The only authority on exhaustion — +/// clients must not infer `has_more` from row counts. +pub const KIND_WINDOW_BOUNDS: u32 = 39006; + /// Workflow definition (parameterized replaceable, d=workflow_uuid). pub const KIND_WORKFLOW_DEF: u32 = 30620; @@ -471,6 +482,8 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_ROLES, + KIND_THREAD_SUMMARY, + KIND_WINDOW_BOUNDS, KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, KIND_HUDDLE_REACTION, @@ -615,6 +628,8 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { | KIND_PRESENCE_SNAPSHOT | KIND_MESH_LLM_RELAY_STATUS | KIND_DM_VISIBILITY + | KIND_THREAD_SUMMARY + | KIND_WINDOW_BOUNDS ) } @@ -639,6 +654,8 @@ const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 266bb5600..d78c84350 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1243,23 +1243,21 @@ impl Db { thread::get_thread_summary(&self.pool, community_id, event_id).await } - /// Top-level messages for a channel. - pub async fn get_channel_messages_top_level( + /// One channel window: top-level rows + summaries + server `has_more`. + pub async fn get_channel_window( &self, community_id: CommunityId, channel_id: Uuid, limit: u32, - before_cursor: Option>, - since_cursor: Option>, + cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, - ) -> Result> { - thread::get_channel_messages_top_level( + ) -> Result { + thread::get_channel_window( &self.pool, community_id, channel_id, limit, - before_cursor, - since_cursor, + cursor, kind_filter, ) .await diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 7caf822c2..a05970cfb 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -698,7 +698,7 @@ mod tests { run_migrations(&pool).await.expect("run migrations"); - assert_eq!(applied_versions(&pool).await, vec![1, 2]); + assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]); let sql = migration_sql(); let tables = create_tables(sql.as_str()); for table in [ diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 794695af6..3f92212dd 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -55,27 +55,30 @@ pub struct ThreadSummary { pub participants: Vec>, } -/// A top-level channel message with optional thread summary. +/// One row of a channel window: the reconstructed signed event plus its +/// thread summary (populated when the row has thread activity). #[derive(Debug, Clone)] -pub struct TopLevelMessage { - /// The Nostr event ID of this message. - pub event_id: Vec, - /// Compressed public key of the message author. - pub pubkey: Vec, - /// Nostr event tags (JSON array), used to extract effective author. - pub tags: serde_json::Value, - /// Text content of the message. - pub content: String, - /// Nostr event kind number. - pub kind: i32, - /// When the message was created. - pub created_at: DateTime, - /// The channel this message belongs to. - pub channel_id: Uuid, - /// Thread statistics for this message, if it has replies. +pub struct ChannelWindowRow { + /// Fully reconstructed signed event for this row. + pub stored_event: StoredEvent, + /// Thread statistics for this row; `None` when it has no replies. pub thread_summary: Option, } +/// A page of top-level channel rows plus the server-side exhaustion fact. +#[derive(Debug, Clone)] +pub struct ChannelWindow { + /// Retained rows in `(created_at DESC, id ASC)` order — at most `limit`. + pub rows: Vec, + /// Whether more rows exist past the last retained row. Computed from an + /// internal `limit + 1` probe; the sentinel row never leaves this module. + pub has_more: bool, + /// Composite keyset cursor `(created_at, id)` of the last retained row — + /// the scan position, captured before event reconstruction so a page whose + /// tail row fails to reconstruct still advances. `Some` iff `has_more`. + pub next_cursor: Option<(DateTime, Vec)>, +} + /// Raw thread_metadata row -- used when processing deletes or computing ancestry. #[derive(Debug, Clone)] pub struct ThreadMetadataRecord { @@ -547,40 +550,42 @@ pub async fn get_thread_summary( })) } -/// Fetch top-level messages for a channel (depth = 0, or broadcast replies). +/// Fetch one channel window: top-level rows (depth = 0, missing metadata, or +/// broadcast depth-1 replies) in `(created_at DESC, id ASC)` keyset order, +/// with thread summaries joined in, plus the server-side `has_more` fact. /// -/// Returns events that are either: -/// - Not in thread_metadata at all (no thread context set yet), OR -/// - At depth 0 (root messages), OR -/// - At depth 1 with `broadcast = true` (replies surfaced to the channel) +/// `cursor` is the composite `(created_at, id)` of the last retained row from +/// the previous page — there is no timestamp-only fallback on this path (the +/// bridge rejects `until` without `before_id`). `None` = head of the channel. /// -/// Default ordering is newest-first (DESC). When `since_cursor` is provided -/// without `before_cursor`, ordering flips to oldest-first (ASC) for -/// chronological polling. -/// -/// `before_cursor` enables backward keyset pagination (pass the `created_at` -/// of the last item from the previous page). `since_cursor` enables forward -/// polling (returns only messages created after the given timestamp). -pub async fn get_channel_messages_top_level( +/// `has_more` comes from an internal `limit + 1` probe evaluated after all +/// predicates (deletion, top-level, kinds). The sentinel row is dropped here +/// and never reaches the wire; callers must not re-derive exhaustion from row +/// counts (`rows < limit` proves nothing on an exact-multiple final page). +pub async fn get_channel_window( pool: &PgPool, community_id: CommunityId, channel_id: Uuid, limit: u32, - before_cursor: Option>, - since_cursor: Option>, + cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, -) -> Result> { +) -> Result { let mut param_idx = 3u32; // $1 is community_id, $2 is channel_id let mut sql = String::from( r#" SELECT - e.id AS event_id, + e.id, e.pubkey, + e.created_at, + e.kind, e.tags, e.content, - e.kind, - e.created_at, - e.channel_id + e.sig, + e.received_at, + e.channel_id, + tm.reply_count, + tm.descendant_count, + tm.last_reply_at FROM events e LEFT JOIN thread_metadata tm ON tm.community_id = e.community_id @@ -597,14 +602,15 @@ pub async fn get_channel_messages_top_level( "#, ); - if before_cursor.is_some() { - sql.push_str(&format!(" AND e.created_at < ${param_idx}")); - param_idx += 1; - } - - if since_cursor.is_some() { - sql.push_str(&format!(" AND e.created_at > ${param_idx}")); - param_idx += 1; + if cursor.is_some() { + // Composite keyset: with ORDER BY created_at DESC, id ASC, the page + // after (ts, id) is created_at < ts OR (created_at = ts AND id > id). + let ts_idx = param_idx; + let id_idx = param_idx + 1; + sql.push_str(&format!( + " AND (e.created_at < ${ts_idx} OR (e.created_at = ${ts_idx} AND e.id > ${id_idx}))" + )); + param_idx += 2; } if let Some(kinds) = kind_filter { @@ -618,52 +624,128 @@ pub async fn get_channel_messages_top_level( } } - let order = if since_cursor.is_some() && before_cursor.is_none() { - "ASC" - } else { - "DESC" - }; sql.push_str(&format!( - " ORDER BY e.created_at {order} LIMIT ${param_idx}" + " ORDER BY e.created_at DESC, e.id ASC LIMIT ${param_idx}" )); let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(community_id.as_uuid()) .bind(channel_id); - - if let Some(cursor) = before_cursor { - q = q.bind(cursor); - } - if let Some(cursor) = since_cursor { - q = q.bind(cursor); + if let Some((ts, id)) = &cursor { + q = q.bind(*ts).bind(id.clone()); } - q = q.bind(limit as i32); + // The +1 probe row is the server-internal has_more evidence. + q = q.bind(limit as i64 + 1); + + let mut db_rows = q.fetch_all(pool).await?; + + let has_more = db_rows.len() > limit as usize; + db_rows.truncate(limit as usize); + + // Scan position of this page: the (created_at, id) of the last retained + // raw row, captured before reconstruction so skip-and-continue rows can't + // stall the cursor. Only meaningful when more rows exist past it. + let next_cursor = if has_more { + match db_rows.last() { + Some(row) => Some(( + row.try_get::, _>("created_at")?, + row.try_get::, _>("id")?, + )), + None => None, + } + } else { + None + }; - let rows = q.fetch_all(pool).await?; + let mut rows = Vec::with_capacity(db_rows.len()); + for row in db_rows { + let reply_count: Option = row.try_get("reply_count")?; + let descendant_count: Option = row.try_get("descendant_count")?; + let last_reply_at: Option> = row.try_get("last_reply_at")?; - let mut messages = Vec::with_capacity(rows.len()); - for row in rows { - let event_id: Vec = row.try_get("event_id")?; - let pubkey: Vec = row.try_get("pubkey")?; - let tags: serde_json::Value = row.try_get("tags")?; - let content: String = row.try_get("content")?; - let kind: i32 = row.try_get("kind")?; - let created_at: DateTime = row.try_get("created_at")?; - let ch_id: Uuid = row.try_get("channel_id")?; + // Skip rows that fail event reconstruction rather than failing the + // window, matching get_thread_replies' skip-and-continue semantics. + let stored_event = match row_to_stored_event(row)? { + Some(se) => se, + None => continue, + }; - messages.push(TopLevelMessage { - event_id, - pubkey, - tags, - content, - kind, - created_at, - channel_id: ch_id, - thread_summary: None, // Populated by caller if needed + let thread_summary = match reply_count { + Some(rc) if rc > 0 => Some(ThreadSummary { + reply_count: rc, + descendant_count: descendant_count.unwrap_or(0), + last_reply_at, + participants: Vec::new(), // batch-filled below + }), + _ => None, + }; + + rows.push(ChannelWindowRow { + stored_event, + thread_summary, }); } - Ok(messages) + // Batch participants for every row with thread activity — one query for + // the whole window instead of a per-root fan-out. Same shape and 10-cap + // as get_thread_summary. + let roots: Vec> = rows + .iter() + .filter(|r| r.thread_summary.is_some()) + .map(|r| r.stored_event.event.id.as_bytes().to_vec()) + .collect(); + if !roots.is_empty() { + let participant_rows = sqlx::query( + r#" + SELECT root_event_id, pubkey FROM ( + SELECT + tm.root_event_id, + e.pubkey, + MAX(e.created_at) AS last_seen, + ROW_NUMBER() OVER ( + PARTITION BY tm.root_event_id + ORDER BY MAX(e.created_at) DESC + ) AS rn + FROM thread_metadata tm + JOIN events e + ON e.community_id = tm.community_id + AND e.created_at = tm.event_created_at + AND e.id = tm.event_id + WHERE tm.community_id = $1 + AND tm.root_event_id = ANY($2) + AND e.deleted_at IS NULL + GROUP BY tm.root_event_id, e.pubkey + ) sub + WHERE rn <= 10 + ORDER BY root_event_id, rn + "#, + ) + .bind(community_id.as_uuid()) + .bind(&roots) + .fetch_all(pool) + .await?; + + let mut by_root: std::collections::HashMap, Vec>> = + std::collections::HashMap::new(); + for row in participant_rows { + let root: Vec = row.try_get("root_event_id")?; + let pubkey: Vec = row.try_get("pubkey")?; + by_root.entry(root).or_default().push(pubkey); + } + for row in &mut rows { + if let Some(summary) = &mut row.thread_summary { + if let Some(p) = by_root.remove(row.stored_event.event.id.as_bytes().as_slice()) { + summary.participants = p; + } + } + } + } + + Ok(ChannelWindow { + rows, + has_more, + next_cursor, + }) } /// Look up a single thread_metadata row by event_id. @@ -1353,4 +1435,295 @@ mod tests { assert_eq!(replies[0].stored_event.event.id.to_hex(), good_id); assert_eq!(replies[0].stored_event.event.content, "good"); } + + /// Insert one top-level event (root metadata, broadcast) into a channel. + async fn insert_root( + pool: &PgPool, + community: CommunityId, + channel_id: Uuid, + event: &nostr::Event, + ) { + insert_event_with_thread_metadata( + pool, + community, + event, + Some(channel_id), + Some(ThreadMetadataParams { + event_id: event.id.as_bytes(), + event_created_at: event_created_at(event), + channel_id, + parent_event_id: None, + parent_event_created_at: None, + root_event_id: None, + root_event_created_at: None, + depth: 0, + broadcast: true, + }), + ) + .await + .expect("insert top-level event"); + } + + /// Insert a depth-1 reply under `root`, with the given broadcast flag. + async fn insert_reply( + pool: &PgPool, + community: CommunityId, + channel_id: Uuid, + root: &nostr::Event, + reply: &nostr::Event, + broadcast: bool, + ) { + insert_event_with_thread_metadata( + pool, + community, + reply, + Some(channel_id), + Some(ThreadMetadataParams { + event_id: reply.id.as_bytes(), + event_created_at: event_created_at(reply), + channel_id, + parent_event_id: Some(root.id.as_bytes()), + parent_event_created_at: Some(event_created_at(root)), + root_event_id: Some(root.id.as_bytes()), + root_event_created_at: Some(event_created_at(root)), + depth: 1, + broadcast, + }), + ) + .await + .expect("insert reply event"); + } + + /// The window's top-level predicate: roots (depth 0), events with no + /// thread metadata at all (pre-metadata legacy rows), and broadcast + /// depth-1 replies are rows; ordinary replies never are. This is the + /// SQL-level guarantee that replaces the client-side "filter out replies, + /// splice back islands" machinery. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_window_top_level_predicate() { + let pool = setup_pool().await; + let author = Keys::generate(); + let (channel, community) = create_test_channel( + &pool, + &format!("window-predicate-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + + let root = make_stream_event(&author, "root"); + insert_root(&pool, community, channel.id, &root).await; + + // No thread metadata at all — the legacy-ingest shape. Top-level. + let bare = make_stream_event(&author, "bare"); + insert_event_with_thread_metadata(&pool, community, &bare, Some(channel.id), None) + .await + .expect("insert bare event"); + + let broadcast_reply = make_stream_event(&author, "broadcast reply"); + insert_reply(&pool, community, channel.id, &root, &broadcast_reply, true).await; + + let quiet_reply = make_stream_event(&author, "quiet reply"); + insert_reply(&pool, community, channel.id, &root, &quiet_reply, false).await; + + let window = get_channel_window(&pool, community, channel.id, 50, None, None) + .await + .expect("fetch window"); + + let ids: Vec = window + .rows + .iter() + .map(|r| r.stored_event.event.id.to_hex()) + .collect(); + assert!(ids.contains(&root.id.to_hex()), "root is a row"); + assert!( + ids.contains(&bare.id.to_hex()), + "metadata-less event is a row" + ); + assert!( + ids.contains(&broadcast_reply.id.to_hex()), + "broadcast depth-1 reply is a row" + ); + assert!( + !ids.contains(&quiet_reply.id.to_hex()), + "ordinary reply must never be a channel row" + ); + assert!(!window.has_more); + assert!(window.next_cursor.is_none()); + } + + /// Same-second top-level rows must paginate by the composite + /// `(created_at, id)` keyset without loss or duplication, chaining each + /// page from the server-issued `next_cursor` — the exact loop the GUI + /// runs. A timestamp-only cursor loses every tied row past the first + /// page; this pins the tiebreak on the window path. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_window_pages_same_second_ties_without_loss() { + use nostr::Timestamp; + + let pool = setup_pool().await; + let author = Keys::generate(); + let (channel, community) = create_test_channel( + &pool, + &format!("window-ties-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + + let tie_secs = nostr::Timestamp::now().as_secs(); + let row_count = 5usize; + let mut expected_ids = Vec::with_capacity(row_count); + for i in 0..row_count { + let event = EventBuilder::new(Kind::Custom(9), format!("tie-{i}")) + .custom_created_at(Timestamp::from(tie_secs)) + .sign_with_keys(&author) + .expect("sign tied event"); + expected_ids.push(event.id.as_bytes().to_vec()); + insert_root(&pool, community, channel.id, &event).await; + } + + let mut collected: Vec> = Vec::new(); + let mut cursor: Option<(DateTime, Vec)> = None; + loop { + let window = get_channel_window(&pool, community, channel.id, 2, cursor, None) + .await + .expect("fetch window page"); + for row in &window.rows { + collected.push(row.stored_event.event.id.as_bytes().to_vec()); + } + if !window.has_more { + break; + } + cursor = Some(window.next_cursor.expect("has_more implies next_cursor")); + } + + assert_eq!(collected.len(), row_count, "paged set lost or grew rows"); + let mut unique = collected.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), row_count, "paging produced duplicates"); + let mut expected_sorted = expected_ids.clone(); + expected_sorted.sort(); + assert_eq!(unique, expected_sorted, "paged set != inserted tied set"); + } + + /// The exact-multiple final page: when the channel's row count is an + /// exact multiple of the page limit, the last full page must report + /// `has_more = false` (from the limit+1 probe) even though it contains + /// exactly `limit` rows — `rows < limit` proves nothing, and `rows == + /// limit` must not imply more. Frozen ruling from the contract thread. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_window_exact_multiple_final_page_reports_exhausted() { + let pool = setup_pool().await; + let author = Keys::generate(); + let (channel, community) = create_test_channel( + &pool, + &format!("window-exact-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + + // Exactly 4 rows, page limit 2 → two full pages. + for i in 0..4 { + let event = make_stream_event(&author, &format!("row-{i}")); + insert_root(&pool, community, channel.id, &event).await; + } + + let page1 = get_channel_window(&pool, community, channel.id, 2, None, None) + .await + .expect("fetch page 1"); + assert_eq!(page1.rows.len(), 2); + assert!(page1.has_more, "two more rows exist past page 1"); + let cursor = page1.next_cursor.expect("has_more implies next_cursor"); + + let page2 = get_channel_window(&pool, community, channel.id, 2, Some(cursor), None) + .await + .expect("fetch page 2"); + assert_eq!(page2.rows.len(), 2, "final page is exactly full"); + assert!( + !page2.has_more, + "exact-multiple final page must report exhausted despite rows == limit" + ); + assert!(page2.next_cursor.is_none(), "no cursor past the last row"); + } + + /// Rows with replies carry a thread summary (counts + batched + /// participants); rows without replies carry none. This is the join that + /// lets the GUI render thread affordances without a per-root fan-out. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_window_joins_thread_summaries_with_participants() { + let pool = setup_pool().await; + let author = Keys::generate(); + let replier = Keys::generate(); + let (channel, community) = create_test_channel( + &pool, + &format!("window-summaries-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + + let discussed = make_stream_event(&author, "discussed"); + insert_root(&pool, community, channel.id, &discussed).await; + let quiet = make_stream_event(&author, "quiet"); + insert_root(&pool, community, channel.id, &quiet).await; + + for i in 0..2 { + let reply = make_stream_event(&replier, &format!("reply-{i}")); + insert_reply(&pool, community, channel.id, &discussed, &reply, false).await; + } + + let window = get_channel_window(&pool, community, channel.id, 50, None, None) + .await + .expect("fetch window"); + + let discussed_row = window + .rows + .iter() + .find(|r| r.stored_event.event.id == discussed.id) + .expect("discussed row present"); + let summary = discussed_row + .thread_summary + .as_ref() + .expect("replied row has a summary"); + assert_eq!(summary.reply_count, 2); + assert!( + summary + .participants + .contains(&replier.public_key().to_bytes().to_vec()), + "batched participants include the replier" + ); + + let quiet_row = window + .rows + .iter() + .find(|r| r.stored_event.event.id == quiet.id) + .expect("quiet row present"); + assert!( + quiet_row.thread_summary.is_none(), + "reply-less row carries no summary" + ); + } } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 59093a060..2b48a2bcd 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -190,15 +190,35 @@ fn extract_channel_from_filter(filter: &nostr::Filter) -> Option { const BRIDGE_FEED_MAX_LIMIT: i64 = 100; const BRIDGE_THREAD_MAX_LIMIT: u32 = 500; -fn extract_before_id(raw: &Value) -> Option> { - let hex_str = raw.get("before_id")?.as_str()?; - if hex_str.len() == 64 { - hex::decode(hex_str).ok() - } else { - None +/// The `before_id` extension field, with "present but malformed" kept distinct +/// from "absent": NIP-CW's cursor grammar says a malformed value MUST reject +/// the request, never silently demote it to a half cursor or a head request. +enum BeforeId { + Absent, + Valid(Vec), + Malformed, +} + +fn extract_before_id(raw: &Value) -> BeforeId { + let Some(value) = raw.get("before_id") else { + return BeforeId::Absent; + }; + match value + .as_str() + .filter(|hex_str| hex_str.len() == 64) + .and_then(|hex_str| hex::decode(hex_str).ok()) + { + Some(id) => BeforeId::Valid(id), + None => BeforeId::Malformed, } } +/// True when the raw filter opts into a bridge extension flag (`top_level`, +/// `include_summaries`, `include_aux`). Absent or non-boolean = false. +fn extension_flag(raw: &Value, key: &str) -> bool { + raw.get(key).and_then(Value::as_bool).unwrap_or(false) +} + fn extract_depth_limit(raw: &Value) -> Option { raw.get("depth_limit")? .as_u64() @@ -293,6 +313,212 @@ fn extract_page_offset(raw: &Value, limit: Option) -> Option { page.checked_sub(1)?.checked_mul(per_page) } +/// Default and maximum row budget for a channel-window request. The budget +/// counts row events only; summary/bounds overlays and the aux closure never +/// consume it (docs/bridge-channel-window.md). +const BRIDGE_WINDOW_DEFAULT_LIMIT: u32 = 50; +const BRIDGE_WINDOW_MAX_LIMIT: u32 = 200; + +/// Aux closure kinds: reactions, deletions (NIP-09 + NIP-29), edits. +const WINDOW_AUX_KINDS: [u32; 4] = [ + buzz_core::kind::KIND_DELETION, + buzz_core::kind::KIND_REACTION, + buzz_core::kind::KIND_NIP29_DELETE_EVENT, + buzz_core::kind::KIND_STREAM_MESSAGE_EDIT, +]; +/// Second-hop kinds: deletions targeting aux events (delete-of-a-reaction). +const WINDOW_AUX_DELETE_KINDS: [u32; 2] = [ + buzz_core::kind::KIND_DELETION, + buzz_core::kind::KIND_NIP29_DELETE_EVENT, +]; + +/// Serve one `top_level: true` channel-window filter on the bridge `/query` +/// path (docs/bridge-channel-window.md). Appends, in order: row events, the +/// aux closure (`include_aux`), `39005` thread-summary overlays +/// (`include_summaries`), and exactly one `39006` window-bounds overlay. +/// +/// Validation errors (missing `#h`, half a cursor) are deterministic client +/// mistakes and return `400`; an inaccessible channel is an access-scope skip +/// that still emits nothing, matching every other read path here. +async fn handle_channel_window_filter( + state: &AppState, + tenant: &buzz_core::TenantContext, + raw: &Value, + filter: &nostr::Filter, + accessible_channels: &[uuid::Uuid], + events: &mut Vec, +) -> Result<(), (StatusCode, Json)> { + use buzz_core::kind::{KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS}; + + let Some(ch_id) = extract_channel_from_filter(filter) else { + return Err(api_error( + StatusCode::BAD_REQUEST, + "top_level requires exactly one #h channel", + )); + }; + if !accessible_channels.contains(&ch_id) { + return Ok(()); + } + + // Composite request cursor: `until` + `before_id`, both or neither. The + // window path has no timestamp-only fallback — that ambiguity is the + // dense-second dup/loss bug this surface exists to kill. A malformed + // `before_id` is likewise rejected outright (NIP-CW cursor grammar), + // never demoted to a half cursor or a head request. + let before_id = match extract_before_id(raw) { + BeforeId::Malformed => { + return Err(api_error( + StatusCode::BAD_REQUEST, + "top_level: before_id must be a 64-hex event id", + )); + } + BeforeId::Valid(id) => Some(id), + BeforeId::Absent => None, + }; + let cursor = match (filter.until, before_id) { + (Some(ts), Some(id)) => { + let ts = chrono::DateTime::from_timestamp(ts.as_secs() as i64, 0).ok_or_else(|| { + api_error(StatusCode::BAD_REQUEST, "top_level: until is out of range") + })?; + Some((ts, id)) + } + (None, None) => None, + _ => { + return Err(api_error( + StatusCode::BAD_REQUEST, + "top_level cursor requires both until and before_id, or neither", + )); + } + }; + + let limit = filter + .limit + .map(|l| (l as u32).min(BRIDGE_WINDOW_MAX_LIMIT)) + .unwrap_or(BRIDGE_WINDOW_DEFAULT_LIMIT) + .max(1); + let kind_filter: Option> = filter + .kinds + .as_ref() + .map(|ks| ks.iter().map(|k| k.as_u16() as u32).collect()); + + let window = state + .db + .get_channel_window( + tenant.community(), + ch_id, + limit, + cursor.clone(), + kind_filter.as_deref(), + ) + .await + .map_err(|e| internal_error(&format!("channel window error: {e}")))?; + + // 1. Rows, in keyset order. + let mut row_ids_hex = Vec::with_capacity(window.rows.len()); + for row in &window.rows { + row_ids_hex.push(row.stored_event.event.id.to_hex()); + let v = serde_json::to_value(&row.stored_event.event) + .map_err(|e| internal_error(&format!("window row serialize: {e}")))?; + events.push(v); + } + + // 2. Aux closure: reactions/deletions/edits targeting retained rows, plus + // deletions targeting those aux events (the transitive second hop). + // One round trip for the client instead of an #e fan-out. + if extension_flag(raw, "include_aux") && !row_ids_hex.is_empty() { + let mut seen_aux: std::collections::HashSet = + std::collections::HashSet::new(); + let mut hop_ids = row_ids_hex.clone(); + for hop_kinds in [&WINDOW_AUX_KINDS[..], &WINDOW_AUX_DELETE_KINDS[..]] { + let mut aux_query = buzz_db::EventQuery::for_community(tenant.community()); + aux_query.kinds = Some(hop_kinds.iter().map(|k| *k as i32).collect()); + aux_query.e_tags = Some(std::mem::take(&mut hop_ids)); + aux_query.limit = Some(1000); + let aux_events = state + .db + .query_events(&aux_query) + .await + .map_err(|e| internal_error(&format!("window aux error: {e}")))?; + for se in aux_events { + if !seen_aux.insert(se.event.id) { + continue; + } + // Deletions can be stored channel-less; access-check instead + // of channel-constraining so they aren't silently dropped. + if !event_in_accessible_channel(&se, accessible_channels) { + continue; + } + hop_ids.push(se.event.id.to_hex()); + let v = serde_json::to_value(&se.event) + .map_err(|e| internal_error(&format!("window aux serialize: {e}")))?; + events.push(v); + } + if hop_ids.is_empty() { + break; + } + } + } + + let sign_overlay = |kind: u32, tags: Vec, content: String| { + nostr::EventBuilder::new(nostr::Kind::Custom(kind as u16), content) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| internal_error(&format!("window overlay sign: {e}"))) + }; + let parse_tag = |parts: [&str; 2]| { + nostr::Tag::parse(parts).map_err(|e| internal_error(&format!("window overlay tag: {e}"))) + }; + let ch_hex = ch_id.to_string(); + + // 3. Thread-summary overlays: one relay-signed 39005 per row with replies. + if extension_flag(raw, "include_summaries") { + for row in &window.rows { + let Some(summary) = &row.thread_summary else { + continue; + }; + let root_hex = row.stored_event.event.id.to_hex(); + let content = serde_json::json!({ + "reply_count": summary.reply_count, + "descendant_count": summary.descendant_count, + "last_reply_at": summary.last_reply_at.map(|t| t.timestamp()), + "participants": summary.participants.iter().map(hex::encode).collect::>(), + }); + let tags = vec![ + parse_tag(["e", &root_hex])?, + parse_tag(["d", &root_hex])?, + parse_tag(["h", &ch_hex])?, + ]; + let overlay = sign_overlay(KIND_THREAD_SUMMARY, tags, content.to_string())?; + let v = serde_json::to_value(&overlay) + .map_err(|e| internal_error(&format!("window overlay serialize: {e}")))?; + events.push(v); + } + } + + // 4. Window bounds: exactly one 39006 per window response — the only + // authority on exhaustion. `rows < limit` proves nothing on an + // exact-multiple final page. + let cursor_suffix = match &cursor { + Some((ts, id)) => format!("{}:{}", ts.timestamp(), hex::encode(id)), + None => "head".to_owned(), + }; + let d_val = format!("{ch_hex}:{cursor_suffix}"); + let content = serde_json::json!({ + "has_more": window.has_more, + "next_cursor": window.next_cursor.as_ref().map(|(ts, id)| serde_json::json!({ + "created_at": ts.timestamp(), + "id": hex::encode(id), + })), + }); + let tags = vec![parse_tag(["d", &d_val])?, parse_tag(["h", &ch_hex])?]; + let overlay = sign_overlay(KIND_WINDOW_BOUNDS, tags, content.to_string())?; + let v = serde_json::to_value(&overlay) + .map_err(|e| internal_error(&format!("window overlay serialize: {e}")))?; + events.push(v); + + Ok(()) +} + fn event_in_accessible_channel(se: &buzz_core::StoredEvent, accessible: &[uuid::Uuid]) -> bool { match se.channel_id { Some(ch_id) => accessible.contains(&ch_id), @@ -502,7 +728,28 @@ pub async fn query_events( let mut events: Vec = Vec::new(); let mut handled: std::collections::HashSet = std::collections::HashSet::new(); + // Channel-window filters (`top_level: true`) — the GUI read-model surface. + // Dispatched first: a window filter is never a feed/thread/catchall query. + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if !extension_flag(raw, "top_level") { + continue; + } + handle_channel_window_filter( + &state, + &tenant, + raw, + filter, + &accessible_channels, + &mut events, + ) + .await?; + handled.insert(idx); + } + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if handled.contains(&idx) { + continue; + } let feed_types = match extract_feed_types(raw) { Some(t) => t, None => continue, @@ -660,14 +907,23 @@ pub async fn query_events( ) .await; - if let Some(bid) = extract_before_id(raw) { - if query.until.is_none() { + match extract_before_id(raw) { + BeforeId::Malformed => { return Err(api_error( StatusCode::BAD_REQUEST, - "before_id requires until to be set", + "before_id must be a 64-char hex event id", )); } - query.before_id = Some(bid); + BeforeId::Valid(bid) => { + if query.until.is_none() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "before_id requires until to be set", + )); + } + query.before_id = Some(bid); + } + BeforeId::Absent => {} } // Honor `page` on non-search general queries so offset paging works for @@ -1903,39 +2159,64 @@ mod tests { fn extract_before_id_valid_hex() { let hex = "a".repeat(64); let raw = serde_json::json!({ "before_id": hex }); - let result = extract_before_id(&raw); - assert!(result.is_some()); - assert_eq!(result.unwrap().len(), 32); + match extract_before_id(&raw) { + BeforeId::Valid(id) => assert_eq!(id.len(), 32), + _ => panic!("64-char hex must parse as Valid"), + } } #[test] fn extract_before_id_short_hex() { let raw = serde_json::json!({ "before_id": "a".repeat(63) }); - assert!(extract_before_id(&raw).is_none()); + assert!(matches!(extract_before_id(&raw), BeforeId::Malformed)); } #[test] fn extract_before_id_long_hex() { let raw = serde_json::json!({ "before_id": "a".repeat(65) }); - assert!(extract_before_id(&raw).is_none()); + assert!(matches!(extract_before_id(&raw), BeforeId::Malformed)); } #[test] fn extract_before_id_invalid_hex_chars() { let raw = serde_json::json!({ "before_id": "z".repeat(64) }); - assert!(extract_before_id(&raw).is_none()); + assert!(matches!(extract_before_id(&raw), BeforeId::Malformed)); } #[test] fn extract_before_id_absent() { let raw = serde_json::json!({}); - assert!(extract_before_id(&raw).is_none()); + assert!(matches!(extract_before_id(&raw), BeforeId::Absent)); } #[test] fn extract_before_id_non_string() { let raw = serde_json::json!({ "before_id": 12345 }); - assert!(extract_before_id(&raw).is_none()); + assert!(matches!(extract_before_id(&raw), BeforeId::Malformed)); + } + + /// Extension flags opt in only on a literal JSON `true` — absent, + /// non-boolean, and truthy-but-not-bool values all read as false, so a + /// malformed filter degrades to a normal query instead of a wrong window. + #[test] + fn extension_flag_only_true_on_literal_bool() { + assert!(extension_flag( + &serde_json::json!({ "top_level": true }), + "top_level" + )); + assert!(!extension_flag( + &serde_json::json!({ "top_level": false }), + "top_level" + )); + assert!(!extension_flag(&serde_json::json!({}), "top_level")); + assert!(!extension_flag( + &serde_json::json!({ "top_level": "true" }), + "top_level" + )); + assert!(!extension_flag( + &serde_json::json!({ "top_level": 1 }), + "top_level" + )); } #[test] diff --git a/crates/buzz-test-client/tests/e2e_nostr_interop.rs b/crates/buzz-test-client/tests/e2e_nostr_interop.rs index d459a6874..fce787767 100644 --- a/crates/buzz-test-client/tests/e2e_nostr_interop.rs +++ b/crates/buzz-test-client/tests/e2e_nostr_interop.rs @@ -214,7 +214,7 @@ async fn query_thread_replies( /// True if a queried event JSON carries the `["broadcast", "1"]` tag. /// /// The relay sets `thread_metadata.broadcast` from exactly this tag -/// (`ingest.rs`), and `get_channel_messages_top_level` surfaces a depth-1 reply +/// (`ingest.rs`), and `get_channel_window` surfaces a depth-1 reply /// at top level only when `broadcast = true`. The bridge returns raw events, so /// this tag is the faithful, test-observable proxy for the `broadcast` column. fn has_broadcast_tag(event: &serde_json::Value) -> bool { @@ -863,14 +863,14 @@ async fn test_dm_discovery_events_emitted() { /// Send a non-broadcast NIP-10 reply AND a broadcast (`["broadcast","1"]`) /// reply, then prove the relay's real top-level rule both directions. /// -/// The relay's top-level view is `get_channel_messages_top_level` +/// The relay's top-level view is `get_channel_window` /// (`thread.rs`): a message is surfaced at top level iff /// `depth IS NULL OR depth = 0 OR (depth = 1 AND broadcast = true)`. So a /// depth-1 reply is EXCLUDED only when `broadcast = false`, and a depth-1 reply -/// with `broadcast = true` IS surfaced. That predicate is not exposed over any -/// `POST /query` surface (`feed_types` routes to feed queries that never touch -/// `thread_metadata.depth`/`broadcast`; `get_channel_messages_top_level` is -/// wired to no relay HTTP route). We therefore pin the rule via its two +/// with `broadcast = true` IS surfaced. That predicate is now exposed over +/// `POST /query` via the `top_level: true` window extension +/// (docs/bridge-channel-window.md), but this test predates it and pins the +/// rule via its two /// test-observable inputs — recorded depth and the `broadcast` tag — instead of /// a one-sided "threads under root" correlate. #[tokio::test] @@ -1697,3 +1697,291 @@ async fn test_nipdv_search_rejects_third_party() { events.len() ); } + +/// POST /query with `top_level: true` and its extension flags — the channel +/// window surface (docs/bridge-channel-window.md). +async fn query_channel_window( + keys: &Keys, + channel_id: &str, + limit: u32, + cursor: Option<(i64, &str)>, +) -> Vec { + let client = reqwest::Client::new(); + let mut filter = serde_json::json!({ + "kinds": [9], + "#h": [channel_id], + "limit": limit, + "top_level": true, + "include_summaries": true, + "include_aux": true, + }); + if let Some((until, before_id)) = cursor { + filter["until"] = serde_json::json!(until); + filter["before_id"] = serde_json::json!(before_id); + } + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&serde_json::json!([filter])).unwrap()) + .send() + .await + .expect("submit window query"); + assert!( + resp.status().is_success(), + "window query failed: {}", + resp.status() + ); + let body: serde_json::Value = resp.json().await.expect("parse window query response"); + body.as_array().cloned().unwrap_or_default() +} + +/// Partition a window response by kind, the way the client contract requires: +/// rows (9), summaries (39005), exactly-one bounds (39006), aux (the rest). +fn partition_window( + events: &[serde_json::Value], +) -> ( + Vec, + Vec, + serde_json::Value, + Vec, +) { + let mut rows = Vec::new(); + let mut summaries = Vec::new(); + let mut bounds = Vec::new(); + let mut aux = Vec::new(); + for e in events { + match e["kind"].as_u64() { + Some(9) => rows.push(e.clone()), + Some(39005) => summaries.push(e.clone()), + Some(39006) => bounds.push(e.clone()), + _ => aux.push(e.clone()), + } + } + assert_eq!( + bounds.len(), + 1, + "exactly one 39006 bounds overlay per window response, got {}", + bounds.len() + ); + (rows, summaries, bounds.remove(0), aux) +} + +/// End-to-end channel window: replies stay out of the rows, thread summaries +/// and reactions ride along, and `39006.has_more`/`next_cursor` chain pages +/// to exhaustion — including the exact-multiple final page, where row count +/// alone would lie. +#[tokio::test] +#[ignore] +async fn test_channel_window_rows_overlays_and_exact_multiple_exhaustion() { + let url = relay_url(); + let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; + + // 4 top-level messages: with page limit 2 the channel is an exact + // multiple — the shape where "rows < limit" heuristics fail. + let mut top_ids = Vec::new(); + for i in 0..4 { + top_ids.push(send_rest_message(&keys, &channel, &format!("window top {i}")).await); + } + + // All four roots share a created_at second, so ordering is decided by + // the composite key's id ASC tie-break — the dense-second case the old + // timestamp-only cursor got wrong. Probe the window to learn which root + // the relay puts first, then hang the reply and reaction off that row so + // its 39005/aux land on page 1. + let probe = query_channel_window(&keys, &channel, 2, None).await; + let (probe_rows, _, _, _) = partition_window(&probe); + let root_id = probe_rows[0]["id"] + .as_str() + .expect("probe row id") + .to_string(); + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let reply = EventBuilder::new(Kind::Custom(9), "window reply") + .tags([ + Tag::parse(["h", &channel]).unwrap(), + Tag::parse(["e", &root_id, "", "reply"]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reply"); + let reply_id = reply.id.to_hex(); + let ok = client.send_event(reply).await.expect("send reply"); + assert!(ok.accepted, "relay rejected reply: {}", ok.message); + let reaction = EventBuilder::new(Kind::Reaction, "👍") + .tags([ + Tag::parse(["h", &channel]).unwrap(), + Tag::parse(["e", &root_id]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reaction"); + let ok = client.send_event(reaction).await.expect("send reaction"); + assert!(ok.accepted, "relay rejected reaction: {}", ok.message); + client.disconnect().await.expect("disconnect"); + + // Page 1 (head request). + let page1 = query_channel_window(&keys, &channel, 2, None).await; + let (rows1, summaries1, bounds1, aux1) = partition_window(&page1); + + assert_eq!(rows1.len(), 2, "page 1 rows: {rows1:?}"); + assert!( + rows1 + .iter() + .all(|r| r["id"].as_str() != Some(reply_id.as_str())), + "reply must never appear as a channel row" + ); + // Newest-first: the replied/reacted root is the newest top-level row. + assert_eq!(rows1[0]["id"].as_str(), Some(root_id.as_str())); + + // The replied root carries a 39005 with its reply count, signed by a key + // that is not the requester (the relay's). + let summary = summaries1 + .iter() + .find(|s| { + s["tags"].as_array().is_some_and(|tags| { + tags.iter() + .any(|t| t[0].as_str() == Some("e") && t[1].as_str() == Some(root_id.as_str())) + }) + }) + .unwrap_or_else(|| panic!("no 39005 for replied root. summaries: {summaries1:?}")); + let summary_content: serde_json::Value = + serde_json::from_str(summary["content"].as_str().unwrap()).expect("summary content JSON"); + assert_eq!(summary_content["reply_count"].as_i64(), Some(1)); + assert_ne!( + summary["pubkey"].as_str(), + Some(keys.public_key().to_hex().as_str()), + "39005 must be relay-signed, not requester-signed" + ); + + // The reaction rides in the aux closure. + assert!( + aux1.iter().any(|a| a["kind"].as_u64() == Some(7)), + "reaction missing from aux closure: {aux1:?}" + ); + + // Bounds: head request d-tag suffix, has_more true, cursor present. + let d1 = bounds1["tags"] + .as_array() + .unwrap() + .iter() + .find_map(|t| (t[0].as_str() == Some("d")).then(|| t[1].as_str().unwrap().to_string())); + assert_eq!(d1, Some(format!("{channel}:head")), "head d-tag suffix"); + let bc1: serde_json::Value = + serde_json::from_str(bounds1["content"].as_str().unwrap()).expect("bounds content JSON"); + assert_eq!(bc1["has_more"].as_bool(), Some(true)); + let cursor = &bc1["next_cursor"]; + let (c_ts, c_id) = ( + cursor["created_at"].as_i64().expect("cursor created_at"), + cursor["id"].as_str().expect("cursor id").to_string(), + ); + + // Page 2 (cursor request): the exact-multiple final page. Two full rows, + // yet has_more must be false and next_cursor null — the server fact, not + // a row-count guess. + let page2 = query_channel_window(&keys, &channel, 2, Some((c_ts, &c_id))).await; + let (rows2, _summaries2, bounds2, _aux2) = partition_window(&page2); + assert_eq!(rows2.len(), 2, "final page is exactly full"); + let d2 = bounds2["tags"] + .as_array() + .unwrap() + .iter() + .find_map(|t| (t[0].as_str() == Some("d")).then(|| t[1].as_str().unwrap().to_string())); + assert_eq!( + d2, + Some(format!("{channel}:{c_ts}:{c_id}")), + "cursor-request d-tag echoes the request cursor" + ); + let bc2: serde_json::Value = + serde_json::from_str(bounds2["content"].as_str().unwrap()).expect("bounds content JSON"); + assert_eq!( + bc2["has_more"].as_bool(), + Some(false), + "exact-multiple final page must report exhausted" + ); + assert!(bc2["next_cursor"].is_null()); + + // No row is lost or duplicated across the two pages. + let mut paged: Vec = rows1 + .iter() + .chain(rows2.iter()) + .map(|r| r["id"].as_str().unwrap().to_string()) + .collect(); + paged.sort(); + let mut expected = top_ids.clone(); + expected.sort(); + assert_eq!(paged, expected, "paged union != inserted top-level set"); +} + +/// The window cursor is composite by contract: `until` without `before_id` +/// (or vice versa) is a deterministic 400, never a silent timestamp-only +/// fallback. And client-submitted 39005/39006 are rejected at ingest — +/// overlay kinds are relay-only. +#[tokio::test] +#[ignore] +async fn test_channel_window_rejects_half_cursor_and_client_overlay_kinds() { + let url = relay_url(); + let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; + send_rest_message(&keys, &channel, "lone row").await; + + // Half a cursor: until without before_id → 400. + let client = reqwest::Client::new(); + let filter = serde_json::json!([{ + "kinds": [9], + "#h": [channel], + "limit": 2, + "top_level": true, + "until": nostr::Timestamp::now().as_secs(), + }]); + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&filter).unwrap()) + .send() + .await + .expect("submit half-cursor query"); + assert_eq!( + resp.status().as_u16(), + 400, + "top_level with until but no before_id must be a 400" + ); + + // Malformed before_id with no until → 400. Before the BeforeId enum, a + // malformed value decoded to None and silently demoted the request to a + // head fetch — the client would receive page zero instead of an error. + let filter = serde_json::json!([{ + "kinds": [9], + "#h": [channel], + "limit": 2, + "top_level": true, + "before_id": "not-a-hex-event-id", + }]); + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&filter).unwrap()) + .send() + .await + .expect("submit malformed before_id query"); + assert_eq!( + resp.status().as_u16(), + 400, + "top_level with malformed before_id must be a 400, not a head request" + ); + + // Client-submitted overlay kinds are rejected at ingest. + let mut ws = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + for kind in [39005u16, 39006u16] { + let forged = EventBuilder::new(Kind::Custom(kind), "{}") + .tags([Tag::parse(["h", &channel]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign forged overlay"); + let ok = ws.send_event(forged).await.expect("send forged overlay"); + assert!( + !ok.accepted, + "client-submitted kind:{kind} must be rejected at ingest" + ); + } + ws.disconnect().await.expect("disconnect"); +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3b5f35d99..c1ff9b89c 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -59,6 +59,8 @@ export default defineConfig({ "**/virtualization.spec.ts", "**/scroll-history.spec.ts", "**/channel-dense-second-reach.spec.ts", + "**/channel-window-mock-paging.spec.ts", + "**/live-broadcast-reply-timeline.spec.ts", "**/overscroll-boundary.spec.ts", "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.spec.ts", @@ -84,6 +86,7 @@ export default defineConfig({ "**/persona-env-vars.spec.ts", "**/persona-sync.spec.ts", "**/mesh-compute.spec.ts", + "**/parity-ancestor-island.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/commands/channel_window.rs b/desktop/src-tauri/src/commands/channel_window.rs new file mode 100644 index 000000000..9230cdd2e --- /dev/null +++ b/desktop/src-tauri/src/commands/channel_window.rs @@ -0,0 +1,56 @@ +use tauri::State; + +use crate::{app_state::AppState, models::ChannelPageCursor, relay::query_relay}; + +const TIMELINE_KINDS: [u32; 11] = [ + 9, + 40002, + 40008, + 40099, + 43001, + 43002, + 43003, + 43004, + 43005, + 43006, + buzz_core_pkg::kind::KIND_HUDDLE_STARTED, +]; + +fn build_channel_window_filter( + channel_id: &str, + cap: u32, + cursor: Option<&ChannelPageCursor>, +) -> serde_json::Value { + let mut filter = serde_json::Map::new(); + filter.insert("#h".to_string(), serde_json::json!([channel_id])); + filter.insert("kinds".to_string(), serde_json::json!(TIMELINE_KINDS)); + filter.insert("limit".to_string(), serde_json::json!(cap)); + filter.insert("top_level".to_string(), serde_json::json!(true)); + filter.insert("include_summaries".to_string(), serde_json::json!(true)); + filter.insert("include_aux".to_string(), serde_json::json!(true)); + if let Some(cursor) = cursor { + filter.insert("until".to_string(), serde_json::json!(cursor.created_at)); + filter.insert("before_id".to_string(), serde_json::json!(cursor.event_id)); + } + serde_json::Value::Object(filter) +} + +/// Fetch one server-assembled channel window over the existing `/query` bridge. +#[tauri::command] +pub async fn get_channel_window( + channel_id: String, + limit_rows: Option, + cursor: Option, + state: State<'_, AppState>, +) -> Result, String> { + let filter = build_channel_window_filter( + &channel_id, + limit_rows.unwrap_or(50).min(200), + cursor.as_ref(), + ); + Ok(query_relay(&state, &[filter]) + .await? + .iter() + .filter_map(|event| serde_json::to_value(event).ok()) + .collect()) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7fea4604a..b9842cbd4 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ mod agent_settings; mod agents; mod canvas; mod channel_templates; +mod channel_window; mod channels; mod dms; mod engrams; @@ -40,6 +41,7 @@ pub use agent_settings::*; pub use agents::*; pub use canvas::*; pub use channel_templates::*; +pub use channel_window::*; pub use channels::*; pub use dms::*; pub use engrams::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 18c987c48..2431cf62e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -493,6 +493,7 @@ pub fn run() { get_forum_posts, get_forum_thread, get_thread_replies, + get_channel_window, get_channel_messages_before, edit_message, delete_message, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 7145507fa..03b9707d4 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -78,6 +78,7 @@ export const ChannelPane = React.memo(function ChannelPane({ isSending, isTimelineLoading, messages, + threadSummaries, firstUnreadMessageId = null, unreadCount = 0, canResetThreadPanelWidth, @@ -444,8 +445,14 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); const mainTimelineEntries = React.useMemo( - () => buildMainTimelineEntries(visibleMessages), - [visibleMessages], + () => + buildMainTimelineEntries( + visibleMessages, + new Set(), + threadSummaries, + profiles, + ), + [profiles, threadSummaries, visibleMessages], ); useRenderScopedReactionHydration({ activeChannel, diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index db0ffaba2..392e7e4ce 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -3,6 +3,7 @@ import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar"; import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { TimelineMessage } from "@/features/messages/types"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -37,6 +38,7 @@ export type ChannelPaneProps = { isSending: boolean; isTimelineLoading: boolean; messages: TimelineMessage[]; + threadSummaries?: ReadonlyMap; firstUnreadMessageId?: string | null; unreadCount?: number; canResetThreadPanelWidth: boolean; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 659f8c44d..47468450b 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -29,6 +29,7 @@ import { mergeMessages, useChannelMessagesQuery, useChannelSubscription, + useChannelWindowQuery, useDeleteMessageMutation, useEditMessageMutation, useSendMessageMutation, @@ -39,6 +40,10 @@ import { collectMessageMentionPubkeys, formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; +import { + channelWindowThreadSummaries, + type ChannelWindowThreadSummary, +} from "@/features/messages/lib/channelWindowStore"; import { getThreadReference } from "@/features/messages/lib/threading"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { @@ -46,8 +51,7 @@ import { selectTimelineLoadingState, } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; -import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors"; -import { useThreadReplies } from "@/features/messages/useThreadReplies"; +import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; import type { TimelineMessage } from "@/features/messages/types"; import { useUsersBatchQuery } from "@/features/profile/hooks"; @@ -175,6 +179,7 @@ export function ChannelScreen({ }); }, [activeChannelId, openThreadHeadId]); const messagesQuery = useChannelMessagesQuery(activeChannel); + const windowQuery = useChannelWindowQuery(activeChannel); useChannelSubscription(activeChannel); const { fetchOlder, hasOlderMessages, isFetchingOlder } = useFetchOlderMessages(activeChannel); @@ -205,16 +210,10 @@ export function ChannelScreen({ // thread itself is read. markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true }); }, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]); - // Install the NIP-RS parent resolver: every `thread:` or `msg:` - // context evaluated while this channel is active belongs to it (both are only - // ever read for the active channel's timeline messages), so the parent is - // always the active channel. Folding `msg:` to the channel — never to another - // message — means reading an ancestor never covers a descendant (LP4 Issue 2 - // by construction); a channel-read still clears any message older than the - // top-level channel frontier. Non-thread/non-message keys (channels) have no - // parent → null, which degrades effective() to the own term. Cleared on - // channel leave / unmount so a stale channel id never becomes the parent of - // another channel's contexts. + // Install the NIP-RS parent resolver. Active `thread:` and `msg:` contexts + // belong to this channel; folding messages to the channel (never another + // message) preserves ancestor/descendant isolation while channel reads still + // cover top-level history. Clear on leave so the parent cannot go stale. React.useEffect(() => { if (!activeChannelId) { setContextParentResolver(null); @@ -442,6 +441,14 @@ export function ChannelScreen({ resolvedMessages, ], ); + const threadSummaries: ReadonlyMap = + React.useMemo( + () => + windowQuery.data + ? channelWindowThreadSummaries(windowQuery.data) + : new Map(), + [windowQuery.data], + ); const handleFindSearchHit = React.useCallback((hit: SearchHit) => { const event = cacheSearchHitEvent(hit); setFindTargetEvents((currentEvents) => @@ -455,6 +462,19 @@ export function ChannelScreen({ messages: timelineMessages, onSearchHit: handleFindSearchHit, }); + const threadPanelData = useIndependentThreadPanel({ + activeChannel, + channelEvents: resolvedMessages, + rootId: effectiveOpenThreadHeadId, + replyTargetId: threadReplyTargetId, + expandedReplyIds: expandedThreadReplyIds, + currentPubkey, + currentAvatarUrl: currentProfile?.avatarUrl ?? null, + profiles: messageProfiles, + members: channelMembers, + personaLookup, + respondToLookup, + }); const { firstUnreadMessageId, getFirstReplyIdForMessage, @@ -465,7 +485,6 @@ export function ChannelScreen({ markRevealedRepliesRead, openThreadHeadMessage, threadFirstUnreadReplyId, - threadMessages, threadReplyTargetMessage, threadReplyUnreadCounts, threadUnreadCounts, @@ -474,9 +493,10 @@ export function ChannelScreen({ activeChannelId, timelineMessages, currentPubkey, - openThreadHeadId, + openThreadHeadId: effectiveOpenThreadHeadId, threadReplyTargetId, expandedThreadReplyIds, + openThreadMessages: threadPanelData.visibleReplies, getChannelReadAt, getMessageReadAt, markChannelUnread, @@ -711,27 +731,15 @@ export function ChannelScreen({ threadReplyTargetMessage, ]); - useLoadMissingAncestors(activeChannel, resolvedMessages); - // Fetch the full reply subtree server-side when a thread is open, closing the - // descendant gap that useLoadMissingAncestors (ancestors-only) leaves. The - // open thread head is the top-level message, i.e. the thread root. - useThreadReplies(activeChannel, effectiveOpenThreadHeadId); const hasAuxiliaryPanel = Boolean( effectiveOpenThreadHeadId || openAgentSessionPubkey || profilePanelPubkey || channelManagementOpen, ); - const displayedThreadHeadMessage = - openThreadHeadMessage?.id === effectiveOpenThreadHeadId - ? openThreadHeadMessage - : null; - const displayedThreadMessages = displayedThreadHeadMessage - ? threadMessages - : []; - const displayedThreadReplyTargetMessage = displayedThreadHeadMessage - ? threadReplyTargetMessage - : null; + const displayedThreadHeadMessage = threadPanelData.threadHead; + const displayedThreadMessages = threadPanelData.visibleReplies; + const displayedThreadReplyTargetMessage = threadPanelData.replyTargetMessage; const displayedThreadFirstUnreadReplyId = displayedThreadHeadMessage ? threadFirstUnreadReplyId : null; @@ -894,6 +902,7 @@ export function ChannelScreen({ isSinglePanelView={isSinglePanelView} isTimelineLoading={isTimelineLoading} messages={timelineMessages} + threadSummaries={threadSummaries} onCancelEdit={handleCancelEdit} onCancelThreadReply={handleCancelThreadReply} onChannelManagementDeleted={handleChannelManagementDeleted} diff --git a/desktop/src/features/channels/ui/useChannelUnreadState.ts b/desktop/src/features/channels/ui/useChannelUnreadState.ts index 34f16dc8f..dc2402355 100644 --- a/desktop/src/features/channels/ui/useChannelUnreadState.ts +++ b/desktop/src/features/channels/ui/useChannelUnreadState.ts @@ -15,6 +15,7 @@ import { import { buildThreadPanelDataFromIndex, buildThreadPanelIndex, + type MainTimelineEntry, } from "@/features/messages/lib/threadPanel"; import { computeChannelUnreadMarker, @@ -32,6 +33,7 @@ type UseChannelUnreadStateOptions = { openThreadHeadId: string | null; threadReplyTargetId: string | null; expandedThreadReplyIds: ReadonlySet; + openThreadMessages?: MainTimelineEntry[]; getChannelReadAt: (channelId: string) => number | null; getMessageReadAt: (messageId: string) => number | null; markChannelUnread: (channelId: string) => void; @@ -59,6 +61,7 @@ export function useChannelUnreadState({ openThreadHeadId, threadReplyTargetId, expandedThreadReplyIds, + openThreadMessages, getChannelReadAt, getMessageReadAt, markChannelUnread, @@ -178,7 +181,9 @@ export function useChannelUnreadState({ ], ); const openThreadHeadMessage = threadPanelData.threadHead; - const threadMessages = useStableArrayShallow(threadPanelData.visibleReplies); + const threadMessages = useStableArrayShallow( + openThreadMessages ?? threadPanelData.visibleReplies, + ); const threadReplyTargetMessage = threadPanelData.replyTargetMessage; // Oldest unread top-level message + count from the open-time frontier. diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 2209f24fa..f262d9e79 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -3,15 +3,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { channelMessagesKey, + channelWindowKey, dedupeMessagesById, - mergeTimelineHistoryMessages, normalizeTimelineMessages, sortMessages, + threadRepliesKey, } from "@/features/messages/lib/messageQueryKeys"; import { buildReplyTags, getChannelIdFromTags, getThreadReference, + isBroadcastReply, normalizeMentionPubkeys, resolveReplyRootId, } from "@/features/messages/lib/threading"; @@ -28,23 +30,22 @@ import { removeReaction, sendChannelMessage, } from "@/shared/api/tauri"; +import { getChannelWindowEvents } from "@/shared/api/channelWindow"; import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; // Same .mjs the renderer uses, so the cache-update projection can't drift // from the on-render overlay. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; -import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill"; -import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; import { - mergeHistoryOverSnapshot, - readMessageSnapshot, - writeMessageSnapshot, -} from "@/features/messages/lib/messageSnapshot"; -import { - MIN_TOP_LEVEL_ROWS_PER_FETCH, - pageOlderMessagesUntilRowFloor, -} from "@/features/messages/lib/pageOlderMessages"; -import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; + emptyChannelWindowStore, + flattenChannelWindowEvents, + mergeLiveChannelWindowEvent, + replaceNewestChannelWindow, + type ChannelWindowStore, +} from "@/features/messages/lib/channelWindowStore"; +import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; import { + CHANNEL_AUX_EVENT_KINDS, + CHANNEL_TIMELINE_CONTENT_KINDS, KIND_STREAM_MESSAGE, KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; @@ -52,10 +53,13 @@ import { type MessageQueryContext = { optimisticId: string; previousMessages: RelayEvent[]; + previousWindow: ChannelWindowStore | undefined; + channelId: string; queryKey: ReturnType; }; -const CHANNEL_HISTORY_LIMIT = 60; +const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); +const CHANNEL_AUX_KINDS = new Set(CHANNEL_AUX_EVENT_KINDS); function getLocalRenderKey(message: RelayEvent) { return message.localKey ?? message.id; @@ -240,128 +244,114 @@ export function resolveThreadReplyTarget( }; } +function retainRefetchReconciliationEvents(events: RelayEvent[]) { + return events.filter((event) => { + if (!CHANNEL_TIMELINE_KINDS.has(event.kind)) return false; + if (event.pending) return true; + const thread = getThreadReference(event.tags); + return thread.parentId !== null && !isBroadcastReply(event.tags); + }); +} + +function mergeRefetchReconciliationEvents( + windowEvents: RelayEvent[], + previousMessages: RelayEvent[], +) { + const authoritativeIds = new Set(windowEvents.map((event) => event.id)); + return retainRefetchReconciliationEvents(previousMessages) + .filter((event) => !authoritativeIds.has(event.id)) + .reduce((current, reply) => mergeMessages(current, reply), windowEvents); +} + +export function useChannelWindowQuery(channel: Channel | null) { + const queryClient = useQueryClient(); + const queryKey = channelWindowKey(channel?.id ?? "none"); + return useQuery({ + enabled: channel !== null && channel.channelType !== "forum", + queryKey, + queryFn: () => + queryClient.getQueryData(queryKey) ?? + emptyChannelWindowStore(), + staleTime: Number.POSITIVE_INFINITY, + }); +} + export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); - const { activeWorkspace } = useWorkspaces(); - const relayUrl = activeWorkspace?.relayUrl ?? null; + const windowKey = channelWindowKey(channel?.id ?? "none"); - const query = useQuery({ + return useQuery({ enabled: channel !== null && channel.channelType !== "forum", - // Paint instantly from the in-memory cache, or — after a restart / gc — - // from the persisted per-channel snapshot, then revalidate behind it. - placeholderData: () => { - const cached = queryClient.getQueryData(queryKey); - if (cached && cached.length > 0) { - return cached; - } - if (!channel || !relayUrl) { - return undefined; - } - const snapshot = readMessageSnapshot(relayUrl, channel.id); - return snapshot ? normalizeTimelineMessages(snapshot) : undefined; - }, queryKey, queryFn: async () => { - if (!channel) { - throw new Error("No channel selected."); - } - - const history = await relayClient.fetchChannelHistory( - channel.id, - CHANNEL_HISTORY_LIMIT, - ); - // Merge over the cache, or over the persisted snapshot when cold; a - // cold snapshot load widens the aux backfill to the merged timeline so - // tombstones/edits for snapshot-only rows are fetched (see helper doc). - const cached = queryClient.getQueryData(queryKey); - const { merged: mergedHistory, auxBackfillWindow } = - mergeHistoryOverSnapshot({ - cached, - snapshot: - !cached && relayUrl - ? readMessageSnapshot(relayUrl, channel.id) - : null, - history, - }); - - // Paint messages immediately; backfill their reactions/edits/deletions - // by `#e` in the background (it self-merges into the same cache key). - void backfillAuxForMessages(queryClient, channel.id, auxBackfillWindow); - - // Seed the cache and paint immediately; if the cold window renders - // thinner than a normal scroll page (reply-heavy channels), top it up - // in the background — it self-merges into the same cache key. - queryClient.setQueryData(queryKey, mergedHistory); - if ( - countTopLevelTimelineRows(mergedHistory) < MIN_TOP_LEVEL_ROWS_PER_FETCH - ) { - void pageOlderMessagesUntilRowFloor( - queryClient, - channel.id, - () => true, - ).catch((error) => { - console.error("Failed to top up channel history", channel.id, error); - }); - } - return queryClient.getQueryData(queryKey) ?? mergedHistory; + if (!channel) throw new Error("No channel selected."); + const previousMessages = + queryClient.getQueryData(queryKey) ?? []; + const events = await getChannelWindowEvents(channel.id); + const page = parseChannelWindowResponse(events, channel.id, null); + const current = + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(); + const next = replaceNewestChannelWindow(current, page); + const windowEvents = flattenChannelWindowEvents(next); + queryClient.setQueryData(windowKey, next); + return mergeRefetchReconciliationEvents(windowEvents, previousMessages); }, staleTime: 5 * 60 * 1_000, - // Long in-memory retention: a channel revisited within the hour paints - // from cache with zero relay round trips; the persisted snapshot covers - // restarts beyond it. gcTime: 60 * 60 * 1_000, }); - - // Persist the newest slice after each settled update so the next cold open - // (restart, gc) paints from the snapshot. Placeholder frames are skipped — - // they are what the snapshot painted, not new information. - const persistSnapshot = useEffectEvent((events: RelayEvent[]) => { - if (relayUrl && channel) { - writeMessageSnapshot(relayUrl, channel.id, events); - } - }); - const settledData = query.isPlaceholderData ? undefined : query.data; - useEffect(() => { - if (settledData && settledData.length > 0) { - persistSnapshot(settledData); - } - }, [settledData]); - - return query; } export function useChannelSubscription(channel: Channel | null) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; const channelType = channel?.channelType ?? null; - const syncLatestHistory = useEffectEvent(async () => { - if (!channelId) { - return; - } - - const history = await relayClient.fetchChannelHistory( - channelId, - CHANNEL_HISTORY_LIMIT, - ); - - queryClient.setQueryData( - channelMessagesKey(channelId), - (current = []) => mergeTimelineHistoryMessages(current, history), - ); - - void backfillAuxForMessages(queryClient, channelId, history); + const refreshNewestWindow = useEffectEvent(async () => { + if (!channelId) return; + await queryClient.invalidateQueries({ + queryKey: channelMessagesKey(channelId), + exact: true, + refetchType: "active", + }); }); const appendMessage = useEffectEvent((event: RelayEvent) => { - if (!channelId) { - return; + if (!channelId) return; + const isTimelineRow = CHANNEL_TIMELINE_KINDS.has(event.kind); + const threadReference = isTimelineRow + ? getThreadReference(event.tags) + : null; + if (threadReference?.parentId != null) { + const rootId = threadReference?.rootId; + if (rootId) { + queryClient.setQueryData( + threadRepliesKey(channelId, rootId), + (current = []) => mergeMessages(current, event), + ); + } + if (!isBroadcastReply(event.tags)) return; + } + if (!isTimelineRow && !CHANNEL_AUX_KINDS.has(event.kind)) return; + if (!isTimelineRow) { + queryClient.setQueriesData( + { queryKey: ["thread-replies", channelId] }, + (current = []) => mergeMessages(current, event), + ); } - queryClient.setQueryData( - channelMessagesKey(channelId), - (current = []) => mergeTimelineCacheMessages(current, event), - ); + const windowKey = channelWindowKey(channelId); + const current = + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(); + const next = mergeLiveChannelWindowEvent(current, event, isTimelineRow); + if (next !== current) { + queryClient.setQueryData(windowKey, next); + queryClient.setQueryData( + channelMessagesKey(channelId), + flattenChannelWindowEvents(next), + ); + } if (event.kind === KIND_SYSTEM_MESSAGE) { try { @@ -393,10 +383,10 @@ export function useChannelSubscription(channel: Channel | null) { let isDisposed = false; let cleanup: (() => Promise) | undefined; const disposeReconnectListener = relayClient.subscribeToReconnects(() => { - void syncLatestHistory().catch((error) => { + void refreshNewestWindow().catch((error) => { if (!isDisposed) { console.error( - "Failed to refresh channel history after reconnecting", + "Failed to refresh channel window after reconnecting", channelId, error, ); @@ -405,7 +395,7 @@ export function useChannelSubscription(channel: Channel | null) { }); relayClient - .subscribeToChannel(channelId, (event) => { + .subscribeToChannelLive(channelId, (event) => { if (!isDisposed) { appendMessage(event); } @@ -417,15 +407,15 @@ export function useChannelSubscription(channel: Channel | null) { } cleanup = dispose; - // No post-subscribe history refetch: useChannelMessagesQuery already - // loaded the latest CHANNEL_HISTORY_LIMIT events, and the live - // subscription itself backfills up to 50 most-recent events via its - // initial REQ (buildChannelFilter(id, 50)). Both write into the same - // channelMessagesKey cache, so any window between the two REQs is - // covered by the live sub's overlap unless >50 messages land in - // <1s — vanishingly rare in practice. The reconnect listener above - // still bridges gaps from connection drops, where the gap *is* - // unbounded. + void refreshNewestWindow().catch((error) => { + if (!isDisposed) { + console.error( + "Failed to refresh channel window after subscribing", + channelId, + error, + ); + } + }); }) .catch((error) => { console.error("Failed to subscribe to channel", channelId, error); @@ -597,6 +587,9 @@ export function useSendMessageMutation( const previousMessages = queryClient.getQueryData(queryKey) ?? []; + const windowKey = channelWindowKey(effectiveChannel.id); + const previousWindow = + queryClient.getQueryData(windowKey); const optimisticMessage = createOptimisticMessage( effectiveChannel.id, content.trim(), @@ -607,14 +600,21 @@ export function useSendMessageMutation( mediaTags ?? [], ); + const nextWindow = mergeLiveChannelWindowEvent( + previousWindow ?? emptyChannelWindowStore(), + optimisticMessage, + ); + queryClient.setQueryData(windowKey, nextWindow); queryClient.setQueryData( queryKey, - mergeTimelineCacheMessages(previousMessages, optimisticMessage), + flattenChannelWindowEvents(nextWindow), ); return { optimisticId: optimisticMessage.id, previousMessages, + previousWindow, + channelId: effectiveChannel.id, queryKey, }; }, @@ -624,17 +624,34 @@ export function useSendMessageMutation( } queryClient.setQueryData(context.queryKey, context.previousMessages); + queryClient.setQueryData( + channelWindowKey(context.channelId), + context.previousWindow, + ); }, onSuccess: (message, _variables, context) => { if (!context) { return; } - queryClient.setQueryData(context.queryKey, (current = []) => - mergeTimelineCacheMessages(current, { - ...message, - localKey: context.optimisticId, - }), + const windowKey = channelWindowKey(context.channelId); + const current = + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(); + const withoutPending: ChannelWindowStore = { + ...current, + liveOverlay: current.liveOverlay.filter( + (event) => event.id !== context.optimisticId, + ), + }; + const next = mergeLiveChannelWindowEvent(withoutPending, { + ...message, + localKey: context.optimisticId, + }); + queryClient.setQueryData(windowKey, next); + queryClient.setQueryData( + context.queryKey, + flattenChannelWindowEvents(next), ); }, }); diff --git a/desktop/src/features/messages/lib/channelWindowResponse.test.mjs b/desktop/src/features/messages/lib/channelWindowResponse.test.mjs new file mode 100644 index 000000000..2e001c665 --- /dev/null +++ b/desktop/src/features/messages/lib/channelWindowResponse.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseChannelWindowResponse } from "./channelWindowResponse.ts"; + +function event(id, kind, createdAt, content = "", tags = []) { + return { + id: id.padEnd(64, "0"), + pubkey: "a".repeat(64), + created_at: createdAt, + kind, + tags: [["h", "channel"], ...tags], + content, + sig: "b".repeat(128), + }; +} + +test("partitions flat rows, summaries, aux, and authoritative bounds", () => { + const root = event("a", 9, 100); + const summary = event( + "s", + 39005, + 200, + JSON.stringify({ + reply_count: 2, + descendant_count: 3, + last_reply_at: 150, + participants: ["c".repeat(64)], + }), + [ + ["e", root.id], + ["d", root.id], + ], + ); + const aux = event("x", 7, 300, "+", [["e", root.id]]); + const bounds = event( + "b", + 39006, + 400, + JSON.stringify({ + has_more: true, + next_cursor: { created_at: 100, id: root.id }, + }), + [["d", "channel:head"]], + ); + const page = parseChannelWindowResponse( + [summary, aux, bounds, root], + "channel", + null, + ); + assert.equal(page.rows.length, 1); + assert.equal(page.rows[0].thread.replyCount, 2); + assert.deepEqual( + page.aux.map((item) => item.id), + [aux.id], + ); + assert.deepEqual(page.nextCursor, { createdAt: 100, eventId: root.id }); + assert.equal(page.hasMore, true); +}); + +test("metadata timestamps never influence row cursor math", () => { + const root = event("a", 9, 100); + const bounds = event( + "b", + 39006, + 9999, + JSON.stringify({ has_more: false, next_cursor: null }), + [["d", "channel:head"]], + ); + const page = parseChannelWindowResponse([bounds, root], "channel", null); + assert.equal(page.nextCursor, null); + assert.deepEqual( + page.rows.map((row) => row.event.id), + [root.id], + ); +}); + +test("rejects bounds signed for a different request cursor", () => { + const root = event("a", 9, 100); + const cursor = { createdAt: 200, eventId: "c".repeat(64) }; + const bounds = event( + "b", + 39006, + 300, + JSON.stringify({ has_more: false, next_cursor: null }), + [["d", `channel:${cursor.createdAt}:${"d".repeat(64)}`]], + ); + + assert.throws( + () => parseChannelWindowResponse([root, bounds], "channel", cursor), + /do not match the request cursor/, + ); +}); + +test("accepts canonical composite request cursor binding", () => { + const root = event("a", 9, 100); + const cursor = { createdAt: 200, eventId: "C".repeat(64) }; + const bounds = event( + "b", + 39006, + 300, + JSON.stringify({ has_more: false, next_cursor: null }), + [["d", `channel:200:${"c".repeat(64)}`]], + ); + + assert.doesNotThrow(() => + parseChannelWindowResponse([root, bounds], "CHANNEL", cursor), + ); +}); + +test("rejects absent or contradictory signed bounds", () => { + const root = event("a", 9, 100); + assert.throws( + () => parseChannelWindowResponse([root], "channel", null), + /exactly one bounds/, + ); + const bad = event( + "b", + 39006, + 200, + JSON.stringify({ has_more: true, next_cursor: null }), + [["d", "channel:head"]], + ); + assert.throws( + () => parseChannelWindowResponse([root, bad], "channel", null), + /disagree/, + ); +}); diff --git a/desktop/src/features/messages/lib/channelWindowResponse.ts b/desktop/src/features/messages/lib/channelWindowResponse.ts new file mode 100644 index 000000000..7fc41ca99 --- /dev/null +++ b/desktop/src/features/messages/lib/channelWindowResponse.ts @@ -0,0 +1,102 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + CHANNEL_AUX_EVENT_KINDS, + CHANNEL_TIMELINE_CONTENT_KINDS, + KIND_CHANNEL_THREAD_SUMMARY, + KIND_CHANNEL_WINDOW_BOUNDS, +} from "@/shared/constants/kinds"; +import type { + ChannelWindowCursor, + ChannelWindowPage, + ChannelWindowThreadSummary, +} from "./channelWindowStore"; + +const CONTENT_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); +const AUX_KINDS = new Set(CHANNEL_AUX_EVENT_KINDS); + +type WireCursor = { created_at: number; id: string }; +type BoundsPayload = { has_more: boolean; next_cursor: WireCursor | null }; +type SummaryPayload = { + reply_count: number; + descendant_count: number; + last_reply_at: number | null; + participants: string[]; +}; + +function targetId(event: RelayEvent, tagName: "d" | "e") { + return event.tags.find((tag) => tag[0] === tagName)?.[1] ?? null; +} + +function parseJson(event: RelayEvent, label: string): T { + try { + return JSON.parse(event.content) as T; + } catch { + throw new Error(`Invalid ${label} event ${event.id}.`); + } +} + +const mapCursor = (cursor: WireCursor | null): ChannelWindowCursor | null => + cursor ? { createdAt: cursor.created_at, eventId: cursor.id } : null; + +function expectedBoundsKey( + channelId: string, + startCursor: ChannelWindowCursor | null, +) { + const suffix = startCursor + ? `${startCursor.createdAt}:${startCursor.eventId.toLowerCase()}` + : "head"; + return `${channelId.toLowerCase()}:${suffix}`; +} + +/** Partition a flat `/query` response before any cursor or timeline math. */ +export function parseChannelWindowResponse( + events: RelayEvent[], + channelId: string, + startCursor: ChannelWindowCursor | null, +): ChannelWindowPage { + const rows = events + .filter((event) => CONTENT_KINDS.has(event.kind)) + .map((event) => ({ + event, + thread: null as ChannelWindowThreadSummary | null, + })); + const rowById = new Map(rows.map((row) => [row.event.id, row])); + + for (const event of events) { + if (event.kind !== KIND_CHANNEL_THREAD_SUMMARY) continue; + const rootId = targetId(event, "e"); + const row = rootId ? rowById.get(rootId) : undefined; + if (!row) continue; + const payload = parseJson(event, "thread summary"); + row.thread = { + replyCount: payload.reply_count, + descendantCount: payload.descendant_count, + lastReplyAt: payload.last_reply_at, + participantPubkeys: payload.participants, + }; + } + + const boundsEvents = events.filter( + (event) => event.kind === KIND_CHANNEL_WINDOW_BOUNDS, + ); + if (boundsEvents.length !== 1) { + throw new Error( + "Channel window response must contain exactly one bounds event.", + ); + } + const boundsEvent = boundsEvents[0]; + if ( + targetId(boundsEvent, "d") !== expectedBoundsKey(channelId, startCursor) + ) { + throw new Error("Channel window bounds do not match the request cursor."); + } + const bounds = parseJson(boundsEvent, "window bounds"); + const nextCursor = mapCursor(bounds.next_cursor); + if (bounds.has_more !== (nextCursor !== null)) { + throw new Error("Channel window bounds has_more and next_cursor disagree."); + } + + // Summaries/bounds are metadata, never durable raw timeline events. + const aux = events.filter((event) => AUX_KINDS.has(event.kind)); + return { startCursor, rows, aux, nextCursor, hasMore: bounds.has_more }; +} diff --git a/desktop/src/features/messages/lib/channelWindowStore.test.mjs b/desktop/src/features/messages/lib/channelWindowStore.test.mjs new file mode 100644 index 000000000..b30804613 --- /dev/null +++ b/desktop/src/features/messages/lib/channelWindowStore.test.mjs @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + appendOlderChannelWindow, + emptyChannelWindowStore, + flattenChannelWindowEvents, + mergeLiveChannelWindowEvent, + replaceNewestChannelWindow, +} from "./channelWindowStore.ts"; + +function event(id, createdAt, kind = 9) { + return { + id: id.padEnd(64, "0"), + pubkey: "a".repeat(64), + created_at: createdAt, + kind, + tags: [["h", "channel"]], + content: id, + sig: "b".repeat(128), + }; +} +const cursor = (item) => ({ createdAt: item.created_at, eventId: item.id }); +function page(startCursor, rows, { aux = [], hasMore = true } = {}) { + return { + startCursor, + rows: rows.map((item) => ({ event: item, thread: null })), + aux, + nextCursor: hasMore ? cursor(rows.at(-1)) : null, + hasMore, + }; +} + +test("dense-second pages form a lossless cursor chain", () => { + const first = page(null, [event("a", 100), event("b", 100)]); + const second = page(first.nextCursor, [event("c", 100), event("z", 99)], { + hasMore: false, + }); + const store = appendOlderChannelWindow( + replaceNewestChannelWindow(emptyChannelWindowStore(), first), + second, + ); + assert.deepEqual( + flattenChannelWindowEvents(store).map((item) => item.content), + ["z", "c", "b", "a"], + ); +}); + +test("accepts a relay cursor beyond the last reconstructed row", () => { + const visible = event("a", 100); + const skippedRawTail = event("z", 99); + const first = { + ...page(null, [visible]), + nextCursor: cursor(skippedRawTail), + }; + const store = replaceNewestChannelWindow(emptyChannelWindowStore(), first); + const complete = appendOlderChannelWindow( + store, + page(first.nextCursor, [event("older", 98)], { hasMore: false }), + ); + + assert.deepEqual(store.pages[0].nextCursor, cursor(skippedRawTail)); + assert.deepEqual( + flattenChannelWindowEvents(complete).map((item) => item.content), + ["older", "a"], + ); +}); + +test("accepts a relay cursor when all retained rows were skipped", () => { + const first = page(null, [event("head", 110)]); + const initial = replaceNewestChannelWindow(emptyChannelWindowStore(), first); + const skippedRawTail = event("z", 99); + const next = appendOlderChannelWindow(initial, { + startCursor: first.nextCursor, + rows: [], + aux: [], + nextCursor: cursor(skippedRawTail), + hasMore: true, + }); + + assert.deepEqual(next.pages[1].nextCursor, cursor(skippedRawTail)); +}); + +test("rejects a response that does not continue the echoed cursor", () => { + const first = page(null, [event("a", 100)]); + const store = replaceNewestChannelWindow(emptyChannelWindowStore(), first); + assert.throws( + () => + appendOlderChannelWindow( + store, + page(cursor(event("x", 50)), [event("z", 49)], { hasMore: false }), + ), + /does not continue/, + ); +}); + +test("rejects inconsistent exhaustion and cursor facts", () => { + const row = event("a", 100); + assert.throws( + () => + replaceNewestChannelWindow(emptyChannelWindowStore(), { + startCursor: null, + rows: [{ event: row, thread: null }], + aux: [], + nextCursor: cursor(row), + hasMore: false, + }), + /disagree/, + ); +}); + +test("newest refresh drops a stale tail when its boundary moves", () => { + const first = page(null, [event("a", 100)]); + const loaded = appendOlderChannelWindow( + replaceNewestChannelWindow(emptyChannelWindowStore(), first), + page(first.nextCursor, [event("z", 90)], { hasMore: false }), + ); + const refreshed = replaceNewestChannelWindow( + loaded, + page(null, [event("n", 110), event("a", 100)]), + ); + assert.equal(refreshed.pages.length, 1); + assert.deepEqual( + flattenChannelWindowEvents(refreshed).map((item) => item.content), + ["a", "n"], + ); +}); + +test("live rows arriving before page zero enter the overlay", () => { + const live = event("n", 110); + const store = mergeLiveChannelWindowEvent(emptyChannelWindowStore(), live); + + assert.deepEqual(store.liveOverlay, [live]); + assert.deepEqual( + flattenChannelWindowEvents(store).map((item) => item.content), + ["n"], + ); +}); + +test("live backdated rows stay outside pages and render in order", () => { + const store = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("n", 110), event("a", 100)]), + ); + const withLive = mergeLiveChannelWindowEvent(store, event("m", 105)); + assert.equal(withLive.pages[0], store.pages[0]); + assert.deepEqual( + flattenChannelWindowEvents(withLive).map((item) => item.content), + ["a", "m", "n"], + ); +}); + +test("live rows below the oldest retained boundary wait for paging", () => { + const store = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("n", 110), event("a", 100)]), + ); + assert.equal(mergeLiveChannelWindowEvent(store, event("old", 90)), store); +}); + +test("live aux stays separate from authoritative page closure", () => { + const store = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("a", 100)]), + ); + const aux = event("reaction", 110, 7); + const withAux = mergeLiveChannelWindowEvent(store, aux, false); + + assert.deepEqual(withAux.pages, store.pages); + assert.deepEqual(withAux.liveAux, [aux]); + assert.equal( + flattenChannelWindowEvents(withAux).filter((item) => item.id === aux.id) + .length, + 1, + ); + assert.equal(mergeLiveChannelWindowEvent(withAux, aux, false), withAux); +}); + +test("authoritative refresh reconciles duplicate live rows", () => { + const initial = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("a", 100)]), + ); + const withLive = mergeLiveChannelWindowEvent(initial, event("n", 110)); + const refreshed = replaceNewestChannelWindow( + withLive, + page(null, [event("n", 110), event("a", 100)]), + ); + assert.deepEqual(refreshed.liveOverlay, []); + assert.equal( + flattenChannelWindowEvents(refreshed).filter((item) => item.content === "n") + .length, + 1, + ); +}); + +test("older-page append reconciles a live row pushed below page zero", () => { + const initial = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("a", 100)]), + ); + const live = event("n", 110); + const withLive = mergeLiveChannelWindowEvent(initial, live); + const refreshed = replaceNewestChannelWindow( + withLive, + page(null, [event("newer", 120)]), + ); + const reconciled = appendOlderChannelWindow( + refreshed, + page(refreshed.pages[0].nextCursor, [live], { hasMore: false }), + ); + + assert.deepEqual(reconciled.liveOverlay, []); + assert.equal( + flattenChannelWindowEvents(reconciled).filter((item) => item.id === live.id) + .length, + 1, + ); +}); + +// Sharper than the count-only reconcile checks above: when the live-overlay copy +// and the authoritative relay copy share an id but DIFFER in content (an +// optimistic/pending row later re-served by the relay in an older page), the +// rendered row must be the relay copy. `flattenChannelWindowEvents` sets page +// rows before liveOverlay, so an un-reconciled overlay entry shadows the +// authoritative row — user sees the stale/pending version after paginating. +test("older-page append: authoritative relay row wins over a stale overlay copy", () => { + const withContent = (id, createdAt, content) => ({ + ...event(id, createdAt), + content, + pending: content.startsWith("PENDING"), + }); + const initial = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("a", 100)]), + ); + const staleOverlay = withContent("n", 110, "PENDING n"); + const withLive = mergeLiveChannelWindowEvent(initial, staleOverlay); + const refreshed = replaceNewestChannelWindow( + withLive, + page(null, [event("newer", 120)]), + ); + const authoritative = withContent("n", 110, "CONFIRMED n"); + const reconciled = appendOlderChannelWindow( + refreshed, + page(refreshed.pages[0].nextCursor, [authoritative], { hasMore: false }), + ); + + const rows = flattenChannelWindowEvents(reconciled).filter( + (item) => item.id === staleOverlay.id, + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].content, "CONFIRMED n"); + assert.deepEqual(reconciled.liveOverlay, []); +}); + +test("flattening dedupes aux closure events returned on adjacent pages", () => { + const deletion = event("d", 120, 5); + const first = page(null, [event("a", 100)], { aux: [deletion] }); + const store = appendOlderChannelWindow( + replaceNewestChannelWindow(emptyChannelWindowStore(), first), + page(first.nextCursor, [event("z", 90)], { + aux: [deletion], + hasMore: false, + }), + ); + assert.equal( + flattenChannelWindowEvents(store).filter((item) => item.id === deletion.id) + .length, + 1, + ); +}); diff --git a/desktop/src/features/messages/lib/channelWindowStore.ts b/desktop/src/features/messages/lib/channelWindowStore.ts new file mode 100644 index 000000000..23adc5a4e --- /dev/null +++ b/desktop/src/features/messages/lib/channelWindowStore.ts @@ -0,0 +1,216 @@ +import type { RelayEvent } from "@/shared/api/types"; + +export type ChannelWindowCursor = { createdAt: number; eventId: string }; +export type ChannelWindowThreadSummary = { + replyCount: number; + descendantCount: number; + lastReplyAt: number | null; + participantPubkeys: string[]; +}; +export type ChannelWindowRow = { + event: RelayEvent; + thread: ChannelWindowThreadSummary | null; +}; +export type ChannelWindowPage = { + startCursor: ChannelWindowCursor | null; + rows: ChannelWindowRow[]; + aux: RelayEvent[]; + nextCursor: ChannelWindowCursor | null; + hasMore: boolean; +}; +export type ChannelWindowStore = { + pages: ChannelWindowPage[]; + /** Top-level live events not represented in an authoritative relay page. */ + liveOverlay: RelayEvent[]; + /** Live structural events retained independently from frozen page closure. */ + liveAux: RelayEvent[]; +}; + +export const emptyChannelWindowStore = (): ChannelWindowStore => ({ + pages: [], + liveOverlay: [], + liveAux: [], +}); + +function cursorsEqual( + left: ChannelWindowCursor | null, + right: ChannelWindowCursor | null, +) { + return ( + left === right || + (left !== null && + right !== null && + left.createdAt === right.createdAt && + left.eventId === right.eventId) + ); +} + +/** Relay order: newest timestamp first, then ascending id within a second. */ +function compareRelayOrder(left: RelayEvent, right: RelayEvent) { + return left.created_at !== right.created_at + ? right.created_at - left.created_at + : left.id < right.id + ? -1 + : left.id > right.id + ? 1 + : 0; +} + +function isStrictlyOlder(event: RelayEvent, cursor: ChannelWindowCursor) { + return ( + event.created_at < cursor.createdAt || + (event.created_at === cursor.createdAt && event.id > cursor.eventId) + ); +} + +function assertValidPage(page: ChannelWindowPage) { + if (page.hasMore !== (page.nextCursor !== null)) { + throw new Error("Channel window hasMore and nextCursor disagree."); + } + const seen = new Set(); + for (let index = 0; index < page.rows.length; index += 1) { + const event = page.rows[index].event; + if (seen.has(event.id)) + throw new Error(`Duplicate channel row ${event.id}.`); + seen.add(event.id); + if (page.startCursor && !isStrictlyOlder(event, page.startCursor)) { + throw new Error( + `Channel row ${event.id} is outside its cursor interval.`, + ); + } + const previous = page.rows[index - 1]?.event; + if (previous && compareRelayOrder(previous, event) > 0) { + throw new Error("Channel window rows are not in relay order."); + } + } +} + +/** + * Replace the authoritative chain at page zero. Its end cursor may move, so + * retaining old tail pages would claim a cursor chain they were not fetched on. + */ +export function replaceNewestChannelWindow( + current: ChannelWindowStore, + page: ChannelWindowPage, +): ChannelWindowStore { + if (page.startCursor !== null) { + throw new Error("Newest channel page must have a null start cursor."); + } + assertValidPage(page); + const ids = new Set(page.rows.map((row) => row.event.id)); + const auxIds = new Set(page.aux.map((event) => event.id)); + return { + pages: [page], + liveOverlay: current.liveOverlay.filter((event) => !ids.has(event.id)), + liveAux: current.liveAux.filter((event) => !auxIds.has(event.id)), + }; +} + +/** Append only a response that continues the retained echoed cursor chain. */ +export function appendOlderChannelWindow( + current: ChannelWindowStore, + page: ChannelWindowPage, +): ChannelWindowStore { + assertValidPage(page); + const tail = current.pages[current.pages.length - 1]; + if (!tail) throw new Error("Load the newest channel page first."); + if (!tail.hasMore || !tail.nextCursor) { + throw new Error("The channel window is already complete."); + } + if (!cursorsEqual(page.startCursor, tail.nextCursor)) { + throw new Error( + "Channel page does not continue the retained cursor chain.", + ); + } + const ids = new Set( + current.pages.flatMap((retained) => + retained.rows.map((row) => row.event.id), + ), + ); + for (const row of page.rows) { + if (ids.has(row.event.id)) { + throw new Error(`Channel row ${row.event.id} overlaps a retained page.`); + } + } + const pageIds = new Set(page.rows.map((row) => row.event.id)); + return { + ...current, + pages: [...current.pages, page], + liveOverlay: current.liveOverlay.filter((event) => !pageIds.has(event.id)), + }; +} + +/** + * Merge a live top-level event without mutating authoritative page boundaries. + * Events below the oldest loaded boundary wait for ordinary relay pagination. + */ +export function mergeLiveChannelWindowEvent( + current: ChannelWindowStore, + event: RelayEvent, + isTimelineRow = true, +): ChannelWindowStore { + if (!isTimelineRow) { + if ( + current.liveAux.some((candidate) => candidate.id === event.id) || + current.pages.some((page) => + page.aux.some((candidate) => candidate.id === event.id), + ) + ) { + return current; + } + return { ...current, liveAux: [...current.liveAux, event] }; + } + if ( + current.pages.some((page) => + page.rows.some((row) => row.event.id === event.id), + ) + ) { + return current; + } + const oldestPage = current.pages[current.pages.length - 1]; + const oldest = oldestPage?.rows[oldestPage.rows.length - 1]?.event; + if (oldest && compareRelayOrder(event, oldest) >= 0) return current; + return { + ...current, + liveOverlay: current.liveOverlay + .filter((candidate) => candidate.id !== event.id) + .concat(event) + .sort(compareRelayOrder), + }; +} + +/** Raw events in the chronological order expected by the existing renderer. */ +export function flattenChannelWindowEvents(store: ChannelWindowStore) { + const byId = new Map(); + for (const page of store.pages) { + for (const row of page.rows) byId.set(row.event.id, row.event); + for (const event of page.aux) byId.set(event.id, event); + } + for (const event of store.liveOverlay) byId.set(event.id, event); + for (const event of store.liveAux) byId.set(event.id, event); + return [...byId.values()].sort((left, right) => + compareRelayOrder(right, left), + ); +} + +/** + * Whether older history remains beyond the retained pages. The authoritative + * signal for paging: the tail page's `hasMore` (kept in lockstep with its + * `nextCursor` by `assertValidPage`). Empty pages mean no window has loaded + * yet — there is no cursor to page with, so report false; the first + * `replaceNewestChannelWindow` makes the signal authoritative. + */ +export function channelWindowHasMore(store: ChannelWindowStore) { + const tail = store.pages[store.pages.length - 1]; + return tail?.hasMore ?? false; +} + +export function channelWindowThreadSummaries(store: ChannelWindowStore) { + return new Map( + store.pages.flatMap((page) => + page.rows.flatMap((row) => + row.thread ? ([[row.event.id, row.thread]] as const) : [], + ), + ), + ); +} diff --git a/desktop/src/features/messages/lib/independentThreadPanel.ts b/desktop/src/features/messages/lib/independentThreadPanel.ts new file mode 100644 index 000000000..4562928d8 --- /dev/null +++ b/desktop/src/features/messages/lib/independentThreadPanel.ts @@ -0,0 +1,31 @@ +import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; +import { buildThreadPanelData } from "@/features/messages/lib/threadPanel"; +import type { RelayEvent } from "@/shared/api/types"; + +export function buildIndependentThreadPanel( + channelEvents: RelayEvent[], + replyEvents: RelayEvent[], + rootId: string | null, + replyTargetId: string | null, + expandedReplyIds: ReadonlySet, + ...formatArgs: Tail> +) { + if (!rootId) { + return buildThreadPanelData([], null, replyTargetId, expandedReplyIds); + } + const head = channelEvents.find((event) => event.id === rootId); + const events = head ? [head, ...replyEvents] : replyEvents; + return buildThreadPanelData( + formatTimelineMessages(events, ...formatArgs), + rootId, + replyTargetId, + expandedReplyIds, + ); +} + +type Tail = T extends readonly [ + unknown, + ...infer R, +] + ? R + : never; diff --git a/desktop/src/features/messages/lib/messageQueryKeys.test.mjs b/desktop/src/features/messages/lib/messageQueryKeys.test.mjs index 0448c0a83..217b171f1 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.test.mjs +++ b/desktop/src/features/messages/lib/messageQueryKeys.test.mjs @@ -5,7 +5,6 @@ import { mergeTimelineHistoryMessages, normalizeTimelineMessages, } from "./messageQueryKeys.ts"; -import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; const CHANNEL_ID = "timeline-window-test"; const PUBKEY = "a".repeat(64); @@ -26,149 +25,35 @@ function id(prefix, index) { return `${prefix}${String(index).padStart(64 - prefix.length, "0")}`; } -test("normalizeTimelineMessages caps visible content, not unrelated auxiliary events", () => { - const june12Roots = [ - "1f86d0450b3c2c376691e7a8232fbd8a5b8408ecad2f5eb0209e7bfcfdf9af80", - "3b745de3d9ff91c464b0cbf26e3e628a5a8c05dccf3f9781b1fbd99a0f6f5e7b", - "cb404eeae1517bb2ed2e9975dfd2efd12d0a7ef17b87c3a6573b251b9865f7a4", - "337de49c712fcf84f5689a6c11ce36018817197d578468b8132c6dc3d1a13131", - "ac683f35cda2e8c1e9ae609e0b9f5dc23a7d434637b4f0505495c3a2b6f52aae", - ]; +test("normalizeTimelineMessages preserves the complete loaded window", () => { const messages = []; - - for (let index = 0; index < 500; index += 1) { - messages.push(event({ id: id("old", index), createdAt: 1_000 + index })); - } - for (const [index, rootId] of june12Roots.entries()) { - messages.push( - event({ id: rootId, createdAt: 2_000 + index, content: "June 12 root" }), - ); - } - for (let index = 0; index < 1_303; index += 1) { - messages.push( - event({ - id: id("del", index), - kind: 5, - createdAt: 3_000 + index, - tags: [ - ["h", CHANNEL_ID], - ["e", id("zzz", index)], - ], - }), - ); - } - for (let index = 0; index < 231; index += 1) { - messages.push( - event({ - id: id("rea", index), - kind: 7, - createdAt: 5_000 + index, - tags: [ - ["h", CHANNEL_ID], - ["e", id("yyy", index)], - ], - content: "+", - }), - ); - } - for (let index = 0; index < 1_495; index += 1) { - messages.push(event({ id: id("new", index), createdAt: 6_000 + index })); - } - - const normalized = normalizeTimelineMessages(messages); - - assert.equal(normalized.filter((item) => item.kind === 9).length, 2_000); - assert.deepEqual( - june12Roots.map((rootId) => normalized.some((item) => item.id === rootId)), - [true, true, true, true, true], - ); - assert.equal(normalized.filter((item) => item.kind === 5).length, 1_303); - assert.equal(normalized.filter((item) => item.kind === 7).length, 231); -}); - -test("normalizeTimelineMessages still caps old visible content", () => { - const retainedRoot = `${"a".repeat(63)}1`; - const reaction = `${"b".repeat(63)}1`; - const reactionDeletion = `${"c".repeat(63)}1`; - const messages = []; - - for (let index = 0; index < 2_000; index += 1) { - messages.push(event({ id: id("old", index), createdAt: 1_000 + index })); + for (let index = 0; index < 2_100; index += 1) { + messages.push(event({ id: id("row", index), createdAt: 1_000 + index })); } - messages.push(event({ id: retainedRoot, createdAt: 4_000 })); messages.push( event({ - id: reaction, + id: id("aux", 0), kind: 7, - createdAt: 4_001, + createdAt: 4_000, tags: [ ["h", CHANNEL_ID], - ["e", retainedRoot], + ["e", id("row", 0)], ], content: "+", }), ); - messages.push( - event({ - id: reactionDeletion, - kind: 5, - createdAt: 4_002, - tags: [ - ["h", CHANNEL_ID], - ["e", reaction], - ], - }), - ); const normalized = normalizeTimelineMessages(messages); + assert.equal(normalized.filter((item) => item.kind === 9).length, 2_100); assert.equal( - normalized.some((item) => item.id === id("old", 0)), - false, - ); - assert.equal( - normalized.some((item) => item.id === retainedRoot), - true, - ); - assert.equal( - normalized.some((item) => item.id === reaction), - true, - ); - assert.equal( - normalized.some((item) => item.id === reactionDeletion), + normalized.some((item) => item.id === id("row", 0)), true, ); -}); - -test("normalizeTimelineMessages counts huddle starts as visible content", () => { - const huddleId = `${"d".repeat(63)}1`; - const messages = []; - - for (let index = 0; index < 2_000; index += 1) { - messages.push(event({ id: id("old", index), createdAt: 1_000 + index })); - } - messages.push( - event({ - id: huddleId, - kind: KIND_HUDDLE_STARTED, - createdAt: 4_000, - content: JSON.stringify({ - ephemeral_channel_id: "8d764100-fd8f-44cf-9c98-6d8fbd739b8c", - }), - }), - ); - - const normalized = normalizeTimelineMessages(messages); - - assert.equal( - normalized.some((item) => item.id === id("old", 0)), - false, - ); assert.equal( - normalized.some((item) => item.id === huddleId), + normalized.some((item) => item.id === id("aux", 0)), true, ); - assert.equal(normalized.filter((item) => item.kind === 9).length, 1_999); }); test("timeline history merge preserves freshly fetched older content roots", () => { diff --git a/desktop/src/features/messages/lib/messageQueryKeys.ts b/desktop/src/features/messages/lib/messageQueryKeys.ts index 2a247b1b1..67d8a8d7e 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.ts +++ b/desktop/src/features/messages/lib/messageQueryKeys.ts @@ -1,24 +1,17 @@ import type { RelayEvent } from "@/shared/api/types"; -import { - KIND_JOB_ACCEPTED, - KIND_JOB_CANCEL, - KIND_JOB_ERROR, - KIND_JOB_PROGRESS, - KIND_JOB_REQUEST, - KIND_JOB_RESULT, - KIND_HUDDLE_STARTED, - KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_V2, - KIND_SYSTEM_MESSAGE, -} from "@/shared/constants/kinds"; - -const MAX_TIMELINE_MESSAGES = 2_000; export function channelMessagesKey(channelId: string) { return ["channel-messages", channelId] as const; } +export function channelWindowKey(channelId: string) { + return ["channel-window", channelId] as const; +} + +export function threadRepliesKey(channelId: string, rootId: string) { + return ["thread-replies", channelId, rootId] as const; +} + export function dedupeMessagesById(messages: RelayEvent[]) { const seenIds = new Set(); const deduped: RelayEvent[] = []; @@ -50,50 +43,8 @@ export function sortMessages(messages: RelayEvent[]) { }); } -function isTimelineWindowContentEvent(event: RelayEvent) { - return ( - event.kind === KIND_STREAM_MESSAGE || - event.kind === KIND_STREAM_MESSAGE_V2 || - event.kind === KIND_STREAM_MESSAGE_DIFF || - event.kind === KIND_SYSTEM_MESSAGE || - event.kind === KIND_JOB_REQUEST || - event.kind === KIND_JOB_ACCEPTED || - event.kind === KIND_JOB_PROGRESS || - event.kind === KIND_JOB_RESULT || - event.kind === KIND_JOB_CANCEL || - event.kind === KIND_JOB_ERROR || - event.kind === KIND_HUDDLE_STARTED - ); -} - -function capNewestTimelineMessages(normalized: RelayEvent[]) { - const contentEvents = normalized.filter(isTimelineWindowContentEvent); - - if (contentEvents.length <= MAX_TIMELINE_MESSAGES) { - return normalized; - } - - const retainedContentIds = new Set( - contentEvents.slice(-MAX_TIMELINE_MESSAGES).map((event) => event.id), - ); - - return normalized.filter( - (event) => - !isTimelineWindowContentEvent(event) || retainedContentIds.has(event.id), - ); -} - -/** - * Sort, dedupe, and cap the timeline at {@link MAX_TIMELINE_MESSAGES} visible - * content events so de-virtualized rendering does not grow into an unbounded - * DOM during long-lived channel sessions. - * - * Auxiliary events (reactions, edits, tombstones) are kept in cache so they can - * still apply to retained or later-loaded content, but they must not consume the - * visible message window and evict older loaded roots. - */ export function normalizeTimelineMessages(messages: RelayEvent[]) { - return capNewestTimelineMessages(sortMessages(messages)); + return sortMessages(messages); } function isOlderHistoryPage(current: RelayEvent[], history: RelayEvent[]) { diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts index dd639b1e9..064dc3636 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -1,281 +1,67 @@ import type { QueryClient } from "@tanstack/react-query"; -import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; -import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill"; +import { + appendOlderChannelWindow, + flattenChannelWindowEvents, + type ChannelWindowStore, +} from "@/features/messages/lib/channelWindowStore"; +import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; import { channelMessagesKey, - mergeTimelineHistoryMessages, + channelWindowKey, } from "@/features/messages/lib/messageQueryKeys"; -import { relayClient } from "@/shared/api/relayClient"; -import { getChannelMessagesBefore } from "@/shared/api/tauri"; -import type { ChannelPageCursor, RelayEvent } from "@/shared/api/types"; - -const OLDER_MESSAGES_BATCH_SIZE = 200; - -// One fetch should advance the timeline by a predictable, *visible* amount. -// Thread replies collapse into their parent and non-content events never render, -// so a single batch can add far fewer rows than that — page in more batches -// until at least this many top-level rows are added (or history runs out). -// Counting rows, not messages, keeps a reply-heavy window from feeling like the -// fetch did nothing. The cold load and scrollback share this floor so the first -// page is the same size as later ones. -export const MIN_TOP_LEVEL_ROWS_PER_FETCH = 30; +import { getChannelWindowEvents } from "@/shared/api/channelWindow"; +import type { RelayEvent } from "@/shared/api/types"; -// Hard ceiling on relay pages fetched in one pass. On reply-heavy channels a -// batch yields only a few visible rows, so the row floor alone could dig through -// hundreds of messages behind one spinner. Capping per-pass keeps each fetch a -// bounded page; the scroll observer re-arms to page further while in view. -const MAX_BATCHES_PER_FETCH = 3; - -export type PageOlderResult = { - /** False once a short relay page proves history is exhausted. */ - hasOlderMessages: boolean; -}; - -// One paging pass per channel at a time: the background cold-load top-up and -// a scroll-up fetch share the running pass instead of overlapping REQs. +const CHANNEL_WINDOW_PAGE_SIZE = 50; +export type PageOlderResult = { hasOlderMessages: boolean }; const inFlightPasses = new Map>(); -/** - * Seed the bridge keyset cursor for the dense-second escape hatch. - * - * The relay orders `created_at DESC, id ASC` and advances past a second denser - * than one WS page via `id > before_id`. So the cursor must point at the - * *furthest* relay-order position already known at the stalled second — the - * **max** event id among all cached/fetched events at `created_at === second`. - * Seeding from the min id (e.g. `baseline[0].id`) would re-request rows already - * held; seeding from the max id asks the relay for the strictly-unreached tail. - */ -function maxEventIdAtSecond( - events: RelayEvent[], - second: number, -): string | null { - let maxId: string | null = null; - for (const event of events) { - if (event.created_at !== second) { - continue; - } - if (maxId === null || event.id > maxId) { - maxId = event.id; - } - } - return maxId; -} - -/** - * Dense-second escape hatch: drain older history via the bridge composite - * keyset once the WS `until` cursor has stalled on a second denser than one - * page. Seeds from the max event id at `boundarySecond` (the furthest - * relay-order position already held) and pages `(created_at, event_id)`, - * clearing the *entire* boundary second first (the wall must be broken in the - * pass that detects it), then honoring the shared per-pass row-floor / batch - * budget for any older history. Stops on a short page (history exhausted). - * Appends fetched events to `fetched` in place; returns whether more history is - * believed to remain (`false` only on a short page). - */ -async function drainOlderViaKeyset(args: { - channelId: string; - boundarySecond: number; - baseline: RelayEvent[]; - fetched: RelayEvent[]; - baselineRowCount: number; - batchesFetched: number; - shouldContinue: () => boolean; -}): Promise { - const { - channelId, - boundarySecond, - baseline, - fetched, - baselineRowCount, - shouldContinue, - } = args; - - const seedId = maxEventIdAtSecond([...baseline, ...fetched], boundarySecond); - if (seedId === null) { - // Nothing known at the boundary second to key off — shouldn't happen once - // stalled, but bail rather than fabricate a cursor. - return true; - } - - let cursor: ChannelPageCursor | null = { - createdAt: boundarySecond, - eventId: seedId, - }; - let batchesFetched = args.batchesFetched; - let hasOlderMessages = true; - - while (cursor !== null && shouldContinue()) { - const page = await getChannelMessagesBefore( - channelId, - cursor, - OLDER_MESSAGES_BATCH_SIZE, - ); - batchesFetched += 1; - - if (page.events.length > 0) { - fetched.push(...page.events); - } - - // A null next cursor means the relay returned a short page — history is - // exhausted. A full page yields a next cursor to continue from. - if (page.nextCursor === null) { - hasOlderMessages = false; - break; - } - cursor = page.nextCursor; - - // Fully clear the dense second before honoring the per-pass budget. The - // wall is a single `created_at` second denser than one page; yielding while - // the cursor is still *inside* that second would strand its tail behind the - // same stall, and the scroll sentinel may not re-arm to summon us again — - // the wall must be cleared in the pass that detects it. Once the cursor has - // advanced to an older second, ordinary older history resumes and the row - // floor / batch budget bound the pass as usual. - if (cursor.createdAt >= boundarySecond) { - continue; - } - - const rowsGained = - countTopLevelTimelineRows( - mergeTimelineHistoryMessages(baseline, fetched), - ) - baselineRowCount; - if (rowsGained >= MIN_TOP_LEVEL_ROWS_PER_FETCH) { - break; - } - - if (batchesFetched >= MAX_BATCHES_PER_FETCH) { - break; - } - } - - return hasOlderMessages; -} - -/** - * Page older history into the channel cache until the timeline has gained - * {@link MIN_TOP_LEVEL_ROWS_PER_FETCH} visible rows, history runs out, or the - * {@link MAX_BATCHES_PER_FETCH} ceiling is hit. Shared by the cold-load query - * and the scroll-up loader so both produce the same visible page size. - * - * `shouldContinue` lets the caller bail mid-pass (e.g. channel switch). Returns - * whether more history is believed to remain. - */ +/** Fetch exactly one server-defined older window and append it atomically. */ export function pageOlderMessagesUntilRowFloor( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, ): Promise { - const inFlight = inFlightPasses.get(channelId); - if (inFlight) { - return inFlight; - } - - const pass = runPageOlderPass(queryClient, channelId, shouldContinue).finally( - () => { - inFlightPasses.delete(channelId); - }, - ); + const running = inFlightPasses.get(channelId); + if (running) return running; + const pass = runPage(queryClient, channelId, shouldContinue).finally(() => { + inFlightPasses.delete(channelId); + }); inFlightPasses.set(channelId, pass); return pass; } -async function runPageOlderPass( +async function runPage( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, ): Promise { - const queryKey = channelMessagesKey(channelId); - const baseline = queryClient.getQueryData(queryKey) ?? []; - if (baseline.length === 0) { + const store = queryClient.getQueryData( + channelWindowKey(channelId), + ); + const tail = store?.pages[store.pages.length - 1]; + if (!store || !tail?.hasMore || !tail.nextCursor || !shouldContinue()) { return { hasOlderMessages: false }; } - const baselineRowCount = countTopLevelTimelineRows(baseline); - let hasOlderMessages = true; - let batchesFetched = 0; - - // Accumulate every batch of this pass and commit to the cache once at the - // end. Committing per batch paints the timeline in several small steps under - // one spinner — on reply-heavy windows each 200-event batch adds only a few - // visible rows, so the user sees the loader dribble messages in 1-5 at a - // time. One commit = one bounded growth step. - const fetched: RelayEvent[] = []; - let oldestTimestamp = baseline[0].created_at; - - while (hasOlderMessages && shouldContinue()) { - const olderMessages = await relayClient.fetchChannelHistoryBefore( - channelId, - oldestTimestamp, - OLDER_MESSAGES_BATCH_SIZE, - ); - batchesFetched += 1; - - // A full page means more likely remains; a short page is the only signal - // of true exhaustion. An *empty* page is ambiguous (transient relay - // pressure returns []), so don't end paging on it — let the progress guard - // below stop this pass instead. - if ( - olderMessages.length > 0 && - olderMessages.length < OLDER_MESSAGES_BATCH_SIZE - ) { - hasOlderMessages = false; - } - - if (olderMessages.length > 0) { - fetched.push(...olderMessages); - } - - // Progress guard, not exhaustion: if the oldest timestamp didn't move back - // (empty page, or all-duplicate), stop this pass to avoid re-fetching the - // same `until`. - const oldestFetched = fetched.reduce( - (min, event) => (event.created_at < min ? event.created_at : min), - oldestTimestamp, - ); - if (oldestFetched >= oldestTimestamp) { - // A *full* WS page that didn't advance the boundary is the dense-second - // wall: >1 page of events share `oldestTimestamp`, so the bare `until` - // cursor re-returns the same newest slice forever. Switch to the bridge - // composite `(created_at, event_id)` keyset for the rest of this pass — - // it advances within the tied second via `id > before_id` and pages all - // older history too, so once engaged there is nothing left for WS to add. - // An empty/short page here is transient or genuine exhaustion, not a - // wall: fall through to break and let the scroll observer re-arm. - if (olderMessages.length === OLDER_MESSAGES_BATCH_SIZE) { - hasOlderMessages = await drainOlderViaKeyset({ - channelId, - boundarySecond: oldestTimestamp, - baseline, - fetched, - baselineRowCount, - batchesFetched, - shouldContinue, - }); - } - break; - } - oldestTimestamp = oldestFetched; - - const rowsGained = - countTopLevelTimelineRows( - mergeTimelineHistoryMessages(baseline, fetched), - ) - baselineRowCount; - if (rowsGained >= MIN_TOP_LEVEL_ROWS_PER_FETCH) { - break; - } - - if (batchesFetched >= MAX_BATCHES_PER_FETCH) { - break; - } - } - - if (fetched.length > 0 && shouldContinue()) { - queryClient.setQueryData(queryKey, (current = []) => - mergeTimelineHistoryMessages(current, fetched), - ); - void backfillAuxForMessages(queryClient, channelId, fetched); - } - - return { hasOlderMessages }; + const requestCursor = tail.nextCursor; + const events = await getChannelWindowEvents( + channelId, + requestCursor, + CHANNEL_WINDOW_PAGE_SIZE, + ); + if (!shouldContinue()) return { hasOlderMessages: true }; + const page = parseChannelWindowResponse(events, channelId, requestCursor); + const retained = queryClient.getQueryData( + channelWindowKey(channelId), + ); + if (!retained) return { hasOlderMessages: true }; + const next = appendOlderChannelWindow(retained, page); + queryClient.setQueryData(channelWindowKey(channelId), next); + queryClient.setQueryData( + channelMessagesKey(channelId), + flattenChannelWindowEvents(next), + ); + return { hasOlderMessages: page.hasMore }; } diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 5bf870c2b..212333b79 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -604,3 +604,73 @@ test("buildThreadPanelDataFromIndex matches direct panel data", () => { assert.deepEqual(indexed, direct); }); + +test("buildMainTimelineEntries renders a relay-only thread summary", () => { + const root = message({ id: "root", createdAt: 1 }); + const summaries = new Map([ + [ + "root", + { + replyCount: 2, + descendantCount: 4, + lastReplyAt: 9, + participantPubkeys: ["alice", "bob"], + }, + ], + ]); + const profiles = { alice: { displayName: "Alice", avatarUrl: "alice.png" } }; + + const [entry] = buildMainTimelineEntries( + [root], + new Set(), + summaries, + profiles, + ); + + assert.deepEqual(entry.summary, { + threadHeadId: "root", + replyCount: 4, + lastReplyAt: 9, + participants: [ + { id: "alice", author: "Alice", avatarUrl: "alice.png" }, + { id: "bob", author: "bob", avatarUrl: null }, + ], + }); +}); + +test("buildMainTimelineEntries merges local knowledge over the relay floor", () => { + const root = message({ id: "root", createdAt: 1 }); + const localReply = message({ + id: "reply", + createdAt: 12, + parentId: "root", + rootId: "root", + depth: 1, + pubkey: "local", + author: "Local", + }); + const summaries = new Map([ + [ + "root", + { + replyCount: 2, + descendantCount: 5, + lastReplyAt: 10, + participantPubkeys: ["relay"], + }, + ], + ]); + + const [entry] = buildMainTimelineEntries( + [root, localReply], + new Set(), + summaries, + ); + + assert.equal(entry.summary?.replyCount, 5); + assert.equal(entry.summary?.lastReplyAt, 12); + assert.deepEqual( + entry.summary?.participants.map((participant) => participant.id), + ["relay", "local"], + ); +}); diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 300b5213a..b71b3a6de 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -1,4 +1,6 @@ import type { TimelineMessage } from "@/features/messages/types"; +import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isBroadcastReply } from "@/features/messages/lib/threading"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; @@ -381,9 +383,49 @@ function buildVisibleThreadReplies(params: { return entries; } +function buildRelayThreadSummary( + messageId: string, + summary: ChannelWindowThreadSummary, + profiles: UserProfileLookup | undefined, +): TimelineThreadSummary { + return { + threadHeadId: messageId, + replyCount: summary.descendantCount, + lastReplyAt: summary.lastReplyAt, + participants: summary.participantPubkeys.slice(0, 3).map((pubkey) => ({ + id: pubkey, + author: profiles?.[pubkey.toLowerCase()]?.displayName ?? pubkey, + avatarUrl: profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null, + })), + }; +} + +function mergeThreadSummaries( + local: TimelineThreadSummary | null, + relay: TimelineThreadSummary | null, +): TimelineThreadSummary | null { + if (!local) return relay; + if (!relay) return local; + const participants = new Map( + [...relay.participants, ...local.participants].map((participant) => [ + participant.id, + participant, + ]), + ); + return { + threadHeadId: local.threadHeadId, + replyCount: Math.max(local.replyCount, relay.replyCount), + lastReplyAt: + Math.max(local.lastReplyAt ?? 0, relay.lastReplyAt ?? 0) || null, + participants: [...participants.values()].slice(-3), + }; +} + export function buildMainTimelineEntries( messages: TimelineMessage[], unreadReplyIds: ReadonlySet = new Set(), + relaySummaries: ReadonlyMap = new Map(), + profiles?: UserProfileLookup, ): MainTimelineEntry[] { const { descendantStatsByMessageId } = buildThreadPanelIndex( messages, @@ -396,14 +438,20 @@ export function buildMainTimelineEntries( message.parentId == null || isBroadcastReply(message.tags ?? []), ) .map((message) => { + const relaySummary = relaySummaries.get(message.id); return { message, summary: message.kind === KIND_HUDDLE_STARTED ? null - : buildSummaryForDirectReplies( - message.id, - descendantStatsByMessageId, + : mergeThreadSummaries( + buildSummaryForDirectReplies( + message.id, + descendantStatsByMessageId, + ), + relaySummary + ? buildRelayThreadSummary(message.id, relaySummary, profiles) + : null, ), }; }); diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 50ad7ca9d..02d20bc54 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -1,62 +1,67 @@ import { useCallback, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; +import { channelWindowKey } from "@/features/messages/lib/messageQueryKeys"; +import { + channelWindowHasMore, + emptyChannelWindowStore, + type ChannelWindowStore, +} from "@/features/messages/lib/channelWindowStore"; import { pageOlderMessagesUntilRowFloor } from "@/features/messages/lib/pageOlderMessages"; -import type { Channel, RelayEvent } from "@/shared/api/types"; +import type { Channel } from "@/shared/api/types"; export function useFetchOlderMessages(channel: Channel | null) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; const [isFetchingOlder, setIsFetchingOlder] = useState(false); - const [hasOlderMessages, setHasOlderMessages] = useState(true); const isFetchingOlderRef = useRef(false); - const hasOlderMessagesRef = useRef(true); - const previousChannelIdRef = useRef(channelId); - if (previousChannelIdRef.current !== channelId) { - previousChannelIdRef.current = channelId; - hasOlderMessagesRef.current = true; - setHasOlderMessages(true); - } + // Whether older history remains, derived reactively from the authoritative + // window store rather than a private latch. A latch only reset on channelId + // change went stale on reconnect: `refreshNewestWindow` replaces the newest + // window with fresh `hasMore:true` rows, but the latch — flipped false when + // the pre-reconnect window exhausted — kept the scroll observer uninstalled, + // freezing paging at page one. Reading the store's tail `hasMore` self-heals: + // the observer re-arms the moment the refreshed window reports more history. + const windowKey = channelWindowKey(channelId ?? "none"); + const { data: hasOlderMessages = false } = useQuery({ + enabled: channelId !== null, + queryKey: windowKey, + select: channelWindowHasMore, + // Passive subscription: the window store is written by the messages query + // and the live subscription via setQueryData; this observer only reads. + queryFn: () => + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(), + }); const fetchOlder = useCallback(async () => { - if ( - !channelId || - isFetchingOlderRef.current || - !hasOlderMessagesRef.current - ) { + if (!channelId || isFetchingOlderRef.current) { return; } - - const queryKey = channelMessagesKey(channelId); - const currentMessages = - queryClient.getQueryData(queryKey) ?? []; - if (currentMessages.length === 0) { - hasOlderMessagesRef.current = false; - setHasOlderMessages(false); + const store = + queryClient.getQueryData( + channelWindowKey(channelId), + ) ?? emptyChannelWindowStore(); + if (!channelWindowHasMore(store)) { return; } isFetchingOlderRef.current = true; setIsFetchingOlder(true); try { - const { hasOlderMessages: more } = await pageOlderMessagesUntilRowFloor( + await pageOlderMessagesUntilRowFloor( queryClient, channelId, - () => previousChannelIdRef.current === channelId, + () => channelId === channel?.id, ); - if (!more) { - hasOlderMessagesRef.current = false; - setHasOlderMessages(false); - } } catch (error) { console.error("Failed to fetch older messages", channelId, error); } finally { isFetchingOlderRef.current = false; setIsFetchingOlder(false); } - }, [channelId, queryClient]); + }, [channel?.id, channelId, queryClient]); return { fetchOlder, isFetchingOlder, hasOlderMessages }; } diff --git a/desktop/src/features/messages/useIndependentThreadPanel.ts b/desktop/src/features/messages/useIndependentThreadPanel.ts new file mode 100644 index 000000000..65318ea51 --- /dev/null +++ b/desktop/src/features/messages/useIndependentThreadPanel.ts @@ -0,0 +1,45 @@ +import * as React from "react"; + +import { buildIndependentThreadPanel } from "@/features/messages/lib/independentThreadPanel"; +import { useThreadReplies } from "@/features/messages/useThreadReplies"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { + Channel, + ChannelMember, + RelayEvent, + RespondToMode, +} from "@/shared/api/types"; + +export function useIndependentThreadPanel(args: { + activeChannel: Channel | null; + channelEvents: RelayEvent[]; + rootId: string | null; + replyTargetId: string | null; + expandedReplyIds: ReadonlySet; + currentPubkey: string | undefined; + currentAvatarUrl: string | null; + profiles: UserProfileLookup | undefined; + members: ChannelMember[] | undefined; + personaLookup: Map; + respondToLookup: Map; +}) { + const replies = useThreadReplies(args.activeChannel, args.rootId); + return React.useMemo( + () => + buildIndependentThreadPanel( + args.channelEvents, + replies.data ?? [], + args.rootId, + args.replyTargetId, + args.expandedReplyIds, + args.activeChannel, + args.currentPubkey, + args.currentAvatarUrl, + args.profiles, + args.members, + args.personaLookup, + args.respondToLookup, + ), + [args, replies.data], + ); +} diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index ede6ef1dc..0d0d2a592 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -1,109 +1,44 @@ -import * as React from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; -import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; -import { mergeMessages } from "@/features/messages/hooks"; +import { threadRepliesKey } from "@/features/messages/lib/messageQueryKeys"; import { getThreadReplies } from "@/shared/api/tauri"; import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types"; -// Bounded per-page fetch; the hook pages to the floor so this is a page size, -// not a terminal cap. Matches the desktop command's own 500 max. const THREAD_PAGE_LIMIT = 200; -// A hard stop so a pathological/looping cursor can never spin forever. At 200 -// replies per page this covers a 100k-reply thread — far past any real one. const MAX_THREAD_PAGES = 500; -/** - * When a thread is open, fetch its full reply subtree server-side and merge the - * events into the channel cache. - * - * The thread panel derives its replies from the channel cache - * (`channelMessagesKey`); `useLoadMissingAncestors` only backfills *ancestors* - * (walking `e`-tags upward), so replies that fell outside the channel - * cold-load window were never fetched — the thread rendered silently - * incomplete. This closes that descendant gap using the same cache seam: page - * `get_thread_replies` to the floor (gap-free `(created_at, event_id)` keyset) - * and merge each event in. All downstream grouping/ordering/unread derivation - * keeps working unchanged; the thread simply becomes complete. - * - * Idempotent per (channel, root): `mergeMessages` dedupes by id, so replies - * already in the cache from the live subscription or cold load are no-ops. - */ +/** Fetch a thread subtree into a cache independent from channel window pages. */ export function useThreadReplies( activeChannel: Channel | null, openThreadRootId: string | null, ) { - const queryClient = useQueryClient(); - const activeChannelId = activeChannel?.id ?? null; - const activeChannelType = activeChannel?.channelType ?? null; - // Track which roots we've already fetched per channel so re-opening a thread - // (or a re-render) doesn't re-page the whole subtree every time. - const fetchedRootsRef = React.useRef>(new Set()); - const previousChannelIdRef = React.useRef(null); - - React.useEffect(() => { - if (previousChannelIdRef.current === activeChannelId) { - return; - } - previousChannelIdRef.current = activeChannelId; - fetchedRootsRef.current.clear(); - }, [activeChannelId]); - - React.useEffect(() => { - if ( - !activeChannelId || - activeChannelType === "forum" || - !openThreadRootId - ) { - return; - } - if (fetchedRootsRef.current.has(openThreadRootId)) { - return; - } - fetchedRootsRef.current.add(openThreadRootId); - - const channelId = activeChannelId; - const rootId = openThreadRootId; - let isCancelled = false; - let completed = false; - - void (async () => { + const channelId = activeChannel?.id ?? "none"; + const rootId = openThreadRootId ?? "none"; + return useQuery({ + queryKey: threadRepliesKey(channelId, rootId), + enabled: + activeChannel !== null && + activeChannel.channelType !== "forum" && + openThreadRootId !== null, + queryFn: async (): Promise => { + if (!activeChannel || !openThreadRootId) return []; + const replies: RelayEvent[] = []; let cursor: ThreadCursor | null = null; - try { - for (let page = 0; page < MAX_THREAD_PAGES; page++) { - const response = await getThreadReplies(rootId, channelId, { - limit: THREAD_PAGE_LIMIT, - cursor, - }); - if (isCancelled) { - return; - } - - if (response.events.length > 0) { - queryClient.setQueryData( - channelMessagesKey(channelId), - (current = []) => response.events.reduce(mergeMessages, current), - ); - } - - if (!response.nextCursor) { - completed = true; - break; - } - cursor = response.nextCursor; - } - } catch (error) { - // Let a later re-open retry rather than caching a partial subtree. - fetchedRootsRef.current.delete(rootId); - console.error("Failed to load thread replies", rootId, error); - } - })(); - - return () => { - isCancelled = true; - if (!completed) { - fetchedRootsRef.current.delete(rootId); + for (let page = 0; page < MAX_THREAD_PAGES; page += 1) { + const response = await getThreadReplies( + openThreadRootId, + activeChannel.id, + { limit: THREAD_PAGE_LIMIT, cursor }, + ); + replies.push(...response.events); + if (!response.nextCursor) return replies; + cursor = response.nextCursor; } - }; - }, [activeChannelId, activeChannelType, openThreadRootId, queryClient]); + throw new Error( + `Thread ${openThreadRootId} exceeded the page safety limit.`, + ); + }, + staleTime: 0, + gcTime: 60 * 60 * 1_000, + }); } diff --git a/desktop/src/shared/api/channelWindow.ts b/desktop/src/shared/api/channelWindow.ts new file mode 100644 index 000000000..6703522d2 --- /dev/null +++ b/desktop/src/shared/api/channelWindow.ts @@ -0,0 +1,17 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { ChannelPageCursor, RelayEvent } from "@/shared/api/types"; + +/** Fetch the flat Nostr event array for one server-assembled channel window. */ +export async function getChannelWindowEvents( + channelId: string, + cursor: ChannelPageCursor | null = null, + limitRows = 50, +): Promise { + return invokeTauri("get_channel_window", { + channelId, + limitRows, + cursor: cursor + ? { created_at: cursor.createdAt, event_id: cursor.eventId } + : null, + }); +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 74e99feb5..1298f2101 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -10,6 +10,7 @@ import { KIND_STREAM_MESSAGE, KIND_TYPING_INDICATOR, KIND_USER_STATUS, + CHANNEL_EVENT_KINDS, } from "@/shared/constants/kinds"; import { getTextPayload, @@ -332,19 +333,14 @@ export class RelayClient { return this.subscribe(buildChannelFilter(channelId, 50), onEvent); } - /** - * Subscribe to a channel starting from NOW — no history backfill. - * Used by huddle TTS where only live kind:9 messages should be spoken. - * The `since` filter ensures the relay never sends historical backlog. - * The high `limit` ensures reconnect replay can recover all missed events. - */ + /** Subscribe to channel rows and aux starting now, with no history replay. */ async subscribeToChannelLive( channelId: string, onEvent: (event: RelayEvent) => void, ) { return this.subscribe( { - kinds: [KIND_STREAM_MESSAGE], + kinds: [...CHANNEL_EVENT_KINDS], "#h": [channelId], limit: 1000, since: Math.floor(Date.now() / 1_000), diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index c5ea0a899..f1d185fef 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -6,6 +6,8 @@ export const KIND_STREAM_MESSAGE = 9; export const KIND_NIP29_DELETE_EVENT = 9005; export const KIND_STREAM_MESSAGE_V2 = 40002; export const KIND_STREAM_MESSAGE_EDIT = 40003; +export const KIND_CHANNEL_THREAD_SUMMARY = 39005; +export const KIND_CHANNEL_WINDOW_BOUNDS = 39006; export const KIND_STREAM_MESSAGE_DIFF = 40008; export const KIND_REMINDER = 40007; export const KIND_SYSTEM_MESSAGE = 40099; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3d248014c..9a1b7f15a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -15,6 +15,8 @@ import { } from "@/shared/api/customEmoji"; import { KIND_AGENT_OBSERVER_FRAME, + KIND_CHANNEL_THREAD_SUMMARY, + KIND_CHANNEL_WINDOW_BOUNDS, KIND_DM_VISIBILITY, KIND_EVENT_REMINDER, KIND_HUDDLE_STARTED, @@ -113,9 +115,9 @@ type E2eConfig = { openDmDelayMs?: number; sendMessageDelayMs?: number; usersBatchDelayMs?: number; - /** Delay (ms) applied to older-history (`history-` subId) fetches so e2e + /** Delay (ms) applied to continuation channel-window requests so e2e * tests can observe the in-flight prepend window. 0/undefined = instant. */ - historyDelayMs?: number; + channelWindowDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; profileUpdateError?: string; @@ -722,6 +724,17 @@ const DEFAULT_RELAY_WS_URL = "ws://localhost:3000"; // NIP event kinds the mock reaction handlers emit. const KIND_REACTION = 7; // NIP-25 reaction const KIND_DELETION = 5; // NIP-09 deletion +const KIND_NIP29_DELETION = 9005; +const CHANNEL_WINDOW_AUX_KINDS = new Set([ + KIND_REACTION, + KIND_DELETION, + KIND_NIP29_DELETION, + KIND_STREAM_MESSAGE_EDIT, +]); +const CHANNEL_WINDOW_AUX_DELETION_KINDS = new Set([ + KIND_DELETION, + KIND_NIP29_DELETION, +]); // Fake media-proxy port the mock answers for `get_media_proxy_port`, so // `rewriteRelayUrl()` produces a real `http://127.0.0.1:/media/...` src @@ -2775,6 +2788,31 @@ function getThreadReferenceFromTags(tags: string[][]) { }; } +/** + * A reply broadcast to the channel timeline carries the exact tag + * `["broadcast", "1"]` (NIP-CW §Top-level Classification). + */ +function isMockBroadcastReply(tags: string[][]): boolean { + return tags.some((tag) => tag[0] === "broadcast" && tag[1] === "1"); +} + +/** + * Mirror the relay's channel-window row set (buzz-db `thread.rs`, NIP-CW + * §Top-level Classification): an event is a timeline row iff its depth is 0 + * (no reply marker → `rootEventId === null`) OR its depth is 1 (its parent is + * the thread root) AND it is broadcast. Depth ≥ 2 replies never surface on the + * timeline. A bare-`rootEventId === null` predicate silently dropped broadcast + * depth-1 replies the real relay serves. + */ +function isMockTopLevelRow(event: RelayEvent): boolean { + const { parentEventId, rootEventId } = getThreadReferenceFromTags(event.tags); + if (rootEventId === null) { + return true; + } + const isDepthOne = parentEventId !== null && parentEventId === rootEventId; + return isDepthOne && isMockBroadcastReply(event.tags); +} + function appendMentionTags( tags: string[][], mentionPubkeys: string[] | undefined, @@ -3115,35 +3153,6 @@ function emitMockHistory( sendWsText(socket.handler, ["EOSE", subId]); }; - // Optionally pace older-history fetches so e2e tests can observe the - // in-flight prepend window (scroll up, abandon, etc.). Scoped to - // `history-` subscriptions — the prefix `relayClientSession` uses for - // older-message pagination — so live/initial subscriptions stay instant. - const delayMs = getConfig()?.mock?.historyDelayMs ?? 0; - const isVisibleOlderHistoryPage = - subId.startsWith("history-") && filter.until !== undefined && !filter["#e"]; - if (isVisibleOlderHistoryPage) { - const counter = window as unknown as { __HISTORY_FETCH_COUNT__?: number }; - counter.__HISTORY_FETCH_COUNT__ = - (counter.__HISTORY_FETCH_COUNT__ ?? 0) + 1; - } - if (delayMs > 0 && isVisibleOlderHistoryPage) { - const probe = window as unknown as { - __HISTORY_INFLIGHT__?: number; - __HISTORY_INFLIGHT_PEAK__?: number; - }; - probe.__HISTORY_INFLIGHT__ = (probe.__HISTORY_INFLIGHT__ ?? 0) + 1; - probe.__HISTORY_INFLIGHT_PEAK__ = Math.max( - probe.__HISTORY_INFLIGHT_PEAK__ ?? 0, - probe.__HISTORY_INFLIGHT__, - ); - window.setTimeout(() => { - probe.__HISTORY_INFLIGHT__ = (probe.__HISTORY_INFLIGHT__ ?? 1) - 1; - emit(); - }, delayMs); - return; - } - emit(); } @@ -3654,7 +3663,7 @@ async function handleGetChannelMessagesBefore( if (!TIMELINE_KINDS.has(event.kind)) { return false; } - return getThreadReferenceFromTags(event.tags).rootEventId === null; + return isMockTopLevelRow(event); }); } else { // Config mode: exercise the real bridge keyset over /query. @@ -3709,6 +3718,239 @@ async function handleGetChannelMessagesBefore( return { events: page, next_cursor: nextCursor }; } +function getEventTargets(event: RelayEvent) { + return event.tags.flatMap((tag) => + tag[0] === "e" && typeof tag[1] === "string" ? [tag[1]] : [], + ); +} + +function buildMockChannelWindowAux( + events: RelayEvent[], + rows: RelayEvent[], +): RelayEvent[] { + const collectHop = (kinds: Set, targetIds: Set) => + events.filter( + (event) => + kinds.has(event.kind) && + getEventTargets(event).some((target) => targetIds.has(target)), + ); + + const firstHop = collectHop( + CHANNEL_WINDOW_AUX_KINDS, + new Set(rows.map((row) => row.id)), + ); + const secondHop = collectHop( + CHANNEL_WINDOW_AUX_DELETION_KINDS, + new Set(firstHop.map((event) => event.id)), + ); + const byId = new Map(firstHop.map((event) => [event.id, event])); + for (const event of secondHop) byId.set(event.id, event); + return [...byId.values()]; +} + +function buildMockChannelThreadSummary( + channelId: string, + root: RelayEvent, + events: RelayEvent[], +): RelayEvent | null { + const replies = events.filter((event) => { + const thread = getThreadReferenceFromTags(event.tags); + return thread.rootEventId === root.id; + }); + if (replies.length === 0) return null; + + const directReplies = replies.filter( + (event) => getThreadReferenceFromTags(event.tags).parentEventId === root.id, + ); + const participants = [ + ...new Set( + replies + .sort( + (left, right) => + right.created_at - left.created_at || + left.id.localeCompare(right.id), + ) + .map((event) => event.pubkey), + ), + ].slice(0, 10); + const lastReplyAt = Math.max(...replies.map((event) => event.created_at)); + return { + id: `mock-window-summary-${root.id}`, + pubkey: DEFAULT_MOCK_IDENTITY.pubkey, + created_at: Math.floor(Date.now() / 1000), + kind: KIND_CHANNEL_THREAD_SUMMARY, + tags: [ + ["e", root.id], + ["d", root.id], + ["h", channelId], + ], + content: JSON.stringify({ + reply_count: directReplies.length, + descendant_count: replies.length, + last_reply_at: lastReplyAt, + participants, + }), + sig: "mocksig".repeat(20).slice(0, 128), + }; +} + +/** + * Build the single kind-39006 bounds event a channel window response must carry. + * The `d` tag key must match `expectedBoundsKey` in channelWindowResponse.ts: + * `:head` at the frontier, else `::` of + * the request cursor (lower-cased). `has_more`/`next_cursor` must agree — the + * parser rejects a bounds event where they disagree. + */ +function buildMockChannelWindowBounds( + args: { + channelId: string; + cursor?: { created_at: number; event_id: string } | null; + }, + hasMore: boolean, + nextCursor: { created_at: number; id: string } | null, +): RelayEvent { + const suffix = args.cursor + ? `${args.cursor.created_at}:${args.cursor.event_id.toLowerCase()}` + : "head"; + const boundsKey = `${args.channelId.toLowerCase()}:${suffix}`; + return { + id: `mock-window-bounds-${boundsKey}`, + pubkey: DEFAULT_MOCK_IDENTITY.pubkey, + created_at: Math.floor(Date.now() / 1000), + kind: KIND_CHANNEL_WINDOW_BOUNDS, + tags: [["d", boundsKey]], + content: JSON.stringify({ has_more: hasMore, next_cursor: nextCursor }), + sig: "mocksig".repeat(20).slice(0, 128), + }; +} + +/** + * one server-assembled channel window over the `/query` bridge. Emits the flat + * event array the relay assembles — top-level rows (newest first), then the aux + * closure, then relay-signed `39005` summaries and exactly one `39006` bounds + * event carrying `has_more` + `next_cursor`. The client derives its cursor and + * exhaustion solely from `39006`, never from the rows, so this handler returns + * the raw array unchanged. + * + * This is the window read-model surface the overhaul introduced; without it the + * relay-mode bridge has no handler and the timeline renders empty. + */ +async function handleGetChannelWindow( + args: { + channelId: string; + limitRows?: number | null; + cursor?: { created_at: number; event_id: string } | null; + }, + config: E2eConfig | undefined, +): Promise { + const execute = async () => { + const cap = Math.min(args.limitRows ?? 50, 200); + const identity = getIdentity(config); + + if (!identity) { + // Mock store: server-assembled channel window over the mock event store, + // mirroring the relay path's shape so callers (parseChannelWindowResponse) + // parse both modes identically. Top-level timeline rows in relay order, + // then exactly one kind-39006 bounds event. + const events = getMockMessageStore(args.channelId); + const candidates = events + .filter( + (event) => TIMELINE_KINDS.has(event.kind) && isMockTopLevelRow(event), + ) + .sort( + (left, right) => + right.created_at - left.created_at || + left.id.localeCompare(right.id), + ); + // Honor the composite (until, before_id) cursor exactly like the relay's + // keyset: keep only rows strictly older than the cursor under the + // (created_at DESC, id ASC) order — older created_at, or the same second + // with a strictly greater id. + const cursor = args.cursor; + const afterCursor = cursor + ? candidates.filter( + (event) => + event.created_at < cursor.created_at || + (event.created_at === cursor.created_at && + event.id > cursor.event_id), + ) + : candidates; + const rows = afterCursor.slice(0, cap); + // Exhaustion probe mirrors the relay's limit+1: more rows past the cursor + // than the page cap means another page exists. next_cursor is the last + // retained row. + const hasMore = afterCursor.length > cap; + const lastRow = rows[rows.length - 1]; + const nextCursor = + hasMore && lastRow + ? { created_at: lastRow.created_at, id: lastRow.id } + : null; + const aux = buildMockChannelWindowAux(events, rows); + const summaries = rows.flatMap((row) => { + const summary = buildMockChannelThreadSummary( + args.channelId, + row, + events, + ); + return summary ? [summary] : []; + }); + return [ + ...rows, + ...aux, + ...summaries, + buildMockChannelWindowBounds(args, hasMore, nextCursor), + ]; + } + + // Relay mode: mirror build_channel_window_filter exactly — top-level dispatch + // with summaries + aux, composite (until, before_id) cursor (both or neither). + const filter: Record = { + "#h": [args.channelId], + kinds: [...TIMELINE_KINDS], + limit: cap, + top_level: true, + include_summaries: true, + include_aux: true, + }; + if (args.cursor) { + filter.until = args.cursor.created_at; + filter.before_id = args.cursor.event_id; + } + return relayQuery(config, [filter]); + }; + + if (!args.cursor) { + return execute(); + } + + const probe = window as unknown as { + __CHANNEL_WINDOW_FETCH_COUNT__?: number; + __CHANNEL_WINDOW_INFLIGHT__?: number; + __CHANNEL_WINDOW_INFLIGHT_PEAK__?: number; + }; + probe.__CHANNEL_WINDOW_FETCH_COUNT__ = + (probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1; + + const delayMs = getConfig()?.mock?.channelWindowDelayMs ?? 0; + if (delayMs <= 0) { + return execute(); + } + + probe.__CHANNEL_WINDOW_INFLIGHT__ = + (probe.__CHANNEL_WINDOW_INFLIGHT__ ?? 0) + 1; + probe.__CHANNEL_WINDOW_INFLIGHT_PEAK__ = Math.max( + probe.__CHANNEL_WINDOW_INFLIGHT_PEAK__ ?? 0, + probe.__CHANNEL_WINDOW_INFLIGHT__, + ); + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + try { + return await execute(); + } finally { + probe.__CHANNEL_WINDOW_INFLIGHT__ = + (probe.__CHANNEL_WINDOW_INFLIGHT__ ?? 1) - 1; + } +} + function getMockUserNotes(pubkey: string): RawUserNote[] { const now = Math.floor(Date.now() / 1000); @@ -7962,6 +8204,11 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); + case "get_channel_window": + return handleGetChannelWindow( + payload as Parameters[0], + activeConfig, + ); case "send_channel_message": return handleSendChannelMessage( payload as Parameters[0], diff --git a/desktop/tests/e2e/channel-dense-second-reach.spec.ts b/desktop/tests/e2e/channel-dense-second-reach.spec.ts index 29b6a0884..34999be50 100644 --- a/desktop/tests/e2e/channel-dense-second-reach.spec.ts +++ b/desktop/tests/e2e/channel-dense-second-reach.spec.ts @@ -4,28 +4,28 @@ import { installMockBridge } from "../helpers/bridge"; // Lane 1c regression — the dense-second reachability wall. // -// The desktop timeline pages older history over a WS `REQ` with a bare `until` -// (`created_at`) cursor. That cursor cannot advance past a single `created_at` -// second holding more messages than one WS page (200): `until` re-returns the +// A bare `until` (`created_at`) cursor cannot advance past a single +// `created_at` second holding more messages than one page: it re-returns the // same newest slice of that second forever, so everything behind it is // unreachable and the progress guard stalls. // -// The fix (`pageOlderMessagesUntilRowFloor`) detects the stall on a *full* -// page and switches that pass to the bridge composite `(created_at, event_id)` -// keyset command `get_channel_messages_before`, seeded from the max event id at -// the boundary second — which advances within the tied second via -// `id > before_id` under the relay's `created_at DESC, id ASC` order. +// The channel-window read path (`get_channel_window`, NIP-CW) pages with a +// composite `(created_at, event_id)` keyset cursor instead, which advances +// within a tied second via `id > event_id` under the relay's +// `created_at DESC, id ASC` order. // -// This test seeds one second with ~450 top-level messages (>2 WS pages) sitting -// behind the cold-load window, then pages to the top and asserts: -// (a) the escape-hatch command actually fired, and +// This test seeds one second with ~450 top-level messages (many window pages) +// sitting behind the cold-load window, then pages to the top and asserts: +// (a) a *continuation* window request fired (cursor != null) — the head load +// always issues `get_channel_window`, so only a cursor-bearing request +// proves keyset paging engaged, and // (b) every dense-second message becomes reachable (union of rendered rows -// equals the full seed) — impossible behind the bare-`until` wall. +// equals the full seed) — impossible behind a bare-`until` wall. const DENSE_SECOND = 1_700_000_000; -const DENSE_COUNT = 450; // > 2 * OLDER_MESSAGES_BATCH_SIZE (200) +const DENSE_COUNT = 450; // many multiples of CHANNEL_WINDOW_PAGE_SIZE (50) const NEWER_COUNT = 60; // fills the cold-load window, pushing the dense block older -test("dense single second beyond one WS page is fully reachable via keyset escape hatch", async ({ +test("dense single second beyond one window page is fully reachable via composite keyset cursor", async ({ page, }, testInfo) => { testInfo.setTimeout(90_000); @@ -145,17 +145,24 @@ test("dense single second beyond one WS page is fully reachable via keyset escap } } - // (a) The escape hatch actually engaged — the bare-`until` WS path alone can - // never reach the high-id tail, so this proves we exercised the fix. - const commands = await page.evaluate( - () => window.__BUZZ_E2E_COMMANDS__ ?? [], + // (a) Keyset paging actually engaged — the head load always issues + // `get_channel_window` with a null cursor, so require at least one + // continuation request carrying a composite cursor. + const continuationRequests = await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => + entry.command === "get_channel_window" && + (entry.payload as { cursor?: unknown } | null)?.cursor != null, + ).length, ); - expect(commands).toContain("get_channel_messages_before"); + expect(continuationRequests).toBeGreaterThan(0); - // (b) Reachability parity: the union of paged dense rows crosses far past one - // WS page (200) — impossible behind the bare-`until` wall, where it caps at - // 200 of the 450. We assert the vast majority became reachable; - // virtualization can drop a few transient rows between scroll settles, so we - // allow a small slack rather than demanding an exact 450. + // (b) Reachability parity: the union of paged dense rows crosses far past + // one window page — impossible behind a bare-`until` wall, where paging + // stalls on the newest slice of the dense second. We assert the vast + // majority became reachable; virtualization can drop a few transient rows + // between scroll settles, so we allow a small slack rather than demanding + // an exact 450. expect(seen.size).toBeGreaterThan(DENSE_COUNT * 0.9); }); diff --git a/desktop/tests/e2e/channel-window-mock-paging.spec.ts b/desktop/tests/e2e/channel-window-mock-paging.spec.ts new file mode 100644 index 000000000..5c7b5da76 --- /dev/null +++ b/desktop/tests/e2e/channel-window-mock-paging.spec.ts @@ -0,0 +1,169 @@ +import { expect, test } from "@playwright/test"; + +import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; +import type { RelayEvent } from "@/shared/api/types"; + +import { installMockBridge } from "../helpers/bridge"; + +// Focused mock-mode regression for the get_channel_window bridge handler. +// +// The relay-mode parity gate (parity-ancestor-island) never exercises the mock +// branch (identity present). A rows-only mock branch broke every ordinary +// mock-mode channel open, because parseChannelWindowResponse requires exactly +// one kind-39006 bounds event and throws otherwise. This proves the mock branch +// now returns a parseable page in both positions of a two-page walk, and that +// the page-1/page-2 union of rows has no duplication and no loss. +// +// Uses the empty `random` channel (no pre-seeds) so union math is exact. +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; +const PAGE_CAP = 50; // getChannelWindowEvents default limitRows +const SEED_COUNT = 75; // > one page, so page-1 is full and page-2 holds the tail + +async function invokeWindow( + page: import("@playwright/test").Page, + payload: Record, +) { + return page.evaluate( + ([channelId, cursor, limitRows]) => + (window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ as unknown as WindowInvoke)( + "get_channel_window", + { channelId, cursor, limitRows }, + ), + [payload.channelId, payload.cursor, payload.limitRows] as const, + ); +} + +type WindowInvoke = ( + command: string, + payload?: Record, +) => Promise; + +test("mock-mode channel window pages parse with no dup or loss across page-1/page-2", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + ); + + // Seed SEED_COUNT top-level messages, strictly increasing created_at so their + // relay order (created_at DESC, id ASC) is deterministic. + await page.evaluate( + ({ seedCount }) => { + const base = 1_700_000_000; + for (let index = 0; index < seedCount; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: `paging ${index}`, + createdAt: base + index, + }); + } + }, + { seedCount: SEED_COUNT }, + ); + + // Page 1: head (no cursor). + const rawPage1 = await invokeWindow(page, { + channelId: RANDOM_CHANNEL_ID, + cursor: null, + limitRows: PAGE_CAP, + }); + const page1 = parseChannelWindowResponse(rawPage1, RANDOM_CHANNEL_ID, null); + expect(page1.rows.length).toBe(PAGE_CAP); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).not.toBeNull(); + + // Page 2: feed page-1's signed cursor back verbatim as the request cursor. + const cursor = page1.nextCursor; + if (!cursor) throw new Error("page-1 must expose a next cursor"); + const rawPage2 = await invokeWindow(page, { + channelId: RANDOM_CHANNEL_ID, + cursor: { created_at: cursor.createdAt, event_id: cursor.eventId }, + limitRows: PAGE_CAP, + }); + const page2 = parseChannelWindowResponse(rawPage2, RANDOM_CHANNEL_ID, cursor); + expect(page2.rows.length).toBe(SEED_COUNT - PAGE_CAP); + expect(page2.hasMore).toBe(false); + expect(page2.nextCursor).toBeNull(); + + // Union: no duplication, no loss — exactly the SEED_COUNT distinct rows. + const ids = [ + ...page1.rows.map((row) => row.event.id), + ...page2.rows.map((row) => row.event.id), + ]; + const unique = new Set(ids); + expect(unique.size).toBe(SEED_COUNT); // no loss + expect(ids.length).toBe(unique.size); // no duplication +}); + +test("mock-mode channel window includes summaries and the two-hop aux closure", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + ); + + const seeded = await page.evaluate(() => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("mock emitter is not installed"); + const root = emit({ channelName: "random", content: "window root" }); + const reply = emit({ + channelName: "random", + content: "window reply", + parentEventId: root.id, + }); + const reaction = emit({ + channelName: "random", + content: "🔥", + kind: 7, + extraTags: [["e", root.id]], + }); + const edit = emit({ + channelName: "random", + content: "edited window root", + kind: 40003, + extraTags: [["e", root.id]], + }); + const rowDeletion = emit({ + channelName: "random", + content: "", + kind: 5, + extraTags: [["e", root.id]], + }); + const reactionDeletion = emit({ + channelName: "random", + content: "", + kind: 5, + extraTags: [["e", reaction.id]], + }); + return { + rootId: root.id, + replyId: reply.id, + auxIds: [reaction.id, edit.id, rowDeletion.id, reactionDeletion.id], + }; + }); + + const raw = await invokeWindow(page, { + channelId: RANDOM_CHANNEL_ID, + cursor: null, + limitRows: PAGE_CAP, + }); + const parsed = parseChannelWindowResponse(raw, RANDOM_CHANNEL_ID, null); + + expect(parsed.rows.map((row) => row.event.id)).toEqual([seeded.rootId]); + expect(parsed.aux.map((event) => event.id)).toEqual( + expect.arrayContaining(seeded.auxIds), + ); + expect(parsed.aux.map((event) => event.id)).not.toContain(seeded.replyId); + expect(parsed.rows[0].thread).toMatchObject({ + replyCount: 1, + descendantCount: 1, + }); +}); diff --git a/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts b/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts new file mode 100644 index 000000000..d63be2747 --- /dev/null +++ b/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// ============================================================================= +// Regression — a live broadcast reply must enter the authoritative channel +// window store (PR #1500 review, blocker 2) +// ============================================================================= +// +// NIP-CW §Top-level Classification: a depth-1 reply carrying `["broadcast","1"]` +// is a channel-window row — it belongs on the timeline as well as in its thread. +// The relay serves it as a row (`buzz-db/src/thread.rs`: "top-level rows = +// depth 0, missing metadata, or broadcast depth-1 replies"). +// +// The client's live-append path drops it from the WINDOW STORE. `appendMessage` +// (hooks.ts) routes every parented timeline event into the thread cache and +// returns BEFORE the window-store merge, gating only on `parentId !== null` +// with no broadcast check. So a live broadcast reply never reaches the +// `channel-window` store's `liveOverlay` — it survives on the timeline only via +// the incidental `useLiveChannelUpdates` write to `channel-messages`, which any +// window-store rebuild (page-zero refresh, later top-level append) erases. +// +// We assert the durable invariant — the broadcast reply IS in the window +// store's `liveOverlay` — rather than a DOM row, because the timeline render is +// masked by the second (unfiltered) subscriber. The window store is the +// authoritative source `flattenChannelWindowEvents` rebuilds from. +// +// RED at f2a551f2 (appendMessage returns before the overlay merge → liveOverlay +// omits the broadcast reply). GREEN on wren/review-live-window-fixes @ 9a533a9e +// (broadcast replies fall through into `mergeLiveChannelWindowEvent`). +// +// An ordinary (non-broadcast) depth-1 reply is emitted as a control: it is NOT +// a window row and MUST stay out of the overlay, so a naive "merge every +// parented event" fix can't false-green this spec. + +const CHANNEL = "general"; + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(() => + page.evaluate( + (ch) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: ch, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + +async function emit( + page: import("@playwright/test").Page, + input: { + content: string; + parentEventId?: string | null; + createdAt?: number; + extraTags?: string[][]; + }, +) { + const event = await page.evaluate( + (payload) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: payload.channel, + content: payload.content, + parentEventId: payload.parentEventId, + createdAt: payload.createdAt, + extraTags: payload.extraTags, + }), + { + channel: CHANNEL, + content: input.content, + parentEventId: input.parentEventId ?? null, + createdAt: input.createdAt, + extraTags: input.extraTags, + }, + ); + if (!event) throw new Error("mock message emitter is not installed"); + return event; +} + +async function liveOverlayContents(page: import("@playwright/test").Page) { + return page.evaluate(() => { + const qc = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + getQueriesData: (f: unknown) => Array<[readonly unknown[], unknown]>; + }; + const win = qc + .getQueriesData({ queryKey: [] }) + .find(([key]) => JSON.stringify(key).includes("channel-window")); + const store = win?.[1] as + | { liveOverlay?: Array<{ content?: string }> } + | undefined; + return (store?.liveOverlay ?? []).map((event) => event.content ?? ""); + }); +} + +test("a live broadcast depth-1 reply enters the authoritative channel window store", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + // Seed a top-level root into the cold window before opening the channel, so + // there is a thread for the broadcast reply to descend from. + const now = Math.floor(Date.now() / 1000); + const root = await emit(page, { content: "timeline root", createdAt: now }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL); + await expect( + page.getByTestId("message-timeline").getByText("timeline root"), + ).toBeVisible(); + + // The live subscription must be established before we emit, or the event is + // delivered before appendMessage is listening — that would be a cold-load + // test, not a live-append test. + await waitForMockLiveSubscription(page, CHANNEL); + + // LIVE broadcast depth-1 reply: parent is the root, carries ["broadcast","1"]. + await emit(page, { + content: "broadcast to the channel", + parentEventId: root.id, + createdAt: now + 1, + extraTags: [["broadcast", "1"]], + }); + + // CONTROL — an ordinary (non-broadcast) depth-1 reply: NOT a window row, MUST + // stay out of the overlay. + await emit(page, { + content: "ordinary thread reply", + parentEventId: root.id, + createdAt: now + 2, + }); + + // The broadcast reply must land in the authoritative window-store overlay via + // live append alone — the invariant that survives any window-store rebuild. + await expect + .poll(() => liveOverlayContents(page)) + .toContain("broadcast to the channel"); + + // The ordinary reply is a thread reply, never a window row. + expect(await liveOverlayContents(page)).not.toContain( + "ordinary thread reply", + ); +}); diff --git a/desktop/tests/e2e/parity-ancestor-island.spec.ts b/desktop/tests/e2e/parity-ancestor-island.spec.ts new file mode 100644 index 000000000..07426fe5f --- /dev/null +++ b/desktop/tests/e2e/parity-ancestor-island.spec.ts @@ -0,0 +1,161 @@ +import { expect, test } from "@playwright/test"; + +import { installRelayBridge } from "../helpers/bridge"; +import { assertRelaySeeded } from "../helpers/seed"; +import { ancestorIsland, seedScenario } from "../helpers/seedRelay"; + +// ============================================================================= +// LIVE-RELAY parity — ancestor-island cursor poisoning (GUI read-model overhaul) +// ============================================================================= +// +// The deterministic current-main RED (Dawn's Jul-2 root cause, Wren's Jul-3 +// proven repro `239cc161`). The dense-second wall is NOT the disease — main +// already drains a tied second via the PR #1418 keyset fallback. THIS is the +// flaw the server-assembled windowed read model deletes: +// +// 1. Cold channel load paints the newest CHANNEL_HISTORY_LIMIT (60) rows. +// 2. One of those rows is a reply whose root/parent is OUTSIDE that window. +// 3. useLoadMissingAncestors fetches that old root by id and merges it into +// the SAME channel cache — a non-contiguous "island" days older than the +// contiguous window. +// 4. pageOlderMessages anchors `oldestTimestamp = baseline[0].created_at` on +// the island, so every scroll-up pages backward FROM the island and +// permanently skips the real history between it and the true frontier. +// 5. CLI `/query` returns those `gap-*` rows; the GUI never requests them. +// +// Contract: every top-level `gap-*` row the relay holds must be reachable in the +// timeline. RED on main (the pager anchors on the island and skips the gap +// entirely — `gap` rows reached === 0); GREEN on the windowed read model, whose +// relay-owned cursor cannot be moved by an out-of-band ancestor merge. + +const RELAY_HTTP = process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"; + +// uuid5(NAMESPACE_DNS, "buzz.channel.general") — the seeded `general` channel. +const GENERAL_CHANNEL_ID = "9f28288a-d724-587a-9709-92dc7f967110"; + +// >60 newest rows so the cold window (CHANNEL_HISTORY_LIMIT=60) does NOT reach +// the gap; the gap sits below the frontier, the old root below the gap. +const GAP_COUNT = 100; +const NEWEST_COUNT = 70; + +test.beforeAll(async () => { + test.setTimeout(90_000); + await assertRelaySeeded(); +}); + +test("live relay: an ancestor island does not strand the history frontier", async ({ + page, +}, testInfo) => { + testInfo.setTimeout(180_000); + + // Per-run isolation: the suite seeds into shared `general`, which accumulates + // rows across runs. A generic `gap \d+` match lets a prior run's rows inflate + // the reachable set and false-green the parity assertion (observed: a + // contaminated relay "passed" in 3.3s while a clean channel is RED with + // seen.size === 0). Tag this run's rows and assert only against them. + const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + + const scenario = ancestorIsland({ + channelId: GENERAL_CHANNEL_ID, + gapCount: GAP_COUNT, + newestCount: NEWEST_COUNT, + nonce, + }); + const expected = await seedScenario(scenario, { relayHttpUrl: RELAY_HTTP }); + const expectedGap = new Set(expected.map((e) => e.content)); + expect(expectedGap.size).toBe(GAP_COUNT); + + await installRelayBridge(page, "tyler"); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + + // Establish the poison precondition on the diseased client: on main, + // useLoadMissingAncestors fetches the old root by id and merges it into the + // channel cache, and "island root" appears in the timeline — that injected + // island is what strands the pager. The overhaul DELETES useLoadMissingAncestors + // (the relay-owned window cursor can't be moved by an out-of-band merge), so + // the island is never injected and this row never appears. Best-effort, not a + // hard gate: on main it settles the poison before we scroll; on the overhaul + // it just times out harmlessly and we proceed to the reachability invariant — + // which is the assertion that actually flips 0/100 (stranded) → 100/100. + await timeline + .getByText(`island root ${nonce}`, { exact: false }) + .first() + .waitFor({ state: "visible", timeout: 15_000 }) + .catch(() => { + /* cured client: no ancestor fetch, no island — expected on the overhaul */ + }); + + // Union of THIS run's `gap` contents ever rendered. Virtualization only + // mounts a window, so accumulate across scroll passes rather than snapshot + // once. Match on the run nonce so a prior run's rows can't inflate the set. + const gapPattern = new RegExp(`gap ${nonce} \\d+`); + const renderedGapContents = async () => + timeline.evaluate((element, pattern: string) => { + const re = new RegExp(pattern); + const found: string[] = []; + for (const row of ( + element as HTMLDivElement + ).querySelectorAll("[data-message-id]")) { + const match = row.textContent?.match(re); + if (match) found.push(match[0]); + } + return found; + }, gapPattern.source); + + // A real wheel-up gesture per pass: the older-history sentinel arms on a + // genuine leave→enter transition (IntersectionObserver), so a raw scrollTop=0 + // write can fail to re-fire. A wheel event is what a real user issues. + const wheelToTop = async () => { + for (let step = 0; step < 12; step += 1) { + const atTop = await timeline.evaluate( + (element) => (element as HTMLDivElement).scrollTop <= 1, + ); + if (atTop) break; + await page.mouse.wheel(0, -6000); + await page.waitForTimeout(40); + } + }; + + const seen = new Set(); + const collect = async () => { + for (const content of await renderedGapContents()) seen.add(content); + }; + + await timeline.hover(); + let stallStreak = 0; + for (let attempt = 0; attempt < 200 && seen.size < GAP_COUNT; attempt += 1) { + const before = seen.size; + await wheelToTop(); + try { + await expect + .poll( + async () => { + await collect(); + return seen.size; + }, + { timeout: 4_000 }, + ) + .toBeGreaterThan(before); + } catch { + // No growth this pass — count toward a genuine stall. + } + await collect(); + if (seen.size > before) { + stallStreak = 0; + } else { + stallStreak += 1; + if (stallStreak > 8) break; + } + } + + // Parity: every gap row the relay holds must be reachable. RED on main — the + // pager anchors on the injected island (old root) and pages backward from it, + // skipping the entire gap span (reaches ~0 of GAP_COUNT). GREEN on the + // windowed read model, whose relay-owned cursor an ancestor merge can't move. + expect(seen.size).toBeGreaterThan(GAP_COUNT * 0.9); +}); diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index ba19b38bb..b8d752152 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -93,7 +93,7 @@ test("first channel load paints the first window without waiting for the row-flo window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, - mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 5_000 }, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 5_000 }, }; }); @@ -182,22 +182,22 @@ test("preserves user scroll while older channel history loads", async ({ expect(deepest).toBeLessThan(400); // PHASE 2 -- now delay the next history page so it stays in flight long - // enough to observe the anchor across the landing. historyDelayMs is read + // enough to observe the anchor across the landing. channelWindowDelayMs is read // live by the bridge, so toggling it here applies to the next fetch only. await page.evaluate(() => { window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, - mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_000 }, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 1_000 }, }; ( - window as unknown as { __HISTORY_INFLIGHT__?: number } - ).__HISTORY_INFLIGHT__ = 0; + window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number } + ).__CHANNEL_WINDOW_INFLIGHT__ = 0; }); const inflightCount = () => page.evaluate( () => - (window as unknown as { __HISTORY_INFLIGHT__?: number }) - .__HISTORY_INFLIGHT__ ?? 0, + (window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number }) + .__CHANNEL_WINDOW_INFLIGHT__ ?? 0, ); // Snapshot the oldest rendered index BEFORE firing the delayed page, so the @@ -210,6 +210,11 @@ test("preserves user scroll while older channel history loads", async ({ const oldestBeforeLanding = await oldestRenderedIndex(); expect(oldestBeforeLanding).not.toBeNull(); + // Move the top sentinel out of its trigger band after the phase-1 climb so + // returning to it is a fresh continuation gesture. + await page.mouse.wheel(0, 1_500); + await page.waitForTimeout(100); + // One wheel tick to fire the delayed older-history page. for (let attempt = 0; attempt < 50; attempt += 1) { if ((await inflightCount()) > 0) break; @@ -219,7 +224,7 @@ test("preserves user scroll while older channel history loads", async ({ expect(await inflightCount()).toBeGreaterThan(0); // Capture the first-visible row id AFTER the fire wheel but WHILE the page is - // still in flight (the prepend lands ~historyDelayMs later). The fire wheel + // still in flight (the prepend lands ~channelWindowDelayMs later). The fire wheel // moves the viewport, so the anchor must be read at this settled in-flight // position -- a row captured before the fire wheel can scroll out of the // virtualized window before the prepend lands. This row exists before the @@ -335,7 +340,7 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, - historyDelayMs: 5_000, + channelWindowDelayMs: 5_000, }, }; }); @@ -373,6 +378,7 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a break; } await page.mouse.wheel(0, -2000); + await page.waitForTimeout(25); } await page.waitForTimeout(150); @@ -402,7 +408,7 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a // // The button triggers `scrollToBottom("smooth")` which animates the // scroll, so we poll for the at-bottom condition rather than waiting - // a fixed interval. Cap at 2s; well inside our 5s historyDelayMs + // a fixed interval. Cap at 2s; well inside our 5s channelWindowDelayMs // window so the prepend is still in flight when this resolves. await page.getByTestId("message-scroll-to-latest").click(); await expect @@ -425,7 +431,7 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a // at the bottom (no upward teleport to the abandoned anchor). // // Timeout: 12s. The wheel-up + smooth-abandon path can burn 2-3s of the - // 5s historyDelayMs window before this poll begins, so the prepend may not + // 5s channelWindowDelayMs window before this poll begins, so the prepend may not // land for another 2-3s. On a loaded CI runner that squeezes a tight 6s // budget into a timeout (the prepend is a deterministic 5s mock timer, so // the only failure mode is waiting too little); 12s leaves comfortable @@ -1269,7 +1275,7 @@ test("channel intro stays hidden while older history is loading", async ({ ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, - historyDelayMs: 5_000, + channelWindowDelayMs: 5_000, }, }; }); @@ -1292,6 +1298,7 @@ test("channel intro stays hidden while older history is loading", async ({ break; } await page.mouse.wheel(0, -2000); + await page.waitForTimeout(25); } await page.waitForTimeout(150); @@ -1463,7 +1470,7 @@ test("channel intro stays hidden while paginating past the timeline cap", async // Regression for the flood Wes reported: scrolling back while an older-history // fetch is already in flight must NOT queue a second concurrent visible page -// fetch. Aux backfills also use `history-*` subscriptions, so the e2e bridge +// fetch. Aux backfills also use `get_channel_window` continuation requests, so the e2e bridge // probe counts only visible older-page requests (`until` and not `#e`). test("older-history fetches never overlap (no concurrent in-flight requests)", async ({ page, @@ -1489,11 +1496,11 @@ test("older-history fetches never overlap (no concurrent in-flight requests)", a lineCount: 2, }); ( - window as unknown as { __HISTORY_INFLIGHT_PEAK__?: number } - ).__HISTORY_INFLIGHT_PEAK__ = 0; + window as unknown as { __CHANNEL_WINDOW_INFLIGHT_PEAK__?: number } + ).__CHANNEL_WINDOW_INFLIGHT_PEAK__ = 0; window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, - mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 400 }, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 400 }, }; }); @@ -1520,8 +1527,8 @@ test("older-history fetches never overlap (no concurrent in-flight requests)", a const peak = await page.evaluate( () => - (window as unknown as { __HISTORY_INFLIGHT_PEAK__?: number }) - .__HISTORY_INFLIGHT_PEAK__ ?? 0, + (window as unknown as { __CHANNEL_WINDOW_INFLIGHT_PEAK__?: number }) + .__CHANNEL_WINDOW_INFLIGHT_PEAK__ ?? 0, ); expect(peak).toBeLessThanOrEqual(1); }); @@ -1556,7 +1563,7 @@ test("older-history spinner stays visible in viewport while fetching mid-scroll" }); window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, - mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 2_000 }, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 2_000 }, }; }); @@ -1566,11 +1573,11 @@ test("older-history spinner stays visible in viewport while fetching mid-scroll" await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await timeline.hover(); - await timeline.evaluate((element) => { - const timelineElement = element as HTMLDivElement; - timelineElement.scrollTop = 150; - timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); - }); + await page.mouse.wheel(0, -1_500); + await page.waitForTimeout(100); + await page.mouse.wheel(0, 1_000); + await page.waitForTimeout(100); + await page.mouse.wheel(0, -1_000); const indicator = page.getByTestId("message-timeline-fetching-older"); await expect(indicator).toBeVisible({ timeout: 2_000 }); @@ -1633,8 +1640,8 @@ test("one scroll-up gesture pages older history once, not to the channel top", a lineCount: 2, }); ( - window as unknown as { __HISTORY_FETCH_COUNT__?: number } - ).__HISTORY_FETCH_COUNT__ = 0; + window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number } + ).__CHANNEL_WINDOW_FETCH_COUNT__ = 0; }); await page.getByTestId("channel-general").click(); @@ -1652,15 +1659,15 @@ test("one scroll-up gesture pages older history once, not to the channel top", a // before the user gesture by resetting the counter at the settled bottom. await page.evaluate(() => { ( - window as unknown as { __HISTORY_FETCH_COUNT__?: number } - ).__HISTORY_FETCH_COUNT__ = 0; + window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number } + ).__CHANNEL_WINDOW_FETCH_COUNT__ = 0; }); const fetchCount = () => page.evaluate( () => - (window as unknown as { __HISTORY_FETCH_COUNT__?: number }) - .__HISTORY_FETCH_COUNT__ ?? 0, + (window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number }) + .__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0, ); const oldestRenderedIndex = () => timeline.evaluate((element) => { @@ -1741,22 +1748,22 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as // Pace the older fetch BEFORE scrolling up, so the very first older page the // top sentinel triggers stays in flight long enough to read the anchor before - // and after it lands. No climb loop — the cold load left ~900 older roots - // behind the cursor, so one scroll-up to the top band fires a genuine page. + // and after it lands. The cold window leaves older roots behind the composite + // cursor, so one scroll-up to the top band fires a genuine page. await page.evaluate(() => { window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, - mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_500 }, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 1_500 }, }; ( - window as unknown as { __HISTORY_INFLIGHT__?: number } - ).__HISTORY_INFLIGHT__ = 0; + window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number } + ).__CHANNEL_WINDOW_INFLIGHT__ = 0; }); const inflightCount = () => page.evaluate( () => - (window as unknown as { __HISTORY_INFLIGHT__?: number }) - .__HISTORY_INFLIGHT__ ?? 0, + (window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number }) + .__CHANNEL_WINDOW_INFLIGHT__ ?? 0, ); const oldestRenderedIndex = () => timeline.evaluate((element) => { @@ -1775,8 +1782,11 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as // landing gate below still requires a real older row to appear after fetch. const oldestBeforeLanding = await oldestRenderedIndex(); - // One scroll-up gesture to the top band fires the (delayed) older page. + // Re-enter the top band so the persistent observer sees a fresh continuation + // gesture even when the cold window initially placed its sentinel there. await timeline.hover(); + await page.mouse.wheel(0, 1_000); + await page.waitForTimeout(100); for (let attempt = 0; attempt < 40; attempt += 1) { if ((await inflightCount()) > 0) break; await page.mouse.wheel(0, -3000); diff --git a/desktop/tests/e2e/timeline-no-shift.spec.ts b/desktop/tests/e2e/timeline-no-shift.spec.ts index 7ab4c7104..c33d399d5 100644 --- a/desktop/tests/e2e/timeline-no-shift.spec.ts +++ b/desktop/tests/e2e/timeline-no-shift.spec.ts @@ -220,7 +220,7 @@ test("timeline reserves mixed-media rows before fast scrollback", async ({ await page.evaluate(() => { window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, - mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 10_000 }, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 10_000 }, }; }); await page.evaluate((imageUrl) => { @@ -456,13 +456,15 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async await page.evaluate(() => { window.__BUZZ_E2E__ = { ...window.__BUZZ_E2E__, - mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_000 }, + mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: 1_000 }, }; ( - window as unknown as { __HISTORY_INFLIGHT__?: number } - ).__HISTORY_INFLIGHT__ = 0; + window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number } + ).__CHANNEL_WINDOW_INFLIGHT__ = 0; }); + await page.mouse.wheel(0, 1_000); + await page.waitForTimeout(100); await timeline.evaluate((element) => { const scroller = element as HTMLDivElement; scroller.scrollTop = 150; @@ -474,8 +476,8 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async async () => page.evaluate( () => - (window as unknown as { __HISTORY_INFLIGHT__?: number }) - .__HISTORY_INFLIGHT__ ?? 0, + (window as unknown as { __CHANNEL_WINDOW_INFLIGHT__?: number }) + .__CHANNEL_WINDOW_INFLIGHT__ ?? 0, ), { timeout: 5_000 }, ) diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c595f8485..ce816e3d3 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -131,7 +131,7 @@ type MockBridgeOptions = { sendMessageDelayMs?: number; usersBatchDelayMs?: number; /** Delay (ms) for older-history fetches; see e2eBridge mock config. */ - historyDelayMs?: number; + channelWindowDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; profileUpdateError?: string; @@ -210,7 +210,12 @@ const WELCOME_CHANNEL_ENSURED_STORAGE_KEY_PREFIX = "buzz-welcome-channel-ensured.v2:"; const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX = "buzz-onboarding-complete.v1:"; const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); -const DEFAULT_RELAY_WS_URL = "ws://localhost:3000"; +// The relay HTTP/WS URLs follow BUZZ_E2E_RELAY_URL (same env var seed.ts reads), +// so a suite pointed at an isolated relay (e.g. the read-model harness on :3030) +// uses it without per-spec wiring. Falls back to the shared dev relay when unset. +const DEFAULT_RELAY_HTTP_URL = + process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"; +const DEFAULT_RELAY_WS_URL = DEFAULT_RELAY_HTTP_URL.replace(/^http/, "ws"); function cloneEngramEntry(entry: MockEngramEntry): MockEngramEntry { return { @@ -512,6 +517,11 @@ export async function installRelayBridge( await installBridge(page, { mode: "relay", user, + // Thread BUZZ_E2E_RELAY_URL into BOTH transports. The app defaults these to + // :3000 in relay mode; without explicit wiring HTTP queries (channel list, + // feed) miss an isolated relay and surface as "Failed to fetch". + relayHttpUrl: DEFAULT_RELAY_HTTP_URL, + relayWsUrl: DEFAULT_RELAY_WS_URL, seedPreviewFeatures: options?.seedPreviewFeatures, }); } diff --git a/desktop/tests/helpers/seedRelay.ts b/desktop/tests/helpers/seedRelay.ts new file mode 100644 index 000000000..eadd16cf3 --- /dev/null +++ b/desktop/tests/helpers/seedRelay.ts @@ -0,0 +1,702 @@ +import { hexToBytes } from "@noble/hashes/utils.js"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; +import type { Event as NostrEvent, EventTemplate } from "nostr-tools/pure"; + +import { TEST_IDENTITIES } from "./bridge"; + +// ============================================================================= +// Hard-dataset relay seeder — GUI read-model overhaul (Dawn's lane) +// ============================================================================= +// +// Publishes REAL signed Nostr events through the relay ingest path +// (`POST /events`), never raw SQL. This is the load-bearing fidelity choice: +// `thread_metadata` (depth, root, reply counts) is computed AT INGEST +// (buzz-relay/src/handlers/ingest.rs). Eva's channel-window surface reads that +// metadata; a raw-SQL bulk load would bypass computation and hand the window +// surface empty/wrong summaries — a false green. See +// PLANS/GUI_OVERHAUL_TEST_HARNESS_DAWN.md. +// +// Transport auth: the test relay runs BUZZ_REQUIRE_AUTH_TOKEN=false +// (start-relay-for-tests.sh), so `POST /events` accepts a plain `X-Pubkey` +// header (bridge.rs verify_bridge_auth dev fallback). The EVENT is still fully +// signed — only the per-request NIP-98 auth envelope is skipped. `POST /events` +// ingests ONE event per request, so bulk seeding parallelizes with a bounded +// concurrency pool. Authors must be seeded channel members +// (setup-desktop-test-data.sh seeds tyler/alice/bob/charlie into `general`), +// which `enforce_relay_membership` requires. +// +// Canonical tag shapes (crates/buzz-sdk/src/builders.rs thread_tags + ingest): +// top-level : ["h", channelId] — no e-tag → depth NULL +// direct : ["e", parentId, "", "reply"] — reply alone; root=parent +// nested : ["e", rootId, "", "root"], +// ["e", parentId, "", "reply"] — depth N +// reaction : kind 7, ["e", targetId], ["h", channelId], content = emoji +// deletion : kind 5, ["e", targetId] +// A reply carrying ONLY ["e", id, "", "root"] (no "reply" marker) is stored +// WITHOUT thread_metadata (ingest.rs returns None) — the "legacy/spurious row" +// shape used to probe contract-v1.1 item 7 (`depth IS NULL` = top-level). + +const KIND_MESSAGE = 9; +const KIND_REACTION = 7; +const KIND_DELETION = 5; + +const DEFAULT_RELAY_HTTP = + process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"; + +type IdentityName = keyof typeof TEST_IDENTITIES; + +export type SeededEvent = { + id: string; + kind: number; + pubkey: string; + created_at: number; + content: string; + tags: string[][]; +}; + +/** A signer bound to one seeded identity. */ +class Signer { + readonly pubkey: string; + private readonly sk: Uint8Array; + + constructor(privateKeyHex: string) { + this.sk = hexToBytes(privateKeyHex); + this.pubkey = getPublicKey(this.sk); + } + + sign(template: EventTemplate): NostrEvent { + return finalizeEvent(template, this.sk); + } +} + +const signerCache = new Map(); + +function signerFor(name: IdentityName): Signer { + const cached = signerCache.get(name); + if (cached) return cached; + const signer = new Signer(TEST_IDENTITIES[name].privateKey); + signerCache.set(name, signer); + return signer; +} + +export type SeedOptions = { + relayHttpUrl?: string; + /** Max in-flight POST /events requests. */ + concurrency?: number; +}; + +/** + * POST one signed event through the relay ingest path. Throws on non-2xx so a + * broken seed fails loudly rather than producing a silently-partial dataset. + */ +async function publishEvent( + event: NostrEvent, + relayHttpUrl: string, +): Promise { + const response = await fetch(`${relayHttpUrl}/events`, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Dev-mode transport auth; the event body is fully signed regardless. + "X-Pubkey": event.pubkey, + }, + body: JSON.stringify(event), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `POST /events failed (${response.status}) for kind ${event.kind} ${event.id.slice(0, 8)}: ${detail}`, + ); + } +} + +/** + * Publish a batch with bounded concurrency, preserving ORDER GUARANTEES the + * caller encodes as `barrier` boundaries: events within one barrier group may + * publish concurrently, but a group only starts once every earlier group has + * fully landed. Thread replies MUST NOT race their parents — ingest hard-errors + * on an unknown parent (ingest.rs "reply parent not found") — so parents go in + * an earlier group than their children. + */ +async function publishGroups( + groups: NostrEvent[][], + relayHttpUrl: string, + concurrency: number, +): Promise { + for (const group of groups) { + let cursor = 0; + const workers: Promise[] = []; + const worker = async () => { + while (cursor < group.length) { + const event = group[cursor]; + cursor += 1; + await publishEvent(event, relayHttpUrl); + } + }; + for (let i = 0; i < Math.min(concurrency, group.length); i += 1) { + workers.push(worker()); + } + await Promise.all(workers); + } +} + +// ── Event builders (canonical tag shapes) ──────────────────────────────────── + +function buildMessage( + signer: Signer, + channelId: string, + content: string, + createdAt: number, + extraTags: string[][] = [], +): NostrEvent { + return signer.sign({ + kind: KIND_MESSAGE, + content, + created_at: createdAt, + tags: [["h", channelId], ...extraTags], + }); +} + +function directReplyTags(parentId: string): string[][] { + return [["e", parentId, "", "reply"]]; +} + +function nestedReplyTags(rootId: string, parentId: string): string[][] { + return [ + ["e", rootId, "", "root"], + ["e", parentId, "", "reply"], + ]; +} + +/** The malformed "root-only" reply shape — no `reply` marker → no metadata. */ +function legacyRootOnlyTags(rootId: string): string[][] { + return [["e", rootId, "", "root"]]; +} + +function buildReaction( + signer: Signer, + channelId: string, + targetId: string, + emoji: string, + createdAt: number, +): NostrEvent { + return signer.sign({ + kind: KIND_REACTION, + content: emoji, + created_at: createdAt, + tags: [ + ["e", targetId], + ["h", channelId], + ], + }); +} + +function buildDeletion( + signer: Signer, + targetId: string, + createdAt: number, +): NostrEvent { + return signer.sign({ + kind: KIND_DELETION, + content: "", + created_at: createdAt, + tags: [["e", targetId]], + }); +} + +const toRow = (e: NostrEvent): SeededEvent => ({ + id: e.id, + kind: e.kind, + pubkey: e.pubkey, + created_at: e.created_at, + content: e.content, + tags: e.tags, +}); + +const AUTHORS: IdentityName[] = ["tyler", "alice", "bob", "charlie"]; + +// ── Scenario builders ──────────────────────────────────────────────────────── +// +// Each scenario returns { groups, expected }: +// - groups: ordered barrier groups for publishGroups (parents before children) +// - expected: the ground-truth SeededEvent[] the correctness suite asserts the +// GUI must render (or, for aux/deleted, reason about) — the "every event the +// relay returns must render" contract. + +export type Scenario = { + name: string; + groups: NostrEvent[][]; + expected: SeededEvent[]; +}; + +/** + * Dense same-second wall: `count` top-level messages all at ONE created_at. + * The exact keyset hazard — a bare `until` cursor can never advance past a + * single second holding more rows than one page. All independent → one group. + */ +export function denseSecondWall(opts: { + channelId: string; + second: number; + count: number; +}): Scenario { + const { channelId, second, count } = opts; + const events: NostrEvent[] = []; + for (let i = 0; i < count; i += 1) { + const signer = signerFor(AUTHORS[i % AUTHORS.length]); + events.push(buildMessage(signer, channelId, `dense ${i}`, second)); + } + return { + name: "dense-second-wall", + groups: [events], + expected: events.map(toRow), + }; +} + +/** + * A single root with a `depth`-deep linear reply chain. Each level references + * the previous as parent → must publish level-by-level (barrier per level), or + * ingest rejects the child before its parent lands. + */ +export function deepThread(opts: { + channelId: string; + depth: number; + startAt: number; +}): Scenario { + const { channelId, depth, startAt } = opts; + const root = buildMessage( + signerFor("tyler"), + channelId, + "thread root", + startAt, + [], + ); + const groups: NostrEvent[][] = [[root]]; + const expected: SeededEvent[] = [toRow(root)]; + let parentId = root.id; + const rootId = root.id; + for (let level = 1; level <= depth; level += 1) { + const signer = signerFor(AUTHORS[level % AUTHORS.length]); + const tags = + level === 1 ? directReplyTags(rootId) : nestedReplyTags(rootId, parentId); + const reply = buildMessage( + signer, + channelId, + `reply depth ${level}`, + startAt + level, + tags, + ); + groups.push([reply]); + expected.push(toRow(reply)); + parentId = reply.id; + } + return { name: "deep-thread", groups, expected }; +} + +/** + * Backdated events: `created_at` older than publish order — the author-clock + * hazard that broke the created_at-anchored pager. Independent tops → one group. + */ +export function backdated(opts: { + channelId: string; + now: number; + count: number; +}): Scenario { + const { channelId, now, count } = opts; + const events: NostrEvent[] = []; + for (let i = 0; i < count; i += 1) { + // Publish newest-first but stamp them progressively OLDER: the wire arrival + // order and the created_at order deliberately disagree. + const createdAt = now - (i + 1) * 37; + const signer = signerFor(AUTHORS[i % AUTHORS.length]); + events.push( + buildMessage( + signer, + channelId, + `backdated ${i} @${createdAt}`, + createdAt, + ), + ); + } + return { name: "backdated", groups: [events], expected: events.map(toRow) }; +} + +/** + * Aux transitive closure (contract v1.1 item 1): a top-level row, a reaction on + * it (aux), an edit-style follow, a deletion of the ROW, and — the two-hop case + * — a DELETION OF THE REACTION. The window surface must return the reaction's + * deletion even though it targets an aux id, not a row. + */ +export function auxClosure(opts: { + channelId: string; + startAt: number; +}): Scenario { + const { channelId, startAt } = opts; + const row = buildMessage( + signerFor("tyler"), + channelId, + "aux target row", + startAt, + ); + const reaction = buildReaction( + signerFor("alice"), + channelId, + row.id, + "🔥", + startAt + 1, + ); + const groups: NostrEvent[][] = [[row]]; + const expected: SeededEvent[] = [toRow(row)]; + groups.push([reaction]); + expected.push(toRow(reaction)); + // Two-hop: delete the reaction (targets an aux id, not the row). + const reactionDeletion = buildDeletion( + signerFor("alice"), + reaction.id, + startAt + 2, + ); + groups.push([reactionDeletion]); + expected.push(toRow(reactionDeletion)); + return { name: "aux-closure", groups, expected }; +} + +/** + * Legacy/spurious-row probe (contract v1.1 item 7): a reply carrying only a + * `root` marker (no `reply`) → stored WITHOUT thread_metadata. The correctness + * suite asserts whether it wrongly surfaces as a top-level row, which is the + * signal for whether a backfill migration is required. + */ +export function legacyReply(opts: { + channelId: string; + startAt: number; +}): Scenario { + const { channelId, startAt } = opts; + const root = buildMessage( + signerFor("tyler"), + channelId, + "legacy root", + startAt, + ); + const spurious = buildMessage( + signerFor("bob"), + channelId, + "legacy reply (root-only marker)", + startAt + 1, + legacyRootOnlyTags(root.id), + ); + return { + name: "legacy-reply", + groups: [[root], [spurious]], + expected: [toRow(root), toRow(spurious)], + }; +} + +/** + * Bulk mixed channel (3k+ events): a long span of top-level messages, a fraction + * carrying short reply chains, reactions and a few deletions sprinkled across + * rendered rows so `settled` (aux backfill committed — Sami's metric) reflects a + * realistic fan-out cost, not a bare timeline. Groups: all roots + tops first + * (independent), then replies (parents exist), then aux (targets exist). + */ +export function bulkChannel(opts: { + channelId: string; + topLevelCount: number; + startAt: number; + /** Fraction of top-level rows that get a 2-3 reply chain. */ + threadedFraction?: number; + /** Fraction of rows that get a reaction. */ + reactionFraction?: number; +}): Scenario { + const { + channelId, + topLevelCount, + startAt, + threadedFraction = 0.25, + reactionFraction = 0.4, + } = opts; + + const tops: NostrEvent[] = []; + const replies: NostrEvent[] = []; + const aux: NostrEvent[] = []; + const expected: SeededEvent[] = []; + + for (let i = 0; i < topLevelCount; i += 1) { + const at = startAt + i * 5; // spread across time, some collisions below + const signer = signerFor(AUTHORS[i % AUTHORS.length]); + // Every ~50th row shares a second with its neighbour — light dense pockets. + const createdAt = i % 50 === 0 && i > 0 ? at - 5 : at; + const top = buildMessage(signer, channelId, `msg ${i}`, createdAt); + tops.push(top); + expected.push(toRow(top)); + + if (i % Math.max(1, Math.round(1 / threadedFraction)) === 0) { + const chainLen = 2 + (i % 2); // 2 or 3 deep + let parentId = top.id; + const rootId = top.id; + for (let level = 1; level <= chainLen; level += 1) { + const rSigner = signerFor(AUTHORS[(i + level) % AUTHORS.length]); + const tags = + level === 1 + ? directReplyTags(rootId) + : nestedReplyTags(rootId, parentId); + const reply = buildMessage( + rSigner, + channelId, + `msg ${i} reply ${level}`, + createdAt + level, + tags, + ); + replies.push(reply); + expected.push(toRow(reply)); + parentId = reply.id; + } + } + + if (i % Math.max(1, Math.round(1 / reactionFraction)) === 0) { + const rSigner = signerFor(AUTHORS[(i + 1) % AUTHORS.length]); + const reaction = buildReaction( + rSigner, + channelId, + top.id, + i % 3 === 0 ? "👍" : "🎉", + createdAt + 1, + ); + aux.push(reaction); + expected.push(toRow(reaction)); + } + } + + // Barrier ordering: tops must land before any reply; a depth-2 reply's parent + // is a depth-1 reply, so replies split by depth into successive groups. A + // depth-1 reply carries only a `reply` marker; deeper replies also carry a + // `root` marker (nestedReplyTags). aux targets tops, so it goes last. + const hasRootMarker = (e: NostrEvent) => + e.tags.some((t) => t[0] === "e" && t[3] === "root"); + const depth1 = replies.filter((r) => !hasRootMarker(r)); + const deeper = replies.filter((r) => hasRootMarker(r)); + + const groups: NostrEvent[][] = [tops]; + if (depth1.length) groups.push(depth1); + if (deeper.length) groups.push(deeper); + if (aux.length) groups.push(aux); + + return { name: "bulk-channel", groups, expected }; +} + +/** + * Dense same-second wall buried behind `fillerCount` NEWER top-level events, so + * the pre-overhaul client's bulk `since:0 limit:1000` head fetch cannot reach it + * and MUST fall onto the bare-`until` history pager — where the dense second + * strands its sub-page tail (the RED-on-main proof). Wren, 2026-07-03: the head + * fetch front-loads the newest ~1000, so `fillerCount` must exceed that ceiling. + * + * Timestamps stay inside the relay's ±120s ingest window: filler is spread over + * the seconds just before NOW, the wall sits one second below the oldest filler. + * Filler and wall share ONE barrier group (all independent top-levels). + * + * `expected` is the WALL only — the contract under test is "every event behind + * the front-load ceiling is still reachable". Filler is context, not asserted. + */ +export function denseWallBehindFiller(opts: { + channelId: string; + wallCount: number; + fillerCount: number; + /** Newest filler second; defaults to NOW. Wall sits `wallOffset`s below. */ + now?: number; + /** Seconds the filler spans below `now`. Default 100 (well inside ±120s). */ + fillerSpanSeconds?: number; +}): Scenario { + const { + channelId, + wallCount, + fillerCount, + now = Math.floor(Date.now() / 1000), + fillerSpanSeconds = 100, + } = opts; + + const events: NostrEvent[] = []; + + // Filler: `fillerCount` rows spread across [now - fillerSpanSeconds, now]. + // Oldest filler second is `now - fillerSpanSeconds`; the wall sits below it. + const oldestFillerSecond = now - fillerSpanSeconds; + for (let i = 0; i < fillerCount; i += 1) { + const signer = signerFor(AUTHORS[i % AUTHORS.length]); + // Deterministic spread: newest first index → newest second, wrapping the + // span. Exact distribution is irrelevant; only "all newer than wall" matters. + const second = now - (i % (fillerSpanSeconds + 1)); + events.push(buildMessage(signer, channelId, `filler ${i}`, second)); + } + + // Wall: `wallCount` rows all at one second strictly below the oldest filler. + const wallSecond = oldestFillerSecond - 1; + const wall: NostrEvent[] = []; + for (let i = 0; i < wallCount; i += 1) { + const signer = signerFor(AUTHORS[i % AUTHORS.length]); + const event = buildMessage(signer, channelId, `wall ${i}`, wallSecond); + events.push(event); + wall.push(event); + } + + return { + name: "dense-wall-behind-filler", + groups: [events], + expected: wall.map(toRow), + }; +} + +/** + * Ancestor-island cursor poisoning (Dawn's Jul-2 root cause + Wren's Jul-3 + * proven repro `239cc161`): the disease the read-model overhaul deletes. + * + * On main, the cold channel load paints the newest `CHANNEL_HISTORY_LIMIT` (60) + * top-level rows. If one of those rows is a reply whose root/parent is OUTSIDE + * that window, `useLoadMissingAncestors` fetches that old root by id and merges + * it into the SAME channel cache — a non-contiguous "island". The older-history + * pager then anchors `oldestTimestamp = baseline[0].created_at` on the island + * (the injected old root), so every scroll-up pages backward FROM the island and + * permanently skips the real history between the island and the true frontier. + * CLI `/query` returns those `gap-*` rows; the GUI never requests them → RED. + * + * Shape (all inside the ±120s ingest window — the boundary here is ROW COUNT + * (60), not time, so timestamps stay tight around NOW): + * - 1 old thread root at `now - oldRootOffset` (default 115s) + * - `gapCount` `gap-*` top-levels between the old root and the newest window + * - `newestCount` (> 60) `new-*` top-levels at the newest seconds, exactly ONE + * of which is a reply whose root+parent point at the old root → the trigger + * + * Barrier ordering: the old root must land before the reply that references it + * (ingest rejects an unknown parent), so the reply is its own later group. + * + * `expected` is the `gap-*` set — the contract is "every gap row CLI returns + * must render". RED on main (0 reachable); GREEN on the windowed read model. + */ +export function ancestorIsland(opts: { + channelId: string; + gapCount: number; + newestCount: number; + now?: number; + /** Seconds below NOW for the old island root. Default 115 (inside ±120s). */ + oldRootOffset?: number; + /** + * Per-run isolation tag. The suite seeds into shared `general`, so every run + * accumulates rows; without a unique marker, a prior run's `gap` rows inflate + * the reachable set and false-green the parity assertion (observed 2026-07-03: + * a contaminated relay "passed" in 3.3s while a clean channel is RED with + * `seen.size === 0`). Callers pass a unique nonce and assert only against this + * run's expected set. Default is time+random so ad-hoc calls are still safe. + */ + nonce?: string; +}): Scenario { + const { + channelId, + gapCount, + newestCount, + now = Math.floor(Date.now() / 1000), + oldRootOffset = 115, + nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + } = opts; + + const oldRootSecond = now - oldRootOffset; + const oldRoot = buildMessage( + signerFor("tyler"), + channelId, + `island root ${nonce}`, + oldRootSecond, + ); + + // Gap rows: strictly between the old root and the newest window. Spread them + // across the seconds just above the old root so none collide with it. + const gapTop = now - 10; // newest gap second, still below the newest window + const gapBottom = oldRootSecond + 1; + const gapSpan = Math.max(1, gapTop - gapBottom); + const gap: NostrEvent[] = []; + const gapExpected: SeededEvent[] = []; + for (let i = 0; i < gapCount; i += 1) { + const signer = signerFor(AUTHORS[i % AUTHORS.length]); + const second = gapBottom + (i % gapSpan); + const event = buildMessage(signer, channelId, `gap ${nonce} ${i}`, second); + gap.push(event); + gapExpected.push(toRow(event)); + } + + // Newest window: `newestCount` rows at the top seconds. One is a reply to the + // old root (the ancestor-fetch trigger); the rest are plain top-levels. + const newestBottom = now - 9; + const newest: NostrEvent[] = []; + const replyIndex = Math.floor(newestCount / 2); + for (let i = 0; i < newestCount; i += 1) { + const signer = signerFor(AUTHORS[i % AUTHORS.length]); + const second = newestBottom + (i % 10); + if (i === replyIndex) { + newest.push( + buildMessage( + signer, + channelId, + `new ${nonce} ${i} (reply to island root)`, + second, + nestedReplyTags(oldRoot.id, oldRoot.id), + ), + ); + } else { + newest.push(buildMessage(signer, channelId, `new ${nonce} ${i}`, second)); + } + } + + const reply = newest[replyIndex]; + const nonReplyNewest = newest.filter((_, i) => i !== replyIndex); + + // Group 1: old root + gap + newest (except the cross-gap reply). Group 2: the + // reply (its parent/root is the old root, which must land first). + return { + name: "ancestor-island", + groups: [[oldRoot, ...gap, ...nonReplyNewest], [reply]], + expected: gapExpected, + }; +} + +/** + * Exact-multiple final page (Eva 2026-07-03, `39006` window-bounds authority): + * regression. Deterministic distinct seconds so ordering is unambiguous. + */ +export function exactMultiplePage(opts: { + channelId: string; + limitRows: number; + pages: number; + startAt: number; +}): Scenario { + const { channelId, limitRows, pages, startAt } = opts; + const total = limitRows * pages; + const events: NostrEvent[] = []; + for (let i = 0; i < total; i += 1) { + const signer = signerFor(AUTHORS[i % AUTHORS.length]); + events.push(buildMessage(signer, channelId, `exact ${i}`, startAt + i)); + } + return { + name: "exact-multiple-page", + groups: [events], + expected: events.map(toRow), + }; +} + +/** + * Publish an already-built scenario, returning the ground-truth expected set. + */ +export async function seedScenario( + scenario: Scenario, + options: SeedOptions = {}, +): Promise { + const relayHttpUrl = options.relayHttpUrl ?? DEFAULT_RELAY_HTTP; + const concurrency = options.concurrency ?? 16; + await publishGroups(scenario.groups, relayHttpUrl, concurrency); + return scenario.expected; +} + +export const _internal = { + buildMessage, + buildReaction, + buildDeletion, + directReplyTags, + nestedReplyTags, + legacyRootOnlyTags, + signerFor, + publishGroups, +}; diff --git a/docker-compose.harness.yml b/docker-compose.harness.yml new file mode 100644 index 000000000..f687a9f84 --- /dev/null +++ b/docker-compose.harness.yml @@ -0,0 +1,77 @@ +# ============================================================================= +# Isolated test-relay backing stack — GUI read-model overhaul harness (Dawn). +# +# A SEPARATE Compose project (`buzz-harness`) so the shared :3000 relay and the +# default `buzz-*` dev stack are never touched. No fixed container_names (they +# collide across projects) and alternate host ports. Mirrors Eva's evaperf-* +# isolation pattern. +# +# Bring up: docker compose -p buzz-harness -f docker-compose.harness.yml up -d +# Ports: postgres 5471 · redis 6471 · minio 9471/9472 +# Relay (run separately by scripts/start-isolated-test-relay.sh): +# main 3030 · health 8088 · metrics 9202 +# ============================================================================= +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: buzz + POSTGRES_PASSWORD: buzz_dev + POSTGRES_DB: buzz + PGDATA: /var/lib/postgresql/data + ports: + - "5471:5432" + volumes: + - harness-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U buzz"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + redis: + image: redis:7-alpine + ports: + - "6471:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: buzz_dev + MINIO_ROOT_PASSWORD: buzz_dev_secret + ports: + - "9471:9000" + - "9472:9001" + volumes: + - harness-minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 buzz_dev buzz_dev_secret && + mc mb --ignore-existing local/buzz-media && + mc anonymous set none local/buzz-media + " + restart: "no" + +volumes: + harness-postgres-data: + harness-minio-data: diff --git a/docs/bridge-channel-window.md b/docs/bridge-channel-window.md new file mode 100644 index 000000000..42f1d8283 --- /dev/null +++ b/docs/bridge-channel-window.md @@ -0,0 +1,135 @@ +# Bridge `/query` Extension: Channel Window + +> **Normative spec:** [NIP-CW](nips/NIP-CW.md) is the canonical, standalone +> specification of the channel window (kinds 39005/39006, filter extension, +> cursor and trust semantics). This document remains as the ratified +> engineering contract and internal design record; where wording differs, +> NIP-CW governs. + +Status: frozen contract v2 (2026-07-03) — GUI read-model overhaul. +Reviewed by: Mari (relay ground truth), Wren (client core), Quinn (spec +guardian), Perci (NIP landscape). Ratified in +`#buzz-gui-formal-relay-interaction-spec`, thread `a7c68013`. + +The channel window is how Buzz clients page a channel timeline by +**top-level rows** instead of raw events. It is a raw-filter extension on +the existing HTTP bridge `POST /query` — the same extension family as +`before_id` and `thread_cursor`. There is no new endpoint, and the wire +carries only signed nostr events. + +Vanilla NIP-01 cannot express "messages with no reply e-tag" (filters have +no negation), which is why generic nostr clients page raw events and +reassemble threads client-side. This relay computes `thread_metadata` +(depth, root, reply counts) at ingest, so it can serve the top-level view +directly. The WS REQ path ignores all fields below via `nostr::Filter`'s +unknown-field behavior — generic clients degrade gracefully to a normal +full-event query; they never see a wrong-but-plausible timeline. + +## Request + +A standard bridge filter plus extension fields: + +```json +{ + "kinds": [9], + "#h": [""], + "limit": 50, + "top_level": true, + "include_summaries": true, + "include_aux": true, + "until": 1751500000, + "before_id": "<64-hex event id>" +} +``` + +- `top_level: true` — routes this filter to the top-level SQL view. + Requires exactly one `#h` channel the caller can access. +- `limit` — row budget. Counts **row events only**; summaries, aux, and + bounds overlays never consume it. +- `until` + `before_id` — the composite request cursor `(created_at, id)` + of the last retained row from the previous page. **Both or neither.** + `top_level` with `until` but no `before_id` is rejected (`400`): the + window path has no timestamp-only fallback, ever. Neither = head request. +- `include_summaries` / `include_aux` — opt-in overlay/closure appends. + +`page`/OFFSET is not honored on the window path. + +## Top-level predicate + +A row is top-level iff `depth IS NULL OR depth = 0 OR (depth = 1 AND +broadcast = true)` in `thread_metadata` (v1 ruling: `NULL` — an event +ingested before thread metadata existed — counts as top-level; the harness +`legacyReply` scenario decides whether a backfill migration is needed). +Deleted rows (`deleted_at IS NOT NULL`) are excluded before the limit. + +## Ordering and cursor + +Rows are ordered `(created_at DESC, id ASC)` — the same composite every +other read path uses. The next-page cursor is the `(created_at, id)` of the +**last retained row**; the server echoes it in the `39006` bounds overlay +as `next_cursor`. Keyset comparison is +`created_at < $ts OR (created_at = $ts AND id > $id)`; dense seconds +paginate without loss or duplication by construction. + +`has_more` is a **server fact**: the relay probes `limit + 1` rows after +all predicates (access, deletion, top-level, kinds), returns at most +`limit`, and reports the probe result in `39006`. The sentinel row never +reaches the wire, and no closure is computed for it. Clients must not +infer exhaustion from row count (`rows < limit` does not imply anything on +an exact-multiple final page) — `39006.has_more` is the only authority. + +## Response + +The existing flat bridge shape: a JSON array of signed nostr events. +Clients **partition by kind before any cursor math**: + +1. **Rows** — the top-level events, in keyset order. +2. **Aux closure** (`include_aux`) — reactions (7), deletions (5, 9005), + and edits (40003) targeting the retained rows by `#e`, **plus** + deletions targeting those aux events (the transitive second hop, e.g. + a delete-of-a-reaction). One round trip; no client `#e` fan-out. +3. **Thread summaries** (`include_summaries`) — one relay-signed + `kind:39005` per row that has replies. +4. **Window bounds** — exactly one relay-signed `kind:39006` per window + response. + +### `kind:39005` — thread summary overlay + +- tags: `["e", ]`, `["d", ]`, `["h", ]` +- content: `{"reply_count":n,"descendant_count":n,"last_reply_at":ts|null,"participants":["",...]}` + (participants: up to 10, most recent first) +- Signed by the relay keypair. Synthesized at query time, **never stored**. + Clients treat it as replace-by-target metadata keyed by the `e`/`d` tag + (the `d` tag gives parameterized-replaceable semantics natively); it is + never a row, never a cursor input, never durable timeline history. + +### `kind:39006` — window bounds overlay + +- tags: `["d", ":"]`, `["h", ""]` +- content: `{"has_more": bool, "next_cursor": {"created_at": ts, "id": ""} | null}` +- `next_cursor = null ⇔ has_more = false`. +- `d`-tag suffix serialization (canonical): `head` for a head request, + else `:` — decimal unix seconds, then the full + 64-char lowercase hex id, colon-delimited. Clients must verify the + suffix equals the cursor they sent and reject the overlay on mismatch. +- Reserved field: `oldest_retained` (retention gap), added without a wire + break if needed. +- Same overlay rules as 39005: relay-signed, query-time, never stored, + never a row or cursor input. + +Both kinds are relay-only: client submission is rejected at ingest. + +## Client obligations (frozen) + +- Pages are immutable authoritative history chained cursor→cursor; live + events land in a separate overlay, never spliced into pages. +- Reconnect refetches page 0 and re-arms the live subscription + (`since: now`); deeper pages need no repair path. +- Replies never enter the channel timeline; the thread panel uses the + existing `thread_cursor` surface (#1418). + +## Siblings + +`before_id` (requires `until`), `thread_cursor`/`thread_cursor_id`, +`depth_limit`, `feed_types` — see `bridge.rs`. All are bridge-only raw +filter extensions invisible to vanilla relays and clients. diff --git a/docs/nips/NIP-CW.md b/docs/nips/NIP-CW.md new file mode 100644 index 000000000..f21b63405 --- /dev/null +++ b/docs/nips/NIP-CW.md @@ -0,0 +1,207 @@ +NIP-CW +====== + +Channel Window +-------------- + +`draft` `optional` `relay` + +**Depends on**: NIP-01 (basic event format, filters), NIP-11 (relay information document), NIP-29 (relay-based groups), NIP-98 (HTTP auth) + +## Abstract + +This NIP defines the **channel window**: a relay-computed, cursor-paged view of a channel's *top-level* timeline, served as ordinary signed Nostr events through an extended NIP-01 filter. One request returns a page of top-level rows in stable keyset order, optionally accompanied by the aux closure and two relay-signed overlay families: + +- the **aux closure** — stored reactions, deletions, and edits targeting the returned rows, with their original authors and signatures (`include_aux`), +- **thread summaries** — one relay-signed `kind:39005` per row that has replies (`include_summaries`), +- **window bounds** — exactly one relay-signed `kind:39006` carrying the authoritative `has_more` fact and the next-page cursor. + +The extension adds no endpoint and no envelope. The wire format is the flat array of signed events the query surface already returns; a client that ignores this NIP receives standard behavior everywhere. + +## Motivation + +A NIP-01 filter can only *match* tag values; it cannot express their absence. "Channel messages that are **not** replies" — the timeline every threaded-chat client renders first — is therefore inexpressible in vanilla filters, so generic clients page the full event stream and reassemble threads client-side. That costs bandwidth proportional to reply volume, and worse, it breaks pagination correctness: `limit` counts raw events, so a page of 50 events may contain 3 top-level rows or 50, and the client cannot ask for "the next 50 rows." + +Timestamp pagination (`until` alone) has a second defect: `created_at` has one-second resolution, so bursts of same-second events make a timestamp cursor lossy or duplicative at every page boundary. + +A relay that computes thread structure at ingest already knows which events are top-level. This NIP lets a client request that view directly, with a composite `(created_at, id)` cursor that is exact under same-second bursts, and with server-computed exhaustion (`has_more`) so an exact-multiple final page is not misread as "more available." + +## Non-Goals + +This NIP does not change ingest, storage, or fan-out. Rows returned in a window are ordinary stored events; the overlays are computed per query and never stored. + +This NIP does not define thread *reading*. Replies never appear as window rows; fetching a thread's contents is out of scope. + +This NIP does not require WebSocket REQ support. A relay MAY serve window filters only on an HTTP query surface and ignore the extension fields on REQ (see §Degradation). + +## Terminology + +This document uses MUST, MUST NOT, SHOULD, MAY, and RECOMMENDED as defined in RFC 2119. + +- **relay identity**: The keypair whose pubkey the relay advertises (e.g. NIP-11 `self`). All overlay events are signed with it. +- **row**: A stored, signed event returned as part of the page proper (usually client-authored; Buzz also stores relay-signed events carrying actor provenance). Rows are the only events that count against `limit`. +- **top-level**: An event that opens a thread rather than replying into one — defined by wire tags in §Top-level Classification. +- **overlay**: A relay-signed event (`kind:39005`, `kind:39006`) synthesized at query time. Overlays are metadata *about* rows: never a row, never a cursor input, never durable history. +- **composite cursor**: The pair `(created_at, id)` identifying a position in the total order. `created_at` is unix seconds; `id` is a 64-character lowercase hex event id. +- **scan position**: The composite cursor of the last event the relay's query *retained*, whether or not that event was ultimately delivered as a row (see §Relay Processing step 3). The cursor tracks where the scan stopped, not what the client received. + +## Request + +A window request is a standard filter plus extension fields, submitted wherever the relay accepts filters (for Buzz: the NIP-98-authenticated HTTP bridge `POST /query`): + +```jsonc +{ + "kinds": [9], // optional row-kind restriction + "#h": [""], // REQUIRED: exactly one channel + "limit": 50, // row budget (rows only, never overlays) + "top_level": true, // selects the window path + "include_summaries": true, // optional: kind:39005 overlays + "include_aux": true, // optional: aux closure + "until": 1751500000, // ┐ composite request cursor — + "before_id": "<64-hex id>" // ┘ both or neither +} +``` + +- `top_level` — MUST be boolean `true` to select the window path. Any other value (absent, `false`, string, number) means the filter is served as a normal filter. +- `#h` — the window MUST target exactly one channel. Zero or multiple channels: reject with an error (Buzz: HTTP `400`). A channel the requester cannot access is handled by §Access Scoping, not by an error that confirms the channel exists. +- `limit` — the row budget. Overlays and aux events MUST NOT count against it. Relays SHOULD clamp it to a documented range (Buzz: default 50, maximum 200, minimum 1). +- `until` + `before_id` — the request cursor: the `next_cursor` from the previous page's `kind:39006` overlay, echoed verbatim — `until` = `next_cursor.created_at`, `before_id` = `next_cursor.id`. **Both present or both absent.** Exactly one present MUST be rejected: a timestamp-only cursor silently loses or duplicates same-second rows, which is the failure mode this NIP exists to remove. Both absent = head-of-channel request. +- `kinds` — optional; restricts which kinds may be rows. It does not affect overlay or aux kinds. + +Cursor grammar: `until` MUST be a non-negative integer of unix seconds representable by the relay's timestamp type; `before_id` MUST be exactly 64 hexadecimal characters (the lowercase form emitted in `next_cursor.id` is canonical). A malformed value MUST cause rejection of the request — it MUST NOT be ignored and demoted to a half cursor or a head request. + +Offset/page-number pagination MUST NOT be honored on the window path. + +## Top-level Classification + +The row set must be reproducible from wire data alone, so the reply/top-level distinction is defined by tags, not by any relay's storage schema. + +An event is a **reply** iff it carries a NIP-10 *marked* `e` tag with the `reply` marker (`["e", "", , "reply"]`, parent id being 64 hex characters). An event with no marked `reply` e-tag — including one carrying only a `root`-marked tag, unmarked/positional e-tags, or no e-tags at all — is **not** a reply. + +From that predicate: + +- **depth** 0 = not a reply. A reply's depth is its parent's depth + 1, following `reply` markers up the ancestry (relays MAY cap depth; Buzz rejects beyond 100). A reply MUST target a parent in the same channel; its `root` marker, when present, MUST agree with the parent's ancestry. +- **broadcast**: a reply is *broadcast to the channel* iff it carries the exact tag `["broadcast", "1"]`. Broadcasting is an author's opt-in to surface a depth-1 reply on the channel timeline as well as in its thread. + +An event is **top-level** — eligible to be a window row — iff its depth is 0, or its depth is 1 and it is broadcast. + +Storage fallback (fail-open): a relay that indexes this classification at ingest may hold events stored before the index existed, whose depth is unknown. Such events MUST be treated as top-level rather than vanishing from every window. This is a compatibility rule for pre-index data, not a third protocol state — an interoperating implementation classifying from tags alone has no unknown case. + +## Relay Processing Algorithm + +For a valid window filter on an accessible channel (§Access Scoping) the relay MUST: + +1. **Select rows.** From the target channel, take events that are top-level (§Top-level Classification), not deleted, and matching `kinds` if present, in the total order `created_at DESC, id ASC` (`id` compared bytewise). With a cursor `(ts, id)`, retain only events where `created_at < ts OR (created_at = ts AND id > id)`. +2. **Probe exhaustion.** Evaluate the query with an internal budget of `limit + 1` rows *after all predicates*. If `limit + 1` rows match, `has_more = true` and the sentinel row is discarded — it MUST NOT appear on the wire, in overlays, or in the aux closure. Otherwise `has_more = false`. +3. **Derive the next cursor.** If `has_more`, `next_cursor` is the **scan position**: the composite cursor of the last retained candidate, captured *before* any serving-time reconstruction or filtering of individual events. Otherwise `next_cursor = null`. The invariant `next_cursor = null ⇔ has_more = false` MUST hold. Because it is a scan position, `next_cursor` MAY reference an event that does not appear in the response (e.g. one skipped by the relay as unreconstructable); it is authoritative regardless, and deriving it from delivered rows instead would stall pagination on every skipped event. +4. **Append the aux closure** (if `include_aux` and at least one row): two hops of events referencing the rows by `e` tag. Hop 1: reactions (`kind:7`), deletions (`kind:5`, `kind:9005`), and edits (Buzz `kind:40003`) whose `e` tag is a row id. Hop 2: deletions whose `e` tag is a hop-1 event id (a delete-of-a-reaction). Each event appears at most once; access-scoped events the requester cannot read are omitted. Relays MAY cap each hop (Buzz: 1000 events per hop). +5. **Append thread summaries** (if `include_summaries`): one `kind:39005` per row that has at least one reply. Rows without replies get none. +6. **Append window bounds**: exactly one `kind:39006` per served window response, always — including empty and exhausted pages. + +The response is the surface's ordinary flat array of signed events — rows first in keyset order, then aux, then summaries, then bounds. Clients MUST partition by kind and MUST NOT rely on array position beyond the ordering of rows. + +## Access Scoping + +Access is evaluated before any of the steps above. A syntactically valid window request for a channel the requester cannot access — including a channel that does not exist — MUST produce the relay's ordinary access-scoped result for that surface, with **no rows and no overlays**. For Buzz's query surface that ordinary result is an empty array, exactly as any other filter against an inaccessible channel produces. + +Two consequences implementers MUST NOT miss: + +- The "exactly one `kind:39006`" guarantee applies only to *served* windows — responses where access succeeded. The absence of a bounds overlay is therefore meaningful: it tells an extension-aware client that no window was served (access-scoped, or the relay does not implement this NIP — see §Degradation). +- An inaccessible channel is thereby indistinguishable from a nonexistent one, but *not* from an accessible empty channel: the latter is a served window and does return a `39006` (`has_more: false`). This is the same existence-disclosure posture as the relay's ordinary reads — a requester who can query a channel at all was already entitled to know it exists. + +## Overlay Event Formats + +Overlays are signed by the relay identity and synthesized per response. Both kinds sit in the parameterized-replaceable range, so a client that caches them gets replace-by-`d`-tag semantics from NIP-01 with no special handling. Relays MUST reject client-submitted events of either kind at ingest. + +### `kind:39005` — thread summary + +One per returned row with replies. Tag cardinality is exact: one `e`, one `d`, one `h`, nothing else. + +```jsonc +{ + "kind": 39005, + "pubkey": "", + "tags": [ + ["e", ""], + ["d", ""], + ["h", ""] + ], + "content": "{\"reply_count\":4,\"descendant_count\":7,\"last_reply_at\":1751500123,\"participants\":[\"\",\"...\"]}" +} +``` + +- `reply_count` — direct replies to the row. `descendant_count` — all events in the row's thread subtree. +- `last_reply_at` — unix seconds of the newest descendant, or `null`. +- `participants` — up to 10 distinct author pubkeys from the thread, most recent first. +- The `e` and `d` tags both carry the row's event id: `e` for reference-following, `d` for replaceable addressing. + +### `kind:39006` — window bounds + +Exactly one per served window response. The **only** authority on exhaustion. Tag cardinality is exact: one `d`, one `h`, nothing else. + +```jsonc +{ + "kind": 39006, + "pubkey": "", + "tags": [ + ["d", ":"], + ["h", ""] + ], + "content": "{\"has_more\":true,\"next_cursor\":{\"created_at\":1751499000,\"id\":\"<64-hex id>\"}}" +} +``` + +- `d`-tag suffix (canonical serialization): the literal string `head` for a head request, else `:` — decimal unix seconds, colon, full 64-character lowercase hex id — identifying the *request* cursor this page answered. Clients MUST verify the suffix equals the cursor they sent and discard the overlay (and the page) on mismatch; this binds each bounds overlay to its request and makes concurrent-page responses unambiguous. +- `next_cursor` — the composite cursor to echo as `until` + `before_id` for the next page, or `null` iff `has_more` is `false`. +- Reserved: an `oldest_retained` content field may be added (retention gap signaling) without a wire break. Clients MUST ignore unknown content fields. + +## Client Behavior + +1. **Head request**: send the window filter with no cursor. Render rows in received order. +2. **Continue**: read `kind:39006`; if `has_more`, send the same filter with `until = next_cursor.created_at`, `before_id = next_cursor.id`. Repeat until `has_more = false`. +3. **Exhaustion**: `39006.has_more` is the only exhaustion signal. `rows < limit` proves nothing — an exact-multiple final page returns `limit` rows with `has_more = false`, and predicate filtering can shrink any page. A client MUST NOT stop paging on row count, and MUST NOT treat a full page as "more available." +4. **Immutability**: fetched pages are immutable history chained cursor→cursor. New live events MUST NOT be spliced into fetched pages; deliver them through a separate live subscription (`since: now`) and merge at render time. On reconnect, refetch the head page and re-arm the live subscription; deeper pages need no repair. +5. **Bounds integrity**: a window response missing its `kind:39006`, or carrying more than one, or carrying one whose `d`-tag binding does not echo the request cursor, whose content is not parseable JSON, or whose content violates `has_more = true ⇔ next_cursor ≠ null`, is not a usable page — the client MUST discard it (and MAY retry) rather than guess at exhaustion. Clients SHOULD additionally reject overlays that violate the exact tag cardinality of §Overlay Event Formats or whose content fields have the wrong runtime types (hardening against a malformed or hostile serializer). Cryptographic verification is governed by §Overlay Trust. +6. **Overlays are metadata**: never render a `39005`/`39006` as a message, never feed one into cursor math, and key cached summaries by their `d` tag (latest wins). + +## Degradation + +Every extension field in this NIP is an *additional* key on a standard filter, and clients and relays that do not implement it need no changes: + +- **Extension-unaware relay**: a tolerant filter parser (one that ignores unknown keys, as common NIP-01 implementations do) serves the filter as a plain `kinds` + `#h` query — a complete, correct, standard event stream. A strict parser may instead reject the filter outright. Both are safe: neither produces a wrong-but-plausible top-level timeline. A client MUST treat *either* signal — a response with no valid `kind:39006`, or an error/unsupported-filter response — as a downgrade, and fall back by reissuing a clean standard filter with all extension keys removed and assembling threads client-side. (Buzz's own WebSocket REQ path is such a tolerant parser: the filter deserializer drops the extension fields, so a window filter on REQ serves the standard query.) +- **Extension-unaware client**: never sends `top_level`, never sees an overlay kind, and observes a completely standard relay. + +A relay implementing this NIP MAY advertise it in its NIP-11 relay information document; the discovery mechanism is out of scope for this NIP. A client needs no advertisement to probe safely: send one head window request and apply the downgrade rule above — the presence of a valid `kind:39006` is the capability signal. + +## Security and Privacy Considerations + +Overlays are relay-authored facts about data the requester can already read. A relay MUST apply its normal access scoping to rows and to every aux-closure event, and §Access Scoping governs inaccessible channels: no rows, no overlays, no distinguishable error. + +`kind:39005` aggregates thread activity (participant pubkeys, counts, recency) into one event. It only ever describes threads rooted in a channel the requester can read, so it reveals nothing a client could not compute from readable events — it saves round trips, not permissions. + +Client-submitted `39005`/`39006` MUST be rejected at ingest (relay-only kinds); a forged overlay accepted into storage could later masquerade as relay-signed state. + +### Overlay Trust + +Because `kind:39006` is the pagination authority, a client MUST adopt exactly one of these trust profiles before using the window fast path: + +- **Authenticated-transport profile** (what Buzz desktop ships): the client speaks to a relay it deliberately configured as its source of truth, over TLS (HTTPS/WSS) to that configured origin — server-origin authentication comes from the TLS certificate chain, which is what proves the response bytes came from the relay. (NIP-98 request signing and NIP-42 auth run over this channel too, but they authenticate the *requester* to the relay for access control; they are not evidence of response provenance.) The MUST-level structural checks of §Client Behavior step 5 — exactly one bounds, request binding, parseable content, `has_more`/`next_cursor` agreement — are still mandatory and are what #1500 enforces. The SHOULD-level checks of step 5 (exact tag cardinality, runtime field-type validation) and cryptographically binding overlay signatures to the advertised NIP-11 identity are future hardening, to be applied uniformly across all relay-signed reads (with NIP-DV, NIP-IA), not a current guarantee. Under this profile, "relay-signed" is a TLS-origin claim, not a client-verified cryptographic one. +- **Identity-verified profile**: the client has obtained and trusts the relay identity pubkey out-of-band or via NIP-11. It MUST verify each overlay's event id, Schnorr signature, and signer against that identity, and treat any failure as the §step-5 discard. This is the profile for clients that cannot or do not authenticate their transport end-to-end. + +A client with neither an authenticated transport nor a verifiable relay identity MUST NOT use the window fast path: it falls back to the standard filter (§Degradation), where it verifies every event signature itself. + +## Implementation Gotchas + +- The `limit + 1` probe MUST run after *all* predicates (access, deletion, top-level, `kinds`). A probe over a superset produces false `has_more = true` on the last page. +- The cursor comparison uses `id > $id` (bytewise ascending) because the total order is `created_at DESC, id ASC`. Getting the id inequality backwards drops or duplicates same-second rows — precisely the bug the composite cursor removes. +- `next_cursor` is the last retained *scan candidate*, not the last delivered row: capture the scan position before per-event reconstruction so a skipped event cannot stall pagination. Clients echo it verbatim and never derive or validate it against the rows they received. +- Events ingested before the relay computed thread metadata have no depth; they MUST be treated as top-level rather than vanishing from every window. +- The `d` tag on `39006` differs per request cursor by design: concurrent pages of one channel coexist in a replaceable-event cache instead of clobbering each other. The per-channel-singleton alternative would make page N overwrite page N+1's bounds. + +## Relation to Other NIPs + +- **NIP-01**: Supplies the filter grammar this NIP extends and the parameterized-replaceable semantics overlays lean on. (Degradation safety comes from this NIP's explicit downgrade-and-retry rule, not from assuming universal unknown-field tolerance.) +- **NIP-29**: Supplies the channel model (`h` tags, group-scoped reads) windows are scoped by. +- **NIP-50** and relay-side search: sibling precedent — a relay-computed view requested through extended filter fields, invisible to relays that do not implement it. +- **NIP-98**: Authenticates the HTTP query surface Buzz serves windows on. +- **NIP-11**: Names the relay identity that signs overlays and the natural place to advertise support. diff --git a/scripts/setup-desktop-test-data.sh b/scripts/setup-desktop-test-data.sh index c7a18a21d..a20c2f5fb 100755 --- a/scripts/setup-desktop-test-data.sh +++ b/scripts/setup-desktop-test-data.sh @@ -53,7 +53,10 @@ run_sql "SELECT 1" >/dev/null # so the channel reconciler (BUZZ_RECONCILE_CHANNELS, which binds localhost:3000 # fail-closed and retries every 5s for 2min) can resolve the tenant. COMMUNITY_ID="00000000-0000-4000-8000-00000000c0de" -COMMUNITY_HOST="localhost:3000" +# Host must match the relay's normalized bind host verbatim (non-default ports +# are kept by normalize_host). Overridable so an isolated relay on an alternate +# port can seed the same channels/members against its own tenant. +COMMUNITY_HOST="${BUZZ_COMMUNITY_HOST:-localhost:3000}" run_sql " INSERT INTO communities (id, host) diff --git a/scripts/start-isolated-test-relay.sh b/scripts/start-isolated-test-relay.sh new file mode 100755 index 000000000..53675423a --- /dev/null +++ b/scripts/start-isolated-test-relay.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# ============================================================================= +# start-isolated-test-relay.sh — GUI read-model overhaul test harness (Dawn) +# ============================================================================= +# Stands up a FULLY ISOLATED relay for seeding + parity/perf runs, from source +# on the current branch. Never touches the shared :3000 team relay or the +# default `buzz-*` dev stack. Backing services run under the dedicated +# `buzz-harness` Compose project (docker-compose.harness.yml); the relay runs +# in the foreground on override ports. +# +# Topology (reuse this exact tuple for desktop parity runs): +# compose project : buzz-harness +# postgres : localhost:5471 (db=buzz, user=buzz, pass=buzz_dev) +# redis : localhost:6471 +# minio : localhost:9471 (console 9472) +# relay main : localhost:3030 ← BUZZ_E2E_RELAY_URL=http://localhost:3030 +# relay health : localhost:8088 +# relay metrics : localhost:9202 +# +# Usage: +# ./scripts/start-isolated-test-relay.sh [--profile ] +# +# Teardown (safe — scoped to our project only): +# docker compose -p buzz-harness -f docker-compose.harness.yml down -v +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +CARGO_PROFILE="${CARGO_PROFILE:-ci}" +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) CARGO_PROFILE="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +PROJECT="buzz-harness" +COMPOSE_FILE="docker-compose.harness.yml" + +# Isolated ports (distinct from :3000 team relay, default dev stack, and Eva's +# evaperf :5470/:6470/:9470/:3170 stack). +PG_PORT=5471 +REDIS_PORT=6471 +MINIO_PORT=9471 +RELAY_MAIN=3030 +RELAY_HEALTH=8088 +RELAY_METRICS=9202 +COMMUNITY_HOST="localhost:${RELAY_MAIN}" + +BLUE='\033[0;34m'; GREEN='\033[0;32m'; RED='\033[0;31m'; NC='\033[0m' +log() { echo -e "${BLUE}[isolated-relay]${NC} $*"; } +ok() { echo -e "${GREEN}[isolated-relay]${NC} $*"; } +err() { echo -e "${RED}[isolated-relay]${NC} $*" >&2; } + +# ── Backing services (scoped to buzz-harness only) ─────────────────────────── +log "Bringing up backing services (project=${PROJECT})..." +docker compose -p "${PROJECT}" -f "${COMPOSE_FILE}" up -d + +wait_pg() { + for _ in $(seq 1 60); do + if docker compose -p "${PROJECT}" -f "${COMPOSE_FILE}" exec -T postgres \ + pg_isready -U buzz >/dev/null 2>&1; then + ok "Postgres ready"; return 0 + fi + sleep 2 + done + err "Postgres did not become ready"; return 1 +} +wait_pg + +# ── Schema + partitions ────────────────────────────────────────────────────── +export PGPASSWORD=buzz_dev +psql_h() { docker compose -p "${PROJECT}" -f "${COMPOSE_FILE}" exec -T postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 "$@"; } + +log "Applying schema..." +export PGSCHEMA_PLAN_HOST=localhost PGSCHEMA_PLAN_PORT=${PG_PORT} +export PGSCHEMA_PLAN_DB=buzz PGSCHEMA_PLAN_USER=buzz PGSCHEMA_PLAN_PASSWORD=buzz_dev +export PGHOST=localhost PGPORT=${PG_PORT} PGUSER=buzz PGDATABASE=buzz +./bin/pgschema apply --file schema/schema.sql --auto-approve +psql_h < scripts/attach-schema-partitions.sql +ok "Schema applied" + +# ── Deployment community + channels + members ──────────────────────────────── +# setup-desktop-test-data.sh is the single writer of the dev community row and +# the channel/member seed. It keys everything off a fixed COMMUNITY_ID and an +# overridable host — point that host at OUR relay so the tenant binding matches, +# and point its DB env at OUR isolated postgres. (psql is on PATH, so it uses +# BUZZ_DB_HOST/PORT rather than the shared `buzz-postgres` container.) +log "Seeding community (host=${COMMUNITY_HOST}), channels, and members..." +BUZZ_COMMUNITY_HOST="${COMMUNITY_HOST}" \ + BUZZ_DB_HOST=localhost BUZZ_DB_PORT=${PG_PORT} BUZZ_DB_USER=buzz \ + BUZZ_DB_PASS=buzz_dev BUZZ_DB_NAME=buzz \ + ./scripts/setup-desktop-test-data.sh +ok "Community + channels + members seeded" + +# ── Build relay from source (current branch) ───────────────────────────────── +# The repo pins Rust via rust-toolchain.toml (1.95.0). Outside the hermit env a +# stray Homebrew `cargo` (1.89) shadows the pin and fails on sqlx's MSRV, so +# prefer the rustup shim, which honors the pin. +if [[ -x "${HOME}/.cargo/bin/cargo" ]]; then + export PATH="${HOME}/.cargo/bin:${PATH}" +fi +log "Building relay (profile=${CARGO_PROFILE}, cargo=$(command -v cargo), $(cargo --version))..." +cargo build --profile "${CARGO_PROFILE}" -p buzz-relay +ok "Relay built" + +# ── Run relay (detached tmux session) ──────────────────────────────────────── +# Run inside tmux, NOT the foreground: this script is invoked from ephemeral +# shells whose process group is reaped on return, which SIGTERMs a foreground +# relay ~seconds after startup. tmux fully daemonizes the session so the relay +# survives (same pattern the perf stack uses). Logs to ${RELAY_LOG}. +RELAY_LOG="${RELAY_LOG:-/tmp/dawn-relay-run.log}" +TMUX_SESSION="${TMUX_SESSION:-dawn-relay}" +tmux kill-session -t "${TMUX_SESSION}" 2>/dev/null || true +log "Starting relay in tmux session '${TMUX_SESSION}' on :${RELAY_MAIN} (health :${RELAY_HEALTH}, metrics :${RELAY_METRICS})..." +tmux new-session -d -s "${TMUX_SESSION}" "cd '${REPO_ROOT}' && env \ + DATABASE_URL=postgres://buzz:buzz_dev@localhost:${PG_PORT}/buzz \ + REDIS_URL=redis://localhost:${REDIS_PORT} \ + RELAY_URL=ws://localhost:${RELAY_MAIN} \ + BUZZ_BIND_ADDR=0.0.0.0:${RELAY_MAIN} \ + BUZZ_HEALTH_PORT=${RELAY_HEALTH} \ + BUZZ_METRICS_PORT=${RELAY_METRICS} \ + BUZZ_S3_ENDPOINT=http://localhost:${MINIO_PORT} \ + BUZZ_S3_ACCESS_KEY=buzz_dev \ + BUZZ_S3_SECRET_KEY=buzz_dev_secret \ + BUZZ_S3_BUCKET=buzz-media \ + BUZZ_REQUIRE_AUTH_TOKEN=false \ + BUZZ_RECONCILE_CHANNELS=true \ + './target/${CARGO_PROFILE}/buzz-relay' > '${RELAY_LOG}' 2>&1" + +# Wait for the main port to accept connections. +for _ in $(seq 1 30); do + if curl -s -o /dev/null "http://localhost:${RELAY_MAIN}/"; then + ok "Relay live — BUZZ_E2E_RELAY_URL=http://localhost:${RELAY_MAIN}" + ok "Logs: ${RELAY_LOG} Attach: tmux attach -t ${TMUX_SESSION}" + ok "Stop relay: tmux kill-session -t ${TMUX_SESSION}" + ok "Full teardown: docker compose -p ${PROJECT} -f ${COMPOSE_FILE} down -v" + exit 0 + fi + sleep 1 +done +err "Relay did not come up on :${RELAY_MAIN} within 30s — check ${RELAY_LOG}" +exit 1