From de49272ae205c29f8c9c3f69e4ede6725cdcc491 Mon Sep 17 00:00:00 2001 From: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Date: Fri, 25 Sep 2026 20:26:06 -0400 Subject: [PATCH 01/18] feat(relay): draft private read-state accessory API Add opt-in /buzz/v1 sidebar and explicit-context reads plus fixed-operand read intents. Store signer-owned channel/thread frontiers separately from NIP-RS events, with receipt-time unread horizons and bounded projections. Wire migration 0050, community deletion catalog, and database/router tests. Draft only: capped projections remain incomplete on mature and hidden-tail workloads. Activity/participation, discovery, lifecycle and final gates remain. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> --- crates/buzz-db/src/lib.rs | 7 +- crates/buzz-db/src/runtime/migration.rs | 30 +- .../tests/thread_window_postgres_tests.rs | 2 +- crates/buzz-db/src/store/deletion.rs | 4 + crates/buzz-db/src/store/mod.rs | 2 + .../src/store/personal_read/context.rs | 190 ++++ crates/buzz-db/src/store/personal_read/mod.rs | 14 + .../buzz-db/src/store/personal_read/model.rs | 193 ++++ .../src/store/personal_read/postgres_tests.rs | 822 ++++++++++++++++++ .../src/store/personal_read/projection.rs | 237 +++++ .../buzz-db/src/store/personal_read/writes.rs | 250 ++++++ crates/buzz-relay/src/api/bridge.rs | 4 +- crates/buzz-relay/src/api/buzz_v1/auth.rs | 148 ++++ crates/buzz-relay/src/api/buzz_v1/handlers.rs | 179 ++++ crates/buzz-relay/src/api/buzz_v1/mod.rs | 23 + .../src/api/buzz_v1/postgres_tests.rs | 255 ++++++ crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/config.rs | 20 + crates/buzz-relay/src/router.rs | 5 +- migrations/0050_personal_read_state.sql | 27 + schema/schema.sql | 27 + 21 files changed, 2431 insertions(+), 9 deletions(-) create mode 100644 crates/buzz-db/src/store/personal_read/context.rs create mode 100644 crates/buzz-db/src/store/personal_read/mod.rs create mode 100644 crates/buzz-db/src/store/personal_read/model.rs create mode 100644 crates/buzz-db/src/store/personal_read/postgres_tests.rs create mode 100644 crates/buzz-db/src/store/personal_read/projection.rs create mode 100644 crates/buzz-db/src/store/personal_read/writes.rs create mode 100644 crates/buzz-relay/src/api/buzz_v1/auth.rs create mode 100644 crates/buzz-relay/src/api/buzz_v1/handlers.rs create mode 100644 crates/buzz-relay/src/api/buzz_v1/mod.rs create mode 100644 crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs create mode 100644 migrations/0050_personal_read_state.sql diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index e70c5dd85b1..72fdd3f8d37 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -63,9 +63,10 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, - community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, - reminder, replaceable, storage_accounting, thread, thread_window, usage, user, workflow, + community, deletion, dm, event, feed, git_repo, moderation, partition, personal_read, + product_feedback, push, reaction, read_state, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, storage_accounting, thread, thread_window, usage, user, + workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 20496ac1cfc..6592deb6320 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -703,7 +703,12 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 49); + assert_eq!(migrations.len(), 50); + assert_eq!(migrations[49].version, 50); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE TABLE personal_read_accounts")); assert_eq!(migrations[48].version, 49); assert!(migrations[48] .sql @@ -1833,6 +1838,22 @@ mod postgres_tests { let mut expected_fences = migration.fence_attachments.clone(); expected_fences.remove("product_feedback"); expected_fences.remove("rate_limit_violations"); + let personal = surface( + MIGRATOR + .iter() + .find(|m| m.version == 50) + .expect("personal read migration") + .sql + .as_ref(), + ); + for (table, definition) in personal.tables { + assert_eq!( + schema.tables.get(&table), + Some(&definition), + "personal read table {table} differs" + ); + } + expected_fences.extend(personal.fence_attachments); assert_eq!( expected_fences, schema.fence_attachments, "write-fence attachment targets differ after recovery policy" @@ -2763,7 +2784,12 @@ mod postgres_tests { "all NIP-FI tables must be absent after migration 0044: {present:?}" ); - // The deletion catalog must validate with ledger relations gone. + // Current binaries validate the current catalog, after subsequent additive + // migrations. The exact 0044 removal is asserted above. + MIGRATOR + .run(&pool) + .await + .expect("complete current schema upgrade"); crate::deletion::DeletionStore::new(pool.clone()) .validate_catalog() .await diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index 6062528d550..5ca0185af56 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -311,7 +311,7 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() production_result.is_ok(), "production migrator must preserve ingestion progress: {production_result:?}" ); - assert_eq!(version, 49); + assert_eq!(version, 50); assert_eq!(final_oid, oid, "prebuild must not be replaced"); assert_eq!(count, 4, "all writer witnesses must persist"); } diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 0e184e00d88..91507dd0900 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -69,6 +69,8 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ "moderation_actions", "moderation_reports", "parameterized_event_watermarks", + "personal_read_accounts", + "personal_read_frontiers", "pubkey_allowlist", "push_leases", "push_match_queue", @@ -87,6 +89,8 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ /// Foreign-key-safe child-before-parent order for the PostgreSQL purge. pub const PURGE_SCOPED_TABLES: &[&str] = &[ + "personal_read_frontiers", + "personal_read_accounts", "workflow_approvals", "scheduled_workflow_fires", "workflow_runs", diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 559460e15b9..3a57c32ccb6 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -28,6 +28,8 @@ pub mod git_repo; pub mod moderation; /// Monthly table partition management. pub mod partition; +/// Private signer-owned accessory read progress. +pub mod personal_read; /// Buzz product-feedback sidecar persistence. pub mod product_feedback; /// Community-scoped push lease and durable wake-outbox persistence. diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs new file mode 100644 index 00000000000..d48e85c3c5b --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/context.rs @@ -0,0 +1,190 @@ +//! Explicit selectors over the same private frontier authority. No history API. +use super::{model::*, projection::read_account, writes}; +use crate::{observability, Db, DbError, Result}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use sqlx::{Acquire, Row}; +use std::collections::HashMap; +use uuid::Uuid; + +impl Db { + /// Resolve bounded explicit contexts/messages in a single read-only snapshot. + /// Callers must recheck admission and resource access outside this snapshot. + pub async fn personal_read_contexts( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + retention_seconds: u32, + queries: &[ContextQuery], + ) -> Result { + if queries.is_empty() + || queries.len() > MAX_CONTEXTS + || queries.iter().map(|q| q.message_ids.len()).sum::() > MAX_CONTEXT_MESSAGES + || queries.iter().any(|q| { + q.message_ids + .iter() + .any(|id| writes::event_id(id).is_none()) + }) + { + return Err(DbError::InvalidData("invalid context selectors".into())); + } + let mut conn = observability::acquire_writer( + &self.pool, + observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let mut tx = conn.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await?; + writes::deadlines(&mut tx).await?; + let actor_bytes = actor.to_bytes(); + let account = read_account(&mut tx, community, &actor_bytes, retention_seconds).await?; + let mut contexts = Vec::with_capacity(queries.len()); + for query in queries { + let root = + match writes::valid_target(&mut tx, community, &actor_bytes, &query.target).await { + Ok(Some(root)) => root, + Ok(None) => { + contexts.push(ContextState::Unavailable); + continue; + } + Err(DbError::InvalidData(_)) => { + contexts.push(ContextState::Unknown); + continue; + } + Err(error) => return Err(error), + }; + let prefix: Option = sqlx::query_scalar( + "SELECT through_timestamp FROM personal_read_frontiers + WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=$4", + ) + .bind(community.as_uuid()) + .bind(actor_bytes.as_slice()) + .bind(query.target.channel_id) + .bind(&root) + .fetch_optional(&mut *tx) + .await?; + let ids: Vec> = query + .message_ids + .iter() + .filter_map(|id| writes::event_id(id)) + .collect(); + let rows = sqlx::query( + "SELECT encode(e.id,'hex') AS id,e.kind,e.created_at,e.received_at, + e.deleted_at IS NOT NULL AS deleted,e.pubkey=$3 AS own, + CASE WHEN octet_length(e.tags::text)<=8192 THEN e.tags ELSE NULL END AS tags, + tm.root_event_id,c.channel_type::text AS channel_type + FROM events e JOIN channels c ON c.community_id=e.community_id AND c.id=e.channel_id + LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id + AND tm.event_id=e.id AND tm.event_created_at=e.created_at AND tm.channel_id=e.channel_id + WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=ANY($4)", + ).bind(community.as_uuid()).bind(query.target.channel_id) + .bind(actor_bytes.as_slice()).bind(&ids).fetch_all(&mut *tx).await?; + let by_id: HashMap = rows + .into_iter() + .map(|row| Ok((row.try_get::("id")?, row))) + .collect::>()?; + let mut messages = Vec::with_capacity(ids.len()); + for id in &query.message_ids { + let state = if let Some(row) = by_id.get(&id.to_ascii_lowercase()) { + let tags: Option = row.try_get("tags")?; + let parsed = + tags.and_then(|v| serde_json::from_value::>>(v).ok()); + if let Some(tags) = parsed { + let canonical: Option> = row.try_get("root_event_id")?; + let marked_reply = buzz_core::nip10::parse_thread_markers_from_parts( + tags.iter().map(Vec::as_slice), + ) + .resolve() + .is_some(); + let message_id = writes::event_id(id).unwrap_or_default(); + let is_reply = canonical.as_ref().is_some_and(|r| r != &message_id); + if marked_reply && canonical.is_none() { + MessageReadState::Unknown + } else if (root.is_empty() && is_reply) + || (!root.is_empty() + && (!is_reply || canonical.as_ref() != Some(&root))) + { + // The root's own timeline state is never the thread's state. + MessageReadState::Unavailable + } else { + let created: DateTime = row.try_get("created_at")?; + let received: DateTime = row.try_get("received_at")?; + let kind: i32 = row.try_get("kind")?; + if row.try_get::("own")? + || row.try_get::("deleted")? + || !ELIGIBLE_KINDS.contains(&kind) + || received.timestamp_millis() < account.cutoff_ms + { + MessageReadState::NotCounted + } else if prefix.is_some_and(|p| created.timestamp() <= p) { + MessageReadState::Read + } else { + let directed = row.try_get::("channel_type")? == "dm" + || tags.iter().any(|t| { + t.len() >= 2 + && ((t[0] == "p" + && t[1].eq_ignore_ascii_case(&actor.to_hex())) + || (t[0] == "broadcast" && t[1] == "1")) + }); + MessageReadState::Unread { + attention: if directed { + Some(true) + } else if is_reply { + None + } else { + Some(false) + }, + } + } + } + } else { + MessageReadState::Unknown + } + } else { + MessageReadState::Unavailable + }; + messages.push(ContextMessage { + message_id: id.clone(), + state, + }); + } + contexts.push(ContextState::Available { + through_timestamp: prefix, + messages, + }); + } + tx.commit().await?; + Ok(ContextPage { account, contexts }) + } + + /// Final bounded access check on the writer, outside a projection snapshot. + /// Open-channel access is independent of joined-sidebar membership. + pub async fn personal_read_accessible_contexts( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + channels: &[Uuid], + ) -> Result> { + if channels.len() > MAX_CONTEXTS { + return Err(DbError::InvalidData("too many context channels".into())); + } + let mut conn = observability::acquire_writer( + &self.pool, + observability::WriterOperation::Authorization, + ) + .await?; + Ok(sqlx::query_scalar( + "SELECT c.id FROM channels c WHERE c.community_id=$1 AND c.id=ANY($2) + AND c.deleted_at IS NULL AND (c.visibility='open' OR EXISTS ( + SELECT 1 FROM channel_members cm WHERE cm.community_id=$1 + AND cm.channel_id=c.id AND cm.pubkey=$3 AND cm.removed_at IS NULL))", + ) + .bind(community.as_uuid()) + .bind(channels) + .bind(actor.to_bytes().as_slice()) + .fetch_all(&mut *conn) + .await?) + } +} diff --git a/crates/buzz-db/src/store/personal_read/mod.rs b/crates/buzz-db/src/store/personal_read/mod.rs new file mode 100644 index 00000000000..ed951e978f7 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/mod.rs @@ -0,0 +1,14 @@ +//! Private, signer-owned accessory read progress, separate from NIP-RS events. +//! +//! Timestamp prefixes use author time; retention uses persisted relay receipt +//! time. Only fixed context intents advance prefixes, never a query scan cap. + +mod context; +mod model; +mod projection; +mod writes; + +pub use model::*; + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs new file mode 100644 index 00000000000..f0cdfb3055c --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -0,0 +1,193 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Maximum independent operations in one HTTP request. +pub const MAX_INTENTS: usize = 100; +/// Default unread-tracking duration, not event or encrypted NIP-RS retention. +pub const DEFAULT_RETENTION_SECONDS: u32 = 30 * 24 * 60 * 60; + +/// A channel or canonical thread; absence of a root denotes only the channel timeline. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ReadTarget { + /// Channel UUID, interpreted only in the authenticated community. + pub channel_id: Uuid, + /// Canonical thread-root event ID, when targeting one thread. + pub root_id: Option, +} + +/// Fixed operands make retries converge without a server operation journal. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ReadIntent { + /// Advance a context through one fixed message, including equal times. + MarkThrough { + /// Channel or canonical thread being marked. + target: ReadTarget, + /// Fixed anchor; retry must not substitute the latest message. + message_id: String, + }, + /// Migration/recovery only: preserve an original legacy timestamp verbatim. + LegacyPrefix { + /// Original channel or canonical thread scope. + target: ReadTarget, + /// Original nonnegative second-resolution event timestamp. + through_timestamp: i64, + }, + /// The client declares its frozen baseline resolved, including exceptions. + CompleteImport, +} + +/// Outcome for one independent transaction, never acknowledged before commit. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum IntentOutcome { + /// The fixed frontier/import operand committed. + Applied, + /// Missing and forbidden contexts deliberately share one outcome. + Blocked, + /// Invalid operands; no changes committed for this intent. + Invalid, +} + +/// The tracking boundary and migration status for the authenticated account. +#[derive(Clone, Debug, Serialize)] +pub struct ReadAccount { + /// Configured tracking duration in seconds. + pub retention_seconds: u32, + /// Read-time relay-receipt cutoff (Unix milliseconds), not a discard boundary. + pub cutoff_ms: i64, + /// Client-declared baseline completion; null means provisional projections. + pub imported_at_ms: Option, +} + +/// Maximum channel summaries in one sidebar page. +pub const MAX_CHANNELS: usize = 20; +/// Bounded event evidence per channel; exhaustion is never inferred at this cap. +pub const MAX_CHANNEL_SCAN: usize = 256; +/// Conversation kinds eligible for ordinary unread state (not edits/reactions). +pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003]; + +/// An honest aggregate: capped evidence cannot establish exact zero. +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ReadCount { + /// Exhausted the authoritative candidate set. + Exact { + /// Total within the tracking horizon. + value: u32, + }, + /// More evidence exists or ancestry/participation could not be proved. + AtLeast { + /// Proven lower bound, not a fabricated badge cap. + value: u32, + }, +} + +/// One joined-channel summary, not a second conversation/history API. +#[derive(Debug, Serialize)] +pub struct ChannelReadSummary { + /// Joined channel UUID. + pub channel_id: Uuid, + /// Existing channel name. + pub name: String, + /// Existing channel type. + pub channel_type: String, + /// Archived channels stay in the roster; presentation remains client-owned. + pub archived: bool, + /// Existing DM visibility preference (not an authorization decision). + pub hidden: bool, + /// Ordinary unread lower bound or exact count. + pub unread: ReadCount, + /// Directed unread (DM, mention/broadcast, participating-thread reply). + pub attention: ReadCount, + /// Latest eligible nondeleted event ID, independent of read progress, author, + /// and the unread-tracking horizon. + /// None proves absence only when latest_message_complete is true. + pub latest_message_id: Option, + /// Whether the latest lookup found a result or exhausted channel history. + /// False means the bounded probe found none, but an unexamined tail remains. + pub latest_message_complete: bool, +} + +/// A bounded roster page, with no cross-page snapshot or removal inference. +#[derive(Debug, Serialize)] +pub struct SidebarPage { + /// Effective read-state lifecycle for this response. + pub account: ReadAccount, + /// Joined channels only, never every accessible public channel. + pub channels: Vec, + /// Exclusive UUID roster cursor. None means this roster scan exhausted. + pub next_cursor: Option, +} + +/// Maximum explicit contexts in one request. +pub const MAX_CONTEXTS: usize = 20; +/// Maximum explicit message selectors across the entire context request. +pub const MAX_CONTEXT_MESSAGES: usize = 100; + +/// A context and concrete messages already known through Nostr history/live reads. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ContextQuery { + /// Channel timeline or canonical thread, never an arbitrary filter. + pub target: ReadTarget, + /// Optional concrete message selectors; not an event history query. + #[serde(default)] + pub message_ids: Vec, +} + +/// Read progress and eligibility for one concrete message, not a public receipt. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MessageReadState { + /// Missing, inaccessible, or outside the requested context. No existence oracle. + Unavailable, + /// Evidence cannot safely establish ancestry or eligibility. + Unknown, + /// Ineligible for unread counts (own, deleted, auxiliary, or outside horizon). + NotCounted, + /// Covered by this context's frontier. + Read, + /// Eligible and beyond this context's frontier. + Unread { + /// True for proven directed attention; null means participation unproved. + attention: Option, + }, +} + +/// An explicit message result, in request order. +#[derive(Debug, Serialize)] +pub struct ContextMessage { + /// Requested ID, not an independently disclosed event ID. + pub message_id: String, + /// Actor-private state within the requested context. + #[serde(flatten)] + pub state: MessageReadState, +} + +/// A context result. Denied and missing resources share an indistinguishable shape. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ContextState { + /// Missing or inaccessible context. + Unavailable, + /// Canonical context could not be proved. + Unknown, + /// Context authority at the response snapshot. + Available { + /// Fixed author-time prefix; null means no read progress. + through_timestamp: Option, + /// Bounded explicit selectors, in request order. + messages: Vec, + }, +} + +/// Actor-private bounded context response; no cross-request snapshot guarantee. +#[derive(Debug, Serialize)] +pub struct ContextPage { + /// Receipt-time horizon and import status at this snapshot. + pub account: ReadAccount, + /// One result per requested context, in request order. + pub contexts: Vec, +} diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs new file mode 100644 index 00000000000..292c99cf851 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -0,0 +1,822 @@ +use super::*; +use crate::{ + channel::{ChannelType, ChannelVisibility}, + Db, +}; +use buzz_core::CommunityId; +use nostr::{EventBuilder, Keys, Kind}; +use sqlx::PgPool; +use uuid::Uuid; + +async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Event) { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("personal-read-{}.local", Uuid::new_v4())) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let channel = db + .create_channel( + community, + "private reads", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(9), "read this, not its sibling") + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + (db, pool, community, channel, actor, event) +} + +async fn add_history(db: &Db, community: CommunityId, channel: Uuid) -> nostr::Event { + let author = Keys::generate(); + let mut last = None; + let base = nostr::Timestamp::now().as_secs(); + for i in 0..300 { + let event = EventBuilder::new(Kind::Custom(9), format!("history {i}")) + .custom_created_at(nostr::Timestamp::from(base + i)) + .sign_with_keys(&author) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + last = Some(event); + } + last.unwrap() +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads_are_unknown() { + let (db, _pool, community, channel, actor, _) = fixture().await; + let last = add_history(&db, community, channel).await; + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: last.id.to_hex(), + }, + ) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::AtLeast { value: 0 } + )); + assert_eq!( + page.channels[0].latest_message_id.as_deref(), + Some(last.id.to_hex().as_str()) + ); + assert!(page.channels[0].latest_message_complete); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_sidebar_expired_unopened_tail_remains_unproved() { + let (db, pool, community, channel, actor, _) = fixture().await; + let latest = add_history(&db, community, channel).await; + sqlx::query( + "UPDATE events SET received_at=clock_timestamp()-interval '31 days' WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::AtLeast { value: 0 }), + "receipt expiry of the scanned window cannot prove expiry of the author-ordered tail" + ); + assert_eq!( + page.channels[0].latest_message_id.as_deref(), + Some(latest.id.to_hex().as_str()) + ); + assert!(page.channels[0].latest_message_complete); + // A fresh receipt with old author time belongs to retention even if it is + // invisible below this bounded window. Never certify that channel as read. + let backfill = EventBuilder::new(Kind::Custom(9), "recently imported old message") + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() - 40 * 86400, + )) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &backfill, Some(channel)) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::AtLeast { value: 0 } + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_sidebar_is_read_only_and_does_not_wait_for_account() { + let (db, pool, community, _channel, actor, _) = fixture().await; + db.personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "GET must not create private state"); + db.apply_personal_read_intent(community, &actor.public_key(), &ReadIntent::CompleteImport) + .await + .unwrap(); + let mut held = pool.begin().await.unwrap(); + sqlx::query("SELECT actor FROM personal_read_accounts WHERE community_id=$1 FOR UPDATE") + .bind(community.as_uuid()) + .fetch_all(&mut *held) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(page.account.imported_at_ms.is_some()); + held.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_intent_does_not_lock_shared_conversation_rows() { + let (db, pool, community, channel, actor, event) = fixture().await; + sqlx::query("UPDATE channels SET ttl_seconds=86400 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(channel) + .execute(&pool) + .await + .unwrap(); + let mut held = pool.begin().await.unwrap(); + super::writes::lock_account(&mut held, community, &actor.public_key().to_bytes()) + .await + .unwrap(); + let result = super::writes::apply( + &mut held, + community, + &actor.public_key().to_bytes(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: event.id.to_hex(), + }, + ) + .await + .unwrap(); + assert!(matches!(result, IntentOutcome::Applied)); + // Exercise the actual event-insert TTL trigger while private progress is + // uncommitted, then verify event deletion can update the observed row. + let incoming = EventBuilder::new(Kind::Custom(9), "concurrent ephemeral ingest") + .sign_with_keys(&Keys::generate()) + .unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(2), + db.insert_event(community, &incoming, Some(channel)), + ) + .await + .unwrap() + .unwrap(); + let mut legacy = pool.begin().await.unwrap(); + sqlx::query("SET LOCAL lock_timeout='100ms'") + .execute(&mut *legacy) + .await + .unwrap(); + sqlx::query("UPDATE channels SET ttl_deadline=clock_timestamp()+interval '1 day' WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()).bind(channel).execute(&mut *legacy).await.unwrap(); + sqlx::query("UPDATE events SET deleted_at=clock_timestamp() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .execute(&mut *legacy) + .await + .unwrap(); + legacy.rollback().await.unwrap(); + held.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { + let (db, pool, community, channel, actor, _) = fixture().await; + let base = nostr::Timestamp::now().as_secs(); + let own = EventBuilder::new(Kind::Custom(9), "own latest") + .custom_created_at(nostr::Timestamp::from(base + 1)) + .sign_with_keys(&actor) + .unwrap(); + db.insert_event(community, &own, Some(channel)) + .await + .unwrap(); + for (offset, kind) in [(2, 9), (4, 7)] { + let event = EventBuilder::new(Kind::Custom(kind), format!("ineligible {offset}")) + .custom_created_at(nostr::Timestamp::from(base + offset)) + .sign_with_keys(&actor) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + if offset == 2 { + sqlx::query( + "UPDATE events SET deleted_at=clock_timestamp() WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + } + } + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert_eq!( + page.channels[0].latest_message_id.as_deref(), + Some(own.id.to_hex().as_str()) + ); + assert!(page.channels[0].latest_message_complete); + sqlx::query( + "UPDATE events SET received_at=clock_timestamp()-interval '31 days' WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert_eq!( + page.channels[0].latest_message_id.as_deref(), + Some(own.id.to_hex().as_str()) + ); + assert!(page.channels[0].latest_message_complete); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_latest_old_message_is_not_an_empty_channel() { + let (db, pool, community, channel, actor, event) = fixture().await; + sqlx::query( + "UPDATE events SET received_at=clock_timestamp()-interval '31 days' WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let empty = db + .create_channel( + community, + "truly empty", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let old = page + .channels + .iter() + .find(|c| c.channel_id == channel) + .unwrap(); + assert_eq!( + old.latest_message_id.as_deref(), + Some(event.id.to_hex().as_str()) + ); + assert!(old.latest_message_complete); + assert!(matches!(old.unread, ReadCount::Exact { value: 0 })); + let empty = page + .channels + .iter() + .find(|c| c.channel_id == empty) + .unwrap(); + assert!(empty.latest_message_id.is_none()); + assert!(empty.latest_message_complete); + // A long run of ineligible rows must instead report that latest is unknown. + sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + add_history(&db, community, channel).await; + sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let unknown = page + .channels + .iter() + .find(|c| c.channel_id == channel) + .unwrap(); + assert!(unknown.latest_message_id.is_none()); + assert!(!unknown.latest_message_complete); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_frontier_retries_old_anchors_and_rejects_future_imports() { + let (db, pool, community, channel, actor, event) = fixture().await; + let target = ReadTarget { + channel_id: channel, + root_id: None, + }; + let invalid = ReadIntent::LegacyPrefix { + target: target.clone(), + through_timestamp: i64::MAX, + }; + assert_eq!( + db.apply_personal_read_intent(community, &actor.public_key(), &invalid) + .await + .unwrap(), + IntentOutcome::Invalid + ); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "invalid intent rolls back account creation"); + sqlx::query( + "UPDATE events SET received_at=clock_timestamp()-interval '60 days' WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let intent = ReadIntent::MarkThrough { + target: target.clone(), + message_id: event.id.to_hex(), + }; + for _ in 0..2 { + assert_eq!( + db.apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap(), + IntentOutcome::Applied + ); + } + assert_eq!( + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::LegacyPrefix { + target, + through_timestamp: 0 + } + ) + .await + .unwrap(), + IntentOutcome::Applied + ); + let frontier: i64 = sqlx::query_scalar( + "SELECT through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(frontier, event.created_at.as_secs() as i64); + let stranger = Keys::generate(); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(stranger.public_key().to_bytes().as_slice()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + let sparse: Option = + sqlx::query_scalar("SELECT to_regclass('personal_read_seen')::text") + .fetch_one(&pool) + .await + .unwrap(); + assert!(sparse.is_none()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_channel_and_thread_never_inherit_each_other() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + let reply = EventBuilder::new(Kind::Custom(9), "unseen thread reply") + .custom_created_at(nostr::Timestamp::from(base + 10)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reply, Some(channel)) + .await + .unwrap(); + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(reply.id.as_bytes().as_slice()).bind((base+10) as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(&pool).await.unwrap(); + let top = EventBuilder::new(Kind::Custom(9), "later timeline message") + .custom_created_at(nostr::Timestamp::from(base + 20)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &top, Some(channel)) + .await + .unwrap(); + let channel_target = ReadTarget { + channel_id: channel, + root_id: None, + }; + assert_eq!( + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: channel_target.clone(), + message_id: reply.id.to_hex() + } + ) + .await + .unwrap(), + IntentOutcome::Blocked + ); + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: channel_target, + message_id: top.id.to_hex(), + }, + ) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value: 1 }), + "timeline reading must leave unseen reply unread" + ); + let second_actor = Keys::generate(); + db.apply_personal_read_intent( + community, + &second_actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_id: reply.id.to_hex(), + }, + ) + .await + .unwrap(); + let roots: Vec> = sqlx::query_scalar( + "SELECT root_id FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(second_actor.public_key().to_bytes().as_slice()) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + roots, + vec![root.id.as_bytes().to_vec()], + "thread reading never creates a channel frontier" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + let reply = EventBuilder::new(Kind::Custom(9), "thread only") + .custom_created_at(nostr::Timestamp::from(base + 1)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reply, Some(channel)) + .await + .unwrap(); + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(reply.id.as_bytes().as_slice()).bind((base+1) as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(&pool).await.unwrap(); + let queries = vec![ + ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: vec![root.id.to_hex(), reply.id.to_hex(), "00".repeat(32)], + }, + ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_ids: vec![root.id.to_hex(), reply.id.to_hex()], + }, + ]; + let page = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(); + let page = serde_json::to_value(page).unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); + assert_eq!(page["contexts"][0]["messages"][1]["status"], "unavailable"); + assert_eq!(page["contexts"][0]["messages"][2]["status"], "unavailable"); + assert_eq!(page["contexts"][1]["messages"][0]["status"], "unavailable"); + assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); + assert!(page["contexts"][1]["messages"][1]["attention"].is_null()); + let accounts: i64 = + sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(accounts, 0, "context GET must never create authority"); + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::LegacyPrefix { + target: queries[0].target.clone(), + through_timestamp: (base + 10) as i64, + }, + ) + .await + .unwrap(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "read"); + assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: queries[1].target.clone(), + message_id: reply.id.to_hex(), + }, + ) + .await + .unwrap(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][1]["messages"][1]["status"], "read"); + let other = Keys::generate(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &other.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); + assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); + assert!(db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[] + ) + .await + .is_err()); + let oversized = vec![ContextQuery { + target: queries[0].target.clone(), + message_ids: vec![root.id.to_hex(); MAX_CONTEXT_MESSAGES + 1], + }]; + assert!(db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &oversized + ) + .await + .is_err()); + sqlx::query("UPDATE channels SET visibility='private' WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(channel) + .execute(&pool) + .await + .unwrap(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &other.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + page["contexts"], + serde_json::json!([{"status":"unavailable"},{"status":"unavailable"}]) + ); + assert!(db + .personal_read_accessible_contexts(community, &other.public_key(), &[channel]) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_context_receipt_horizon_unknown_ancestry_and_deletion() { + let (db, pool, community, channel, actor, root) = fixture().await; + let old = EventBuilder::new(Kind::Custom(9), "old author freshly received") + .custom_created_at(nostr::Timestamp::from( + root.created_at.as_secs() - 40 * 86400, + )) + .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &old, Some(channel)) + .await + .unwrap(); + let unresolved = EventBuilder::new(Kind::Custom(9), "ancestry missing") + .tags([nostr::Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &unresolved, Some(channel)) + .await + .unwrap(); + let queries = [ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: vec![old.id.to_hex(), unresolved.id.to_hex(), root.id.to_hex()], + }]; + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); + assert_eq!(page["contexts"][0]["messages"][0]["attention"], true); + assert_eq!(page["contexts"][0]["messages"][1]["status"], "unknown"); + sqlx::query( + "UPDATE events SET received_at=now()-interval '31 days' WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(old.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(root.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "not_counted"); + assert_eq!(page["contexts"][0]["messages"][2]["status"], "not_counted"); +} diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs new file mode 100644 index 00000000000..d3ea74408b8 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -0,0 +1,237 @@ +//! Bounded evidence projection. The cap limits evidence, not the definition of +//! unread: an unexamined tail yields a lower bound, including lower-bound zero. + +use super::{model::*, writes}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sqlx::{Acquire, PgConnection, Row}; +use uuid::Uuid; + +use crate::{observability, Db, DbError, Result}; + +impl Db { + /// Read a bounded joined roster from the writer. One SQL statement projects + /// channels, event evidence and read authority at a compatible MVCC cut. + /// Callers must recheck admission/resource access before releasing this data. + pub async fn personal_read_sidebar( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + retention_seconds: u32, + limit: usize, + after: Option, + ) -> Result { + if !(1..=MAX_CHANNELS).contains(&limit) { + return Err(DbError::InvalidData("invalid sidebar limit".into())); + } + let mut conn = observability::acquire_writer( + &self.pool, + observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let mut tx = conn.begin().await?; + // Import status and frontier evidence share a compatible read-only cut. + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await?; + writes::deadlines(&mut tx).await?; + let actor_bytes = actor.to_bytes(); + let account = read_account(&mut tx, community, &actor_bytes, retention_seconds).await?; + // The inner event LIMIT is deliberately before eligibility filtering. + // This bounds rows/joins even with long deleted or self-authored runs. + // Tags are bounded before transfer; oversized/corrupt evidence stays + // unknown, never falsely top-level/unmentioned/read. + let rows = sqlx::query( + "WITH roster AS MATERIALIZED ( + SELECT c.id,c.name,c.channel_type::text AS channel_type, + c.archived_at IS NOT NULL AS archived, cm.hidden_at IS NOT NULL AS hidden + FROM channel_members cm JOIN channels c + ON c.community_id=cm.community_id AND c.id=cm.channel_id + WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL + AND c.deleted_at IS NULL AND ($3::uuid IS NULL OR c.id>$3) + ORDER BY c.id LIMIT $4 + ) + SELECT r.*, latest.latest_message_id, + (latest.latest_message_id IS NOT NULL OR latest.candidates <= $5-1) AS latest_message_complete, + COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r + LEFT JOIN LATERAL ( + WITH candidates AS MATERIALIZED ( + SELECT id,created_at,kind,deleted_at FROM events + WHERE community_id=$1 AND channel_id=r.id + ORDER BY created_at DESC,id LIMIT $5 + ) + SELECT count(*) AS candidates, + (array_agg(encode(id,'hex') ORDER BY created_at DESC,id) + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id + FROM candidates + ) latest ON true + LEFT JOIN LATERAL ( + SELECT jsonb_agg(jsonb_build_object( + 'id',encode(e.id,'hex'), 'created_at',extract(epoch FROM e.created_at)::bigint, + 'received_ms',floor(extract(epoch FROM e.received_at)*1000)::bigint, + 'own',e.pubkey=$2, 'deleted',e.deleted_at IS NOT NULL, + 'kind',e.kind, + 'tags',CASE WHEN octet_length(e.tags::text)<=8192 + AND jsonb_typeof(e.tags)='array' THEN + (SELECT COALESCE(jsonb_agg(tag ORDER BY ord),'[]'::jsonb) + FROM jsonb_array_elements(e.tags) WITH ORDINALITY t(tag,ord) + WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END, + 'root',encode(tm.root_event_id,'hex'), + 'channel_prefix',cf.through_timestamp,'thread_prefix',tf.through_timestamp + ) ORDER BY e.created_at DESC,e.id) AS evidence + FROM (SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags + FROM events WHERE community_id=$1 AND channel_id=r.id + ORDER BY created_at DESC,id LIMIT $5) e + LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id + AND tm.event_created_at=e.created_at AND tm.event_id=e.id + LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2 + AND cf.channel_id=r.id AND cf.root_id=''::bytea + LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 + AND tf.channel_id=r.id AND tf.root_id=tm.root_event_id + ) e ON true ORDER BY r.id", + ).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after) + .bind((limit+1) as i64).bind((MAX_CHANNEL_SCAN+1) as i64) + .bind(ELIGIBLE_KINDS.as_slice()) + .fetch_all(&mut *tx).await?; + let has_more = rows.len() > limit; + let mut channels = Vec::new(); + for row in rows.into_iter().take(limit) { + let evidence: Value = row.try_get("evidence")?; + let evidence = evidence + .as_array() + .ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?; + let complete = evidence.len() <= MAX_CHANNEL_SCAN; + let channel_type: String = row.try_get("channel_type")?; + let mut unread = 0; + let mut attention = 0; + let mut unread_complete = complete; + let mut attention_complete = complete; + for e in evidence.iter().take(MAX_CHANNEL_SCAN) { + let kind = e["kind"].as_i64().unwrap_or(-1) as i32; + if !ELIGIBLE_KINDS.contains(&kind) + || e["deleted"] == true + || e["received_ms"] + .as_i64() + .is_none_or(|t| t < account.cutoff_ms) + { + continue; + } + if e["own"] == true { + continue; + } + let Some(created) = e["created_at"].as_i64() else { + unread_complete = false; + attention_complete = false; + continue; + }; + let Some(tags) = e["tags"].as_array() else { + unread_complete = false; + attention_complete = false; + continue; + }; + // Missing metadata for a marked reply could hide a thread + // frontier. Until resolved, it cannot establish ordinary unread. + let parsed: Option>> = tags + .iter() + .map(|tag| { + tag.as_array()? + .iter() + .map(|p| p.as_str().map(str::to_owned)) + .collect() + }) + .collect(); + let Some(parsed) = parsed else { + unread_complete = false; + attention_complete = false; + continue; + }; + let markers = buzz_core::nip10::parse_thread_markers_from_parts( + parsed.iter().map(Vec::as_slice), + ); + if markers.resolve().is_some() && e["root"].is_null() { + unread_complete = false; + attention_complete = false; + continue; + } + // Roots are timeline messages; descendants belong exclusively + // to their canonical thread. Never inherit the channel prefix. + let is_reply = e["root"] + .as_str() + .is_some_and(|root| Some(root) != e["id"].as_str()); + let prefix = if is_reply { + &e["thread_prefix"] + } else { + &e["channel_prefix"] + }; + if prefix.as_i64().is_some_and(|p| created <= p) { + continue; + } + unread += 1; + let directed = channel_type == "dm" + || parsed.iter().any(|tag| { + tag.len() >= 2 + && ((tag[0] == "p" && tag[1].eq_ignore_ascii_case(&actor.to_hex())) + || (tag[0] == "broadcast" && tag[1] == "1")) + }); + if directed { + attention += 1; + } else if is_reply { + // Participation is not inferable from the bounded window: + // the author's own earlier reply may sit outside it. + attention_complete = false; + } + } + let count = |value, complete| { + if complete { + ReadCount::Exact { value } + } else { + ReadCount::AtLeast { value } + } + }; + channels.push(ChannelReadSummary { + channel_id: row.try_get("id")?, + name: row.try_get("name")?, + channel_type, + archived: row.try_get("archived")?, + hidden: row.try_get("hidden")?, + unread: count(unread, unread_complete), + attention: count(attention, attention_complete), + latest_message_id: row.try_get("latest_message_id")?, + latest_message_complete: row.try_get("latest_message_complete")?, + }); + } + let next_cursor = if has_more { + channels.last().map(|c| c.channel_id) + } else { + None + }; + tx.commit().await?; + Ok(SidebarPage { + account, + channels, + next_cursor, + }) + } +} + +/// Read-time horizon only: frontier state is not discarded on expiry. +pub(super) async fn read_account( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + retention_seconds: u32, +) -> Result { + let row = sqlx::query( + "SELECT date_trunc('milliseconds',transaction_timestamp()-make_interval(secs=>$3::double precision)) AS cutoff, + a.imported_at FROM (SELECT 1) singleton LEFT JOIN personal_read_accounts a + ON a.community_id=$1 AND a.actor=$2" + ).bind(community.as_uuid()).bind(actor).bind(f64::from(retention_seconds)).fetch_one(conn).await?; + let cutoff: DateTime = row.try_get("cutoff")?; + let imported: Option> = row.try_get("imported_at")?; + Ok(ReadAccount { + retention_seconds, + cutoff_ms: cutoff.timestamp_millis(), + imported_at_ms: imported.map(|t| t.timestamp_millis()), + }) +} diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs new file mode 100644 index 00000000000..7792cfe1a30 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -0,0 +1,250 @@ +use super::model::*; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use sqlx::{Acquire, PgConnection, Row}; +use uuid::Uuid; + +use crate::{observability, Db, Result}; + +pub(super) fn event_id(value: &str) -> Option> { + if value.len() != 64 || value.bytes().any(|b| !b.is_ascii_hexdigit()) { + return None; + } + hex::decode(value).ok() +} + +pub(super) async fn deadlines(conn: &mut PgConnection) -> Result<()> { + sqlx::query("SET LOCAL statement_timeout = '2000ms'") + .execute(&mut *conn) + .await?; + sqlx::query("SET LOCAL lock_timeout = '500ms'") + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// Serialize private frontier/import writes, never shared conversation rows. +pub(super) async fn lock_account( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], +) -> Result<()> { + sqlx::query( + "INSERT INTO personal_read_accounts (community_id,actor) VALUES ($1,$2) + ON CONFLICT (community_id,actor) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(actor) + .execute(&mut *conn) + .await?; + sqlx::query( + "SELECT actor FROM personal_read_accounts WHERE community_id=$1 AND actor=$2 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(actor) + .fetch_one(conn) + .await?; + Ok(()) +} + +/// Resource access is independent of roster membership. Do not row-lock shared +/// conversation tables: private progress must not serialize legacy ingest or +/// deletion. A racing revoke hides projections; it need not erase private intent. +async fn access( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + channel: Uuid, +) -> Result { + let visibility: Option = sqlx::query_scalar( + "SELECT visibility::text FROM channels + WHERE community_id=$1 AND id=$2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel) + .fetch_optional(&mut *conn) + .await?; + match visibility.as_deref() { + None => Ok(false), + Some("open") => Ok(true), + Some(_) => Ok(sqlx::query_scalar::<_, Vec>( + "SELECT pubkey FROM channel_members WHERE community_id=$1 AND channel_id=$2 + AND pubkey=$3 AND removed_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(actor) + .fetch_optional(&mut *conn) + .await? + .is_some()), + } +} + +struct Message { + id: Vec, + timestamp: i64, + root: Option>, +} + +async fn message( + conn: &mut PgConnection, + community: CommunityId, + channel: Uuid, + id: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT e.id, e.created_at, e.tags, tm.root_event_id + FROM events e LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id + AND tm.event_created_at=e.created_at AND tm.event_id=e.id AND tm.channel_id=e.channel_id + WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=$3 + AND e.kind=ANY($4) + LIMIT 1", + ).bind(community.as_uuid()).bind(channel).bind(id).bind(ELIGIBLE_KINDS.as_slice()).fetch_optional(&mut *conn).await?; + row.map(|row| { + let timestamp: DateTime = row.try_get("created_at")?; + let id: Vec = row.try_get("id")?; + let root: Option> = row.try_get("root_event_id")?; + let tags: serde_json::Value = row.try_get("tags")?; + let tags: Vec> = serde_json::from_value(tags) + .map_err(|_| crate::DbError::InvalidData("invalid message tags".into()))?; + let markers = + buzz_core::nip10::parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice)); + if markers.resolve().is_some() && root.is_none() { + return Err(crate::DbError::InvalidData( + "unresolved message ancestry".into(), + )); + } + Ok(Message { + id, + timestamp: timestamp.timestamp(), + root, + }) + }) + .transpose() +} + +pub(super) async fn valid_target( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + target: &ReadTarget, +) -> Result>> { + let root = match &target.root_id { + Some(root) => match event_id(root) { + Some(id) => id, + None => return Ok(None), + }, + None => Vec::new(), + }; + if !access(conn, community, actor, target.channel_id).await? { + return Ok(None); + } + if !root.is_empty() { + // Deleted roots still own living replies. Validate actual ancestry, + // not absence of metadata: a missing index row is not a top-level proof. + let Some(msg) = message(conn, community, target.channel_id, &root).await? else { + return Ok(None); + }; + if msg.root.as_ref().is_some_and(|r| r != &msg.id) { + return Ok(None); + } + } + Ok(Some(root)) +} + +async fn frontier( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + target: &ReadTarget, + root: &[u8], + timestamp: i64, +) -> Result<()> { + sqlx::query( + "INSERT INTO personal_read_frontiers + (community_id, actor, channel_id, root_id, through_timestamp) VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE + SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp)", + ).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root).bind(timestamp) + .execute(&mut *conn).await?; + Ok(()) +} + +pub(super) async fn apply( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + intent: &ReadIntent, +) -> Result { + match intent { + ReadIntent::MarkThrough { target, message_id } => { + let Some(id) = event_id(message_id) else { + return Ok(IntentOutcome::Invalid); + }; + let Some(root) = valid_target(conn, community, actor, target).await? else { + return Ok(IntentOutcome::Blocked); + }; + let Some(msg) = message(conn, community, target.channel_id, &id).await? else { + return Ok(IntentOutcome::Blocked); + }; + let is_reply = msg.root.as_ref().is_some_and(|r| r != &msg.id); + if (root.is_empty() && is_reply) + || (!root.is_empty() && msg.id != root && msg.root.as_ref() != Some(&root)) + { + return Ok(IntentOutcome::Blocked); + } + frontier(conn, community, actor, target, &root, msg.timestamp).await?; + } + ReadIntent::LegacyPrefix { + target, + through_timestamp, + } => { + let latest_allowed: i64 = sqlx::query_scalar( + "SELECT floor(extract(epoch FROM clock_timestamp()))::bigint + 900", + ) + .fetch_one(&mut *conn) + .await?; + if *through_timestamp < 0 || *through_timestamp > latest_allowed { + return Ok(IntentOutcome::Invalid); + } + let Some(root) = valid_target(conn, community, actor, target).await? else { + return Ok(IntentOutcome::Blocked); + }; + // Import fixed prefixes only; receipt-time horizon does not alter + // the original author-time operand. + frontier(conn, community, actor, target, &root, *through_timestamp).await?; + } + ReadIntent::CompleteImport => { + sqlx::query("UPDATE personal_read_accounts SET imported_at=COALESCE(imported_at,clock_timestamp()) + WHERE community_id=$1 AND actor=$2") + .bind(community.as_uuid()).bind(actor).execute(&mut *conn).await?; + } + } + Ok(IntentOutcome::Applied) +} + +impl Db { + /// Apply one independent intent. Blocked/invalid intents roll back *all* + /// private account changes. Retrying after an + /// ambiguous commit is safe because only fixed max operands are used. + pub async fn apply_personal_read_intent( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + intent: &ReadIntent, + ) -> Result { + let mut conn = + observability::acquire_writer(&self.pool, observability::WriterOperation::EventWrite) + .await?; + let mut tx = conn.begin().await?; + deadlines(&mut tx).await?; + let actor = actor.to_bytes(); + lock_account(&mut tx, community, &actor).await?; + let outcome = apply(&mut tx, community, &actor, intent).await?; + match outcome { + IntentOutcome::Applied => tx.commit().await?, + IntentOutcome::Blocked | IntentOutcome::Invalid => tx.rollback().await?, + } + Ok(outcome) + } +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 4714ce13438..b1d51de37ba 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2678,7 +2678,7 @@ fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { } #[cfg(test)] -mod postgres_tests { +pub(crate) mod postgres_tests { use super::*; use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; use std::sync::Mutex; @@ -4057,7 +4057,7 @@ mod postgres_tests { /// - Redis pool points at the local dev instance for the admission check. /// /// Returns `None` when local Postgres is not reachable. - pub(super) async fn bridge_handler_test_state() -> Option> { + pub(crate) async fn bridge_handler_test_state() -> Option> { let mut config = crate::config::Config::from_env().ok()?; config.database_url = crate::test_support::database_url(); // Use the real local Redis so enforce_http_admission can pass. diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs new file mode 100644 index 00000000000..a24a741fb37 --- /dev/null +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -0,0 +1,148 @@ +use crate::{ + api::{bridge, relay_members}, + state::AppState, +}; +use axum::{ + http::{header, HeaderMap, StatusCode, Uri}, + response::{IntoResponse, Response}, + Json, +}; +use buzz_core::TenantContext; +use serde_json::{json, Value}; +use std::sync::Arc; + +pub(super) struct Error { + status: StatusCode, + code: &'static str, +} +impl Error { + pub(super) fn new(status: StatusCode, code: &'static str) -> Self { + Self { status, code } + } + pub(super) fn unavailable() -> Self { + Self::new(StatusCode::SERVICE_UNAVAILABLE, "unavailable") + } + pub(super) fn invalid() -> Self { + Self::new(StatusCode::BAD_REQUEST, "invalid_request") + } +} +impl IntoResponse for Error { + fn into_response(self) -> Response { + let mut response = ( + self.status, + Json(json!({"error":{"code":self.code, + "request_id":uuid::Uuid::new_v4().to_string()}})), + ) + .into_response(); + response.headers_mut().insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("private, no-store"), + ); + if self.status == StatusCode::TOO_MANY_REQUESTS + || self.status == StatusCode::SERVICE_UNAVAILABLE + { + response + .headers_mut() + .insert(header::RETRY_AFTER, header::HeaderValue::from_static("60")); + } + response + } +} + +pub(super) fn bridge_error((status, _): (StatusCode, Json)) -> Error { + let code = match status { + StatusCode::UNAUTHORIZED => "unauthorized", + StatusCode::FORBIDDEN => "forbidden", + StatusCode::TOO_MANY_REQUESTS => "rate_limited", + _ => "unavailable", + }; + Error::new(status, code) +} + +pub(super) struct Principal { + pub(super) tenant: TenantContext, + pub(super) actor: nostr::PublicKey, + signed_at: Option, +} + +pub(super) async fn authorize( + state: &Arc, + headers: &HeaderMap, + uri: &Uri, + method: &str, + body: Option<&[u8]>, +) -> Result { + let host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, host) + .await + .map_err(|_| Error::new(StatusCode::NOT_FOUND, "not_found"))?; + if !state.config.buzz_v1_enabled { + return Err(Error::new(StatusCode::NOT_FOUND, "not_found")); + } + let path = uri + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or(uri.path()); + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + // Private state always requires cryptographic identity, even on a dev relay. + let verified = + bridge::verify_bridge_auth_with_options(headers, method, &url, body, true, body.is_some()) + .map_err(bridge_error)?; + bridge::enforce_http_admission(state, &tenant, &verified.pubkey) + .await + .map_err(bridge_error)?; + bridge::check_nip98_replay(state, &tenant, verified.event_id_bytes) + .await + .map_err(bridge_error)?; + let principal = Principal { + tenant, + actor: verified.pubkey, + signed_at: verified.signed_created_at, + }; + recheck(state, headers, &principal).await?; + Ok(principal) +} + +pub(super) async fn recheck( + state: &AppState, + headers: &HeaderMap, + principal: &Principal, +) -> Result<(), Error> { + relay_members::enforce_relay_membership( + state, + principal.tenant.community(), + &principal.actor.to_bytes(), + relay_members::extract_auth_tag_header(headers), + principal.signed_at, + ) + .await + .map_err(bridge_error)?; + let restrictions = state + .db + .moderation_restriction_state(principal.tenant.community(), &principal.actor.to_bytes()) + .await + .map_err(|_| Error::unavailable())?; + if restrictions.banned { + return Err(Error::new(StatusCode::FORBIDDEN, "forbidden")); + } + // Timeouts block posting conversation content, not personal reading. + Ok(()) +} + +pub(super) fn response(value: impl serde::Serialize) -> Result { + let bytes = serde_json::to_vec(&value).map_err(|_| Error::unavailable())?; + if bytes.len() > 1024 * 1024 { + return Err(Error::unavailable()); + } + Ok(( + [ + (header::CONTENT_TYPE, "application/json"), + (header::CACHE_CONTROL, "private, no-store"), + ], + bytes, + ) + .into_response()) +} diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs new file mode 100644 index 00000000000..41e00695ea1 --- /dev/null +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -0,0 +1,179 @@ +use super::auth::{self, Error}; +use crate::state::AppState; +use axum::{ + body::Bytes, + extract::{OriginalUri, Query, State}, + http::HeaderMap, + response::Response, +}; +use buzz_db::personal_read::{ReadIntent, MAX_CHANNELS, MAX_INTENTS}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; +use uuid::Uuid; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SidebarQuery { + limit: Option, + cursor: Option, +} + +pub(super) async fn sidebar( + State(state): State>, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + query: Result, axum::extract::rejection::QueryRejection>, +) -> Result { + tokio::time::timeout(Duration::from_secs(8), async { + let principal = auth::authorize(&state, &headers, &uri, "GET", None).await?; + let Query(query) = query.map_err(|_| Error::invalid())?; + if query + .limit + .is_some_and(|limit| !(1..=MAX_CHANNELS).contains(&limit)) + { + return Err(Error::invalid()); + } + let page = state + .db + .personal_read_sidebar( + principal.tenant.community(), + &principal.actor, + state.config.buzz_v1_retention_seconds, + query.limit.unwrap_or(MAX_CHANNELS), + query.cursor, + ) + .await + .map_err(|_| Error::unavailable())?; + auth::recheck(&state, &headers, &principal).await?; + let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect(); + let memberships = state + .db + .membership_pairs( + principal.tenant.community(), + &channels, + &[principal.actor.to_bytes().to_vec()], + ) + .await + .map_err(|_| Error::unavailable())?; + if memberships.len() != channels.len() { + return Err(Error::unavailable()); + } + auth::response(page) + }) + .await + .map_err(|_| Error::unavailable())? +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Batch { + intents: Vec, +} + +pub(super) async fn write( + State(state): State>, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + body: Bytes, +) -> Result { + if body.len() > 64 * 1024 { + return Err(Error::invalid()); + } + let principal = auth::authorize(&state, &headers, &uri, "POST", Some(&body)).await?; + let batch: Batch = serde_json::from_slice(&body).map_err(|_| Error::invalid())?; + if batch.intents.is_empty() || batch.intents.len() > MAX_INTENTS { + return Err(Error::invalid()); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(8); + let mut outcomes = Vec::with_capacity(batch.intents.len()); + for item in batch.intents { + let Ok(intent) = serde_json::from_value::(item) else { + outcomes.push(json!({"status":"invalid"})); + continue; + }; + // A deadline/DB failure after commit is ambiguous, not a false failure. + // Earlier acknowledged commits survive all later projection/item failures. + let result = tokio::time::timeout_at(deadline, async { + auth::recheck(&state, &headers, &principal).await?; + state + .db + .apply_personal_read_intent(principal.tenant.community(), &principal.actor, &intent) + .await + .map_err(|_| Error::unavailable()) + }) + .await; + outcomes.push(match result { + Ok(Ok(outcome)) => serde_json::to_value(outcome).map_err(|_| Error::unavailable())?, + _ => json!({"status":"unknown","retryable":true}), + }); + } + auth::response(json!({"outcomes":outcomes,"projection_status":"not_requested"})) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ContextQuery { + // JSON array carried as one URL-encoded, signed query parameter. + targets: String, +} + +pub(super) async fn contexts( + State(state): State>, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + query: Result, axum::extract::rejection::QueryRejection>, +) -> Result { + use buzz_db::personal_read::{ + ContextQuery as Target, ContextState, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, + }; + tokio::time::timeout(Duration::from_secs(8), async { + if uri.to_string().len() > 16 * 1024 { + return Err(Error::invalid()); + } + let principal = auth::authorize(&state, &headers, &uri, "GET", None).await?; + let Query(query) = query.map_err(|_| Error::invalid())?; + let targets: Vec = + serde_json::from_str(&query.targets).map_err(|_| Error::invalid())?; + let valid_id = |id: &str| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit()); + if targets.is_empty() + || targets.len() > MAX_CONTEXTS + || targets.iter().map(|t| t.message_ids.len()).sum::() > MAX_CONTEXT_MESSAGES + || targets.iter().any(|t| { + t.message_ids.iter().any(|id| !valid_id(id)) + || t.target.root_id.as_deref().is_some_and(|id| !valid_id(id)) + }) + { + return Err(Error::invalid()); + } + let mut page = state + .db + .personal_read_contexts( + principal.tenant.community(), + &principal.actor, + state.config.buzz_v1_retention_seconds, + &targets, + ) + .await + .map_err(|_| Error::unavailable())?; + auth::recheck(&state, &headers, &principal).await?; + let channels: Vec<_> = targets.iter().map(|t| t.target.channel_id).collect(); + let allowed = state + .db + .personal_read_accessible_contexts( + principal.tenant.community(), + &principal.actor, + &channels, + ) + .await + .map_err(|_| Error::unavailable())?; + for (target, result) in targets.iter().zip(&mut page.contexts) { + if !allowed.contains(&target.target.channel_id) { + *result = ContextState::Unavailable; + } + } + auth::response(page) + }) + .await + .map_err(|_| Error::unavailable())? +} diff --git a/crates/buzz-relay/src/api/buzz_v1/mod.rs b/crates/buzz-relay/src/api/buzz_v1/mod.rs new file mode 100644 index 00000000000..681be9688bf --- /dev/null +++ b/crates/buzz-relay/src/api/buzz_v1/mod.rs @@ -0,0 +1,23 @@ +//! Private accessory API. Conversation authority remains signed Nostr events. + +mod auth; +mod handlers; + +use crate::state::AppState; +use axum::{routing::get, Router}; +use std::sync::Arc; + +/// Narrow versioned router; unknown accessory paths never return SPA HTML. +pub fn router(state: Arc) -> Router { + Router::new() + .route("/me/sidebar", get(handlers::sidebar)) + .route( + "/me/read-state", + get(handlers::contexts).post(handlers::write), + ) + .fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") }) + .with_state(state) +} + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs new file mode 100644 index 00000000000..07d029734d6 --- /dev/null +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -0,0 +1,255 @@ +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, +}; +use base64::Engine; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tower::ServiceExt; + +fn proof(key: &Keys, host: &str, path: &str, method: &str, body: Option<&[u8]>) -> String { + let mut tags = vec![ + Tag::parse(["u", &format!("https://{host}{path}")]).unwrap(), + Tag::parse(["method", method]).unwrap(), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ]; + if let Some(body) = body { + tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap()); + } + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(key) + .unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) +} + +async fn request( + state: Arc, + host: &str, + path: &str, + method: &str, + auth: Option<&str>, + body: &[u8], +) -> (StatusCode, Value) { + let mut req = Request::builder() + .method(method) + .uri(path) + .header("host", host); + if let Some(auth) = auth { + req = req.header("authorization", auth); + } + let response = crate::router::build_router(state) + .oneshot(req.body(Body::from(body.to_vec())).unwrap()) + .await + .unwrap(); + let status = response.status(); + assert_eq!( + response.headers().get("cache-control").unwrap(), + "private, no-store" + ); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_router_signed_url_body_replay_and_actor_boundary() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_auth_token = true; + config.require_relay_membership = true; + config.buzz_v1_enabled = true; + state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new( + state.redis_pool.clone(), + )); + let state = Arc::new(state); + let host = format!("bff-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let other = Keys::generate(); + let path = "/buzz/v1/me/sidebar?limit=1"; + let auth = proof(&actor, &host, path, "GET", None); + assert_eq!( + request(state.clone(), &host, path, "GET", Some(&auth), b"") + .await + .0, + StatusCode::FORBIDDEN + ); + for key in [&actor, &other] { + state + .db + .add_relay_member(community, &key.public_key().to_hex(), "member", None) + .await + .unwrap(); + } + assert_eq!( + request(state.clone(), &host, path, "GET", None, b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let wrong_query = proof(&actor, &host, "/buzz/v1/me/sidebar?limit=2", "GET", None); + assert_eq!( + request(state.clone(), &host, path, "GET", Some(&wrong_query), b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let wrong_host = proof(&actor, "other.invalid", path, "GET", None); + assert_eq!( + request(state.clone(), &host, path, "GET", Some(&wrong_host), b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let auth = proof(&actor, &host, path, "GET", None); + let accepted = request(state.clone(), &host, path, "GET", Some(&auth), b"").await; + assert_eq!(accepted.0, StatusCode::OK, "{}", accepted.1); + assert_eq!( + request(state.clone(), &host, path, "GET", Some(&auth), b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let write_path = "/buzz/v1/me/read-state"; + let body = serde_json::to_vec(&json!({"intents":[{"type":"complete_import"}]})).unwrap(); + let missing_hash = proof(&actor, &host, write_path, "POST", None); + assert_eq!( + request( + state.clone(), + &host, + write_path, + "POST", + Some(&missing_hash), + &body + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let wrong_hash = proof(&actor, &host, write_path, "POST", Some(b"{}")); + assert_eq!( + request( + state.clone(), + &host, + write_path, + "POST", + Some(&wrong_hash), + &body + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let auth = proof(&actor, &host, write_path, "POST", Some(&body)); + let applied = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await; + assert_eq!(applied.0, StatusCode::OK, "{}", applied.1); + assert_eq!(applied.1["outcomes"][0]["status"], "applied"); + for (key, complete) in [(&actor, true), (&other, false)] { + let auth = proof(key, &host, path, "GET", None); + let page = request(state.clone(), &host, path, "GET", Some(&auth), b"").await; + assert_eq!(page.0, StatusCode::OK); + assert_eq!(!page.1["account"]["imported_at_ms"].is_null(), complete); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_context_get_signed_query_and_independent_batch_outcomes() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + Arc::make_mut(&mut state.config).buzz_v1_enabled = true; + let state = Arc::new(state); + let host = format!("bff-context-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let channel = state + .db + .create_channel( + community, + "context", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(9), "message selector") + .sign_with_keys(&Keys::generate()) + .unwrap(); + state + .db + .insert_event(community, &event, Some(channel)) + .await + .unwrap(); + let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]); + let encode = |value: &str| { + value + .bytes() + .map(|b| format!("%{b:02X}")) + .collect::() + }; + let path = format!( + "/buzz/v1/me/read-state?targets={}", + encode(&targets.to_string()) + ); + let auth = proof(&actor, &host, &path, "GET", None); + let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(result.0, StatusCode::OK, "{}", result.1); + assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "unread"); + let auth = proof(&actor, &host, "/buzz/v1/me/read-state", "GET", None); + assert_eq!( + request(state.clone(), &host, &path, "GET", Some(&auth), b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let bad_path = "/buzz/v1/me/read-state?targets=invalid"; + let auth = proof(&actor, &host, bad_path, "GET", None); + assert_eq!( + request(state.clone(), &host, bad_path, "GET", Some(&auth), b"") + .await + .0, + StatusCode::BAD_REQUEST + ); + let write_path = "/buzz/v1/me/read-state"; + let body = serde_json::to_vec(&json!({"intents":[ + {"type":"unknown"}, + {"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event.id.to_hex()}, + {"type":"mark_through","target":{"channel_id":channel},"message_id":event.id.to_hex()}, + {"type":"legacy_prefix","target":{"channel_id":channel},"through_timestamp":-1} + ]})).unwrap(); + let auth = proof(&actor, &host, write_path, "POST", Some(&body)); + let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await; + assert_eq!(result.0, StatusCode::OK, "{}", result.1); + assert_eq!( + result.1["outcomes"], + json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},{"status":"invalid"}]) + ); + let auth = proof(&actor, &host, &path, "GET", None); + let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "read"); +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 5745b8d4e59..6f6d491ecfe 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod bridge; +pub mod buzz_v1; pub mod events; pub mod gifs; pub mod git; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 5d831b3f651..9c74bef2a30 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -181,6 +181,10 @@ pub struct Config { /// Whether REST API requests must present a valid token. Independent of /// WebSocket protocol auth, which is *always* required by REQ/EVENT/COUNT. pub require_auth_token: bool, + /// Opt-in private accessory API; disabled until explicitly deployed. + pub buzz_v1_enabled: bool, + /// Receipt-time unread tracking duration, independent of NIP-RS retention. + pub buzz_v1_retention_seconds: u32, /// Comma-separated list of allowed CORS origins. /// If empty, permissive CORS is used (dev mode). /// Example: "tauri://localhost,http://localhost:3000" @@ -1208,6 +1212,20 @@ impl Config { )); } + let buzz_v1_enabled = std::env::var("BUZZ_V1_ENABLED").is_ok_and(|v| v == "true"); + let buzz_v1_retention_seconds = std::env::var("BUZZ_V1_RETENTION_SECONDS") + .map(|v| { + v.parse::().map_err(|_| { + ConfigError::InvalidValue("BUZZ_V1_RETENTION_SECONDS must be positive".into()) + }) + }) + .unwrap_or(Ok(buzz_db::personal_read::DEFAULT_RETENTION_SECONDS))?; + if buzz_v1_retention_seconds == 0 { + return Err(ConfigError::InvalidValue( + "BUZZ_V1_RETENTION_SECONDS must be positive".into(), + )); + } + Ok(Self { bind_addr, database_url, @@ -1227,6 +1245,8 @@ impl Config { slow_client_grace_limit, auth, require_auth_token, + buzz_v1_enabled, + buzz_v1_retention_seconds, cors_origins, relay_private_key, uds_path, diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 61aedf70be0..2196bcfe14d 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -60,6 +60,8 @@ pub fn build_router(state: Arc) -> Router { let admin_router = admin_enabled .then(|| Router::new().nest("/api/admin/v1", api::admin::router(state.clone()))); + let accessory_router = Router::new().nest("/buzz/v1", api::buzz_v1::router(state.clone())); + let api_router = Router::new() // WebSocket + NIP-11 .route("/", get(nip11_or_ws_handler)) @@ -147,6 +149,7 @@ pub fn build_router(state: Arc) -> Router { // Metrics → Trace → CORS applied once over the combined router. let mut merged = api_router .merge(media_router) + .merge(accessory_router) .merge(git_router) .merge(git_policy_router); if let Some(admin_router) = admin_router { @@ -710,7 +713,7 @@ mod tests { async fn readiness_state(evaluator: Arc) -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; - config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); + config.database_url = "postgres://127.0.0.1:1/buzz".to_string(); config.redis_url = "redis://127.0.0.1:1".to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); diff --git a/migrations/0050_personal_read_state.sql b/migrations/0050_personal_read_state.sql new file mode 100644 index 00000000000..c4c6f96e7d6 --- /dev/null +++ b/migrations/0050_personal_read_state.sql @@ -0,0 +1,27 @@ +-- Private accessory read progress. Never included in Nostr event queries. +-- Frontiers use signed event time. An empty root_id covers only the channel +-- timeline; a root-specific frontier covers that thread, without inheritance. +CREATE TABLE personal_read_accounts ( + community_id UUID NOT NULL REFERENCES communities(id), + actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), + imported_at TIMESTAMPTZ, + PRIMARY KEY (community_id, actor) +); + +CREATE TABLE personal_read_frontiers ( + community_id UUID NOT NULL, + actor BYTEA NOT NULL, + channel_id UUID NOT NULL, + root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), + through_timestamp BIGINT NOT NULL CHECK (through_timestamp >= 0), + PRIMARY KEY (community_id, actor, channel_id, root_id), + FOREIGN KEY (community_id, actor) + REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id) ON DELETE CASCADE +); + + + +SELECT attach_community_write_fence('personal_read_accounts'); +SELECT attach_community_write_fence('personal_read_frontiers'); diff --git a/schema/schema.sql b/schema/schema.sql index 1a0a849f7ac..67772c2708e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -193,6 +193,31 @@ CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle)) CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) WHERE okta_user_id IS NOT NULL; +-- Private accessory read progress. Never included in Nostr event queries. +-- Cutoffs are relay acceptance time; frontiers are signed event time. They are +-- deliberately separate clocks. An empty root_id denotes a channel frontier. +CREATE TABLE personal_read_accounts ( + community_id UUID NOT NULL REFERENCES communities(id), + actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), + imported_at TIMESTAMPTZ, + PRIMARY KEY (community_id, actor) +); + +CREATE TABLE personal_read_frontiers ( + community_id UUID NOT NULL, + actor BYTEA NOT NULL, + channel_id UUID NOT NULL, + root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), + through_timestamp BIGINT NOT NULL CHECK (through_timestamp >= 0), + PRIMARY KEY (community_id, actor, channel_id, root_id), + FOREIGN KEY (community_id, actor) + REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id) ON DELETE CASCADE +); + + + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the @@ -1745,6 +1770,8 @@ SELECT attach_community_write_fence('join_policy_acceptances'); SELECT attach_community_write_fence('moderation_actions'); SELECT attach_community_write_fence('moderation_reports'); SELECT attach_community_write_fence('parameterized_event_watermarks'); +SELECT attach_community_write_fence('personal_read_accounts'); +SELECT attach_community_write_fence('personal_read_frontiers'); SELECT attach_community_write_fence('pubkey_allowlist'); SELECT attach_community_write_fence('push_leases'); SELECT attach_community_write_fence('push_match_queue'); From 85ccf2716eed3ca7dd2d93008d6e1db1f78bb71d Mon Sep 17 00:00:00 2001 From: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 09:17:45 -0400 Subject: [PATCH 02/18] feat(relay): complete bounded private read-state projection Project receipt-bounded unread and attention using grouped facts with shared classification and bounded authored-thread participation. Preserve unknown counts when ancestry, retention scans or participation are incomplete. Add migration 0051 and validated brownfield receipt-index deployment guidance. Use shared NIP-FI admission and distinguish terminal per-intent revocation from ambiguous failures. Generate bridge test keys at runtime. Advertise host-bound opt-in NIP-11 discovery and document API semantics and extensions. Add production-path parity, budget, auth, discovery and migration regressions. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> --- Cargo.lock | 1 + crates/buzz-db/src/runtime/migration.rs | 7 +- crates/buzz-db/src/runtime/tests.rs | 71 ++++ .../tests/thread_window_postgres_tests.rs | 2 +- .../src/store/personal_read/classification.rs | 22 ++ .../src/store/personal_read/context.rs | 42 ++- crates/buzz-db/src/store/personal_read/mod.rs | 8 + .../buzz-db/src/store/personal_read/model.rs | 21 +- .../src/store/personal_read/participation.rs | 88 +++++ .../participation_postgres_tests.rs | 328 ++++++++++++++++++ .../src/store/personal_read/postgres_tests.rs | 218 +++++++++++- .../src/store/personal_read/projection.rs | 211 ++++++----- .../projection_postgres_tests.rs | 226 ++++++++++++ crates/buzz-relay/Cargo.toml | 2 + crates/buzz-relay/src/api/bridge.rs | 165 +++++---- crates/buzz-relay/src/api/buzz_v1/auth.rs | 35 +- crates/buzz-relay/src/api/buzz_v1/handlers.rs | 46 ++- crates/buzz-relay/src/api/buzz_v1/mod.rs | 3 + .../src/api/buzz_v1/postgres_tests.rs | 122 +++++++ crates/buzz-relay/src/nip11.rs | 37 ++ crates/buzz-relay/src/router.rs | 3 +- docs/buzz-v1-read-state.md | 195 +++++++++++ docs/events-channel-received-deployment.md | 79 +++++ .../0051_events_channel_received_index.sql | 36 ++ schema/schema.sql | 3 + 25 files changed, 1751 insertions(+), 220 deletions(-) create mode 100644 crates/buzz-db/src/store/personal_read/classification.rs create mode 100644 crates/buzz-db/src/store/personal_read/participation.rs create mode 100644 crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs create mode 100644 crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs create mode 100644 docs/buzz-v1-read-state.md create mode 100644 docs/events-channel-received-deployment.md create mode 100644 migrations/0051_events_channel_received_index.sql diff --git a/Cargo.lock b/Cargo.lock index 95e80f1c129..63be159b758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1461,6 +1461,7 @@ dependencies = [ "rand 0.10.1", "redis", "reqwest 0.13.4", + "ring", "rust-s3", "rustls", "serde", diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 6592deb6320..a98ecaee12c 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -703,7 +703,12 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 50); + assert_eq!(migrations.len(), 51); + assert_eq!(migrations[50].version, 51); + assert!(migrations[50] + .sql + .as_str() + .contains("idx_events_community_channel_received")); assert_eq!(migrations[49].version, 50); assert!(migrations[49] .sql diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 7d70d0a643e..a13b61f0d7a 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -3295,3 +3295,74 @@ async fn floor_guard_blocks_updates_that_move_rows_below_the_fence() { #[path = "tests/thread_window_postgres_tests.rs"] mod thread_window_postgres_tests; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_channel_received_prebuild_validation() { + const INDEX: &str = "idx_events_community_channel_received"; + const COLUMNS: &str = "(community_id, channel_id, received_at DESC, id, created_at)"; + let admin = PgPool::connect(&admin_url().await).await.unwrap(); + let (pool, name) = create_scratch_db_through(&admin, "received_prebuild", Some(50)).await; + let partitions: Vec = sqlx::query_scalar( + "SELECT c.relname::text FROM pg_inherits i JOIN pg_class c ON c.oid=i.inhrelid \ + WHERE i.inhparent='events'::regclass ORDER BY 1", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert!(partitions.len() > 1, "must exercise a partitioned parent"); + for setup in [ + // Wrong definition: ascending receipt order cannot serve the window. + format!("CREATE INDEX {INDEX} ON events (community_id, channel_id, received_at, id, created_at)"), + // Incomplete brownfield prebuild: parent exists, no partition attached. + format!("CREATE INDEX {INDEX} ON ONLY events {COLUMNS}"), + ] { + sqlx::raw_sql(sqlx::AssertSqlSafe(setup)).execute(&pool).await.unwrap(); + let error = migration::run_migrations(&pool).await.unwrap_err(); + assert!( + error.to_string().contains("invalid or wrong definition"), + "must reject catalog shape, not merely time out: {error}" + ); + let version: i64 = sqlx::query_scalar("SELECT max(version) FROM _sqlx_migrations") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(version, 50); + sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP INDEX {INDEX}"))) + .execute(&pool) + .await + .unwrap(); + } + // The documented brownfield procedure: parent ON ONLY, each partition + // CONCURRENTLY, then ATTACH. The migration must adopt it, not rebuild it. + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "CREATE INDEX {INDEX} ON ONLY events {COLUMNS}" + ))) + .execute(&pool) + .await + .unwrap(); + for partition in &partitions { + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "CREATE INDEX CONCURRENTLY {partition}_channel_received ON {partition} {COLUMNS}" + ))) + .execute(&pool) + .await + .unwrap(); + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "ALTER INDEX {INDEX} ATTACH PARTITION {partition}_channel_received" + ))) + .execute(&pool) + .await + .unwrap(); + } + let oid = || { + sqlx::query_scalar::<_, i64>( + "SELECT 'idx_events_community_channel_received'::regclass::oid::bigint", + ) + .fetch_one(&pool) + }; + let prebuilt = oid().await.unwrap(); + migration::run_migrations(&pool).await.unwrap(); + assert_eq!(prebuilt, oid().await.unwrap()); + drop_scratch_db(&admin, pool, &name).await; +} diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index 5ca0185af56..5d51f4348d5 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -311,7 +311,7 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() production_result.is_ok(), "production migrator must preserve ingestion progress: {production_result:?}" ); - assert_eq!(version, 50); + assert_eq!(version, 51); assert_eq!(final_oid, oid, "prebuild must not be replaced"); assert_eq!(count, 4, "all writer witnesses must persist"); } diff --git a/crates/buzz-db/src/store/personal_read/classification.rs b/crates/buzz-db/src/store/personal_read/classification.rs new file mode 100644 index 00000000000..c1f25ea9d19 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/classification.rs @@ -0,0 +1,22 @@ +//! Selector eligibility and shared directed-attention rules. Aggregate SQL applies +//! the same eligibility before grouping, covered by the PostgreSQL parity test. +use super::model::ELIGIBLE_KINDS; + +pub(super) fn eligible( + kind: i32, + own: bool, + deleted: bool, + received_ms: i64, + cutoff_ms: i64, +) -> bool { + ELIGIBLE_KINDS.contains(&kind) && !own && !deleted && received_ms >= cutoff_ms +} + +pub(super) fn directed(channel_type: &str, actor_hex: &str, tags: &[Vec]) -> bool { + channel_type == "dm" + || tags.iter().any(|tag| { + tag.len() >= 2 + && ((tag[0] == "p" && tag[1].eq_ignore_ascii_case(actor_hex)) + || (tag[0] == "broadcast" && tag[1] == "1")) + }) +} diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs index d48e85c3c5b..6ca1a41164f 100644 --- a/crates/buzz-db/src/store/personal_read/context.rs +++ b/crates/buzz-db/src/store/personal_read/context.rs @@ -1,5 +1,5 @@ //! Explicit selectors over the same private frontier authority. No history API. -use super::{model::*, projection::read_account, writes}; +use super::{classification, model::*, participation, projection::read_account, writes}; use crate::{observability, Db, DbError, Result}; use buzz_core::CommunityId; use chrono::{DateTime, Utc}; @@ -85,6 +85,7 @@ impl Db { .into_iter() .map(|row| Ok((row.try_get::("id")?, row))) .collect::>()?; + let mut participation = None; let mut messages = Vec::with_capacity(ids.len()); for id in &query.message_ids { let state = if let Some(row) = by_id.get(&id.to_ascii_lowercase()) { @@ -112,27 +113,40 @@ impl Db { let created: DateTime = row.try_get("created_at")?; let received: DateTime = row.try_get("received_at")?; let kind: i32 = row.try_get("kind")?; - if row.try_get::("own")? - || row.try_get::("deleted")? - || !ELIGIBLE_KINDS.contains(&kind) - || received.timestamp_millis() < account.cutoff_ms - { + if !classification::eligible( + kind, + row.try_get("own")?, + row.try_get("deleted")?, + received.timestamp_millis(), + account.cutoff_ms, + ) { MessageReadState::NotCounted } else if prefix.is_some_and(|p| created.timestamp() <= p) { MessageReadState::Read } else { - let directed = row.try_get::("channel_type")? == "dm" - || tags.iter().any(|t| { - t.len() >= 2 - && ((t[0] == "p" - && t[1].eq_ignore_ascii_case(&actor.to_hex())) - || (t[0] == "broadcast" && t[1] == "1")) - }); + let directed = classification::directed( + &row.try_get::("channel_type")?, + &actor.to_hex(), + &tags, + ); + if !directed && is_reply && participation.is_none() { + participation = Some( + participation::resolve( + &mut tx, + community, + &actor_bytes, + &[(query.target.channel_id, root.clone())], + ) + .await? + .remove(&(query.target.channel_id, root.clone())) + .flatten(), + ); + } MessageReadState::Unread { attention: if directed { Some(true) } else if is_reply { - None + participation.flatten() } else { Some(false) }, diff --git a/crates/buzz-db/src/store/personal_read/mod.rs b/crates/buzz-db/src/store/personal_read/mod.rs index ed951e978f7..a965cd56d6b 100644 --- a/crates/buzz-db/src/store/personal_read/mod.rs +++ b/crates/buzz-db/src/store/personal_read/mod.rs @@ -3,8 +3,10 @@ //! Timestamp prefixes use author time; retention uses persisted relay receipt //! time. Only fixed context intents advance prefixes, never a query scan cap. +mod classification; mod context; mod model; +mod participation; mod projection; mod writes; @@ -12,3 +14,9 @@ pub use model::*; #[cfg(test)] mod postgres_tests; + +#[cfg(test)] +mod participation_postgres_tests; + +#[cfg(test)] +mod projection_postgres_tests; diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index f0cdfb3055c..2498bbaec73 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -65,6 +65,8 @@ pub struct ReadAccount { pub const MAX_CHANNELS: usize = 20; /// Bounded event evidence per channel; exhaustion is never inferred at this cap. pub const MAX_CHANNEL_SCAN: usize = 256; +/// Receipt-window work budget per channel, before eligibility/ancestry joins. +pub const MAX_RECEIPT_SCAN: usize = 4096; /// Conversation kinds eligible for ordinary unread state (not edits/reactions). pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003]; @@ -77,6 +79,8 @@ pub enum ReadCount { /// Total within the tracking horizon. value: u32, }, + /// Incomplete evidence establishes no positive lower bound. No numeric value. + Unknown, /// More evidence exists or ancestry/participation could not be proved. AtLeast { /// Proven lower bound, not a fabricated badge cap. @@ -84,6 +88,18 @@ pub enum ReadCount { }, } +impl ReadCount { + pub(super) fn from_evidence(value: u32, complete: bool) -> Self { + if complete { + Self::Exact { value } + } else if value == 0 { + Self::Unknown + } else { + Self::AtLeast { value } + } + } +} + /// One joined-channel summary, not a second conversation/history API. #[derive(Debug, Serialize)] pub struct ChannelReadSummary { @@ -99,7 +115,10 @@ pub struct ChannelReadSummary { pub hidden: bool, /// Ordinary unread lower bound or exact count. pub unread: ReadCount, - /// Directed unread (DM, mention/broadcast, participating-thread reply). + /// Directed unread: DM, direct mention/broadcast, or a reply in a thread + /// with a live eligible message authored by this actor (including the root). + /// This is not Desktop notification eligibility: follows, mutes and prior + /// mentions elsewhere in a thread do not change this count. pub attention: ReadCount, /// Latest eligible nondeleted event ID, independent of read progress, author, /// and the unread-tracking horizon. diff --git a/crates/buzz-db/src/store/personal_read/participation.rs b/crates/buzz-db/src/store/personal_read/participation.rs new file mode 100644 index 00000000000..e8b6fb4661c --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/participation.rs @@ -0,0 +1,88 @@ +//! Bounded actor participation evidence from the existing conversation store. +use super::model::ELIGIBLE_KINDS; +use crate::Result; +use buzz_core::CommunityId; +use sqlx::{Acquire, PgConnection, Row}; +use std::collections::HashMap; +use uuid::Uuid; + +const MAX_THREAD_SCAN: i64 = 256; +// Bound multiplicative work independently of the receipt evidence window. +const MAX_ROOTS: usize = 1024; + +/// Positive evidence survives truncation; absence requires exhausting the thread. +/// Participation is independent of unread retention and read frontiers. +pub(super) async fn resolve( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + targets: &[(Uuid, Vec)], +) -> Result), Option>> { + if targets.is_empty() { + return Ok(HashMap::new()); + } + let mut targets = targets.to_vec(); + targets.sort_unstable(); + targets.dedup(); + targets.truncate(MAX_ROOTS); + let channels: Vec<_> = targets.iter().map(|(channel, _)| *channel).collect(); + let roots: Vec<_> = targets.iter().map(|(_, root)| root.clone()).collect(); + // Optional inference must not abort authoritative unread/frontier reads. + // A nested transaction is a savepoint; rollback also restores the caller's + // statement timeout. No state is written in this read-only inference. + let mut budget = conn.begin().await?; + sqlx::query("SET LOCAL statement_timeout = '500ms'") + .execute(&mut *budget) + .await?; + sqlx::query("SET LOCAL jit = off") + .execute(&mut *budget) + .await?; + let result = sqlx::query( + "SELECT t.channel_id,t.root_id, + CASE WHEN COALESCE(root.participated,false) OR COALESCE(replies.participated,false) + THEN true WHEN replies.candidates <= $5 THEN false ELSE NULL END AS participated + FROM unnest($3::uuid[],$4::bytea[]) t(channel_id,root_id) + LEFT JOIN LATERAL ( + SELECT e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($6) AS participated + FROM events e WHERE e.community_id=$1 AND e.channel_id=t.channel_id AND e.id=t.root_id + ORDER BY e.created_at DESC LIMIT 1 + ) root ON true + LEFT JOIN LATERAL ( + WITH candidates AS MATERIALIZED ( + SELECT event_created_at,event_id FROM thread_metadata + WHERE community_id=$1 AND root_event_id=t.root_id + ORDER BY event_created_at DESC,event_id LIMIT $5+1 + ) + SELECT count(*) AS candidates, + bool_or(e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($6)) AS participated + FROM candidates tm LEFT JOIN events e ON e.community_id=$1 + AND e.channel_id=t.channel_id AND e.created_at=tm.event_created_at AND e.id=tm.event_id + ) replies ON true", + ) + .bind(community.as_uuid()) + .bind(actor) + .bind(channels) + .bind(roots) + .bind(MAX_THREAD_SCAN) + .bind(ELIGIBLE_KINDS.as_slice()) + .fetch_all(&mut *budget) + .await; + budget.rollback().await?; + let rows = match result { + Ok(rows) => rows, + Err(sqlx::Error::Database(error)) + if matches!(error.code().as_deref(), Some("57014" | "55P03")) => + { + return Ok(HashMap::new()); + } + Err(error) => return Err(error.into()), + }; + rows.into_iter() + .map(|row| { + Ok(( + (row.try_get("channel_id")?, row.try_get("root_id")?), + row.try_get("participated")?, + )) + }) + .collect() +} diff --git a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs new file mode 100644 index 00000000000..28f6b5f2957 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs @@ -0,0 +1,328 @@ +use super::*; +use crate::{ + channel::{ChannelType, ChannelVisibility}, + Db, +}; +use buzz_core::CommunityId; +use nostr::{EventBuilder, Keys, Kind, Timestamp}; +use sqlx::PgPool; +use uuid::Uuid; + +async fn event( + db: &Db, + community: CommunityId, + channel: Uuid, + author: &Keys, + root: Option<&nostr::Event>, + kind: u16, + time: u64, +) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .custom_created_at(Timestamp::from(time)) + .sign_with_keys(author) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + if let Some(root) = root { + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()).bind(time as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(&db.pool).await.unwrap(); + } + event +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_attention_proves_participation_without_retention_or_frontier_inheritance() { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("attention-{}.local", Uuid::new_v4())) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let other = Keys::generate(); + let now = Timestamp::now().as_secs(); + // Root author, old participant, absent, deleted participant, auxiliary author, + // overflowing thread without positive evidence, and overflowing own-root thread. + for case in 0..8 { + let channel = db + .create_channel( + community, + &format!("case-{case}"), + ChannelType::Stream, + ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let root_author = if case == 0 || case == 6 { + &actor + } else { + &other + }; + let root = event( + &db, + community, + channel, + root_author, + None, + 9, + now - 40 * 86400, + ) + .await; + if matches!(case, 1 | 3 | 4 | 7) { + let own = event( + &db, + community, + channel, + &actor, + Some(&root), + if case == 4 { 7 } else { 9 }, + now - 39 * 86400, + ) + .await; + if case == 3 { + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(own.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + } + } + // Root and own reply receipts are old. Participation must not expire with unread. + sqlx::query("UPDATE events SET received_at=now()-interval '35 days' WHERE community_id=$1 AND channel_id=$2") + .bind(community.as_uuid()).bind(channel).execute(&pool).await.unwrap(); + let count = if case >= 5 { 258 } else { 1 }; + let mut last = root.clone(); + for i in 0..count { + last = event(&db, community, channel, &other, Some(&root), 9, now + i).await; + } + let sidebar = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let summary = sidebar + .channels + .iter() + .find(|s| s.channel_id == channel) + .unwrap(); + assert!(matches!(summary.unread, ReadCount::Exact { value } if value == count as u32)); + let expected = match case { + 0 | 1 | 6 => Some(true), + 5 | 7 => None, + _ => Some(false), + }; + match expected { + Some(true) => assert!( + matches!(summary.attention, ReadCount::Exact { value } if value == count as u32), + "case {case}: {:?}", + summary.attention + ), + Some(false) => assert!( + matches!(summary.attention, ReadCount::Exact { value: 0 }), + "case {case}: {:?}", + summary.attention + ), + None => assert!( + matches!(summary.attention, ReadCount::Unknown), + "case {case}: {:?}", + summary.attention + ), + } + let context = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_ids: vec![last.id.to_hex()], + }], + ) + .await + .unwrap(); + let wire = serde_json::to_value(context).unwrap(); + assert_eq!( + wire["contexts"][0]["messages"][0]["attention"], + serde_json::json!(expected), + "case {case}" + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_attention_root_budget_does_not_fabricate_absence() { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("root-budget-{}.local", Uuid::new_v4())) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let other = Keys::generate(); + let channel = db + .create_channel( + community, + "many roots", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let now = Timestamp::now().as_secs(); + for i in 0..1025 { + let root = event(&db, community, channel, &other, None, 9, now).await; + event(&db, community, channel, &other, Some(&root), 9, now + 1).await; + if i == 1023 { + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].attention, + ReadCount::Exact { value: 0 } + )); + } + } + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 2050 } + )); + assert!(matches!(page.channels[0].attention, ReadCount::Unknown)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_attention_timeout_preserves_sidebar_and_context_authority() { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("attention-timeout-{}.local", Uuid::new_v4())) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let other = Keys::generate(); + let channel = db + .create_channel( + community, + "timeout", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let now = Timestamp::now().as_secs(); + let root = event(&db, community, channel, &other, None, 9, now).await; + let reply = event(&db, community, channel, &other, Some(&root), 9, now + 1).await; + let mut held = pool.begin().await.unwrap(); + // Force the real resolver to time out, then check that its outer snapshot + // and original statement budget remain usable. + sqlx::query("LOCK TABLE thread_metadata IN ACCESS EXCLUSIVE MODE") + .execute(&mut *held) + .await + .unwrap(); + let mut reader = pool.begin().await.unwrap(); + sqlx::query("SET LOCAL statement_timeout='2000ms'") + .execute(&mut *reader) + .await + .unwrap(); + for lock_timeout in ["0", "10ms"] { + sqlx::query("SELECT set_config('lock_timeout',$1,true)") + .bind(lock_timeout) + .execute(&mut *reader) + .await + .unwrap(); + sqlx::query("SET LOCAL jit=on") + .execute(&mut *reader) + .await + .unwrap(); + let result = participation::resolve( + &mut reader, + community, + &actor.public_key().to_bytes(), + &[(channel, root.id.as_bytes().to_vec())], + ) + .await + .unwrap(); + assert!(result.is_empty(), "timeout provides no negative evidence"); + let setting: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *reader) + .await + .unwrap(); + assert_eq!(setting, "2s"); + let jit: String = sqlx::query_scalar("SHOW jit") + .fetch_one(&mut *reader) + .await + .unwrap(); + assert_eq!(jit, "on", "optional inference restores caller settings"); + } + reader.rollback().await.unwrap(); + held.rollback().await.unwrap(); + let contexts = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_ids: vec![reply.id.to_hex()], + }], + ) + .await + .unwrap(); + let wire = serde_json::to_value(contexts).unwrap(); + assert_eq!(wire["contexts"][0]["messages"][0]["attention"], false); +} diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index 292c99cf851..244b51001b2 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -8,7 +8,7 @@ use nostr::{EventBuilder, Keys, Kind}; use sqlx::PgPool; use uuid::Uuid; -async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Event) { +pub(super) async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Event) { let pool = PgPool::connect(&crate::test_support::database_url()) .await .unwrap(); @@ -41,11 +41,11 @@ async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Event) { (db, pool, community, channel, actor, event) } -async fn add_history(db: &Db, community: CommunityId, channel: Uuid) -> nostr::Event { +async fn add_history(db: &Db, community: CommunityId, channel: Uuid, count: usize) -> nostr::Event { let author = Keys::generate(); let mut last = None; let base = nostr::Timestamp::now().as_secs(); - for i in 0..300 { + for i in 0..count as u64 { let event = EventBuilder::new(Kind::Custom(9), format!("history {i}")) .custom_created_at(nostr::Timestamp::from(base + i)) .sign_with_keys(&author) @@ -62,7 +62,7 @@ async fn add_history(db: &Db, community: CommunityId, channel: Uuid) -> nostr::E #[ignore = "requires Postgres"] async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads_are_unknown() { let (db, _pool, community, channel, actor, _) = fixture().await; - let last = add_history(&db, community, channel).await; + let last = add_history(&db, community, channel, MAX_RECEIPT_SCAN + 44).await; db.apply_personal_read_intent( community, &actor.public_key(), @@ -86,10 +86,16 @@ async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads ) .await .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::AtLeast { value: 0 } - )); + assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); + let wire = serde_json::to_value(&page).unwrap(); + assert_eq!( + wire["channels"][0]["unread"], + serde_json::json!({"status":"unknown"}) + ); + assert_eq!( + wire["channels"][0]["attention"], + serde_json::json!({"status":"unknown"}) + ); assert_eq!( page.channels[0].latest_message_id.as_deref(), Some(last.id.to_hex().as_str()) @@ -99,9 +105,9 @@ async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_sidebar_expired_unopened_tail_remains_unproved() { +async fn personal_read_sidebar_receipt_window_excludes_expired_but_keeps_fresh_backfill() { let (db, pool, community, channel, actor, _) = fixture().await; - let latest = add_history(&db, community, channel).await; + let latest = add_history(&db, community, channel, MAX_RECEIPT_SCAN + 44).await; sqlx::query( "UPDATE events SET received_at=clock_timestamp()-interval '31 days' WHERE community_id=$1", ) @@ -120,16 +126,16 @@ async fn personal_read_sidebar_expired_unopened_tail_remains_unproved() { .await .unwrap(); assert!( - matches!(page.channels[0].unread, ReadCount::AtLeast { value: 0 }), - "receipt expiry of the scanned window cannot prove expiry of the author-ordered tail" + matches!(page.channels[0].unread, ReadCount::Exact { value: 0 }), + "receipt-ordered window proves exhaustion independent of author time" ); assert_eq!( page.channels[0].latest_message_id.as_deref(), Some(latest.id.to_hex().as_str()) ); assert!(page.channels[0].latest_message_complete); - // A fresh receipt with old author time belongs to retention even if it is - // invisible below this bounded window. Never certify that channel as read. + // A fresh receipt with old author time belongs to retention even below + // the author-ordered history. The receipt window must find it. let backfill = EventBuilder::new(Kind::Custom(9), "recently imported old message") .custom_created_at(nostr::Timestamp::from( nostr::Timestamp::now().as_secs() - 40 * 86400, @@ -151,8 +157,91 @@ async fn personal_read_sidebar_expired_unopened_tail_remains_unproved() { .unwrap(); assert!(matches!( page.channels[0].unread, - ReadCount::AtLeast { value: 0 } + ReadCount::Exact { value: 1 } + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_sidebar_receipt_budget_counts_boundary_and_ineligible_tail() { + let (db, pool, community, channel, actor, _) = fixture().await; + // The fixture contributes one event, so this is exactly the evidence budget. + add_history(&db, community, channel, MAX_RECEIPT_SCAN - 1).await; + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value } if value == MAX_RECEIPT_SCAN as u32) + ); + let overflow = EventBuilder::new(Kind::Custom(9), "one beyond the budget") + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &overflow, Some(channel)) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::AtLeast { value } if value == MAX_RECEIPT_SCAN as u32) + ); + // Expire all but 601 receipts. Of these, 300 are own and 300 deleted. + // Eligibility is downstream of the bounded receipt window, not the old 256 cap. + sqlx::query("WITH ranked AS (SELECT created_at,id,row_number() OVER (ORDER BY received_at DESC,id) AS n FROM events WHERE community_id=$1 AND channel_id=$2) + UPDATE events e SET received_at=CASE WHEN r.n>601 THEN now()-interval '31 days' ELSE e.received_at END, + pubkey=CASE WHEN r.n<=300 THEN $3 ELSE e.pubkey END, + deleted_at=CASE WHEN r.n>300 AND r.n<=600 THEN now() ELSE NULL END + FROM ranked r WHERE e.community_id=$1 AND e.created_at=r.created_at AND e.id=r.id") + .bind(community.as_uuid()).bind(channel).bind(actor.public_key().to_bytes().as_slice()).execute(&pool).await.unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 1 } )); + // Filtering ineligible evidence must not erase the raw-window overflow. + sqlx::query( + "UPDATE events SET received_at=now(),pubkey=$2 WHERE community_id=$1 AND channel_id=$3", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .bind(channel) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); } #[tokio::test] @@ -384,7 +473,7 @@ async fn personal_read_latest_old_message_is_not_an_empty_channel() { .execute(&pool) .await .unwrap(); - add_history(&db, community, channel).await; + add_history(&db, community, channel, MAX_RECEIPT_SCAN + 44).await; sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1") .bind(community.as_uuid()) .execute(&pool) @@ -633,7 +722,7 @@ async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers( assert_eq!(page["contexts"][0]["messages"][2]["status"], "unavailable"); assert_eq!(page["contexts"][1]["messages"][0]["status"], "unavailable"); assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); - assert!(page["contexts"][1]["messages"][1]["attention"].is_null()); + assert_eq!(page["contexts"][1]["messages"][1]["attention"], false); let accounts: i64 = sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") .bind(community.as_uuid()) @@ -820,3 +909,98 @@ async fn personal_read_context_receipt_horizon_unknown_ancestry_and_deletion() { assert_eq!(page["contexts"][0]["messages"][0]["status"], "not_counted"); assert_eq!(page["contexts"][0]["messages"][2]["status"], "not_counted"); } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_covered_corruption_requires_canonical_matching_frontier() { + let (db, pool, community, channel, actor, root) = fixture().await; + let reply = EventBuilder::new(Kind::Custom(9), "canonical reply") + .custom_created_at(nostr::Timestamp::from(root.created_at.as_secs() - 1)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reply, Some(channel)) + .await + .unwrap(); + for (event, depth) in [(&root, 0), (&reply, 1)] { + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,$6)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) + .bind(event.created_at.as_secs() as f64).bind(channel) + .bind(root.id.as_bytes().as_slice()).bind(depth).execute(&pool).await.unwrap(); + } + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: root.id.to_hex(), + }, + ) + .await + .unwrap(); + // Corrupt both stored payloads. Only canonical evidence plus its matching + // frontier can make their tags irrelevant, never the channel prefix alone. + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(serde_json::json!([["p", 42]])) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); + sqlx::query("INSERT INTO personal_read_frontiers (community_id,actor,channel_id,root_id,through_timestamp) + VALUES ($1,$2,$3,$4,$5)") + .bind(community.as_uuid()).bind(actor.public_key().to_bytes().as_slice()).bind(channel) + .bind(root.id.as_bytes().as_slice()).bind(root.created_at.as_secs() as i64) + .execute(&pool).await.unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 0 } + )); + assert!(matches!( + page.channels[0].attention, + ReadCount::Exact { value: 0 } + )); + // Removing canonical metadata must not let a channel frontier hide unknown + // ancestry/corruption, even though both timestamp frontiers cover the row. + sqlx::query("DELETE FROM thread_metadata WHERE community_id=$1 AND event_id=$2") + .bind(community.as_uuid()) + .bind(reply.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); + assert!(matches!(page.channels[0].attention, ReadCount::Unknown)); +} diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index d3ea74408b8..1d6cac72d7c 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -1,11 +1,12 @@ //! Bounded evidence projection. The cap limits evidence, not the definition of -//! unread: an unexamined tail yields a lower bound, including lower-bound zero. +//! unread: an unexamined tail yields a positive lower bound or an unknown count. -use super::{model::*, writes}; +use super::{model::*, participation, writes}; use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use serde_json::Value; use sqlx::{Acquire, PgConnection, Row}; +use std::collections::HashMap; use uuid::Uuid; use crate::{observability, Db, DbError, Result}; @@ -36,14 +37,22 @@ impl Db { .execute(&mut *tx) .await?; writes::deadlines(&mut tx).await?; + sqlx::query("SET LOCAL jit = off").execute(&mut *tx).await?; let actor_bytes = actor.to_bytes(); let account = read_account(&mut tx, community, &actor_bytes, retention_seconds).await?; // The inner event LIMIT is deliberately before eligibility filtering. // This bounds rows/joins even with long deleted or self-authored runs. + // Aggregate equivalent eligible evidence before transfer. Multiplicity + // preserves counts; raw scan count (not group count) proves exhaustion. + // SQL eligibility mirrors classification::eligible (PostgreSQL parity test). + // Canonical covered rows need no tags; missing ancestry stays unknown. + // Validate relevant tag parts before compacting directed/ancestry facts. + // PostgreSQL scalar "p" ->> 0 is "p": the type check must reject it too. + // Canonical timeline roots share an empty (present) root sentinel. // Tags are bounded before transfer; oversized/corrupt evidence stays // unknown, never falsely top-level/unmentioned/read. let rows = sqlx::query( - "WITH roster AS MATERIALIZED ( + r#"WITH roster AS MATERIALIZED ( SELECT c.id,c.name,c.channel_type::text AS channel_type, c.archived_at IS NOT NULL AS archived, cm.hidden_at IS NOT NULL AS hidden FROM channel_members cm JOIN channels c @@ -54,7 +63,7 @@ impl Db { ) SELECT r.*, latest.latest_message_id, (latest.latest_message_id IS NOT NULL OR latest.candidates <= $5-1) AS latest_message_complete, - COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r + e.scanned,COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r LEFT JOIN LATERAL ( WITH candidates AS MATERIALIZED ( SELECT id,created_at,kind,deleted_at FROM events @@ -66,141 +75,151 @@ impl Db { FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id FROM candidates ) latest ON true + LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2 + AND cf.channel_id=r.id AND cf.root_id=''::bytea LEFT JOIN LATERAL ( - SELECT jsonb_agg(jsonb_build_object( - 'id',encode(e.id,'hex'), 'created_at',extract(epoch FROM e.created_at)::bigint, - 'received_ms',floor(extract(epoch FROM e.received_at)*1000)::bigint, - 'own',e.pubkey=$2, 'deleted',e.deleted_at IS NOT NULL, - 'kind',e.kind, - 'tags',CASE WHEN octet_length(e.tags::text)<=8192 - AND jsonb_typeof(e.tags)='array' THEN - (SELECT COALESCE(jsonb_agg(tag ORDER BY ord),'[]'::jsonb) - FROM jsonb_array_elements(e.tags) WITH ORDINALITY t(tag,ord) - WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END, - 'root',encode(tm.root_event_id,'hex'), - 'channel_prefix',cf.through_timestamp,'thread_prefix',tf.through_timestamp - ) ORDER BY e.created_at DESC,e.id) AS evidence - FROM (SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags - FROM events WHERE community_id=$1 AND channel_id=r.id - ORDER BY created_at DESC,id LIMIT $5) e - LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id - AND tm.event_created_at=e.created_at AND tm.event_id=e.id - LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2 - AND cf.channel_id=r.id AND cf.root_id=''::bytea - LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 - AND tf.channel_id=r.id AND tf.root_id=tm.root_event_id - ) e ON true ORDER BY r.id", + WITH candidates AS MATERIALIZED ( + SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags + FROM events WHERE community_id=$1 AND channel_id=r.id AND received_at >= $7 + ORDER BY received_at DESC,id,created_at LIMIT $8 + ), classified AS ( + SELECT e.*, tm.root_event_id AS root, + COALESCE(tm.root_event_id<>e.id,false) AS is_reply, + COALESCE(extract(epoch FROM e.created_at)::bigint <= + CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id + THEN tf.through_timestamp ELSE cf.through_timestamp END,false) AS covered + FROM (SELECT * FROM candidates ORDER BY received_at DESC,id,created_at LIMIT $8-1) e + LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id + AND tm.event_created_at=e.created_at AND tm.event_id=e.id + LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 + AND tf.channel_id=r.id AND tf.root_id=tm.root_event_id AND tm.root_event_id<>e.id + WHERE e.kind=ANY($6) AND e.pubkey<>$2 AND e.deleted_at IS NULL + ), grouped AS ( + SELECT CASE WHEN root IS NULL THEN NULL + WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root, + is_reply, covered, + -- ->>0 also selects scalar "p"/"e": reject nonarrays first. + -- C collation matches Rust's ASCII case/hex rules. Reply + -- markers matter only without canonical ancestry; otherwise + -- metadata, not tag spelling, owns the context. + CASE WHEN octet_length(tags::text)<=8192 + AND jsonb_typeof(tags)='array' THEN + (SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array' + OR jsonb_path_exists(tag,'strict $[*] ? (@.type() != "string")')) + THEN NULL ELSE jsonb_build_object( + 'directed',COALESCE(bool_or( + (tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex')) + OR (tag->>0='broadcast' AND tag->>1='1')),false), + 'reply_marked',root IS NULL AND COALESCE(bool_or(tag->>0='e' + AND tag->>3='reply' + AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'),false)) END + FROM jsonb_array_elements(tags) t(tag) + WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts, + count(*) AS n + FROM classified + WHERE NOT covered OR root IS NULL + GROUP BY 1,2,3,4 + ) + SELECT (SELECT count(*) FROM candidates) AS scanned, + jsonb_agg(to_jsonb(grouped)) AS evidence FROM grouped + ) e ON true ORDER BY r.id"#, ).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after) .bind((limit+1) as i64).bind((MAX_CHANNEL_SCAN+1) as i64) .bind(ELIGIBLE_KINDS.as_slice()) + .bind(DateTime::from_timestamp_millis(account.cutoff_ms) + .ok_or_else(|| DbError::InvalidData("invalid receipt cutoff".into()))?) + .bind((MAX_RECEIPT_SCAN+1) as i64) .fetch_all(&mut *tx).await?; let has_more = rows.len() > limit; let mut channels = Vec::new(); + let mut pending = Vec::new(); for row in rows.into_iter().take(limit) { let evidence: Value = row.try_get("evidence")?; let evidence = evidence .as_array() .ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?; - let complete = evidence.len() <= MAX_CHANNEL_SCAN; + let complete = row.try_get::("scanned")? <= MAX_RECEIPT_SCAN as i64; let channel_type: String = row.try_get("channel_type")?; let mut unread = 0; let mut attention = 0; let mut unread_complete = complete; let mut attention_complete = complete; - for e in evidence.iter().take(MAX_CHANNEL_SCAN) { - let kind = e["kind"].as_i64().unwrap_or(-1) as i32; - if !ELIGIBLE_KINDS.contains(&kind) - || e["deleted"] == true - || e["received_ms"] - .as_i64() - .is_none_or(|t| t < account.cutoff_ms) - { - continue; - } - if e["own"] == true { - continue; - } - let Some(created) = e["created_at"].as_i64() else { - unread_complete = false; - attention_complete = false; - continue; - }; - let Some(tags) = e["tags"].as_array() else { + let mut replies: HashMap, u32> = HashMap::new(); + for e in evidence { + let n = e["n"] + .as_u64() + .filter(|n| *n <= MAX_RECEIPT_SCAN as u64) + .ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))? + as u32; + let Some(facts) = e["facts"].as_object() else { unread_complete = false; attention_complete = false; continue; }; - // Missing metadata for a marked reply could hide a thread - // frontier. Until resolved, it cannot establish ordinary unread. - let parsed: Option>> = tags - .iter() - .map(|tag| { - tag.as_array()? - .iter() - .map(|p| p.as_str().map(str::to_owned)) - .collect() - }) - .collect(); - let Some(parsed) = parsed else { - unread_complete = false; - attention_complete = false; - continue; - }; - let markers = buzz_core::nip10::parse_thread_markers_from_parts( - parsed.iter().map(Vec::as_slice), - ); - if markers.resolve().is_some() && e["root"].is_null() { + // SQL's bounded ancestry fact is parity-tested against the shared + // NIP-10 parser; no raw tag payload crosses the DB boundary. + if facts.get("reply_marked") == Some(&Value::Bool(true)) && e["root"].is_null() { unread_complete = false; attention_complete = false; continue; } // Roots are timeline messages; descendants belong exclusively // to their canonical thread. Never inherit the channel prefix. - let is_reply = e["root"] - .as_str() - .is_some_and(|root| Some(root) != e["id"].as_str()); - let prefix = if is_reply { - &e["thread_prefix"] - } else { - &e["channel_prefix"] - }; - if prefix.as_i64().is_some_and(|p| created <= p) { + let is_reply = e["is_reply"] == true; + if e["covered"] == true { continue; } - unread += 1; - let directed = channel_type == "dm" - || parsed.iter().any(|tag| { - tag.len() >= 2 - && ((tag[0] == "p" && tag[1].eq_ignore_ascii_case(&actor.to_hex())) - || (tag[0] == "broadcast" && tag[1] == "1")) - }); + unread += n; + let directed = + channel_type == "dm" || facts.get("directed") == Some(&Value::Bool(true)); if directed { - attention += 1; + attention += n; } else if is_reply { - // Participation is not inferable from the bounded window: - // the author's own earlier reply may sit outside it. - attention_complete = false; + if let Some(root) = e["root"].as_str().and_then(writes::event_id) { + *replies.entry(root).or_default() += n; + } else { + attention_complete = false; + } } } - let count = |value, complete| { - if complete { - ReadCount::Exact { value } - } else { - ReadCount::AtLeast { value } - } - }; + pending.push((replies, attention, attention_complete)); channels.push(ChannelReadSummary { channel_id: row.try_get("id")?, name: row.try_get("name")?, channel_type, archived: row.try_get("archived")?, hidden: row.try_get("hidden")?, - unread: count(unread, unread_complete), - attention: count(attention, attention_complete), + unread: ReadCount::from_evidence(unread, unread_complete), + attention: ReadCount::from_evidence(attention, attention_complete), latest_message_id: row.try_get("latest_message_id")?, latest_message_complete: row.try_get("latest_message_complete")?, }); } + let targets: Vec<_> = channels + .iter() + .zip(&pending) + .flat_map(|(channel, (replies, _, _))| { + replies + .keys() + .map(|root| (channel.channel_id, root.clone())) + }) + .collect(); + let participation = + participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; + for (channel, (replies, mut attention, mut complete)) in channels.iter_mut().zip(pending) { + for (root, count) in replies { + match participation + .get(&(channel.channel_id, root)) + .copied() + .flatten() + { + Some(true) => attention += count, + Some(false) => {} + None => complete = false, + } + } + channel.attention = ReadCount::from_evidence(attention, complete); + } let next_cursor = if has_more { channels.last().map(|c| c.channel_id) } else { diff --git a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs new file mode 100644 index 00000000000..378a0e7d4f3 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs @@ -0,0 +1,226 @@ +use super::{classification, postgres_tests::fixture, *}; +use serde_json::json; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_sql_eligibility_matches_selector_classifier() { + let (db, pool, community, _, actor, event) = fixture().await; + let now = chrono::Utc::now().timestamp_millis(); + for kind in [9, 40002, 45001, 45003, 1, 7, 39002] { + for own in [false, true] { + for deleted in [false, true] { + for expired in [false, true] { + let received = now - if expired { 31 * 86_400_000 } else { 0 }; + sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,received_at=to_timestamp($5::double precision/1000) WHERE community_id=$1") + .bind(community.as_uuid()).bind(kind) + .bind(if own { actor.public_key().to_bytes() } else { event.pubkey.to_bytes() }.as_slice()) + .bind(deleted).bind(received as f64).execute(&pool).await.unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let expected = u32::from(classification::eligible( + kind, + own, + deleted, + received, + page.account.cutoff_ms, + )); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value } if value == expected), + "kind={kind} own={own} deleted={deleted} expired={expired}" + ); + } + } + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() { + let (db, pool, community, _, actor, _) = fixture().await; + let actor_hex = actor.public_key().to_hex().to_uppercase(); + for (tags, unread, attention) in [ + (json!([]), Some(1), Some(0)), + (json!(["p"]), None, None), + (json!(["e"]), None, None), + (json!(["broadcast"]), None, None), + ( + json!([["p", actor_hex, "relay", "petname"]]), + Some(1), + Some(1), + ), + (json!([["p", "00".repeat(32)]]), Some(1), Some(0)), + (json!([["broadcast", "1", "extra"]]), Some(1), Some(1)), + (json!([["broadcast", "0"]]), Some(1), Some(0)), + (json!([["p", "00".repeat(32), 42]]), None, None), + (json!([["broadcast", "0", 42]]), None, None), + (json!([["e", "00".repeat(32), "", "root", 42]]), None, None), + (json!([["p", actor_hex], ["p", "other", 42]]), None, None), + (json!([["e", "00".repeat(32), "", "reply"]]), None, None), + (json!([["x", 42]]), Some(1), Some(0)), + (json!({"p":actor_hex}), None, None), + (json!([["p", "x".repeat(8193)]]), None, None), + ] { + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(&tags) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + for (actual, expected) in [ + (&page.channels[0].unread, unread), + (&page.channels[0].attention, attention), + ] { + assert!( + match (actual, expected) { + (ReadCount::Exact { value }, Some(n)) => *value == n, + (ReadCount::Unknown, None) => true, + _ => false, + }, + "tags={tags} actual={actual:?} expected={expected:?}" + ); + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_ancestry_fact_matches_shared_nip10_parser() { + let (db, pool, community, _, actor, _) = fixture().await; + let ids = [ + "a".repeat(64), + "A".repeat(64), + "aB09".repeat(16), + "a".repeat(63), + "a".repeat(65), + "g".repeat(64), + "١".repeat(64), + "A".repeat(64), + format!("{}\n", "a".repeat(64)), + ]; + let mut cases: Vec>> = Vec::new(); + for id in ids { + for marker in ["reply", "Reply", "root", "mention"] { + cases.push(vec![vec!["e".into(), id.clone(), "".into(), marker.into()]]); + } + } + cases.push(vec![vec!["e".into(), "a".repeat(64), "reply".into()]]); + cases.push(vec![ + vec!["e".into(), "g".repeat(64), "".into(), "reply".into()], + vec!["e".into(), "a".repeat(64), "".into(), "reply".into()], + ]); + cases.push(vec![ + vec!["e".into(), "a".repeat(64), "".into(), "reply".into()], + vec!["e".into(), "g".repeat(64), "".into(), "reply".into()], + ]); + for tags in cases { + let reply = + buzz_core::nip10::parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice)) + .resolve() + .is_some(); + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(json!(tags)) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + if reply { + matches!(page.channels[0].unread, ReadCount::Unknown) + } else { + matches!(page.channels[0].unread, ReadCount::Exact { value: 1 }) + }, + "tags={tags:?}" + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_directed_fact_matches_selector_classifier() { + let (db, pool, community, channel, actor, _) = fixture().await; + let actor_hex = actor.public_key().to_hex(); + let fullwidth: String = actor_hex + .chars() + .map(|c| if c.is_ascii_alphabetic() { 'A' } else { c }) + .collect(); + assert_ne!(fullwidth, actor_hex); + let cases: Vec>> = serde_json::from_value(json!([ + [], + [["p", actor_hex]], + [["p", actor_hex.to_uppercase()]], + [["p", fullwidth]], + [["p"]], + [["broadcast", "1"]], + [["broadcast", "true"]], + [["p", "00".repeat(32)]], + [["broadcast", "0"], ["p", actor_hex.to_uppercase()]] + ])) + .unwrap(); + for channel_type in ["stream", "dm"] { + sqlx::query( + "UPDATE channels SET channel_type=$3::channel_type WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(channel_type) + .execute(&pool) + .await + .unwrap(); + for tags in &cases { + let expected = u32::from(classification::directed(channel_type, &actor_hex, tags)); + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(json!(tags)) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 1 } + )); + assert!( + matches!(page.channels[0].attention, ReadCount::Exact { value } if value == expected), + "channel_type={channel_type} tags={tags:?} expected={expected}" + ); + } + } +} diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index db2e287a606..23373b7d9d2 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -88,6 +88,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] +# Generate ephemeral P-256 signing fixtures; already in the runtime dependency graph. +ring = "0.17" # The mesh-llm smoke harnesses moved to `crates/buzz-mesh-smoke`. A # dev-dependency feeds every target of its package, so keeping the mesh-llm # SDK here forced the entire mesh-llm/skippy/rmcp tree to compile before any diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a1c30f101bc..b27cbc3a735 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -5033,18 +5033,39 @@ pub(crate) mod postgres_tests { Some(Arc::new(state)) } - // EC P-256 test key constants shared by positive-control and cardinality tests. - // Private key (PKCS#8 PEM) + public key coordinates (JWK x/y/kid). - // Used by `nip_fi_enforce_test_state_with_verifier()` and - // `signed_assertion_for_pubkey()`. const HANDLER_TEST_ISSUER: &str = "https://issuer.example"; const HANDLER_TEST_AUDIENCE: &str = "https://relay.example"; const HANDLER_TEST_KID: &str = "test-key-1"; - const HANDLER_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; + + /// Ephemeral signing material shared by the test signer and verifier. + /// Never persist a private key fixture in source or on disk. + fn handler_test_key() -> &'static (jsonwebtoken::EncodingKey, jsonwebtoken::jwk::JwkSet) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING}; + static KEY: std::sync::OnceLock<(jsonwebtoken::EncodingKey, jsonwebtoken::jwk::JwkSet)> = + std::sync::OnceLock::new(); + KEY.get_or_init(|| { + let rng = ring::rand::SystemRandom::new(); + let algorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; + let der = EcdsaKeyPair::generate_pkcs8(algorithm, &rng).expect("generate test key"); + let pair = EcdsaKeyPair::from_pkcs8(algorithm, der.as_ref(), &rng) + .expect("parse generated test key"); + // P-256 public keys use uncompressed SEC1: 0x04 || x || y. + let public = pair.public_key().as_ref(); + assert_eq!(public.len(), 65); + assert_eq!(public[0], 4); + let jwks = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": HANDLER_TEST_KID, + "x": URL_SAFE_NO_PAD.encode(&public[1..33]), + "y": URL_SAFE_NO_PAD.encode(&public[33..65]) + }] + })) + .expect("valid generated JWKS"); + (jsonwebtoken::EncodingKey::from_ec_der(der.as_ref()), jwks) + }) + } /// Build a NIP-FI Enforce AppState with a real injected P-256 verifier. /// @@ -5056,19 +5077,11 @@ pub(crate) mod postgres_tests { AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, }; - use jsonwebtoken::{jwk::JwkSet, Algorithm}; + use jsonwebtoken::Algorithm; let mut state = (*nip_fi_enforce_test_state().await?).clone(); - let jwks: JwkSet = serde_json::from_value(serde_json::json!({ - "keys": [{ - "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", - "kid": HANDLER_TEST_KID, - "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", - "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" - }] - })) - .expect("valid test JWKS"); + let jwks = handler_test_key().1.clone(); let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); let key_set = AssertionKeySet::new_for_test(HANDLER_TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) @@ -5104,7 +5117,7 @@ pub(crate) mod postgres_tests { /// Mint a signed NIP-FI assertion whose `nostr_pubkey` = `pubkey_hex`, /// using the shared HANDLER_TEST_* key material. fn signed_assertion_for_pubkey(pubkey_hex: &str) -> String { - use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use jsonwebtoken::{Algorithm, Header}; let now = chrono::Utc::now().timestamp(); let claims = serde_json::json!({ "iss": HANDLER_TEST_ISSUER, @@ -5117,9 +5130,8 @@ pub(crate) mod postgres_tests { let mut header = Header::new(Algorithm::ES256); header.kid = Some(HANDLER_TEST_KID.to_owned()); header.typ = Some("nip-fi+jwt".to_owned()); - let key = - EncodingKey::from_ec_pem(HANDLER_TEST_EC_PEM.as_bytes()).expect("valid test EC PEM"); - jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + let key = &handler_test_key().0; + jsonwebtoken::encode(&header, &claims, key).expect("sign assertion") } /// Build a HeaderMap containing a valid NIP-98 Authorization header + @@ -6026,6 +6038,54 @@ pub(crate) mod postgres_tests { // Mirror of the GIF case through the moderation route. // Falsifying mutation: remove key-pairing check → admission passes → 403 from // moderation authz (not NIP-FI) with different JSON body. + // BFF admission must pair the asserted identity with the signed request. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_buzz_v1_sidebar_pairs_request_identity() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let mut s = (*state).clone(); + std::sync::Arc::make_mut(&mut s.config).buzz_v1_enabled = true; + let state = std::sync::Arc::new(s); + let host = format!("bffv1-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + let path = "/buzz/v1/me/sidebar?limit=1"; + let url = format!("https://{host}{path}"); + let key_nip98 = Keys::generate(); + let key_assertion = Keys::generate(); + let mut results = Vec::new(); + for (label, akey) in [("paired", &key_nip98), ("mismatched", &key_assertion)] { + let assertion = signed_assertion_for_pubkey(&akey.public_key().to_hex()); + let mut headers = make_nip98_headers(&key_nip98, &url, "GET", b""); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + let (status, _h, body) = rt.block_on(oneshot_request_full( + state.clone(), + "GET", + path, + &host, + headers, + b"", + )); + assert!(!body.is_empty(), "{label} response"); + results.push(status); + } + assert_eq!(results[0], axum::http::StatusCode::OK, "paired control"); + assert_eq!( + results[1], + axum::http::StatusCode::FORBIDDEN, + "mismatched assertion key must be denied on /buzz/v1" + ); + } + #[test] #[ignore = "requires Postgres"] fn nip_fi_enforce_moderation_mismatched_key_is_403() { @@ -6463,7 +6523,7 @@ pub(crate) mod postgres_tests { AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, }; - use jsonwebtoken::{jwk::JwkSet, Algorithm}; + use jsonwebtoken::Algorithm; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -6525,29 +6585,15 @@ pub(crate) mod postgres_tests { // ── 2. Build the verifier with StaticIssuerKeySource + test key ─────── // - // The verifier is seeded with a known P-256 public key. Tokens that - // claim `iss=https://issuer.test` will be verified against this key. + // The verifier is seeded with an ephemeral P-256 public key. Tokens that + // claim `iss=https://issuer.example` will be verified against this key. // A token with an all-zero signature will fail `InvalidSignatureOrClaims` // → DenialClass::EvidenceRejected → 403. // - // Key constants match the canonical test key in buzz-auth - // (verifier/tests.rs): TEST_JWK_X / TEST_JWK_Y / TEST_KID / ISSUER. const TEST_ISSUER: &str = "https://issuer.example"; const TEST_AUDIENCE: &str = "https://relay.example"; - const TEST_KID: &str = "test-key-1"; - let jwks: JwkSet = serde_json::from_value(serde_json::json!({ - "keys": [{ - "kty": "EC", - "crv": "P-256", - "use": "sig", - "alg": "ES256", - "kid": TEST_KID, - "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", - "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" - }] - })) - .expect("valid test JWKS"); + let jwks = handler_test_key().1.clone(); let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) @@ -6706,7 +6752,7 @@ pub(crate) mod postgres_tests { AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, }; - use jsonwebtoken::{jwk::JwkSet, Algorithm}; + use jsonwebtoken::Algorithm; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -6913,27 +6959,12 @@ pub(crate) mod postgres_tests { panic!("local Postgres not reachable (enforce)"); }; - // Inject the real verifier with the static test key. + // Inject the real verifier with the ephemeral test key. const TEST_ISSUER: &str = "https://issuer.example"; const TEST_AUDIENCE: &str = "https://relay.example"; const TEST_KID: &str = "test-key-1"; - // PKCS#8 private key matching TEST_JWK_X/Y — same key used by - // nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires. - const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; - - let jwks: JwkSet = serde_json::from_value(serde_json::json!({ - "keys": [{ - "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", - "kid": TEST_KID, - "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", - "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" - }] - })) - .expect("valid test JWKS"); + + let jwks = handler_test_key().1.clone(); let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) @@ -6975,7 +7006,7 @@ pub(crate) mod postgres_tests { // Mint a valid signed assertion for an arbitrary test pubkey. let assertion_pubkey_hex = nostr::Keys::generate().public_key().to_hex(); let valid_assertion = { - use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use jsonwebtoken::{Algorithm, Header}; let now = chrono::Utc::now().timestamp(); let claims = serde_json::json!({ "iss": TEST_ISSUER, @@ -6988,9 +7019,8 @@ pub(crate) mod postgres_tests { let mut header = Header::new(Algorithm::ES256); header.kid = Some(TEST_KID.to_owned()); header.typ = Some("nip-fi+jwt".to_owned()); - let key = - EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); - jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + let key = &handler_test_key().0; + jsonwebtoken::encode(&header, &claims, key).expect("sign assertion") }; // Pre-condition: verifier accepts the token. @@ -7016,7 +7046,7 @@ pub(crate) mod postgres_tests { // Mint a same-key assertion: nostr_pubkey = keys2's public key. let same_key_assertion = { - use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use jsonwebtoken::{Algorithm, Header}; let now = chrono::Utc::now().timestamp(); let claims = serde_json::json!({ "iss": TEST_ISSUER, @@ -7029,9 +7059,8 @@ pub(crate) mod postgres_tests { let mut header = Header::new(Algorithm::ES256); header.kid = Some(TEST_KID.to_owned()); header.typ = Some("nip-fi+jwt".to_owned()); - let key = - EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); - jsonwebtoken::encode(&header, &claims, &key).expect("sign same-key assertion") + let key = &handler_test_key().0; + jsonwebtoken::encode(&header, &claims, key).expect("sign same-key assertion") }; // Pre-condition: same-key assertion is accepted. assert!( diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index a24a741fb37..b4aef6741cc 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -22,6 +22,12 @@ impl Error { pub(super) fn unavailable() -> Self { Self::new(StatusCode::SERVICE_UNAVAILABLE, "unavailable") } + pub(super) fn terminal_denial(&self) -> bool { + matches!( + self.status, + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ) + } pub(super) fn invalid() -> Self { Self::new(StatusCode::BAD_REQUEST, "invalid_request") } @@ -69,7 +75,7 @@ pub(super) async fn authorize( state: &Arc, headers: &HeaderMap, uri: &Uri, - method: &str, + method: &'static str, body: Option<&[u8]>, ) -> Result { let host = headers @@ -88,19 +94,32 @@ pub(super) async fn authorize( .unwrap_or(uri.path()); let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); // Private state always requires cryptographic identity, even on a dev relay. - let verified = - bridge::verify_bridge_auth_with_options(headers, method, &url, body, true, body.is_some()) - .map_err(bridge_error)?; - bridge::enforce_http_admission(state, &tenant, &verified.pubkey) + // NIP-FI admission owns NIP-98 extraction, assertion pairing and deny map. + let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( + state, + headers, + bridge::make_nip98_closure_for_admission( + headers.clone(), + method, + url, + body.map(<[u8]>::to_vec), + true, + body.is_some(), + ), + ) + .map_err(|r| bridge_error((r.status(), Json(Value::Null))))?; + let actor = *admission.proven_pubkey(); + let (event_id, signed_at) = admission.into_extra(); + bridge::enforce_http_admission(state, &tenant, &actor) .await .map_err(bridge_error)?; - bridge::check_nip98_replay(state, &tenant, verified.event_id_bytes) + bridge::check_nip98_replay(state, &tenant, event_id) .await .map_err(bridge_error)?; let principal = Principal { tenant, - actor: verified.pubkey, - signed_at: verified.signed_created_at, + actor, + signed_at, }; recheck(state, headers, &principal).await?; Ok(principal) diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index 41e00695ea1..ee0bef9c0c7 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -94,19 +94,14 @@ pub(super) async fn write( }; // A deadline/DB failure after commit is ambiguous, not a false failure. // Earlier acknowledged commits survive all later projection/item failures. - let result = tokio::time::timeout_at(deadline, async { - auth::recheck(&state, &headers, &principal).await?; - state - .db - .apply_personal_read_intent(principal.tenant.community(), &principal.actor, &intent) - .await - .map_err(|_| Error::unavailable()) - }) - .await; - outcomes.push(match result { - Ok(Ok(outcome)) => serde_json::to_value(outcome).map_err(|_| Error::unavailable())?, - _ => json!({"status":"unknown","retryable":true}), - }); + outcomes.push( + tokio::time::timeout_at( + deadline, + write_intent(&state, &headers, &principal, &intent), + ) + .await + .unwrap_or_else(|_| json!({"status":"unknown","retryable":true})), + ); } auth::response(json!({"outcomes":outcomes,"projection_status":"not_requested"})) } @@ -177,3 +172,28 @@ pub(super) async fn contexts( .await .map_err(|_| Error::unavailable())? } + +// Preserve earlier committed outcomes while distinguishing a definite denial +// before the transaction from an ambiguous storage/timeout failure. +pub(super) async fn write_intent( + state: &AppState, + headers: &HeaderMap, + principal: &auth::Principal, + intent: &ReadIntent, +) -> Value { + if let Err(error) = auth::recheck(state, headers, principal).await { + return if error.terminal_denial() { + json!({"status":"blocked"}) + } else { + json!({"status":"unknown","retryable":true}) + }; + } + match state + .db + .apply_personal_read_intent(principal.tenant.community(), &principal.actor, intent) + .await + { + Ok(outcome) => json!(outcome), + Err(_) => json!({"status":"unknown","retryable":true}), + } +} diff --git a/crates/buzz-relay/src/api/buzz_v1/mod.rs b/crates/buzz-relay/src/api/buzz_v1/mod.rs index 681be9688bf..63265f1c023 100644 --- a/crates/buzz-relay/src/api/buzz_v1/mod.rs +++ b/crates/buzz-relay/src/api/buzz_v1/mod.rs @@ -7,6 +7,9 @@ use crate::state::AppState; use axum::{routing::get, Router}; use std::sync::Arc; +/// Public accessory prefix, shared by routing and capability discovery. +pub const BASE_PATH: &str = "/buzz/v1"; + /// Narrow versioned router; unknown accessory paths never return SPA HTML. pub fn router(state: Arc) -> Router { Router::new() diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index 07d029734d6..ebc82b8cc52 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -253,3 +253,125 @@ async fn accessory_context_get_signed_query_and_independent_batch_outcomes() { let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "read"); } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_write_revocation_is_terminal_before_persistence() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.buzz_v1_enabled = true; + config.require_relay_membership = true; + let state = Arc::new(state); + let host = format!("bff-revocation-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let actor = Keys::generate(); + state + .db + .add_relay_member(community, &actor.public_key().to_hex(), "member", None) + .await + .unwrap(); + let path = "/buzz/v1/me/read-state"; + let body = br#"{"intents":[{"type":"complete_import"}]}"#; + let mut headers = axum::http::HeaderMap::new(); + headers.insert("host", host.parse().unwrap()); + headers.insert( + "authorization", + proof(&actor, &host, path, "POST", Some(body)) + .parse() + .unwrap(), + ); + let principal = + super::auth::authorize(&state, &headers, &path.parse().unwrap(), "POST", Some(body)) + .await + .ok() + .expect("admitted before revoke"); + state + .db + .remove_relay_member(community, &actor.public_key().to_hex()) + .await + .unwrap(); + // Exercise the same per-item function the batch handler uses after admission. + let result = super::handlers::write_intent( + &state, + &headers, + &principal, + &buzz_db::personal_read::ReadIntent::CompleteImport, + ) + .await; + assert_eq!(result, json!({"status":"blocked"})); + let page = state + .db + .personal_read_sidebar(community, &actor.public_key(), 86400, 1, None) + .await + .unwrap(); + assert!( + page.account.imported_at_ms.is_none(), + "denied intent must not persist" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_discovery_is_host_bound_and_opt_in() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let host = format!("bff-discovery-{}.local", uuid::Uuid::new_v4()); + fixture.db.ensure_configured_community(&host).await.unwrap(); + for enabled in [false, true] { + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.buzz_v1_enabled = enabled; + config.buzz_v1_retention_seconds = 1234; + let state = Arc::new(state); + for known_host in [true, false] { + let request_host = if known_host { + host.as_str() + } else { + "unknown.invalid" + }; + for path in ["/", "/info"] { + let response = crate::router::build_router(state.clone()) + .oneshot( + Request::builder() + .uri(path) + .header("host", request_host) + .header("accept", "application/nostr+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let doc: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + doc.get("buzz_v1").is_some(), + enabled && known_host, + "enabled={enabled} known_host={known_host} path={path}" + ); + if enabled && known_host { + let d = &doc["buzz_v1"]; + assert_eq!(d["version"], 1); + assert_eq!(d["base_path"], "/buzz/v1"); + assert_eq!(d["retention_seconds"], 1234); + assert_eq!(d["max_channels"], buzz_db::personal_read::MAX_CHANNELS); + assert_eq!(d["max_intents"], buzz_db::personal_read::MAX_INTENTS); + assert_eq!(d["max_contexts"], buzz_db::personal_read::MAX_CONTEXTS); + assert_eq!( + d["max_context_messages"], + buzz_db::personal_read::MAX_CONTEXT_MESSAGES + ); + } + } + } + } +} diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 043458b4bca..f1ee6a30663 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -34,6 +34,9 @@ pub struct RelayInfo { /// Host-bound atomic read-state snapshot capability; absent on unresolved hosts. #[serde(skip_serializing_if = "Option::is_none")] pub read_state_snapshot: Option, + /// Opt-in, host-bound private accessory API; separate from legacy NIP-RS. + #[serde(skip_serializing_if = "Option::is_none")] + pub buzz_v1: Option, /// Relay operator's public key (hex), if published. pub pubkey: Option, /// Contact address for the relay operator. @@ -72,6 +75,25 @@ pub struct RelayInfo { pub relay_self: Option, } +/// Private read-state accessory contract and enforced client request limits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuzzV1Descriptor { + /// Accessory contract version, not a Nostr protocol number. + pub version: u32, + /// Relay-relative API prefix; callers retain the requesting origin. + pub base_path: String, + /// Receipt-time unread horizon; does not expire messages or frontiers. + pub retention_seconds: u32, + /// Maximum joined channels per sidebar page. + pub max_channels: usize, + /// Maximum independent write intents per request. + pub max_intents: usize, + /// Maximum explicit contexts per read request. + pub max_contexts: usize, + /// Maximum message selectors across one context request. + pub max_context_messages: usize, +} + /// Public capability descriptor for relay-proxied GIF search. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GifDescriptor { @@ -207,6 +229,7 @@ impl RelayInfo { description: "Buzz — private team communication relay".to_string(), icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()), read_state_snapshot: None, + buzz_v1: None, pubkey: None, contact: None, supported_nips, @@ -298,6 +321,20 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st state.config.klipy.as_ref().map(|_| "klipy"), ); if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { + if state.config.buzz_v1_enabled { + use buzz_db::personal_read::{ + MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS, + }; + info.buzz_v1 = Some(BuzzV1Descriptor { + version: 1, + base_path: crate::api::buzz_v1::BASE_PATH.to_owned(), + retention_seconds: state.config.buzz_v1_retention_seconds, + max_channels: MAX_CHANNELS, + max_intents: MAX_INTENTS, + max_contexts: MAX_CONTEXTS, + max_context_messages: MAX_CONTEXT_MESSAGES, + }); + } info.read_state_snapshot = Some(serde_json::json!({ "version": 1, "community_id": tenant.community().as_uuid(), diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index ed85986d88c..46844f93c27 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -289,7 +289,8 @@ pub fn build_router(state: Arc) -> Router { let admin_router = admin_enabled .then(|| Router::new().nest("/api/admin/v1", api::admin::router(state.clone()))); - let accessory_router = Router::new().nest("/buzz/v1", api::buzz_v1::router(state.clone())); + let accessory_router = + Router::new().nest(api::buzz_v1::BASE_PATH, api::buzz_v1::router(state.clone())); let api_router = Router::new() // WebSocket + NIP-11 diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md new file mode 100644 index 00000000000..c28625324a6 --- /dev/null +++ b/docs/buzz-v1-read-state.md @@ -0,0 +1,195 @@ +# Private read-state accessory API + +`BUZZ_V1_ENABLED=true` opts into `/buzz/v1`; it is disabled by default. +Conversation history, live events, edits and deletion remain Nostr-authoritative. +This API neither replaces Nostr reads nor writes artificial signed events. +Legacy NIP-RS continues unchanged, but does not synchronize with these tables. + +## Discovery and identity + +On a known community host, NIP-11 (`GET /` with `Accept: +application/nostr+json`, or `GET /info`) includes `buzz_v1` only when enabled: + +```json +{"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000, +"max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100}} +``` + +Use the requesting origin plus this relative prefix. Discovery is a configured +capability, not a promise that the next request cannot fail. Unknown hosts and +disabled deployments omit it. Capability loss must not restart NIP-RS writes. + +Every API request requires NIP-98, including on development relays. Sign the +exact externally addressed URL, including the encoded query, and method. POST +also requires the SHA-256 payload tag for the exact body bytes. Each retry needs +fresh authorization; replay protection is shared with the bridge. Applicable +NIP-FI admission is enforced and its asserted key must match the request signer. +The host chooses the community; the signer chooses `/me`. NIP-OA admission does +not grant access to the owner's personal state. Relay membership, bans and +resource access are enforced; moderation timeouts do not prohibit reading. + +All API responses are `Cache-Control: private, no-store`. Errors use +`{"error":{"code":"invalid_request","request_id":"..."}}`, with 400 invalid, +401 unauthorized/replay, 403 forbidden, 404 unavailable capability/host/path, +429 rate limited or 503 temporarily unavailable. 429/503 include `Retry-After`. +Unknown request fields are rejected. Never turn a transport failure into read. + +## Sidebar + +`GET /buzz/v1/me/sidebar?limit=20&cursor=` returns +`account`, `channels`, and `next_cursor`. Omit the cursor on the first request. +Each channel includes identity/name/type, archived and hidden flags, `unread`, +`attention`, `latest_message_id`, and `latest_message_complete`. +Only joined, nondeleted channels are listed. Hidden/archived presentation remains +client-owned. Each page has a writer-consistent snapshot; separate pages do not +share a snapshot, and an unfinished traversal cannot prove channel removal. + +Counts have exactly three representations: + +```json +[{"status":"exact","value":0},{"status":"at_least","value":7},{"status":"unknown"}] +``` + +Only exact zero proves absence. Unknown has no numeric value. Ordinary unread +counts eligible non-own, nondeleted conversation kinds 9, 40002, 45001 and 45003 +beyond the matching context frontier. Attention is the unread subset consisting +of DMs, direct actor mentions, `broadcast=1`, and replies in a thread containing +a live eligible message authored by the actor. Participation is independent of +read progress and retention. This is not Desktop notification policy: follows, +mutes and earlier mentions elsewhere in a thread do not affect this count. + +The receipt-time horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`). It +filters unread/attention, not latest activity, event storage or frontier state. +A later configuration expansion can change counts without having lost progress. +Latest activity is independent of actor and frontiers. A null latest ID proves +an empty eligible history only when `latest_message_complete=true`. + +## Explicit contexts + +`GET /buzz/v1/me/read-state?targets=` accepts up to 20 +contexts and 100 total concrete message selectors. It is not event history or a +global export of frontiers. Example decoded `targets`: + +```json +[{"target":{"channel_id":"","root_id":"<64-hex-root>"}, + "message_ids":["<64-hex-event>"]}] +``` + +Omitting `root_id` selects the channel timeline. The result contains `account` +and one `contexts` entry per request entry, in order. Context status is +`available` (with nullable `through_timestamp` and `messages`), `unknown`, or +`unavailable`. Message status is `read`, `not_counted`, `unread` (with nullable +`attention`), `unknown`, or `unavailable`. Wrong-context, missing and forbidden +selectors share unavailable. Null attention means participation is unproved. +Conversation bytes must still come from the existing Nostr path. + +## Fixed-operand writes + +`POST /buzz/v1/me/read-state` accepts 1–100 independent intents: + +```json +{"intents":[ + {"type":"mark_through","target":{"channel_id":""},"message_id":"<64-hex-event>"}, + {"type":"legacy_prefix","target":{"channel_id":"","root_id":"<64-hex-root>"},"through_timestamp":1700000000}, + {"type":"complete_import"} +]} +``` + +Each intent commits atomically and returns its own `applied`, `blocked` or +`invalid` outcome. An ambiguous timeout/storage failure returns +`{"status":"unknown","retryable":true}`. Earlier committed outcomes survive +later failures. `projection_status` is `not_requested`; a successful write does +not assert a client has refreshed. Retry the same operands, never substitute +latest. Keep pending intent durably on the client until its outcome is resolved. + +A mark-through validates a fixed message and advances its context's monotone +second-resolution author-time prefix. Equal-time messages and later-arriving +backdated messages at/below it are covered. Channel and thread frontiers never +inherit in either direction. Opening a view is not itself a reading action; +client dwell/focus policy determines when to send an actual observed anchor. +Old or deleted valid anchors may advance a frontier. Legacy prefixes preserve +the original nonnegative timestamp; more than DB-now + 900 seconds is invalid, +not clamped. Complete-import is a client declaration, not proof that the server +verified or decrypted a legacy snapshot. Null `imported_at_ms` means provisional. + +Sparse legacy seen hints must not be converted to the largest timestamp: that +would acknowledge unseen holes. The client retains recovery data and discloses +unsupported hints/manual overrides before declaring import complete. Manual +unread remains device-local. There is no automatic dual-authority rollback. + +## Bounds and deployment + +- 20 sidebar rows, 100 intents, 20 contexts / 100 selectors per request. +- 64 KiB write body; 16 KiB context URL; 1 MiB serialized API response. +- 4096 raw receipts per channel plus one exhaustion sentinel, before eligibility. +- Latest activity probes 256 events plus a sentinel; long ineligible tails may + leave latest incomplete even when unread is exact. +- Tag documents over 8192 bytes or malformed relevant tags yield uncertainty. + Compact boolean facts cross the database boundary, never raw tag payloads. +- Optional participation: at most 1024 unique roots and 257 metadata candidates + per root, with a 500 ms savepoint budget. Budget exhaustion preserves ordinary + counts and leaves unproved attention unknown/lower-bound. +- DB statement/lock deadlines and HTTP read deadlines bound work; writes use a + shared eight-second intent-processing deadline after admission. Limits are + containment, not a production capacity claim. + +Apply migrations 0050 and 0051 (or the equivalent desired schema). Brownfield +operators must prebuild the receipt index using +[the concurrent deployment procedure](events-channel-received-deployment.md). +Do not run an unbounded blocking index build on a large events table. No new +per-message ingest write path or stored unread counters are introduced. + +Use existing HTTP route/status/latency metrics for `/buzz/v1/me/sidebar` and +`/buzz/v1/me/read-state`, plus database pool/statement metrics. Inspect exact / +lower-bound / unknown proportions in controlled acceptance captures; no payload, +actor, channel or frontier values should become metric labels. The measured +local seed is not a DAU/concurrency or p95/p99 production acceptance result. + +## Compatibility and extension rules + +Within `/buzz/v1`, clients must ignore unknown response object fields. Existing +required fields, status variants and their meanings remain stable; additive +fields do not authorize silently changing `attention` or frontier semantics. +Breaking changes require an explicitly negotiated contract or a new API version. +Requests remain strict: send new parameters or intent types only after the relay +advertises the corresponding capability. Missing optional data means unsupported +or not requested, never an empty list, zero count or unchanged revision. + +Future thread previews can add an optional bounded summary to each channel row, +with independent list completeness and count evidence. Expensive signed-event +expansions should be opt-in and preserve the base sidebar's work/byte budgets; +fetching detail separately remains valid. Reuse canonical classification and +read frontiers, not a second definition of unread. Historical-mention eligibility +or mute-aware attention requires an explicit policy contract, not an unnoticed +change to today's `attention` field. + +Channel-content revisions, personal-state revisions and synchronized preferences +are separate from read frontiers. Future revisions must describe the same +snapshot as their payload, and are invalidation tokens, not history cursors or +access grants. Count reuse also needs time/configuration validity because the +receipt horizon moves without writes. Mutes and manual-unread overrides must not +be encoded by advancing or rewinding a read frontier. + +These are extension constraints, not implemented capabilities: this version does +not advertise thread previews, revision-based reuse or synchronized overrides. + +## Privacy and lifecycle + +These typed relational frontiers are signer-private application state, **not +self-encrypted**. Database operators can see reading progress; ordinary Nostr +queries, search and moderator interfaces do not expose it. No public receipts +are emitted. Storage grows by touched contexts, not observed messages, and has +no fixed context-count ceiling. + +Leaving/rejoining does not erase progress; revoked access hides it. Soft-deleted +channels are inaccessible, while hard channel deletion cascades their frontiers. +Deleting an account row cascades that actor's frontiers in the same community; +community erasure inventories both tables under the existing write fence. There +is no new public account export/reset endpoint. Operator-assisted erasure/export +must use the established authenticated operational process and explicitly scope +both community and actor; never equate the read-time horizon with data erasure. + +Roll out disabled-by-default to controlled accounts after agent and human live +acceptance. The client migration is separate work. Disabling the API leaves +migrated clients stale with their pending journal intact; it cannot silently +restore the old encrypted snapshot as current authority. diff --git a/docs/events-channel-received-deployment.md b/docs/events-channel-received-deployment.md new file mode 100644 index 00000000000..4c174ece4af --- /dev/null +++ b/docs/events-channel-received-deployment.md @@ -0,0 +1,79 @@ +# Channel receipt index deployment + +Migration `0051_events_channel_received_index.sql` adds the index behind the +sidebar's receipt window (`received_at >= cutoff ORDER BY received_at DESC, id, +created_at LIMIT n` per channel). `events` is partitioned by `created_at`, so +without this index every sidebar read scans each channel's full history in +every partition and filters on `received_at`. With it, each partition stops +after `n` rows. The index is maintained on every event insert, like the other +channel indexes: + +```sql +CREATE INDEX idx_events_community_channel_received + ON public.events (community_id, channel_id, received_at DESC, id, created_at); +``` + +Like 0049, the migration bounds lock acquisition and execution time. It builds +the index on fresh or small databases and deliberately fails on a populated +one rather than block ingestion. Brownfield deployments must prebuild first. + +## Brownfield procedure + +`events` is partitioned, and PostgreSQL does not support `CREATE INDEX +CONCURRENTLY` on a partitioned parent. Create the parent index on the parent +only (catalog-only, invalid until complete), build each partition's index +concurrently, then attach it. None of these statements may run inside a +transaction block. + +```sql +CREATE INDEX idx_events_community_channel_received + ON ONLY public.events (community_id, channel_id, received_at DESC, id, created_at); +``` + +List the partitions: + +```sql +SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid +WHERE i.inhparent = 'public.events'::regclass ORDER BY 1; +``` + +For each partition `

`: + +```sql +CREATE INDEX CONCURRENTLY

_channel_received + ON public.

(community_id, channel_id, received_at DESC, id, created_at); +ALTER INDEX public.idx_events_community_channel_received + ATTACH PARTITION public.

_channel_received; +``` + +The parent becomes valid only after every partition has an attached, valid +index. Partitions created later inherit the index automatically. Verify: + +```sql +SELECT i.indisvalid, i.indisready, i.indislive, pg_get_indexdef(i.indexrelid) +FROM pg_index i +JOIN pg_class c ON c.oid = i.indexrelid +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'public' AND c.relname = 'idx_events_community_channel_received'; +``` + +Expected flags are all `true`, with this definition: + +```text +CREATE INDEX idx_events_community_channel_received ON ONLY public.events USING btree (community_id, channel_id, received_at DESC, id, created_at) +``` + +Then deploy normally. Migration 0051 skips `CREATE INDEX`, validates the +catalog shape, and records the migration. + +## Recovery + +A failed concurrent build leaves an invalid partition index, and the parent +stays invalid; migration 0051 rejects both. Drop only the failed partition +index outside a transaction, then repeat that partition's build and attach: + +```sql +DROP INDEX CONCURRENTLY IF EXISTS public.

_channel_received; +``` + +Do not replace this with a non-concurrent build on a populated table. diff --git a/migrations/0051_events_channel_received_index.sql b/migrations/0051_events_channel_received_index.sql new file mode 100644 index 00000000000..b748b7a48ac --- /dev/null +++ b/migrations/0051_events_channel_received_index.sql @@ -0,0 +1,36 @@ +-- Newest-received events per channel as one ordered index range. The sidebar's +-- receipt window (received_at >= cutoff ORDER BY received_at DESC, id, +-- created_at LIMIT n) otherwise reads each channel's whole history in every +-- created_at partition and filters on received_at, so its cap bounds evidence +-- but not work. Key order matches that ORDER BY so each partition stops at n. +-- Additive: no ingest, trigger or NIP-RS semantics change. +-- +-- Partitioned parent: CREATE INDEX recurses to every partition and future +-- partitions inherit it. CONCURRENTLY is not supported on partitioned parents, +-- so startup is bounded exactly like 0049: a populated table fails deployment +-- instead of blocking ingestion. Brownfield operators MUST prebuild per +-- partition as documented in docs/events-channel-received-deployment.md. +SET LOCAL lock_timeout = '1s'; +SET LOCAL statement_timeout = '5s'; +DO $$ +BEGIN + -- IF NOT EXISTS would still request a writer-conflicting ShareLock. + IF to_regclass('public.idx_events_community_channel_received') IS NULL THEN + CREATE INDEX idx_events_community_channel_received + ON public.events (community_id, channel_id, received_at DESC, id, created_at); + END IF; + + -- A partitioned index is valid only once every partition has an attached, + -- valid child index, so this also rejects an incomplete prebuild. + IF NOT EXISTS ( + SELECT 1 FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = 'idx_events_community_channel_received' + AND i.indisvalid AND i.indisready AND i.indislive + AND pg_get_indexdef(i.indexrelid) = + 'CREATE INDEX idx_events_community_channel_received ON ONLY public.events USING btree (community_id, channel_id, received_at DESC, id, created_at)' + ) THEN + RAISE EXCEPTION 'idx_events_community_channel_received invalid or wrong definition; see docs/events-channel-received-deployment.md'; + END IF; +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index 67772c2708e..81720006063 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -289,6 +289,9 @@ CREATE INDEX idx_events_community_pubkey_kind_created CREATE INDEX idx_events_community_kind_created ON events (community_id, kind, created_at DESC, id); CREATE INDEX idx_events_community_deleted ON events (community_id, deleted_at); +-- Sidebar receipt window per channel (0051); key order = its ORDER BY. +CREATE INDEX idx_events_community_channel_received + ON events (community_id, channel_id, received_at DESC, id, created_at); -- Addressable (replaceable) and NIP-33 parameterized lookups. CREATE INDEX idx_events_addressable ON events (community_id, kind, pubkey, channel_id, deleted_at); From beb99226f83db04d7476d290341faaf1d4c1632d Mon Sep 17 00:00:00 2001 From: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 09:30:01 -0400 Subject: [PATCH 03/18] fix(db): follow renumbered personal read migration in parity test Select migration 0051 when comparing private tables and write-fence attachments with the desired schema after integrating main's 0050. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 5010e622a52..ff7c66426a0 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -1901,7 +1901,7 @@ mod postgres_tests { let personal = surface( MIGRATOR .iter() - .find(|m| m.version == 50) + .find(|m| m.version == 51) .expect("personal read migration") .sql .as_ref(), From 7451d79b21a823629fb53120f6ce730d3e36aa9f Mon Sep 17 00:00:00 2001 From: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 09:36:23 -0400 Subject: [PATCH 04/18] docs(relay): separate extension design from current API contract Keep compatibility rules in the API reference and link future extension constraints as a design note. Correct the receipt-index schema comment to migration 0052 and remove excess spacing. No runtime changes. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> --- docs/buzz-v1-extension-design.md | 22 ++++++++++++++++++++++ docs/buzz-v1-read-state.md | 20 +++----------------- schema/schema.sql | 4 +--- 3 files changed, 26 insertions(+), 20 deletions(-) create mode 100644 docs/buzz-v1-extension-design.md diff --git a/docs/buzz-v1-extension-design.md b/docs/buzz-v1-extension-design.md new file mode 100644 index 00000000000..42bc6978730 --- /dev/null +++ b/docs/buzz-v1-extension-design.md @@ -0,0 +1,22 @@ +# Read-state accessory extension constraints + +Design guidance for follow-up work, not implemented wire capabilities. +The current [API contract](buzz-v1-read-state.md) remains authoritative. + +Future thread previews can add an optional bounded summary to each channel row, +with independent list completeness and count evidence. Expensive signed-event +expansions should be opt-in and preserve the base sidebar's work/byte budgets; +fetching detail separately remains valid. Reuse canonical classification and +read frontiers, not a second definition of unread. Historical-mention eligibility +or mute-aware attention requires an explicit policy contract, not an unnoticed +change to today's `attention` field. + +Channel-content revisions, personal-state revisions and synchronized preferences +are separate from read frontiers. Future revisions must describe the same +snapshot as their payload, and are invalidation tokens, not history cursors or +access grants. Count reuse also needs time/configuration validity because the +receipt horizon moves without writes. Mutes and manual-unread overrides must not +be encoded by advancing or rewinding a read frontier. + +These are extension constraints, not implemented capabilities: this version does +not advertise thread previews, revision-based reuse or synchronized overrides. diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index 7203ea20d36..1c40497d10d 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -155,23 +155,9 @@ Requests remain strict: send new parameters or intent types only after the relay advertises the corresponding capability. Missing optional data means unsupported or not requested, never an empty list, zero count or unchanged revision. -Future thread previews can add an optional bounded summary to each channel row, -with independent list completeness and count evidence. Expensive signed-event -expansions should be opt-in and preserve the base sidebar's work/byte budgets; -fetching detail separately remains valid. Reuse canonical classification and -read frontiers, not a second definition of unread. Historical-mention eligibility -or mute-aware attention requires an explicit policy contract, not an unnoticed -change to today's `attention` field. - -Channel-content revisions, personal-state revisions and synchronized preferences -are separate from read frontiers. Future revisions must describe the same -snapshot as their payload, and are invalidation tokens, not history cursors or -access grants. Count reuse also needs time/configuration validity because the -receipt horizon moves without writes. Mutes and manual-unread overrides must not -be encoded by advancing or rewinding a read frontier. - -These are extension constraints, not implemented capabilities: this version does -not advertise thread previews, revision-based reuse or synchronized overrides. +Follow-up design constraints are recorded in +[the extension design note](buzz-v1-extension-design.md); they do not advertise +additional capabilities. ## Privacy and lifecycle diff --git a/schema/schema.sql b/schema/schema.sql index 715aaa38116..ef54a232b74 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -216,8 +216,6 @@ CREATE TABLE personal_read_frontiers ( REFERENCES channels (community_id, id) ON DELETE CASCADE ); - - -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the @@ -289,7 +287,7 @@ CREATE INDEX idx_events_community_pubkey_kind_created CREATE INDEX idx_events_community_kind_created ON events (community_id, kind, created_at DESC, id); CREATE INDEX idx_events_community_deleted ON events (community_id, deleted_at); --- Sidebar receipt window per channel (0051); key order = its ORDER BY. +-- Sidebar receipt window per channel (0052); key order = its ORDER BY. CREATE INDEX idx_events_community_channel_received ON events (community_id, channel_id, received_at DESC, id, created_at); -- Addressable (replaceable) and NIP-33 parameterized lookups. From 4288ba55ae0ed919df80485332d24aa26a69e5b5 Mon Sep 17 00:00:00 2001 From: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 10:20:32 -0400 Subject: [PATCH 05/18] test(db): separate participation root cap from SQL deadline Extract the existing target selection into the production helper and pin unique-root boundaries and channel identity independently of SQL timing. Allow documented timeout uncertainty at the pre-cap integration checkpoint while preserving strict post-cap unknown and exact ordinary unread counts. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> --- .../src/store/personal_read/participation.rs | 48 +++++++++++++++++-- .../participation_postgres_tests.rs | 8 +++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/buzz-db/src/store/personal_read/participation.rs b/crates/buzz-db/src/store/personal_read/participation.rs index e8b6fb4661c..4db8189d564 100644 --- a/crates/buzz-db/src/store/personal_read/participation.rs +++ b/crates/buzz-db/src/store/personal_read/participation.rs @@ -21,10 +21,7 @@ pub(super) async fn resolve( if targets.is_empty() { return Ok(HashMap::new()); } - let mut targets = targets.to_vec(); - targets.sort_unstable(); - targets.dedup(); - targets.truncate(MAX_ROOTS); + let targets = select_targets(targets); let channels: Vec<_> = targets.iter().map(|(channel, _)| *channel).collect(); let roots: Vec<_> = targets.iter().map(|(_, root)| root.clone()).collect(); // Optional inference must not abort authoritative unread/frontier reads. @@ -86,3 +83,46 @@ pub(super) async fn resolve( }) .collect() } + +// Keep selection independent of the optional SQL deadline: a timeout must not +// hide a regression in the cardinality bound. +fn select_targets(targets: &[(Uuid, Vec)]) -> Vec<(Uuid, Vec)> { + let mut targets = targets.to_vec(); + targets.sort_unstable(); + targets.dedup(); + targets.truncate(MAX_ROOTS); + targets +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_selection_caps_unique_roots_independently_of_sql_timeout() { + // Literal contract boundaries deliberately do not derive from MAX_ROOTS. + for count in [0_u32, 1, 1023, 1024, 1025] { + let unique: Vec<_> = (0..count) + .map(|i| (Uuid::nil(), i.to_be_bytes().to_vec())) + .collect(); + let input: Vec<_> = unique + .iter() + .rev() + .chain(unique.iter().rev()) + .cloned() + .collect(); + let selected = select_targets(&input); + assert_eq!(selected, unique[..unique.len().min(1024)], "count {count}"); + } + } + + #[test] + fn target_selection_keeps_channel_identity() { + let first = (Uuid::from_u128(1), vec![7; 32]); + let second = (Uuid::from_u128(2), vec![7; 32]); + assert_eq!( + select_targets(&[second.clone(), first.clone(), second.clone()]), + vec![first, second] + ); + } +} diff --git a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs index 28f6b5f2957..e555be48fbb 100644 --- a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs @@ -211,9 +211,15 @@ async fn personal_read_attention_root_budget_does_not_fabricate_absence() { ) .await .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 2048 } + )); + // Root selection is tested deterministically at its production seam. + // The independent SQL deadline may withhold participation evidence. assert!(matches!( page.channels[0].attention, - ReadCount::Exact { value: 0 } + ReadCount::Exact { value: 0 } | ReadCount::Unknown )); } } From a81e8fb35b1965b536e52031894acdccf0b7c4b7 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 12:00:40 -0400 Subject: [PATCH 06/18] feat(relay): whole-channel reads, thread summaries and targeted refresh Add mark_channel_read, a fixed-anchor cut that advances the channel timeline and every thread through one author time. The anchor needs only an accessible eligible-kind message; ancestry is not checked, and ordinary mark_through validation is unchanged. Absent cuts stay NULL. Each sidebar row carries up to five unread thread summaries, newest observed reply first, with completeness that fails closed on truncated scans, unresolved ancestry and unusable tags, plus latest_message_at. GET /me/sidebar?channel_ids= refreshes 1-20 unique rows in one snapshot. Amends the unreleased 0051 migration and desired schema in place. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../src/store/personal_read/context.rs | 12 +- crates/buzz-db/src/store/personal_read/mod.rs | 3 + .../buzz-db/src/store/personal_read/model.rs | 38 ++ .../src/store/personal_read/projection.rs | 217 +++++++- .../personal_read/threads_postgres_tests.rs | 519 ++++++++++++++++++ .../buzz-db/src/store/personal_read/writes.rs | 44 ++ crates/buzz-relay/src/api/buzz_v1/handlers.rs | 49 +- .../src/api/buzz_v1/postgres_tests.rs | 28 + crates/buzz-relay/src/nip11.rs | 5 +- docs/buzz-v1-extension-design.md | 7 +- docs/buzz-v1-read-state.md | 53 +- migrations/0051_personal_read_state.sql | 6 + schema/schema.sql | 4 + 13 files changed, 937 insertions(+), 48 deletions(-) create mode 100644 crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs index 6ca1a41164f..72158b6171d 100644 --- a/crates/buzz-db/src/store/personal_read/context.rs +++ b/crates/buzz-db/src/store/personal_read/context.rs @@ -55,15 +55,21 @@ impl Db { } Err(error) => return Err(error), }; + // A thread's effective prefix includes the channel's whole-channel cut, + // exactly as the sidebar projection counts it. let prefix: Option = sqlx::query_scalar( - "SELECT through_timestamp FROM personal_read_frontiers - WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=$4", + "SELECT GREATEST( + (SELECT through_timestamp FROM personal_read_frontiers + WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=$4), + (SELECT threads_through_timestamp FROM personal_read_frontiers + WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=''::bytea + AND $4<>''::bytea))", ) .bind(community.as_uuid()) .bind(actor_bytes.as_slice()) .bind(query.target.channel_id) .bind(&root) - .fetch_optional(&mut *tx) + .fetch_one(&mut *tx) .await?; let ids: Vec> = query .message_ids diff --git a/crates/buzz-db/src/store/personal_read/mod.rs b/crates/buzz-db/src/store/personal_read/mod.rs index a965cd56d6b..282113830a6 100644 --- a/crates/buzz-db/src/store/personal_read/mod.rs +++ b/crates/buzz-db/src/store/personal_read/mod.rs @@ -20,3 +20,6 @@ mod participation_postgres_tests; #[cfg(test)] mod projection_postgres_tests; + +#[cfg(test)] +mod threads_postgres_tests; diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index 2498bbaec73..558f85cc566 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -27,6 +27,14 @@ pub enum ReadIntent { /// Fixed anchor; retry must not substitute the latest message. message_id: String, }, + /// Advance the channel timeline and every thread in it through one fixed + /// message's author time. The anchor may be a reply; ancestry is irrelevant. + MarkChannelRead { + /// Channel being marked, including all of its threads. + channel_id: Uuid, + /// Fixed anchor; retry must not substitute the latest message. + message_id: String, + }, /// Migration/recovery only: preserve an original legacy timestamp verbatim. LegacyPrefix { /// Original channel or canonical thread scope. @@ -63,6 +71,8 @@ pub struct ReadAccount { /// Maximum channel summaries in one sidebar page. pub const MAX_CHANNELS: usize = 20; +/// Maximum unread-thread summaries per channel row. +pub const MAX_THREAD_SUMMARIES: usize = 5; /// Bounded event evidence per channel; exhaustion is never inferred at this cap. pub const MAX_CHANNEL_SCAN: usize = 256; /// Receipt-window work budget per channel, before eligibility/ancestry joins. @@ -124,9 +134,37 @@ pub struct ChannelReadSummary { /// and the unread-tracking horizon. /// None proves absence only when latest_message_complete is true. pub latest_message_id: Option, + /// Author time (Unix seconds) of latest_message_id; None exactly when it is None. + pub latest_message_at: Option, /// Whether the latest lookup found a result or exhausted channel history. /// False means the bounded probe found none, but an unexamined tail remains. pub latest_message_complete: bool, + /// Threads with unread replies, newest unread reply first. + pub threads: ThreadSummaries, +} + +/// A bounded, ordered list of unread threads within one channel row. +#[derive(Debug, Serialize)] +pub struct ThreadSummaries { + /// At most MAX_THREAD_SUMMARIES, by latest_reply_at DESC then root_id ASC. + pub items: Vec, + /// True only when evidence was exhausted and no thread was omitted. + pub complete: bool, +} + +/// Unread replies in one canonical thread. No conversation bytes. +#[derive(Debug, Serialize)] +pub struct ThreadReadSummary { + /// Canonical thread-root event ID. + pub root_id: String, + /// Unread replies in this thread (same definition as the row count). + pub unread: ReadCount, + /// Directed subset, with the same definition as the row's attention. + pub attention: ReadCount, + /// Newest observed unread reply: a valid thread mark_through anchor. + pub latest_reply_id: String, + /// Author time (Unix seconds) of latest_reply_id. + pub latest_reply_at: i64, } /// A bounded roster page, with no cross-page snapshot or removal inference. diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index 1d6cac72d7c..22f0cbb1127 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -26,6 +26,43 @@ impl Db { if !(1..=MAX_CHANNELS).contains(&limit) { return Err(DbError::InvalidData("invalid sidebar limit".into())); } + self.sidebar(community, actor, retention_seconds, limit, after, None) + .await + } + + /// Refresh specific joined channels in one snapshot. A requested channel + /// absent from the result was not a joined, nondeleted channel at that cut. + pub async fn personal_read_sidebar_channels( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + retention_seconds: u32, + channels: &[Uuid], + ) -> Result { + let unique: std::collections::HashSet<_> = channels.iter().collect(); + if !(1..=MAX_CHANNELS).contains(&channels.len()) || unique.len() != channels.len() { + return Err(DbError::InvalidData("invalid sidebar channels".into())); + } + self.sidebar( + community, + actor, + retention_seconds, + channels.len(), + None, + Some(channels), + ) + .await + } + + async fn sidebar( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + retention_seconds: u32, + limit: usize, + after: Option, + only: Option<&[Uuid]>, + ) -> Result { let mut conn = observability::acquire_writer( &self.pool, observability::WriterOperation::SubscriptionHistory, @@ -59,9 +96,10 @@ impl Db { ON c.community_id=cm.community_id AND c.id=cm.channel_id WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL AND c.deleted_at IS NULL AND ($3::uuid IS NULL OR c.id>$3) + AND ($9::uuid[] IS NULL OR c.id=ANY($9)) ORDER BY c.id LIMIT $4 ) - SELECT r.*, latest.latest_message_id, + SELECT r.*, latest.latest_message_id, latest.latest_message_at, (latest.latest_message_id IS NOT NULL OR latest.candidates <= $5-1) AS latest_message_complete, e.scanned,COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r LEFT JOIN LATERAL ( @@ -72,7 +110,9 @@ impl Db { ) SELECT count(*) AS candidates, (array_agg(encode(id,'hex') ORDER BY created_at DESC,id) - FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id, + (array_agg(extract(epoch FROM created_at)::bigint ORDER BY created_at DESC,id) + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_at FROM candidates ) latest ON true LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2 @@ -87,7 +127,8 @@ impl Db { COALESCE(tm.root_event_id<>e.id,false) AS is_reply, COALESCE(extract(epoch FROM e.created_at)::bigint <= CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id - THEN tf.through_timestamp ELSE cf.through_timestamp END,false) AS covered + THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp) + ELSE cf.through_timestamp END,false) AS covered FROM (SELECT * FROM candidates ORDER BY received_at DESC,id,created_at LIMIT $8-1) e LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id AND tm.event_created_at=e.created_at AND tm.event_id=e.id @@ -115,7 +156,9 @@ impl Db { AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'),false)) END FROM jsonb_array_elements(tags) t(tag) WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts, - count(*) AS n + count(*) AS n, + extract(epoch FROM max(created_at))::bigint AS newest_at, + (array_agg(encode(id,'hex') ORDER BY created_at DESC,id))[1] AS newest_id FROM classified WHERE NOT covered OR root IS NULL GROUP BY 1,2,3,4 @@ -129,6 +172,7 @@ impl Db { .bind(DateTime::from_timestamp_millis(account.cutoff_ms) .ok_or_else(|| DbError::InvalidData("invalid receipt cutoff".into()))?) .bind((MAX_RECEIPT_SCAN+1) as i64) + .bind(only) .fetch_all(&mut *tx).await?; let has_more = rows.len() > limit; let mut channels = Vec::new(); @@ -144,7 +188,7 @@ impl Db { let mut attention = 0; let mut unread_complete = complete; let mut attention_complete = complete; - let mut replies: HashMap, u32> = HashMap::new(); + let mut threads: HashMap, ThreadEvidence> = HashMap::new(); for e in evidence { let n = e["n"] .as_u64() @@ -174,15 +218,41 @@ impl Db { channel_type == "dm" || facts.get("directed") == Some(&Value::Bool(true)); if directed { attention += n; - } else if is_reply { - if let Some(root) = e["root"].as_str().and_then(writes::event_id) { - *replies.entry(root).or_default() += n; - } else { + } + if is_reply { + let Some(root) = e["root"].as_str().and_then(writes::event_id) else { + unread_complete = false; attention_complete = false; + continue; + }; + let newest = ( + e["newest_at"].as_i64().ok_or_else(invalid_newest)?, + e["newest_id"] + .as_str() + .ok_or_else(invalid_newest)? + .to_owned(), + ); + let thread = threads.entry(root).or_insert_with(|| ThreadEvidence { + unread: 0, + directed: 0, + undirected: 0, + newest: newest.clone(), + }); + thread.unread += n; + if directed { + thread.directed += n; + } else { + thread.undirected += n; + } + // Newest first; equal author times break toward the smaller ID. + if (newest.0, std::cmp::Reverse(&newest.1)) + > (thread.newest.0, std::cmp::Reverse(&thread.newest.1)) + { + thread.newest = newest; } } } - pending.push((replies, attention, attention_complete)); + pending.push((threads, attention, unread_complete, attention_complete)); channels.push(ChannelReadSummary { channel_id: row.try_get("id")?, name: row.try_get("name")?, @@ -192,33 +262,62 @@ impl Db { unread: ReadCount::from_evidence(unread, unread_complete), attention: ReadCount::from_evidence(attention, attention_complete), latest_message_id: row.try_get("latest_message_id")?, + latest_message_at: row.try_get("latest_message_at")?, latest_message_complete: row.try_get("latest_message_complete")?, + threads: ThreadSummaries { + items: Vec::new(), + complete: false, + }, }); } let targets: Vec<_> = channels .iter() .zip(&pending) - .flat_map(|(channel, (replies, _, _))| { - replies - .keys() - .map(|root| (channel.channel_id, root.clone())) + .flat_map(|(channel, (threads, ..))| { + threads + .iter() + .filter(|(_, t)| t.undirected > 0) + .map(|(root, _)| (channel.channel_id, root.clone())) }) .collect(); let participation = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; - for (channel, (replies, mut attention, mut complete)) in channels.iter_mut().zip(pending) { - for (root, count) in replies { - match participation - .get(&(channel.channel_id, root)) - .copied() - .flatten() - { - Some(true) => attention += count, + for (channel, (threads, mut attention, unread_complete, evidence_complete)) in + channels.iter_mut().zip(pending) + { + let mut complete = evidence_complete; + let mut items = Vec::with_capacity(threads.len()); + for (root, thread) in threads { + let participating = if thread.undirected == 0 { + Some(false) + } else { + participation + .get(&(channel.channel_id, root.clone())) + .copied() + .flatten() + }; + let thread_attention = match participating { + Some(true) => thread.directed + thread.undirected, + _ => thread.directed, + }; + match participating { + Some(true) => attention += thread.undirected, Some(false) => {} None => complete = false, } + items.push(ThreadReadSummary { + root_id: hex::encode(root), + unread: ReadCount::from_evidence(thread.unread, unread_complete), + attention: ReadCount::from_evidence( + thread_attention, + evidence_complete && participating.is_some(), + ), + latest_reply_id: thread.newest.1, + latest_reply_at: thread.newest.0, + }); } channel.attention = ReadCount::from_evidence(attention, complete); + channel.threads = summarize(items, unread_complete); } let next_cursor = if has_more { channels.last().map(|c| c.channel_id) @@ -234,6 +333,35 @@ impl Db { } } +/// Uncovered reply evidence for one canonical thread within a channel scan. +struct ThreadEvidence { + unread: u32, + directed: u32, + undirected: u32, + /// Newest observed unread reply: (author seconds, lowercase hex ID). + newest: (i64, String), +} + +fn invalid_newest() -> DbError { + DbError::InvalidData("invalid thread evidence".into()) +} + +/// Order newest unread reply first (root ID breaks ties) and cap the list. The +/// list is complete only when evidence was exhausted and nothing was omitted. +pub(super) fn summarize( + mut items: Vec, + evidence_complete: bool, +) -> ThreadSummaries { + items.sort_unstable_by(|a, b| { + b.latest_reply_at + .cmp(&a.latest_reply_at) + .then_with(|| a.root_id.cmp(&b.root_id)) + }); + let complete = evidence_complete && items.len() <= MAX_THREAD_SUMMARIES; + items.truncate(MAX_THREAD_SUMMARIES); + ThreadSummaries { items, complete } +} + /// Read-time horizon only: frontier state is not discarded on expiry. pub(super) async fn read_account( conn: &mut PgConnection, @@ -254,3 +382,48 @@ pub(super) async fn read_account( imported_at_ms: imported.map(|t| t.timestamp_millis()), }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn item(root: u8, at: i64) -> ThreadReadSummary { + ThreadReadSummary { + root_id: hex::encode([root; 32]), + unread: ReadCount::Exact { value: 1 }, + attention: ReadCount::Exact { value: 0 }, + latest_reply_id: hex::encode([root; 32]), + latest_reply_at: at, + } + } + + #[test] + fn thread_summaries_order_newest_first_break_ties_by_root_and_cap_at_five() { + // Literal contract boundaries deliberately do not derive from the constant. + for count in [0_u8, 1, 4, 5, 6, 7] { + // Descending roots with pairwise-equal times exercise the tie-break. + let items: Vec<_> = (0..count) + .rev() + .map(|i| item(i, i64::from(i / 2))) + .collect(); + let summaries = summarize(items, true); + let roots: Vec<_> = summaries.items.iter().map(|t| t.root_id.clone()).collect(); + let mut expected: Vec<_> = (0..count).map(|i| (i64::from(i / 2), i)).collect(); + expected.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + let expected: Vec<_> = expected + .into_iter() + .take(5) + .map(|(_, i)| hex::encode([i; 32])) + .collect(); + assert_eq!(roots, expected, "count {count}"); + assert_eq!(summaries.complete, count <= 5, "count {count}"); + } + } + + #[test] + fn thread_summaries_are_incomplete_when_evidence_is() { + let summaries = summarize(vec![item(1, 1)], false); + assert_eq!(summaries.items.len(), 1); + assert!(!summaries.complete); + } +} diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs new file mode 100644 index 00000000000..edfb9b37447 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -0,0 +1,519 @@ +//! Whole-channel reads, thread summaries and targeted refresh. +use super::{postgres_tests::fixture, *}; +use crate::{ + channel::{ChannelType, ChannelVisibility}, + Db, +}; +use buzz_core::CommunityId; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use sqlx::PgPool; +use uuid::Uuid; + +async fn post( + db: &Db, + community: CommunityId, + channel: Uuid, + at: u64, + tags: Vec, +) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(9), format!("message at {at}")) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(at)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + event +} + +/// A canonical reply: stored event plus its thread metadata. +async fn reply( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + root: &nostr::Event, + at: u64, + tags: Vec, +) -> nostr::Event { + let event = post(db, community, channel, at, tags).await; + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()).bind(at as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(pool).await.unwrap(); + event +} + +async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSummary { + db.personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap() + .channels + .remove(0) +} + +async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) -> IntentOutcome { + db.apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap() +} + +fn channel_read(channel: Uuid, message: &nostr::Event) -> ReadIntent { + ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: message.id.to_hex(), + } +} + +fn exact(count: &ReadCount) -> Option { + match count { + ReadCount::Exact { value } => Some(*value), + _ => None, + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + let first = reply(&db, &pool, community, channel, &root, base + 10, vec![]).await; + post(&db, community, channel, base + 20, vec![]).await; + let second = reply(&db, &pool, community, channel, &root, base + 30, vec![]).await; + + // A reply anchor is valid for the whole channel, unlike channel mark_through. + let channel_only = ReadTarget { + channel_id: channel, + root_id: None, + }; + let through_reply = ReadIntent::MarkThrough { + target: channel_only, + message_id: first.id.to_hex(), + }; + assert_eq!( + apply(&db, community, &actor, through_reply).await, + IntentOutcome::Blocked + ); + assert_eq!( + apply(&db, community, &actor, channel_read(channel, &first)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!( + exact(&row.unread), + Some(2), + "top at +20 and reply at +30 remain" + ); + assert_eq!(row.threads.items.len(), 1); + assert_eq!(row.threads.items[0].latest_reply_id, second.id.to_hex()); + assert!(row.threads.complete); + + assert_eq!( + apply(&db, community, &actor, channel_read(channel, &second)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!( + exact(&row.unread), + Some(0), + "every thread is covered by the cut" + ); + assert!(row.threads.items.is_empty() && row.threads.complete); + + // Late-arriving backdated reply is covered; a newer reply is not. + reply(&db, &pool, community, channel, &root, base + 5, vec![]).await; + let newer = reply(&db, &pool, community, channel, &root, base + 40, vec![]).await; + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(1)); + assert_eq!(row.threads.items[0].latest_reply_id, newer.id.to_hex()); + + // Contexts report and apply the same effective thread prefix. + let page = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_ids: vec![second.id.to_hex(), newer.id.to_hex()], + }], + ) + .await + .unwrap(); + let wire = serde_json::to_value(&page).unwrap(); + assert_eq!(wire["contexts"][0]["through_timestamp"], (base + 30) as i64); + assert_eq!(wire["contexts"][0]["messages"][0]["status"], "read"); + assert_eq!(wire["contexts"][0]["messages"][1]["status"], "unread"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independent() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + // Unresolved ancestry blocks ordinary marks, but not a time-only cut. + let orphan_parent = "ab".repeat(32); + let orphan = post( + &db, + community, + channel, + base + 10, + vec![Tag::parse(["e", &orphan_parent, "", "reply"]).unwrap()], + ) + .await; + assert!(db + .apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None + }, + message_id: orphan.id.to_hex(), + }, + ) + .await + .is_err()); + assert_eq!( + apply(&db, community, &actor, channel_read(channel, &orphan)).await, + IntentOutcome::Applied + ); + + // Missing, auxiliary and malformed anchors. + let missing = ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: "cd".repeat(32), + }; + assert_eq!( + apply(&db, community, &actor, missing).await, + IntentOutcome::Blocked + ); + let malformed = ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: "zz".into(), + }; + assert_eq!( + apply(&db, community, &actor, malformed).await, + IntentOutcome::Invalid + ); + let reaction = EventBuilder::new(Kind::Custom(7), "+") + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reaction, Some(channel)) + .await + .unwrap(); + assert_eq!( + apply(&db, community, &actor, channel_read(channel, &reaction)).await, + IntentOutcome::Blocked + ); + + // Ordinary channel marks never set the whole-channel cut. + let other = Keys::generate(); + assert_eq!( + apply( + &db, + community, + &other, + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None + }, + message_id: root.id.to_hex(), + }, + ) + .await, + IntentOutcome::Applied + ); + let cuts: Vec> = sqlx::query_scalar( + "SELECT threads_through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(other.public_key().to_bytes().as_slice()) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(cuts, vec![None]); + + // The schema admits the cut on channel rows only. + let thread_cut = sqlx::query( + "UPDATE personal_read_frontiers SET threads_through_timestamp=1 + WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .execute(&pool) + .await; + assert!(thread_cut.is_ok(), "channel row accepts the cut"); + let root_id = root.id.to_hex(); + let thread = ReadTarget { + channel_id: channel, + root_id: Some(root_id), + }; + let mark = ReadIntent::MarkThrough { + target: thread, + message_id: root.id.to_hex(), + }; + assert_eq!( + apply(&db, community, &actor, mark).await, + IntentOutcome::Applied + ); + assert!(sqlx::query( + "UPDATE personal_read_frontiers SET threads_through_timestamp=1 + WHERE community_id=$1 AND actor=$2 AND root_id<>''::bytea", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .execute(&pool) + .await + .is_err()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn absent_whole_channel_cut_never_covers_epoch_zero_replies() { + let (db, pool, community, channel, actor, root) = fixture().await; + reply(&db, &pool, community, channel, &root, 0, vec![]).await; + // A channel row exists, with no whole-channel cut: NULL must stay NULL. + apply( + &db, + community, + &actor, + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: root.id.to_hex(), + }, + ) + .await; + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(1), "epoch-zero reply stays unread"); + assert_eq!(row.threads.items[0].latest_reply_at, 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { + let (db, pool, community, channel, actor, fixture_root) = fixture().await; + // After the fixture root, so marking the newest root covers every root. + let base = fixture_root.created_at.as_secs() + 1; + let mut roots = Vec::new(); + for i in 0..6 { + roots.push(post(&db, community, channel, base + i, vec![]).await); + } + let newest_root = roots.last().unwrap().clone(); + apply( + &db, + community, + &actor, + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: newest_root.id.to_hex(), + }, + ) + .await; + // Threads 0 and 1 tie on newest reply time; thread 2 has two equal-time + // replies and one directed reply. + let mut anchors = Vec::new(); + for (i, root) in roots.iter().enumerate() { + let at = base + 100 + [50, 50, 40, 30, 20, 10][i]; + anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await); + } + let twin = reply( + &db, + &pool, + community, + channel, + &roots[2], + base + 140, + vec![], + ) + .await; + reply( + &db, + &pool, + community, + channel, + &roots[2], + base + 101, + vec![Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()], + ) + .await; + + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(8)); + assert!(!row.threads.complete, "six unread threads exceed the cap"); + let mut tied = [roots[0].id.to_hex(), roots[1].id.to_hex()]; + tied.sort(); + let order: Vec<_> = row + .threads + .items + .iter() + .map(|t| t.root_id.clone()) + .collect(); + assert_eq!( + order, + vec![ + tied[0].clone(), + tied[1].clone(), + roots[2].id.to_hex(), + roots[3].id.to_hex(), + roots[4].id.to_hex(), + ] + ); + let third = &row.threads.items[2]; + assert_eq!(exact(&third.unread), Some(3)); + assert_eq!(exact(&third.attention), Some(1)); + assert_eq!(third.latest_reply_at, (base + 140) as i64); + assert_eq!( + third.latest_reply_id, + std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()), + "equal reply times break toward the smaller ID" + ); + let newest = std::cmp::min(anchors[0].id.to_hex(), anchors[1].id.to_hex()); + assert_eq!(row.latest_message_id, Some(newest)); + assert_eq!(row.latest_message_at, Some((base + 150) as i64)); + + // Reading one listed thread through its anchor completes the list. + let first = row.threads.items[0].clone_target(channel); + assert_eq!( + apply(&db, community, &actor, first).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert!(row.threads.complete); + assert_eq!(row.threads.items.len(), 5); + let sum: u32 = row + .threads + .items + .iter() + .map(|t| exact(&t.unread).unwrap()) + .sum(); + assert_eq!( + Some(sum), + exact(&row.unread), + "complete summaries account for every reply" + ); +} + +impl ThreadReadSummary { + fn clone_target(&self, channel: Uuid) -> ReadIntent { + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: Some(self.root_id.clone()), + }, + message_id: self.latest_reply_id.clone(), + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_summaries_are_incomplete_when_tag_evidence_could_hide_a_root() { + let (db, pool, community, channel, actor, root) = fixture().await; + let listed = reply( + &db, + &pool, + community, + channel, + &root, + root.created_at.as_secs() + 1, + vec![], + ) + .await; + let hidden = post( + &db, + community, + channel, + root.created_at.as_secs() + 2, + vec![], + ) + .await; + sqlx::query("UPDATE events SET tags=$3 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(hidden.id.as_bytes().as_slice()) + .bind(serde_json::json!([["e", "x".repeat(9000)]])) + .execute(&pool) + .await + .unwrap(); + let row = sidebar(&db, community, &actor).await; + assert_eq!(row.threads.items.len(), 1); + assert_eq!(row.threads.items[0].latest_reply_id, listed.id.to_hex()); + assert!(!row.threads.complete); + assert!(!matches!( + row.threads.items[0].unread, + ReadCount::Exact { .. } + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn targeted_sidebar_returns_only_requested_joined_channels_in_one_snapshot() { + let (db, _pool, community, channel, actor, _) = fixture().await; + let create = |name: &'static str, owner: Keys| { + let db = db.clone(); + async move { + db.create_channel( + community, + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id + } + }; + let joined = create("joined", actor.clone()).await; + let foreign = create("not joined", Keys::generate()).await; + let _unrequested = create("unrequested", actor.clone()).await; + let page = db + .personal_read_sidebar_channels( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[joined, foreign, channel], + ) + .await + .unwrap(); + let mut expected = vec![channel, joined]; + expected.sort(); + let ids: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect(); + assert_eq!(ids, expected); + assert!(page.next_cursor.is_none()); + for bad in [ + vec![], + vec![channel, channel], + (0..21).map(|_| Uuid::new_v4()).collect(), + ] { + assert!(db + .personal_read_sidebar_channels( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &bad + ) + .await + .is_err()); + } +} diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index 7792cfe1a30..3aa7d14fb10 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -123,6 +123,26 @@ async fn message( .transpose() } +/// An eligible-kind message's author time, deleted or not, without tags. +async fn anchor_timestamp( + conn: &mut PgConnection, + community: CommunityId, + channel: Uuid, + id: &[u8], +) -> Result> { + let created: Option> = sqlx::query_scalar( + "SELECT created_at FROM events + WHERE community_id=$1 AND channel_id=$2 AND id=$3 AND kind=ANY($4) LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(id) + .bind(ELIGIBLE_KINDS.as_slice()) + .fetch_optional(conn) + .await?; + Ok(created.map(|t| t.timestamp())) +} + pub(super) async fn valid_target( conn: &mut PgConnection, community: CommunityId, @@ -195,6 +215,30 @@ pub(super) async fn apply( } frontier(conn, community, actor, target, &root, msg.timestamp).await?; } + ReadIntent::MarkChannelRead { + channel_id, + message_id, + } => { + let Some(id) = event_id(message_id) else { + return Ok(IntentOutcome::Invalid); + }; + if !access(conn, community, actor, *channel_id).await? { + return Ok(IntentOutcome::Blocked); + } + // Only the anchor's time matters: ancestry cannot change which + // messages a whole-channel cut covers. + let Some(timestamp) = anchor_timestamp(conn, community, *channel_id, &id).await? else { + return Ok(IntentOutcome::Blocked); + }; + sqlx::query( + "INSERT INTO personal_read_frontiers (community_id, actor, channel_id, root_id, + through_timestamp, threads_through_timestamp) VALUES ($1,$2,$3,''::bytea,$4,$4) + ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE + SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, $4), + threads_through_timestamp=GREATEST(personal_read_frontiers.threads_through_timestamp, $4)", + ).bind(community.as_uuid()).bind(actor).bind(channel_id).bind(timestamp) + .execute(&mut *conn).await?; + } ReadIntent::LegacyPrefix { target, through_timestamp, diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index ee0bef9c0c7..01e0e4110ec 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -12,11 +12,23 @@ use serde_json::{json, Value}; use std::{sync::Arc, time::Duration}; use uuid::Uuid; +/// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid. +fn parse_channel_ids(value: &str) -> Option> { + let ids = value + .split(',') + .map(|id| Uuid::parse_str(id).ok()) + .collect::>>()?; + let unique: std::collections::HashSet<_> = ids.iter().collect(); + ((1..=MAX_CHANNELS).contains(&ids.len()) && unique.len() == ids.len()).then_some(ids) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct SidebarQuery { limit: Option, cursor: Option, + /// Comma-separated channel UUIDs to refresh; exclusive with paging. + channel_ids: Option, } pub(super) async fn sidebar( @@ -34,17 +46,32 @@ pub(super) async fn sidebar( { return Err(Error::invalid()); } - let page = state - .db - .personal_read_sidebar( - principal.tenant.community(), - &principal.actor, - state.config.buzz_v1_retention_seconds, - query.limit.unwrap_or(MAX_CHANNELS), - query.cursor, - ) - .await - .map_err(|_| Error::unavailable())?; + let community = principal.tenant.community(); + let retention = state.config.buzz_v1_retention_seconds; + let page = match query.channel_ids { + Some(ids) => { + let ids = parse_channel_ids(&ids) + .filter(|_| query.limit.is_none() && query.cursor.is_none()) + .ok_or_else(Error::invalid)?; + state + .db + .personal_read_sidebar_channels(community, &principal.actor, retention, &ids) + .await + } + None => { + state + .db + .personal_read_sidebar( + community, + &principal.actor, + retention, + query.limit.unwrap_or(MAX_CHANNELS), + query.cursor, + ) + .await + } + } + .map_err(|_| Error::unavailable())?; auth::recheck(&state, &headers, &principal).await?; let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect(); let memberships = state diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index ebc82b8cc52..9544ad3baf1 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -124,6 +124,33 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() { .0, StatusCode::UNAUTHORIZED ); + // Targeted refresh is exclusive with paging and bounded to unique IDs. + let id = uuid::Uuid::new_v4(); + let many = (0..21) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>() + .join(","); + for (query, status) in [ + (format!("channel_ids={id}"), StatusCode::OK), + (format!("channel_ids={id},{id}"), StatusCode::BAD_REQUEST), + (format!("channel_ids={id}&limit=1"), StatusCode::BAD_REQUEST), + ( + format!("channel_ids={id}&cursor={id}"), + StatusCode::BAD_REQUEST, + ), + ("channel_ids=".into(), StatusCode::BAD_REQUEST), + ("channel_ids=not-a-uuid".into(), StatusCode::BAD_REQUEST), + (format!("channel_ids={many}"), StatusCode::BAD_REQUEST), + ] { + let path = format!("/buzz/v1/me/sidebar?{query}"); + let auth = proof(&actor, &host, &path, "GET", None); + let (got, body) = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(got, status, "{query}: {body}"); + if status == StatusCode::OK { + assert_eq!(body["channels"], json!([]), "unjoined ID is simply absent"); + assert!(body["next_cursor"].is_null()); + } + } let write_path = "/buzz/v1/me/read-state"; let body = serde_json::to_vec(&json!({"intents":[{"type":"complete_import"}]})).unwrap(); let missing_hash = proof(&actor, &host, write_path, "POST", None); @@ -370,6 +397,7 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { d["max_context_messages"], buzz_db::personal_read::MAX_CONTEXT_MESSAGES ); + assert_eq!(d["max_thread_summaries"], 5); } } } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index f1ee6a30663..4b2c4a3fe5e 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -92,6 +92,8 @@ pub struct BuzzV1Descriptor { pub max_contexts: usize, /// Maximum message selectors across one context request. pub max_context_messages: usize, + /// Maximum unread-thread summaries per sidebar channel row. + pub max_thread_summaries: usize, } /// Public capability descriptor for relay-proxied GIF search. @@ -323,7 +325,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { if state.config.buzz_v1_enabled { use buzz_db::personal_read::{ - MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS, + MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS, MAX_THREAD_SUMMARIES, }; info.buzz_v1 = Some(BuzzV1Descriptor { version: 1, @@ -333,6 +335,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st max_intents: MAX_INTENTS, max_contexts: MAX_CONTEXTS, max_context_messages: MAX_CONTEXT_MESSAGES, + max_thread_summaries: MAX_THREAD_SUMMARIES, }); } info.read_state_snapshot = Some(serde_json::json!({ diff --git a/docs/buzz-v1-extension-design.md b/docs/buzz-v1-extension-design.md index 42bc6978730..515210799ae 100644 --- a/docs/buzz-v1-extension-design.md +++ b/docs/buzz-v1-extension-design.md @@ -3,9 +3,8 @@ Design guidance for follow-up work, not implemented wire capabilities. The current [API contract](buzz-v1-read-state.md) remains authoritative. -Future thread previews can add an optional bounded summary to each channel row, -with independent list completeness and count evidence. Expensive signed-event -expansions should be opt-in and preserve the base sidebar's work/byte budgets; +Thread summaries are implemented (see `threads` in the API contract). Richer +previews that carry signed-event content should be opt-in and preserve the base sidebar's work/byte budgets; fetching detail separately remains valid. Reuse canonical classification and read frontiers, not a second definition of unread. Historical-mention eligibility or mute-aware attention requires an explicit policy contract, not an unnoticed @@ -19,4 +18,4 @@ receipt horizon moves without writes. Mutes and manual-unread overrides must not be encoded by advancing or rewinding a read frontier. These are extension constraints, not implemented capabilities: this version does -not advertise thread previews, revision-based reuse or synchronized overrides. +not advertise revision-based reuse or synchronized overrides. diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index 1c40497d10d..9e56a4bb56e 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -12,7 +12,8 @@ application/nostr+json`, or `GET /info`) includes `buzz_v1` only when enabled: ```json {"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000, -"max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100}} +"max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100, +"max_thread_summaries":5}} ``` Use the requesting origin plus this relative prefix. Discovery is a configured @@ -39,10 +40,36 @@ Unknown request fields are rejected. Never turn a transport failure into read. `GET /buzz/v1/me/sidebar?limit=20&cursor=` returns `account`, `channels`, and `next_cursor`. Omit the cursor on the first request. Each channel includes identity/name/type, archived and hidden flags, `unread`, -`attention`, `latest_message_id`, and `latest_message_complete`. -Only joined, nondeleted channels are listed. Hidden/archived presentation remains -client-owned. Each page has a writer-consistent snapshot; separate pages do not -share a snapshot, and an unfinished traversal cannot prove channel removal. +`attention`, `latest_message_id`, `latest_message_at` (its author time in +seconds; null exactly when the ID is null), `latest_message_complete`, and +`threads`. Only joined, nondeleted channels are listed. Hidden/archived +presentation remains client-owned. Each page has a writer-consistent snapshot; +separate pages do not share a snapshot, and an unfinished traversal cannot prove +channel removal. + +`GET /buzz/v1/me/sidebar?channel_ids=,` refreshes 1–20 unique +channels in one snapshot, ordered by ID with `next_cursor: null`. It cannot be +combined with `limit` or `cursor`. A requested ID absent from the result was not +a joined, nondeleted, accessible sidebar row at that snapshot: remove its row. +Absence says nothing else about access to an open channel. + +`threads` lists unread threads in the row, newest unread reply first: + +```json +{"items":[{"root_id":"<64-hex>","unread":{"status":"exact","value":2}, + "attention":{"status":"exact","value":0},"latest_reply_id":"<64-hex>", + "latest_reply_at":1700000000}],"complete":true} +``` + +Items are canonical roots with unread eligible replies, ordered by +`latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is the +newest observed unread reply (equal times prefer the smaller ID) and a valid +thread `mark_through` anchor. `unread` and `attention` use the row's +definitions. `complete=true` means the receipt scan was exhausted, no evidence +had unresolved ancestry or unusable tags, and no thread was omitted; then item +unread counts sum to the row's unread replies. Otherwise the list is a cut of +observed evidence and counts may be lower bounds or unknown. Participation +budget exhaustion affects only `attention`. No message bytes are included. Counts have exactly three representations: @@ -78,7 +105,8 @@ global export of frontiers. Example decoded `targets`: Omitting `root_id` selects the channel timeline. The result contains `account` and one `contexts` entry per request entry, in order. Context status is `available` (with nullable `through_timestamp` and `messages`), `unknown`, or -`unavailable`. Message status is `read`, `not_counted`, `unread` (with nullable +`unavailable`. A thread context's `through_timestamp` is its effective prefix, +including any whole-channel cut. Message status is `read`, `not_counted`, `unread` (with nullable `attention`), `unknown`, or `unavailable`. Wrong-context, missing and forbidden selectors share unavailable. Null attention means participation is unproved. Conversation bytes must still come from the existing Nostr path. @@ -90,6 +118,7 @@ Conversation bytes must still come from the existing Nostr path. ```json {"intents":[ {"type":"mark_through","target":{"channel_id":""},"message_id":"<64-hex-event>"}, + {"type":"mark_channel_read","channel_id":"","message_id":"<64-hex-event>"}, {"type":"legacy_prefix","target":{"channel_id":"","root_id":"<64-hex-root>"},"through_timestamp":1700000000}, {"type":"complete_import"} ]} @@ -109,7 +138,17 @@ inherit in either direction. Opening a view is not itself a reading action; client dwell/focus policy determines when to send an actual observed anchor. Old or deleted valid anchors may advance a frontier. Legacy prefixes preserve the original nonnegative timestamp; more than DB-now + 900 seconds is invalid, -not clamped. Complete-import is a client declaration, not proof that the server +not clamped. + +`mark_channel_read` is the one whole-channel cut: it advances the channel +timeline and every thread in that channel, including unlisted ones, through the +anchor's author time. The anchor must be an accessible eligible-kind message in +the channel, top-level or reply, deleted or not; ancestry is not checked. A +reply is read at or below the greater of its thread frontier and this cut. An +anchor that no longer exists is `blocked`. `latest_message_id` is a natural +anchor. A null ID with `latest_message_complete=false` does not prove empty +history; it only leaves the client without an anchor. Thread marks and channel `mark_through` +never set the cut. Complete-import is a client declaration, not proof that the server verified or decrypted a legacy snapshot. Null `imported_at_ms` means provisional. Sparse legacy seen hints must not be converted to the largest timestamp: that diff --git a/migrations/0051_personal_read_state.sql b/migrations/0051_personal_read_state.sql index c4c6f96e7d6..21d13bc1527 100644 --- a/migrations/0051_personal_read_state.sql +++ b/migrations/0051_personal_read_state.sql @@ -1,6 +1,8 @@ -- Private accessory read progress. Never included in Nostr event queries. -- Frontiers use signed event time. An empty root_id covers only the channel -- timeline; a root-specific frontier covers that thread, without inheritance. +-- threads_through_timestamp is the only cross-context cut: an explicit +-- whole-channel read that also covers every thread in that channel. CREATE TABLE personal_read_accounts ( community_id UUID NOT NULL REFERENCES communities(id), actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), @@ -14,6 +16,10 @@ CREATE TABLE personal_read_frontiers ( channel_id UUID NOT NULL, root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), through_timestamp BIGINT NOT NULL CHECK (through_timestamp >= 0), + -- Whole-channel cut covering every thread; channel rows only. + threads_through_timestamp BIGINT + CHECK (threads_through_timestamp IS NULL + OR (threads_through_timestamp >= 0 AND root_id = ''::bytea)), PRIMARY KEY (community_id, actor, channel_id, root_id), FOREIGN KEY (community_id, actor) REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, diff --git a/schema/schema.sql b/schema/schema.sql index ef54a232b74..dbf3b66a616 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -209,6 +209,10 @@ CREATE TABLE personal_read_frontiers ( channel_id UUID NOT NULL, root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), through_timestamp BIGINT NOT NULL CHECK (through_timestamp >= 0), + -- Whole-channel cut covering every thread; channel rows only. + threads_through_timestamp BIGINT + CHECK (threads_through_timestamp IS NULL + OR (threads_through_timestamp >= 0 AND root_id = ''::bytea)), PRIMARY KEY (community_id, actor, channel_id, root_id), FOREIGN KEY (community_id, actor) REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, From 49dfc89173023fea718c2a95800e8b6eab351c04 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 11:20:01 -0400 Subject: [PATCH 07/18] fix(relay): make /buzz/v1 invisible when disabled and drop its events index The accessory read-state API must be optional: a relay with BUZZ_V1_ENABLED unset has to behave for existing clients, NIP-RS included, as if this feature had never been written. Three things broke that. Migration 0055 built and maintained a receipt-time index on `events` for every deployment, enabled or not. The unread horizon now uses author time (`created_at`), the clock every frontier already uses, so both sidebar scans are served by the existing idx_events_community_channel_created and 0055 is deleted with its deployment procedure. This is a contract change, not an index swap: a message accepted late with an author time beyond the horizon is never unread, and unread expires at author time plus the horizon. The contract spells these out. The router mounted /buzz/v1 when disabled and answered with its own errors. It is now unmounted, so those paths answer exactly as any unknown path does. BUZZ_V1_RETENTION_SECONDS was validated at startup even when disabled, so a bad value for an unused feature stopped the relay. It is read only when enabled. Also removed, because no client sends them now that buzz-app starts with no imported read state: the legacy_prefix and complete_import intents and account.imported_at_ms. The descriptor gains eligible_kinds so clients classify live arrivals from the relay's own set instead of a copy. Remaining flag-off footprint, accepted: migration 0054's two empty private tables, and the purge and cascade work on them. Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 7 +- crates/buzz-db/src/runtime/tests.rs | 71 ------- .../tests/thread_window_postgres_tests.rs | 2 +- .../src/store/personal_read/classification.rs | 4 +- .../src/store/personal_read/context.rs | 7 +- crates/buzz-db/src/store/personal_read/mod.rs | 4 +- .../buzz-db/src/store/personal_read/model.rs | 23 +-- .../src/store/personal_read/participation.rs | 2 +- .../participation_postgres_tests.rs | 5 +- .../src/store/personal_read/postgres_tests.rs | 179 +++++++++--------- .../src/store/personal_read/projection.rs | 36 ++-- .../projection_postgres_tests.rs | 49 ++++- .../personal_read/threads_postgres_tests.rs | 9 +- .../buzz-db/src/store/personal_read/writes.rs | 26 +-- crates/buzz-relay/src/api/buzz_v1/auth.rs | 3 - .../src/api/buzz_v1/postgres_tests.rs | 110 ++++++++--- crates/buzz-relay/src/config.rs | 48 +++-- crates/buzz-relay/src/nip11.rs | 9 +- crates/buzz-relay/src/router.rs | 13 +- docs/buzz-v1-extension-design.md | 2 +- docs/buzz-v1-read-state.md | 66 ++++--- docs/events-channel-received-deployment.md | 79 -------- migrations/0054_personal_read_state.sql | 1 - .../0055_events_channel_received_index.sql | 36 ---- schema/schema.sql | 8 +- 25 files changed, 349 insertions(+), 450 deletions(-) delete mode 100644 docs/events-channel-received-deployment.md delete mode 100644 migrations/0055_events_channel_received_index.sql diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 3fe49ac205a..a7d2056705c 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -705,12 +705,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 55); - assert_eq!(migrations[54].version, 55); - assert!(migrations[54] - .sql - .as_str() - .contains("idx_events_community_channel_received")); + assert_eq!(migrations.len(), 54); assert_eq!(migrations[53].version, 54); assert!(migrations[53] .sql diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index a1100246d60..3aa8e906858 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -3612,74 +3612,3 @@ async fn e_tag_any_runs_with_real_binds_on_p_join_and_count() { #[path = "tests/thread_window_postgres_tests.rs"] mod thread_window_postgres_tests; - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn migration_schema_channel_received_prebuild_validation() { - const INDEX: &str = "idx_events_community_channel_received"; - const COLUMNS: &str = "(community_id, channel_id, received_at DESC, id, created_at)"; - let admin = PgPool::connect(&admin_url().await).await.unwrap(); - let (pool, name) = create_scratch_db_through(&admin, "received_prebuild", Some(51)).await; - let partitions: Vec = sqlx::query_scalar( - "SELECT c.relname::text FROM pg_inherits i JOIN pg_class c ON c.oid=i.inhrelid \ - WHERE i.inhparent='events'::regclass ORDER BY 1", - ) - .fetch_all(&pool) - .await - .unwrap(); - assert!(partitions.len() > 1, "must exercise a partitioned parent"); - for setup in [ - // Wrong definition: ascending receipt order cannot serve the window. - format!("CREATE INDEX {INDEX} ON events (community_id, channel_id, received_at, id, created_at)"), - // Incomplete brownfield prebuild: parent exists, no partition attached. - format!("CREATE INDEX {INDEX} ON ONLY events {COLUMNS}"), - ] { - sqlx::raw_sql(sqlx::AssertSqlSafe(setup)).execute(&pool).await.unwrap(); - let error = migration::run_migrations(&pool).await.unwrap_err(); - assert!( - error.to_string().contains("invalid or wrong definition"), - "must reject catalog shape, not merely time out: {error}" - ); - let version: i64 = sqlx::query_scalar("SELECT max(version) FROM _sqlx_migrations") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(version, 54); - sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP INDEX {INDEX}"))) - .execute(&pool) - .await - .unwrap(); - } - // The documented brownfield procedure: parent ON ONLY, each partition - // CONCURRENTLY, then ATTACH. The migration must adopt it, not rebuild it. - sqlx::raw_sql(sqlx::AssertSqlSafe(format!( - "CREATE INDEX {INDEX} ON ONLY events {COLUMNS}" - ))) - .execute(&pool) - .await - .unwrap(); - for partition in &partitions { - sqlx::raw_sql(sqlx::AssertSqlSafe(format!( - "CREATE INDEX CONCURRENTLY {partition}_channel_received ON {partition} {COLUMNS}" - ))) - .execute(&pool) - .await - .unwrap(); - sqlx::raw_sql(sqlx::AssertSqlSafe(format!( - "ALTER INDEX {INDEX} ATTACH PARTITION {partition}_channel_received" - ))) - .execute(&pool) - .await - .unwrap(); - } - let oid = || { - sqlx::query_scalar::<_, i64>( - "SELECT 'idx_events_community_channel_received'::regclass::oid::bigint", - ) - .fetch_one(&pool) - }; - let prebuilt = oid().await.unwrap(); - migration::run_migrations(&pool).await.unwrap(); - assert_eq!(prebuilt, oid().await.unwrap()); - drop_scratch_db(&admin, pool, &name).await; -} diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index 1904bf6363d..e757b65d450 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -311,7 +311,7 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() production_result.is_ok(), "production migrator must preserve ingestion progress: {production_result:?}" ); - assert_eq!(version, 55); + assert_eq!(version, 54); assert_eq!(final_oid, oid, "prebuild must not be replaced"); assert_eq!(count, 4, "all writer witnesses must persist"); } diff --git a/crates/buzz-db/src/store/personal_read/classification.rs b/crates/buzz-db/src/store/personal_read/classification.rs index c1f25ea9d19..ef2635b06a9 100644 --- a/crates/buzz-db/src/store/personal_read/classification.rs +++ b/crates/buzz-db/src/store/personal_read/classification.rs @@ -6,10 +6,10 @@ pub(super) fn eligible( kind: i32, own: bool, deleted: bool, - received_ms: i64, + created_ms: i64, cutoff_ms: i64, ) -> bool { - ELIGIBLE_KINDS.contains(&kind) && !own && !deleted && received_ms >= cutoff_ms + ELIGIBLE_KINDS.contains(&kind) && !own && !deleted && created_ms >= cutoff_ms } pub(super) fn directed(channel_type: &str, actor_hex: &str, tags: &[Vec]) -> bool { diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs index 72158b6171d..305a2f2f8d7 100644 --- a/crates/buzz-db/src/store/personal_read/context.rs +++ b/crates/buzz-db/src/store/personal_read/context.rs @@ -39,7 +39,7 @@ impl Db { .await?; writes::deadlines(&mut tx).await?; let actor_bytes = actor.to_bytes(); - let account = read_account(&mut tx, community, &actor_bytes, retention_seconds).await?; + let account = read_account(&mut tx, retention_seconds).await?; let mut contexts = Vec::with_capacity(queries.len()); for query in queries { let root = @@ -77,7 +77,7 @@ impl Db { .filter_map(|id| writes::event_id(id)) .collect(); let rows = sqlx::query( - "SELECT encode(e.id,'hex') AS id,e.kind,e.created_at,e.received_at, + "SELECT encode(e.id,'hex') AS id,e.kind,e.created_at, e.deleted_at IS NOT NULL AS deleted,e.pubkey=$3 AS own, CASE WHEN octet_length(e.tags::text)<=8192 THEN e.tags ELSE NULL END AS tags, tm.root_event_id,c.channel_type::text AS channel_type @@ -117,13 +117,12 @@ impl Db { MessageReadState::Unavailable } else { let created: DateTime = row.try_get("created_at")?; - let received: DateTime = row.try_get("received_at")?; let kind: i32 = row.try_get("kind")?; if !classification::eligible( kind, row.try_get("own")?, row.try_get("deleted")?, - received.timestamp_millis(), + created.timestamp_millis(), account.cutoff_ms, ) { MessageReadState::NotCounted diff --git a/crates/buzz-db/src/store/personal_read/mod.rs b/crates/buzz-db/src/store/personal_read/mod.rs index 282113830a6..cf61addc9b7 100644 --- a/crates/buzz-db/src/store/personal_read/mod.rs +++ b/crates/buzz-db/src/store/personal_read/mod.rs @@ -1,7 +1,7 @@ //! Private, signer-owned accessory read progress, separate from NIP-RS events. //! -//! Timestamp prefixes use author time; retention uses persisted relay receipt -//! time. Only fixed context intents advance prefixes, never a query scan cap. +//! Timestamp prefixes and the unread horizon both use author time. Only fixed +//! context intents advance prefixes, never a query scan cap. mod classification; mod context; diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index 558f85cc566..e098e35f7ab 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -35,22 +35,13 @@ pub enum ReadIntent { /// Fixed anchor; retry must not substitute the latest message. message_id: String, }, - /// Migration/recovery only: preserve an original legacy timestamp verbatim. - LegacyPrefix { - /// Original channel or canonical thread scope. - target: ReadTarget, - /// Original nonnegative second-resolution event timestamp. - through_timestamp: i64, - }, - /// The client declares its frozen baseline resolved, including exceptions. - CompleteImport, } /// Outcome for one independent transaction, never acknowledged before commit. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(tag = "status", rename_all = "snake_case")] pub enum IntentOutcome { - /// The fixed frontier/import operand committed. + /// The fixed frontier operand committed. Applied, /// Missing and forbidden contexts deliberately share one outcome. Blocked, @@ -58,15 +49,13 @@ pub enum IntentOutcome { Invalid, } -/// The tracking boundary and migration status for the authenticated account. +/// The tracking boundary for the authenticated account. #[derive(Clone, Debug, Serialize)] pub struct ReadAccount { /// Configured tracking duration in seconds. pub retention_seconds: u32, - /// Read-time relay-receipt cutoff (Unix milliseconds), not a discard boundary. + /// Read-time author-time cutoff (Unix milliseconds), not a discard boundary. pub cutoff_ms: i64, - /// Client-declared baseline completion; null means provisional projections. - pub imported_at_ms: Option, } /// Maximum channel summaries in one sidebar page. @@ -75,8 +64,8 @@ pub const MAX_CHANNELS: usize = 20; pub const MAX_THREAD_SUMMARIES: usize = 5; /// Bounded event evidence per channel; exhaustion is never inferred at this cap. pub const MAX_CHANNEL_SCAN: usize = 256; -/// Receipt-window work budget per channel, before eligibility/ancestry joins. -pub const MAX_RECEIPT_SCAN: usize = 4096; +/// Unread-window work budget per channel, before eligibility/ancestry joins. +pub const MAX_UNREAD_SCAN: usize = 4096; /// Conversation kinds eligible for ordinary unread state (not edits/reactions). pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003]; @@ -243,7 +232,7 @@ pub enum ContextState { /// Actor-private bounded context response; no cross-request snapshot guarantee. #[derive(Debug, Serialize)] pub struct ContextPage { - /// Receipt-time horizon and import status at this snapshot. + /// Effective read-state lifecycle for this response. pub account: ReadAccount, /// One result per requested context, in request order. pub contexts: Vec, diff --git a/crates/buzz-db/src/store/personal_read/participation.rs b/crates/buzz-db/src/store/personal_read/participation.rs index 4db8189d564..f410cc092b7 100644 --- a/crates/buzz-db/src/store/personal_read/participation.rs +++ b/crates/buzz-db/src/store/personal_read/participation.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use uuid::Uuid; const MAX_THREAD_SCAN: i64 = 256; -// Bound multiplicative work independently of the receipt evidence window. +// Bound multiplicative work independently of the unread evidence window. const MAX_ROOTS: usize = 1024; /// Positive evidence survives truncation; absence requires exhausting the thread. diff --git a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs index e555be48fbb..b49d815fed5 100644 --- a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs @@ -69,6 +69,8 @@ async fn personal_read_attention_proves_participation_without_retention_or_front } else { &other }; + // Root and own reply predate the unread horizon. Participation must not + // expire with unread. let root = event( &db, community, @@ -99,9 +101,6 @@ async fn personal_read_attention_proves_participation_without_retention_or_front .unwrap(); } } - // Root and own reply receipts are old. Participation must not expire with unread. - sqlx::query("UPDATE events SET received_at=now()-interval '35 days' WHERE community_id=$1 AND channel_id=$2") - .bind(community.as_uuid()).bind(channel).execute(&pool).await.unwrap(); let count = if case >= 5 { 258 } else { 1 }; let mut last = root.clone(); for i in 0..count { diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index 244b51001b2..7bae974c5ad 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -41,6 +41,16 @@ pub(super) async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Ev (db, pool, community, channel, actor, event) } +/// Move every community message past the default horizon by author time, the +/// only clock the unread window reads. +async fn expire(pool: &PgPool, community: CommunityId) { + sqlx::query("UPDATE events SET created_at=created_at-interval '31 days' WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(pool) + .await + .unwrap(); +} + async fn add_history(db: &Db, community: CommunityId, channel: Uuid, count: usize) -> nostr::Event { let author = Keys::generate(); let mut last = None; @@ -62,7 +72,7 @@ async fn add_history(db: &Db, community: CommunityId, channel: Uuid, count: usiz #[ignore = "requires Postgres"] async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads_are_unknown() { let (db, _pool, community, channel, actor, _) = fixture().await; - let last = add_history(&db, community, channel, MAX_RECEIPT_SCAN + 44).await; + let last = add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; db.apply_personal_read_intent( community, &actor.public_key(), @@ -105,16 +115,10 @@ async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_sidebar_receipt_window_excludes_expired_but_keeps_fresh_backfill() { +async fn personal_read_sidebar_author_window_excludes_expired_and_late_old_messages() { let (db, pool, community, channel, actor, _) = fixture().await; - let latest = add_history(&db, community, channel, MAX_RECEIPT_SCAN + 44).await; - sqlx::query( - "UPDATE events SET received_at=clock_timestamp()-interval '31 days' WHERE community_id=$1", - ) - .bind(community.as_uuid()) - .execute(&pool) - .await - .unwrap(); + let latest = add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; + expire(&pool, community).await; let page = db .personal_read_sidebar( community, @@ -127,46 +131,47 @@ async fn personal_read_sidebar_receipt_window_excludes_expired_but_keeps_fresh_b .unwrap(); assert!( matches!(page.channels[0].unread, ReadCount::Exact { value: 0 }), - "receipt-ordered window proves exhaustion independent of author time" + "an empty author-time window proves exhaustion" ); assert_eq!( page.channels[0].latest_message_id.as_deref(), Some(latest.id.to_hex().as_str()) ); assert!(page.channels[0].latest_message_complete); - // A fresh receipt with old author time belongs to retention even below - // the author-ordered history. The receipt window must find it. - let backfill = EventBuilder::new(Kind::Custom(9), "recently imported old message") - .custom_created_at(nostr::Timestamp::from( - nostr::Timestamp::now().as_secs() - 40 * 86400, - )) - .sign_with_keys(&Keys::generate()) - .unwrap(); - db.insert_event(community, &backfill, Some(channel)) - .await - .unwrap(); - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::Exact { value: 1 } - )); + // Acceptance time is irrelevant: a message accepted now with an author + // time beyond the horizon stays out; one authored now is counted. + let now = nostr::Timestamp::now().as_secs(); + for (age, unread) in [(40 * 86400, 0), (0, 1)] { + let event = EventBuilder::new(Kind::Custom(9), "accepted now") + .custom_created_at(nostr::Timestamp::from(now - age)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value } if value == unread), + "age={age}" + ); + } } #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_sidebar_receipt_budget_counts_boundary_and_ineligible_tail() { +async fn personal_read_sidebar_window_budget_counts_boundary_and_ineligible_tail() { let (db, pool, community, channel, actor, _) = fixture().await; // The fixture contributes one event, so this is exactly the evidence budget. - add_history(&db, community, channel, MAX_RECEIPT_SCAN - 1).await; + add_history(&db, community, channel, MAX_UNREAD_SCAN - 1).await; let page = db .personal_read_sidebar( community, @@ -178,7 +183,7 @@ async fn personal_read_sidebar_receipt_budget_counts_boundary_and_ineligible_tai .await .unwrap(); assert!( - matches!(page.channels[0].unread, ReadCount::Exact { value } if value == MAX_RECEIPT_SCAN as u32) + matches!(page.channels[0].unread, ReadCount::Exact { value } if value == MAX_UNREAD_SCAN as u32) ); let overflow = EventBuilder::new(Kind::Custom(9), "one beyond the budget") .sign_with_keys(&Keys::generate()) @@ -197,12 +202,12 @@ async fn personal_read_sidebar_receipt_budget_counts_boundary_and_ineligible_tai .await .unwrap(); assert!( - matches!(page.channels[0].unread, ReadCount::AtLeast { value } if value == MAX_RECEIPT_SCAN as u32) + matches!(page.channels[0].unread, ReadCount::AtLeast { value } if value == MAX_UNREAD_SCAN as u32) ); - // Expire all but 601 receipts. Of these, 300 are own and 300 deleted. - // Eligibility is downstream of the bounded receipt window, not the old 256 cap. - sqlx::query("WITH ranked AS (SELECT created_at,id,row_number() OVER (ORDER BY received_at DESC,id) AS n FROM events WHERE community_id=$1 AND channel_id=$2) - UPDATE events e SET received_at=CASE WHEN r.n>601 THEN now()-interval '31 days' ELSE e.received_at END, + // Expire all but 601 messages. Of these, 300 are own and 300 deleted. + // Eligibility is downstream of the bounded unread window, not the old 256 cap. + sqlx::query("WITH ranked AS (SELECT created_at,id,row_number() OVER (ORDER BY created_at DESC,id) AS n FROM events WHERE community_id=$1 AND channel_id=$2) + UPDATE events e SET created_at=CASE WHEN r.n>601 THEN e.created_at-interval '31 days' ELSE e.created_at END, pubkey=CASE WHEN r.n<=300 THEN $3 ELSE e.pubkey END, deleted_at=CASE WHEN r.n>300 AND r.n<=600 THEN now() ELSE NULL END FROM ranked r WHERE e.community_id=$1 AND e.created_at=r.created_at AND e.id=r.id") @@ -223,7 +228,7 @@ async fn personal_read_sidebar_receipt_budget_counts_boundary_and_ineligible_tai )); // Filtering ineligible evidence must not erase the raw-window overflow. sqlx::query( - "UPDATE events SET received_at=now(),pubkey=$2 WHERE community_id=$1 AND channel_id=$3", + "UPDATE events SET created_at=date_trunc('second',now()),pubkey=$2 WHERE community_id=$1 AND channel_id=$3", ) .bind(community.as_uuid()) .bind(actor.public_key().to_bytes().as_slice()) @@ -247,7 +252,7 @@ async fn personal_read_sidebar_receipt_budget_counts_boundary_and_ineligible_tai #[tokio::test] #[ignore = "requires Postgres"] async fn personal_read_sidebar_is_read_only_and_does_not_wait_for_account() { - let (db, pool, community, _channel, actor, _) = fixture().await; + let (db, pool, community, channel, actor, event) = fixture().await; db.personal_read_sidebar( community, &actor.public_key(), @@ -264,7 +269,11 @@ async fn personal_read_sidebar_is_read_only_and_does_not_wait_for_account() { .await .unwrap(); assert_eq!(count, 0, "GET must not create private state"); - db.apply_personal_read_intent(community, &actor.public_key(), &ReadIntent::CompleteImport) + let mark = ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: event.id.to_hex(), + }; + db.apply_personal_read_intent(community, &actor.public_key(), &mark) .await .unwrap(); let mut held = pool.begin().await.unwrap(); @@ -283,7 +292,10 @@ async fn personal_read_sidebar_is_read_only_and_does_not_wait_for_account() { ) .await .unwrap(); - assert!(page.account.imported_at_ms.is_some()); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 0 } + )); held.rollback().await.unwrap(); } @@ -391,13 +403,7 @@ async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { Some(own.id.to_hex().as_str()) ); assert!(page.channels[0].latest_message_complete); - sqlx::query( - "UPDATE events SET received_at=clock_timestamp()-interval '31 days' WHERE community_id=$1", - ) - .bind(community.as_uuid()) - .execute(&pool) - .await - .unwrap(); + expire(&pool, community).await; let page = db .personal_read_sidebar( community, @@ -419,13 +425,7 @@ async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { #[ignore = "requires Postgres"] async fn personal_read_latest_old_message_is_not_an_empty_channel() { let (db, pool, community, channel, actor, event) = fixture().await; - sqlx::query( - "UPDATE events SET received_at=clock_timestamp()-interval '31 days' WHERE community_id=$1", - ) - .bind(community.as_uuid()) - .execute(&pool) - .await - .unwrap(); + expire(&pool, community).await; let empty = db .create_channel( community, @@ -473,7 +473,7 @@ async fn personal_read_latest_old_message_is_not_an_empty_channel() { .execute(&pool) .await .unwrap(); - add_history(&db, community, channel, MAX_RECEIPT_SCAN + 44).await; + add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1") .bind(community.as_uuid()) .execute(&pool) @@ -500,15 +500,15 @@ async fn personal_read_latest_old_message_is_not_an_empty_channel() { #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_frontier_retries_old_anchors_and_rejects_future_imports() { +async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { let (db, pool, community, channel, actor, event) = fixture().await; let target = ReadTarget { channel_id: channel, root_id: None, }; - let invalid = ReadIntent::LegacyPrefix { + let invalid = ReadIntent::MarkThrough { target: target.clone(), - through_timestamp: i64::MAX, + message_id: "not an event id".into(), }; assert_eq!( db.apply_personal_read_intent(community, &actor.public_key(), &invalid) @@ -523,13 +523,6 @@ async fn personal_read_frontier_retries_old_anchors_and_rejects_future_imports() .await .unwrap(); assert_eq!(count, 0, "invalid intent rolls back account creation"); - sqlx::query( - "UPDATE events SET received_at=clock_timestamp()-interval '60 days' WHERE community_id=$1", - ) - .bind(community.as_uuid()) - .execute(&pool) - .await - .unwrap(); let intent = ReadIntent::MarkThrough { target: target.clone(), message_id: event.id.to_hex(), @@ -542,18 +535,23 @@ async fn personal_read_frontier_retries_old_anchors_and_rejects_future_imports() IntentOutcome::Applied ); } - assert_eq!( - db.apply_personal_read_intent( - community, - &actor.public_key(), - &ReadIntent::LegacyPrefix { - target, - through_timestamp: 0 - } - ) + let earlier = EventBuilder::new(Kind::Custom(9), "earlier") + .custom_created_at(nostr::Timestamp::from(event.created_at.as_secs() - 10)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &earlier, Some(channel)) .await - .unwrap(), - IntentOutcome::Applied + .unwrap(); + let earlier = ReadIntent::MarkThrough { + target, + message_id: earlier.id.to_hex(), + }; + assert_eq!( + db.apply_personal_read_intent(community, &actor.public_key(), &earlier) + .await + .unwrap(), + IntentOutcome::Applied, + "an earlier anchor applies without moving the frontier back" ); let frontier: i64 = sqlx::query_scalar( "SELECT through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", @@ -733,9 +731,9 @@ async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers( db.apply_personal_read_intent( community, &actor.public_key(), - &ReadIntent::LegacyPrefix { + &ReadIntent::MarkThrough { target: queries[0].target.clone(), - through_timestamp: (base + 10) as i64, + message_id: root.id.to_hex(), }, ) .await @@ -841,12 +839,9 @@ async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers( #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_context_receipt_horizon_unknown_ancestry_and_deletion() { +async fn personal_read_context_author_horizon_unknown_ancestry_and_deletion() { let (db, pool, community, channel, actor, root) = fixture().await; - let old = EventBuilder::new(Kind::Custom(9), "old author freshly received") - .custom_created_at(nostr::Timestamp::from( - root.created_at.as_secs() - 40 * 86400, - )) + let old = EventBuilder::new(Kind::Custom(9), "directed, about to expire") .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) .sign_with_keys(&Keys::generate()) .unwrap(); @@ -882,7 +877,7 @@ async fn personal_read_context_receipt_horizon_unknown_ancestry_and_deletion() { assert_eq!(page["contexts"][0]["messages"][0]["attention"], true); assert_eq!(page["contexts"][0]["messages"][1]["status"], "unknown"); sqlx::query( - "UPDATE events SET received_at=now()-interval '31 days' WHERE community_id=$1 AND id=$2", + "UPDATE events SET created_at=created_at-interval '31 days' WHERE community_id=$1 AND id=$2", ) .bind(community.as_uuid()) .bind(old.id.as_bytes().as_slice()) diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index 22f0cbb1127..a1dfa73e5b7 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -69,14 +69,14 @@ impl Db { ) .await?; let mut tx = conn.begin().await?; - // Import status and frontier evidence share a compatible read-only cut. + // The horizon and frontier evidence share one read-only cut. sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") .execute(&mut *tx) .await?; writes::deadlines(&mut tx).await?; sqlx::query("SET LOCAL jit = off").execute(&mut *tx).await?; let actor_bytes = actor.to_bytes(); - let account = read_account(&mut tx, community, &actor_bytes, retention_seconds).await?; + let account = read_account(&mut tx, retention_seconds).await?; // The inner event LIMIT is deliberately before eligibility filtering. // This bounds rows/joins even with long deleted or self-authored runs. // Aggregate equivalent eligible evidence before transfer. Multiplicity @@ -119,9 +119,9 @@ impl Db { AND cf.channel_id=r.id AND cf.root_id=''::bytea LEFT JOIN LATERAL ( WITH candidates AS MATERIALIZED ( - SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags - FROM events WHERE community_id=$1 AND channel_id=r.id AND received_at >= $7 - ORDER BY received_at DESC,id,created_at LIMIT $8 + SELECT id,pubkey,created_at,deleted_at,kind,tags + FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7 + ORDER BY created_at DESC,id LIMIT $8 ), classified AS ( SELECT e.*, tm.root_event_id AS root, COALESCE(tm.root_event_id<>e.id,false) AS is_reply, @@ -129,7 +129,7 @@ impl Db { CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp) ELSE cf.through_timestamp END,false) AS covered - FROM (SELECT * FROM candidates ORDER BY received_at DESC,id,created_at LIMIT $8-1) e + FROM (SELECT * FROM candidates ORDER BY created_at DESC,id LIMIT $8-1) e LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id AND tm.event_created_at=e.created_at AND tm.event_id=e.id LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 @@ -170,8 +170,8 @@ impl Db { .bind((limit+1) as i64).bind((MAX_CHANNEL_SCAN+1) as i64) .bind(ELIGIBLE_KINDS.as_slice()) .bind(DateTime::from_timestamp_millis(account.cutoff_ms) - .ok_or_else(|| DbError::InvalidData("invalid receipt cutoff".into()))?) - .bind((MAX_RECEIPT_SCAN+1) as i64) + .ok_or_else(|| DbError::InvalidData("invalid unread cutoff".into()))?) + .bind((MAX_UNREAD_SCAN+1) as i64) .bind(only) .fetch_all(&mut *tx).await?; let has_more = rows.len() > limit; @@ -182,7 +182,7 @@ impl Db { let evidence = evidence .as_array() .ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?; - let complete = row.try_get::("scanned")? <= MAX_RECEIPT_SCAN as i64; + let complete = row.try_get::("scanned")? <= MAX_UNREAD_SCAN as i64; let channel_type: String = row.try_get("channel_type")?; let mut unread = 0; let mut attention = 0; @@ -192,7 +192,7 @@ impl Db { for e in evidence { let n = e["n"] .as_u64() - .filter(|n| *n <= MAX_RECEIPT_SCAN as u64) + .filter(|n| *n <= MAX_UNREAD_SCAN as u64) .ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))? as u32; let Some(facts) = e["facts"].as_object() else { @@ -365,21 +365,17 @@ pub(super) fn summarize( /// Read-time horizon only: frontier state is not discarded on expiry. pub(super) async fn read_account( conn: &mut PgConnection, - community: CommunityId, - actor: &[u8], retention_seconds: u32, ) -> Result { - let row = sqlx::query( - "SELECT date_trunc('milliseconds',transaction_timestamp()-make_interval(secs=>$3::double precision)) AS cutoff, - a.imported_at FROM (SELECT 1) singleton LEFT JOIN personal_read_accounts a - ON a.community_id=$1 AND a.actor=$2" - ).bind(community.as_uuid()).bind(actor).bind(f64::from(retention_seconds)).fetch_one(conn).await?; - let cutoff: DateTime = row.try_get("cutoff")?; - let imported: Option> = row.try_get("imported_at")?; + let cutoff: DateTime = sqlx::query_scalar( + "SELECT date_trunc('milliseconds',transaction_timestamp()-make_interval(secs=>$1::double precision))", + ) + .bind(f64::from(retention_seconds)) + .fetch_one(conn) + .await?; Ok(ReadAccount { retention_seconds, cutoff_ms: cutoff.timestamp_millis(), - imported_at_ms: imported.map(|t| t.timestamp_millis()), }) } diff --git a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs index 378a0e7d4f3..8c6fcc5463d 100644 --- a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs @@ -1,20 +1,39 @@ use super::{classification, postgres_tests::fixture, *}; use serde_json::json; +#[test] +fn unread_horizon_includes_its_own_cutoff() { + for (created_ms, counted) in [(999, false), (1000, true), (1001, true)] { + assert_eq!( + classification::eligible(9, false, false, created_ms, 1000), + counted + ); + } +} + #[tokio::test] #[ignore = "requires Postgres"] async fn sidebar_sql_eligibility_matches_selector_classifier() { - let (db, pool, community, _, actor, event) = fixture().await; + let (db, pool, community, channel, actor, event) = fixture().await; + let query = [ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: vec![event.id.to_hex()], + }]; let now = chrono::Utc::now().timestamp_millis(); + let horizon = i64::from(DEFAULT_RETENTION_SECONDS) * 1000; for kind in [9, 40002, 45001, 45003, 1, 7, 39002] { for own in [false, true] { for deleted in [false, true] { - for expired in [false, true] { - let received = now - if expired { 31 * 86_400_000 } else { 0 }; - sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,received_at=to_timestamp($5::double precision/1000) WHERE community_id=$1") + // Now, then one minute inside and one minute outside the horizon. + for age in [0, horizon - 60_000, horizon + 60_000] { + let created = now - age; + sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,created_at=to_timestamp($5::double precision/1000) WHERE community_id=$1") .bind(community.as_uuid()).bind(kind) .bind(if own { actor.public_key().to_bytes() } else { event.pubkey.to_bytes() }.as_slice()) - .bind(deleted).bind(received as f64).execute(&pool).await.unwrap(); + .bind(deleted).bind(created as f64).execute(&pool).await.unwrap(); let page = db .personal_read_sidebar( community, @@ -29,12 +48,28 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() { kind, own, deleted, - received, + created, page.account.cutoff_ms, )); assert!( matches!(page.channels[0].unread, ReadCount::Exact { value } if value == expected), - "kind={kind} own={own} deleted={deleted} expired={expired}" + "kind={kind} own={own} deleted={deleted} age={age}" + ); + // The per-message selector must agree with the aggregate. + let contexts = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &query, + ) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(&contexts).unwrap()["contexts"][0]["messages"][0] + ["status"], + ["not_counted", "unread"][expected as usize], + "kind={kind} own={own} deleted={deleted} age={age}" ); } } diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs index edfb9b37447..a6d8cea0e9d 100644 --- a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -299,7 +299,14 @@ async fn absent_whole_channel_cut_never_covers_epoch_zero_replies() { }, ) .await; - let row = sidebar(&db, community, &actor).await; + // Widen the horizon to reach the epoch, so that only a NULL cut read as + // zero could hide this reply. + let row = db + .personal_read_sidebar(community, &actor.public_key(), u32::MAX, 20, None) + .await + .unwrap() + .channels + .remove(0); assert_eq!(exact(&row.unread), Some(1), "epoch-zero reply stays unread"); assert_eq!(row.threads.items[0].latest_reply_at, 0); } diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index 3aa7d14fb10..e48333beb96 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -23,7 +23,7 @@ pub(super) async fn deadlines(conn: &mut PgConnection) -> Result<()> { Ok(()) } -/// Serialize private frontier/import writes, never shared conversation rows. +/// Serialize private frontier writes, never shared conversation rows. pub(super) async fn lock_account( conn: &mut PgConnection, community: CommunityId, @@ -239,30 +239,6 @@ pub(super) async fn apply( ).bind(community.as_uuid()).bind(actor).bind(channel_id).bind(timestamp) .execute(&mut *conn).await?; } - ReadIntent::LegacyPrefix { - target, - through_timestamp, - } => { - let latest_allowed: i64 = sqlx::query_scalar( - "SELECT floor(extract(epoch FROM clock_timestamp()))::bigint + 900", - ) - .fetch_one(&mut *conn) - .await?; - if *through_timestamp < 0 || *through_timestamp > latest_allowed { - return Ok(IntentOutcome::Invalid); - } - let Some(root) = valid_target(conn, community, actor, target).await? else { - return Ok(IntentOutcome::Blocked); - }; - // Import fixed prefixes only; receipt-time horizon does not alter - // the original author-time operand. - frontier(conn, community, actor, target, &root, *through_timestamp).await?; - } - ReadIntent::CompleteImport => { - sqlx::query("UPDATE personal_read_accounts SET imported_at=COALESCE(imported_at,clock_timestamp()) - WHERE community_id=$1 AND actor=$2") - .bind(community.as_uuid()).bind(actor).execute(&mut *conn).await?; - } } Ok(IntentOutcome::Applied) } diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index b4aef6741cc..2879795031e 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -85,9 +85,6 @@ pub(super) async fn authorize( let tenant = crate::tenant::bind_community(&state.db, host) .await .map_err(|_| Error::new(StatusCode::NOT_FOUND, "not_found"))?; - if !state.config.buzz_v1_enabled { - return Err(Error::new(StatusCode::NOT_FOUND, "not_found")); - } let path = uri .path_and_query() .map(|p| p.as_str()) diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index 9544ad3baf1..5b141f065d5 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -151,8 +151,32 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() { assert!(body["next_cursor"].is_null()); } } + let channel = state + .db + .create_channel( + community, + "boundary", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(9), "read by one signer only") + .sign_with_keys(&Keys::generate()) + .unwrap(); + state + .db + .insert_event(community, &event, Some(channel)) + .await + .unwrap(); let write_path = "/buzz/v1/me/read-state"; - let body = serde_json::to_vec(&json!({"intents":[{"type":"complete_import"}]})).unwrap(); + let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_channel_read", + "channel_id":channel,"message_id":event.id.to_hex()}]})) + .unwrap(); let missing_hash = proof(&actor, &host, write_path, "POST", None); assert_eq!( request( @@ -185,11 +209,19 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() { let applied = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await; assert_eq!(applied.0, StatusCode::OK, "{}", applied.1); assert_eq!(applied.1["outcomes"][0]["status"], "applied"); - for (key, complete) in [(&actor, true), (&other, false)] { - let auth = proof(key, &host, path, "GET", None); - let page = request(state.clone(), &host, path, "GET", Some(&auth), b"").await; - assert_eq!(page.0, StatusCode::OK); - assert_eq!(!page.1["account"]["imported_at_ms"].is_null(), complete); + // The frontier belongs to the signer alone. + let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]); + let targets: String = targets + .to_string() + .bytes() + .map(|b| format!("%{b:02X}")) + .collect(); + let path = format!("/buzz/v1/me/read-state?targets={targets}"); + for (key, status) in [(&actor, "read"), (&other, "unread")] { + let auth = proof(key, &host, &path, "GET", None); + let page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(page.0, StatusCode::OK, "{}", page.1); + assert_eq!(page.1["contexts"][0]["messages"][0]["status"], status); } } @@ -267,7 +299,7 @@ async fn accessory_context_get_signed_query_and_independent_batch_outcomes() { {"type":"unknown"}, {"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event.id.to_hex()}, {"type":"mark_through","target":{"channel_id":channel},"message_id":event.id.to_hex()}, - {"type":"legacy_prefix","target":{"channel_id":channel},"through_timestamp":-1} + {"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"} ]})).unwrap(); let auth = proof(&actor, &host, write_path, "POST", Some(&body)); let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await; @@ -305,8 +337,34 @@ async fn accessory_write_revocation_is_terminal_before_persistence() { .add_relay_member(community, &actor.public_key().to_hex(), "member", None) .await .unwrap(); + let channel = state + .db + .create_channel( + community, + "revocation", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(9), "markable before revoke") + .sign_with_keys(&Keys::generate()) + .unwrap(); + state + .db + .insert_event(community, &event, Some(channel)) + .await + .unwrap(); + let intent = buzz_db::personal_read::ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: event.id.to_hex(), + }; let path = "/buzz/v1/me/read-state"; - let body = br#"{"intents":[{"type":"complete_import"}]}"#; + let body = &serde_json::to_vec(&json!({"intents":[intent]})).unwrap()[..]; let mut headers = axum::http::HeaderMap::new(); headers.insert("host", host.parse().unwrap()); headers.insert( @@ -326,23 +384,15 @@ async fn accessory_write_revocation_is_terminal_before_persistence() { .await .unwrap(); // Exercise the same per-item function the batch handler uses after admission. - let result = super::handlers::write_intent( - &state, - &headers, - &principal, - &buzz_db::personal_read::ReadIntent::CompleteImport, - ) - .await; + let result = super::handlers::write_intent(&state, &headers, &principal, &intent).await; assert_eq!(result, json!({"status":"blocked"})); - let page = state - .db - .personal_read_sidebar(community, &actor.public_key(), 86400, 1, None) - .await - .unwrap(); - assert!( - page.account.imported_at_ms.is_none(), - "denied intent must not persist" - ); + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(persisted, 0, "denied intent must not persist"); } #[tokio::test] @@ -398,8 +448,20 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { buzz_db::personal_read::MAX_CONTEXT_MESSAGES ); assert_eq!(d["max_thread_summaries"], 5); + assert_eq!(d["eligible_kinds"], json!([9, 40002, 45001, 45003])); } } } + // Disabled means unmounted: indistinguishable from a path that never existed. + let mut statuses = Vec::new(); + for path in ["/buzz/v1/me/sidebar", "/buzz/v1-never-existed"] { + let request = Request::builder().uri(path).header("host", host.as_str()); + let response = crate::router::build_router(state.clone()) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + statuses.push(response.status()); + } + assert_eq!(statuses[0] == statuses[1], !enabled, "{statuses:?}"); } } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e8a50ea1c5c..a55c5e562bd 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -183,7 +183,7 @@ pub struct Config { pub require_auth_token: bool, /// Opt-in private accessory API; disabled until explicitly deployed. pub buzz_v1_enabled: bool, - /// Receipt-time unread tracking duration, independent of NIP-RS retention. + /// Author-time unread tracking duration, independent of NIP-RS retention. pub buzz_v1_retention_seconds: u32, /// Comma-separated list of allowed CORS origins. /// If empty, permissive CORS is used (dev mode). @@ -1320,18 +1320,13 @@ impl Config { } let buzz_v1_enabled = std::env::var("BUZZ_V1_ENABLED").is_ok_and(|v| v == "true"); - let buzz_v1_retention_seconds = std::env::var("BUZZ_V1_RETENTION_SECONDS") - .map(|v| { - v.parse::().map_err(|_| { - ConfigError::InvalidValue("BUZZ_V1_RETENTION_SECONDS must be positive".into()) - }) - }) - .unwrap_or(Ok(buzz_db::personal_read::DEFAULT_RETENTION_SECONDS))?; - if buzz_v1_retention_seconds == 0 { - return Err(ConfigError::InvalidValue( - "BUZZ_V1_RETENTION_SECONDS must be positive".into(), - )); - } + // Read only when enabled: a disabled relay ignores every v1 setting. + let buzz_v1_retention_seconds = match std::env::var("BUZZ_V1_RETENTION_SECONDS") { + Ok(v) if buzz_v1_enabled => v.parse().ok().filter(|s| *s > 0).ok_or_else(|| { + ConfigError::InvalidValue("BUZZ_V1_RETENTION_SECONDS must be positive".into()) + })?, + _ => buzz_db::personal_read::DEFAULT_RETENTION_SECONDS, + }; Ok(Self { bind_addr, @@ -1904,6 +1899,33 @@ mod tests { assert!(matches!(admin.auth, crate::config::AdminAuth::Nip98)); } + #[test] + fn buzz_v1_retention_is_validated_only_when_enabled() { + let _guards = env_guards(); + const KEYS: [&str; 2] = ["BUZZ_V1_ENABLED", "BUZZ_V1_RETENTION_SECONDS"]; + let previous = KEYS.map(std::env::var_os); + std::env::set_var("BUZZ_V1_RETENTION_SECONDS", "0"); + std::env::remove_var("BUZZ_V1_ENABLED"); + let disabled = Config::from_env(); + std::env::set_var("BUZZ_V1_ENABLED", "true"); + let enabled = Config::from_env(); + for (key, value) in KEYS.into_iter().zip(previous) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + assert!( + disabled.is_ok(), + "a disabled relay ignores it: {disabled:?}" + ); + assert!(matches!( + enabled, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_V1_RETENTION_SECONDS") + )); + } + #[test] fn malformed_relay_owner_pubkey_is_a_startup_error_not_warn_and_ignore() { let _guards = env_guards(); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 15f212e5465..ea0c192a5a9 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -88,7 +88,7 @@ pub struct BuzzV1Descriptor { pub version: u32, /// Relay-relative API prefix; callers retain the requesting origin. pub base_path: String, - /// Receipt-time unread horizon; does not expire messages or frontiers. + /// Author-time unread horizon; does not expire messages or frontiers. pub retention_seconds: u32, /// Maximum joined channels per sidebar page. pub max_channels: usize, @@ -100,6 +100,9 @@ pub struct BuzzV1Descriptor { pub max_context_messages: usize, /// Maximum unread-thread summaries per sidebar channel row. pub max_thread_summaries: usize, + /// Message kinds that count as unread and as latest activity. Clients + /// classify live arrivals with this set instead of keeping a copy. + pub eligible_kinds: [i32; 4], } /// Public capability descriptor for relay-proxied GIF search. @@ -386,7 +389,8 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { if state.config.buzz_v1_enabled { use buzz_db::personal_read::{ - MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS, MAX_THREAD_SUMMARIES, + ELIGIBLE_KINDS, MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS, + MAX_THREAD_SUMMARIES, }; info.buzz_v1 = Some(BuzzV1Descriptor { version: 1, @@ -397,6 +401,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st max_contexts: MAX_CONTEXTS, max_context_messages: MAX_CONTEXT_MESSAGES, max_thread_summaries: MAX_THREAD_SUMMARIES, + eligible_kinds: ELIGIBLE_KINDS, }); } info.read_state_snapshot = Some(serde_json::json!({ diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index f3f6d9ac816..afbcff31a4b 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -289,8 +289,12 @@ pub fn build_router(state: Arc) -> Router { let admin_router = admin_enabled .then(|| Router::new().nest("/api/admin/v1", api::admin::router(state.clone()))); - let accessory_router = - Router::new().nest(api::buzz_v1::BASE_PATH, api::buzz_v1::router(state.clone())); + // Unmounted when disabled, so every /buzz/v1 path answers exactly as it + // did before the accessory API existed. + let accessory_router = state + .config + .buzz_v1_enabled + .then(|| Router::new().nest(api::buzz_v1::BASE_PATH, api::buzz_v1::router(state.clone()))); let api_router = Router::new() // WebSocket + NIP-11 @@ -388,11 +392,10 @@ pub fn build_router(state: Arc) -> Router { // Metrics → Trace → CORS applied once over the combined router. let mut merged = api_router .merge(media_router) - .merge(accessory_router) .merge(git_router) .merge(git_policy_router); - if let Some(admin_router) = admin_router { - merged = merged.merge(admin_router); + for optional in [accessory_router, admin_router].into_iter().flatten() { + merged = merged.merge(optional); } // Serve both bundles from one fallback. The admin host is checked first so diff --git a/docs/buzz-v1-extension-design.md b/docs/buzz-v1-extension-design.md index 515210799ae..51a6448b3b9 100644 --- a/docs/buzz-v1-extension-design.md +++ b/docs/buzz-v1-extension-design.md @@ -14,7 +14,7 @@ Channel-content revisions, personal-state revisions and synchronized preferences are separate from read frontiers. Future revisions must describe the same snapshot as their payload, and are invalidation tokens, not history cursors or access grants. Count reuse also needs time/configuration validity because the -receipt horizon moves without writes. Mutes and manual-unread overrides must not +unread horizon moves without writes. Mutes and manual-unread overrides must not be encoded by advancing or rewinding a read frontier. These are extension constraints, not implemented capabilities: this version does diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index 40553a124b2..21e1d6457f8 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -13,12 +13,14 @@ application/nostr+json`, or `GET /info`) includes `buzz_v1` only when enabled: ```json {"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000, "max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100, -"max_thread_summaries":5}} +"max_thread_summaries":5,"eligible_kinds":[9,40002,45001,45003]}} ``` Use the requesting origin plus this relative prefix. Discovery is a configured capability, not a promise that the next request cannot fail. Unknown hosts and -disabled deployments omit it. Capability loss must not restart NIP-RS writes. +disabled deployments omit it, and a disabled deployment does not mount +`/buzz/v1` at all. A v1 client without the capability has no read state; it +must not fall back to NIP-RS. Every API request requires NIP-98, including on development relays. Sign the exact externally addressed URL, including the encoded query, and method. POST @@ -65,7 +67,7 @@ Items are canonical roots with unread eligible replies, ordered by `latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is the newest observed unread reply (equal times prefer the smaller ID) and a valid thread `mark_through` anchor. `unread` and `attention` use the row's -definitions. `complete=true` means the receipt scan was exhausted, no evidence +definitions. `complete=true` means the unread window was exhausted, no evidence had unresolved ancestry or unusable tags, and no thread was omitted; then item unread counts sum to the row's unread replies. Otherwise the list is a cut of observed evidence and counts may be lower bounds or unknown. Participation @@ -78,15 +80,28 @@ Counts have exactly three representations: ``` Only exact zero proves absence. Unknown has no numeric value. Ordinary unread -counts eligible non-own, nondeleted conversation kinds 9, 40002, 45001 and 45003 -beyond the matching context frontier. Attention is the unread subset consisting +counts non-own, nondeleted messages of the advertised `eligible_kinds` beyond +the matching context frontier. The same kinds alone define latest activity, so +an edit, reaction or diff (40008) neither makes a channel unread nor moves it. +Classify live arrivals with the advertised set, not a client copy. Attention is the unread subset consisting of DMs, direct actor mentions, `broadcast=1`, and replies in a thread containing a live eligible message authored by the actor. Participation is independent of read progress and retention. This is not Desktop notification policy: follows, mutes and earlier mentions elsewhere in a thread do not affect this count. -The receipt-time horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`). It -filters unread/attention, not latest activity, event storage or frontier state. +The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and, like +every frontier, is measured in author time (`created_at`): a message counts +while its author time is at or after `account.cutoff_ms`. It filters +unread/attention, not latest activity, event storage or frontier state. One +clock has three consequences: + +- A message accepted late with an author time beyond the horizon (an import, a + backfill, a long-offline sender) is never unread. It can still be latest. +- Unread expires at author time plus the horizon, however recently the relay + accepted the message. +- Horizon and frontier order messages identically, so a context's unread set + is one author-time range: after the frontier and at or after the cutoff. + A later configuration expansion can change counts without having lost progress. Latest activity is independent of actor and frontiers. A null latest ID proves an empty eligible history only when `latest_message_complete=true`. @@ -118,9 +133,8 @@ Conversation bytes must still come from the existing Nostr path. ```json {"intents":[ {"type":"mark_through","target":{"channel_id":""},"message_id":"<64-hex-event>"}, - {"type":"mark_channel_read","channel_id":"","message_id":"<64-hex-event>"}, - {"type":"legacy_prefix","target":{"channel_id":"","root_id":"<64-hex-root>"},"through_timestamp":1700000000}, - {"type":"complete_import"} + {"type":"mark_through","target":{"channel_id":"","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"}, + {"type":"mark_channel_read","channel_id":"","message_id":"<64-hex-event>"} ]} ``` @@ -136,9 +150,7 @@ second-resolution author-time prefix. Equal-time messages and later-arriving backdated messages at/below it are covered. Channel and thread frontiers never inherit in either direction. Opening a view is not itself a reading action; client dwell/focus policy determines when to send an actual observed anchor. -Old or deleted valid anchors may advance a frontier. Legacy prefixes preserve -the original nonnegative timestamp; more than DB-now + 900 seconds is invalid, -not clamped. +Old or deleted valid anchors may advance a frontier. `mark_channel_read` is the one whole-channel cut: it advances the channel timeline and every thread in that channel, including unlisted ones, through the @@ -148,19 +160,18 @@ reply is read at or below the greater of its thread frontier and this cut. An anchor that no longer exists is `blocked`. `latest_message_id` is a natural anchor. A null ID with `latest_message_complete=false` does not prove empty history; it only leaves the client without an anchor. Thread marks and channel `mark_through` -never set the cut. Complete-import is a client declaration, not proof that the server -verified or decrypted a legacy snapshot. Null `imported_at_ms` means provisional. +never set the cut. -Sparse legacy seen hints must not be converted to the largest timestamp: that -would acknowledge unseen holes. The client retains recovery data and discloses -unsupported hints/manual overrides before declaring import complete. Manual -unread remains device-local. There is no automatic dual-authority rollback. +There is no import of earlier client read state: an account starts with no +frontiers, and the horizon bounds what that can show as unread. Manual unread +remains device-local. ## Bounds and deployment - 20 sidebar rows, 100 intents, 20 contexts / 100 selectors per request. - 64 KiB write body; 16 KiB context URL; 1 MiB serialized API response. -- 4096 raw receipts per channel plus one exhaustion sentinel, before eligibility. +- 4096 raw events per channel inside the horizon plus one exhaustion sentinel, + before eligibility. - Latest activity probes 256 events plus a sentinel; long ineligible tails may leave latest incomplete even when unread is exact. - Tag documents over 8192 bytes or malformed relevant tags yield uncertainty. @@ -172,11 +183,10 @@ unread remains device-local. There is no automatic dual-authority rollback. shared eight-second intent-processing deadline after admission. Limits are containment, not a production capacity claim. -Apply migrations 0054 and 0055 (or the equivalent desired schema). Brownfield -operators must prebuild the receipt index using -[the concurrent deployment procedure](events-channel-received-deployment.md). -Do not run an unbounded blocking index build on a large events table. No new -per-message ingest write path or stored unread counters are introduced. +Apply migration 0054 (or the equivalent desired schema). It creates two empty +private tables and no index on `events`: both sidebar scans are served by the +existing `idx_events_community_channel_created`. No new per-message ingest write +path or stored unread counters are introduced. Use existing HTTP route/status/latency metrics for `/buzz/v1/me/sidebar` and `/buzz/v1/me/read-state`, plus database pool/statement metrics. Inspect exact / @@ -215,6 +225,6 @@ must use the established authenticated operational process and explicitly scope both community and actor; never equate the read-time horizon with data erasure. Roll out disabled-by-default to controlled accounts after agent and human live -acceptance. The client migration is separate work. Disabling the API leaves -migrated clients stale with their pending journal intact; it cannot silently -restore the old encrypted snapshot as current authority. +acceptance. Disabling the API unmounts it and leaves both tables in place: v1 +clients lose read state and keep their unsent intents until it returns, and +NIP-RS clients see no change either way. diff --git a/docs/events-channel-received-deployment.md b/docs/events-channel-received-deployment.md deleted file mode 100644 index 0242e1a7c9f..00000000000 --- a/docs/events-channel-received-deployment.md +++ /dev/null @@ -1,79 +0,0 @@ -# Channel receipt index deployment - -Migration `0055_events_channel_received_index.sql` adds the index behind the -sidebar's receipt window (`received_at >= cutoff ORDER BY received_at DESC, id, -created_at LIMIT n` per channel). `events` is partitioned by `created_at`, so -without this index every sidebar read scans each channel's full history in -every partition and filters on `received_at`. With it, each partition stops -after `n` rows. The index is maintained on every event insert, like the other -channel indexes: - -```sql -CREATE INDEX idx_events_community_channel_received - ON public.events (community_id, channel_id, received_at DESC, id, created_at); -``` - -Like 0049, the migration bounds lock acquisition and execution time. It builds -the index on fresh or small databases and deliberately fails on a populated -one rather than block ingestion. Brownfield deployments must prebuild first. - -## Brownfield procedure - -`events` is partitioned, and PostgreSQL does not support `CREATE INDEX -CONCURRENTLY` on a partitioned parent. Create the parent index on the parent -only (catalog-only, invalid until complete), build each partition's index -concurrently, then attach it. None of these statements may run inside a -transaction block. - -```sql -CREATE INDEX idx_events_community_channel_received - ON ONLY public.events (community_id, channel_id, received_at DESC, id, created_at); -``` - -List the partitions: - -```sql -SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid -WHERE i.inhparent = 'public.events'::regclass ORDER BY 1; -``` - -For each partition `

`: - -```sql -CREATE INDEX CONCURRENTLY

_channel_received - ON public.

(community_id, channel_id, received_at DESC, id, created_at); -ALTER INDEX public.idx_events_community_channel_received - ATTACH PARTITION public.

_channel_received; -``` - -The parent becomes valid only after every partition has an attached, valid -index. Partitions created later inherit the index automatically. Verify: - -```sql -SELECT i.indisvalid, i.indisready, i.indislive, pg_get_indexdef(i.indexrelid) -FROM pg_index i -JOIN pg_class c ON c.oid = i.indexrelid -JOIN pg_namespace n ON n.oid = c.relnamespace -WHERE n.nspname = 'public' AND c.relname = 'idx_events_community_channel_received'; -``` - -Expected flags are all `true`, with this definition: - -```text -CREATE INDEX idx_events_community_channel_received ON ONLY public.events USING btree (community_id, channel_id, received_at DESC, id, created_at) -``` - -Then deploy normally. Migration 0055 skips `CREATE INDEX`, validates the -catalog shape, and records the migration. - -## Recovery - -A failed concurrent build leaves an invalid partition index, and the parent -stays invalid; migration 0055 rejects both. Drop only the failed partition -index outside a transaction, then repeat that partition's build and attach: - -```sql -DROP INDEX CONCURRENTLY IF EXISTS public.

_channel_received; -``` - -Do not replace this with a non-concurrent build on a populated table. diff --git a/migrations/0054_personal_read_state.sql b/migrations/0054_personal_read_state.sql index 21d13bc1527..4459b1a8403 100644 --- a/migrations/0054_personal_read_state.sql +++ b/migrations/0054_personal_read_state.sql @@ -6,7 +6,6 @@ CREATE TABLE personal_read_accounts ( community_id UUID NOT NULL REFERENCES communities(id), actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), - imported_at TIMESTAMPTZ, PRIMARY KEY (community_id, actor) ); diff --git a/migrations/0055_events_channel_received_index.sql b/migrations/0055_events_channel_received_index.sql deleted file mode 100644 index b748b7a48ac..00000000000 --- a/migrations/0055_events_channel_received_index.sql +++ /dev/null @@ -1,36 +0,0 @@ --- Newest-received events per channel as one ordered index range. The sidebar's --- receipt window (received_at >= cutoff ORDER BY received_at DESC, id, --- created_at LIMIT n) otherwise reads each channel's whole history in every --- created_at partition and filters on received_at, so its cap bounds evidence --- but not work. Key order matches that ORDER BY so each partition stops at n. --- Additive: no ingest, trigger or NIP-RS semantics change. --- --- Partitioned parent: CREATE INDEX recurses to every partition and future --- partitions inherit it. CONCURRENTLY is not supported on partitioned parents, --- so startup is bounded exactly like 0049: a populated table fails deployment --- instead of blocking ingestion. Brownfield operators MUST prebuild per --- partition as documented in docs/events-channel-received-deployment.md. -SET LOCAL lock_timeout = '1s'; -SET LOCAL statement_timeout = '5s'; -DO $$ -BEGIN - -- IF NOT EXISTS would still request a writer-conflicting ShareLock. - IF to_regclass('public.idx_events_community_channel_received') IS NULL THEN - CREATE INDEX idx_events_community_channel_received - ON public.events (community_id, channel_id, received_at DESC, id, created_at); - END IF; - - -- A partitioned index is valid only once every partition has an attached, - -- valid child index, so this also rejects an incomplete prebuild. - IF NOT EXISTS ( - SELECT 1 FROM pg_index i - JOIN pg_class c ON c.oid = i.indexrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = 'public' AND c.relname = 'idx_events_community_channel_received' - AND i.indisvalid AND i.indisready AND i.indislive - AND pg_get_indexdef(i.indexrelid) = - 'CREATE INDEX idx_events_community_channel_received ON ONLY public.events USING btree (community_id, channel_id, received_at DESC, id, created_at)' - ) THEN - RAISE EXCEPTION 'idx_events_community_channel_received invalid or wrong definition; see docs/events-channel-received-deployment.md'; - END IF; -END $$; diff --git a/schema/schema.sql b/schema/schema.sql index 9352143b565..7414e471389 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -194,12 +194,11 @@ CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) WHERE okta_user_id IS NOT NULL; -- Private accessory read progress. Never included in Nostr event queries. --- Cutoffs are relay acceptance time; frontiers are signed event time. They are --- deliberately separate clocks. An empty root_id denotes a channel frontier. +-- Cutoffs and frontiers are both signed event time. An empty root_id denotes a +-- channel frontier. CREATE TABLE personal_read_accounts ( community_id UUID NOT NULL REFERENCES communities(id), actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), - imported_at TIMESTAMPTZ, PRIMARY KEY (community_id, actor) ); @@ -291,9 +290,6 @@ CREATE INDEX idx_events_community_pubkey_kind_created CREATE INDEX idx_events_community_kind_created ON events (community_id, kind, created_at DESC, id); CREATE INDEX idx_events_community_deleted ON events (community_id, deleted_at); --- Sidebar receipt window per channel (0055); key order = its ORDER BY. -CREATE INDEX idx_events_community_channel_received - ON events (community_id, channel_id, received_at DESC, id, created_at); -- Addressable (replaceable) and NIP-33 parameterized lookups. CREATE INDEX idx_events_addressable ON events (community_id, kind, pubkey, channel_id, deleted_at); From 0f01e45922ff93ec57371190f4561a4b5d5add0e Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 11:33:46 -0400 Subject: [PATCH 08/18] docs(buzz-v1): scope capability absence; witness that diffs are not read state Review follow-ups to the v1 isolation commit. Contract doc: - Absence of the capability means read state is unavailable, not that everything is read: show no count rather than zero, keep unsent intents. A client may also speak NIP-RS; the two never synchronize. buzz-app uses v1 only. - A late-accepted old message is excluded under the current horizon, not "never unread" (a wider horizon brings it back), and a future-dated author time extends how long a message counts. Tests: - Kind 40008 joins the SQL/classifier eligibility matrix. - personal_read_diff_alone_leaves_the_sidebar_row_unchanged: a diff that is the newest event in the channel and p-tags the actor leaves the serialized sidebar row byte-identical. Not unread, not attention, not latest. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../src/store/personal_read/postgres_tests.rs | 38 +++++++++++++++++++ .../projection_postgres_tests.rs | 2 +- docs/buzz-v1-read-state.md | 13 ++++--- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index 7bae974c5ad..cd8c9aa51b4 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -357,6 +357,44 @@ async fn personal_read_intent_does_not_lock_shared_conversation_rows() { held.rollback().await.unwrap(); } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_diff_alone_leaves_the_sidebar_row_unchanged() { + let (db, _, community, channel, actor, _) = fixture().await; + let mut rows = Vec::new(); + for diff in [false, true] { + if diff { + // Newest in the channel and addressed to the actor: as loud as a + // diff can be. + let at = nostr::Timestamp::now().as_secs() + 5; + let event = EventBuilder::new(Kind::Custom(40008), "a diff") + .custom_created_at(nostr::Timestamp::from(at)) + .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + } + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 1 } + )); + rows.push(serde_json::to_value(&page.channels[0]).unwrap()); + } + assert_eq!(rows[0], rows[1], "not unread, not attention, not latest"); +} + #[tokio::test] #[ignore = "requires Postgres"] async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { diff --git a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs index 8c6fcc5463d..ec678cb465e 100644 --- a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs @@ -24,7 +24,7 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() { }]; let now = chrono::Utc::now().timestamp_millis(); let horizon = i64::from(DEFAULT_RETENTION_SECONDS) * 1000; - for kind in [9, 40002, 45001, 45003, 1, 7, 39002] { + for kind in [9, 40002, 45001, 45003, 1, 7, 39002, 40008] { for own in [false, true] { for deleted in [false, true] { // Now, then one minute inside and one minute outside the horizon. diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index 21e1d6457f8..dcd05181a9e 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -19,8 +19,10 @@ application/nostr+json`, or `GET /info`) includes `buzz_v1` only when enabled: Use the requesting origin plus this relative prefix. Discovery is a configured capability, not a promise that the next request cannot fail. Unknown hosts and disabled deployments omit it, and a disabled deployment does not mount -`/buzz/v1` at all. A v1 client without the capability has no read state; it -must not fall back to NIP-RS. +`/buzz/v1` at all. Absence means read state is unavailable, not that everything +is read: show no count rather than zero, and keep unsent intents. A client may +also speak NIP-RS, which the relay still serves, but the two never synchronize; +buzz-app uses v1 only. Every API request requires NIP-98, including on development relays. Sign the exact externally addressed URL, including the encoded query, and method. POST @@ -96,9 +98,10 @@ unread/attention, not latest activity, event storage or frontier state. One clock has three consequences: - A message accepted late with an author time beyond the horizon (an import, a - backfill, a long-offline sender) is never unread. It can still be latest. -- Unread expires at author time plus the horizon, however recently the relay - accepted the message. + backfill, a long-offline sender) is excluded under the current horizon, + however recently the relay accepted it. It can still be latest. +- Unread expires at author time plus the horizon, so a future-dated author + time extends how long a message counts. - Horizon and frontier order messages identically, so a context's unread set is one author-time range: after the frontier and at or after the cutoff. From 9cfe40ddf7d92b236eb3f6d4de0085082d8f956c Mon Sep 17 00:00:00 2001 From: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 18:30:44 -0400 Subject: [PATCH 09/18] test(relay): cover signed sidebar deletion counts Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> --- .../src/api/buzz_v1/postgres_tests.rs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index 5b141f065d5..e4e27277dc3 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -465,3 +465,127 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { assert_eq!(statuses[0] == statuses[1], !enabled, "{statuses:?}"); } } + +// Exercise the real signed HTTP ingest path, including deletion side effects, +// rather than directly tombstoning an event in the database. +async fn signed_sidebar_deletion(deletion_kind: u16) { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_auth_token = true; + config.require_relay_membership = true; + config.buzz_v1_enabled = true; + let state = Arc::new(state); + let host = format!("bff-deletion-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let admin = Keys::generate(); + let author = Keys::generate(); + let reader = Keys::generate(); + for key in [&admin, &author, &reader] { + state + .db + .add_relay_member(community, &key.public_key().to_hex(), "member", None) + .await + .unwrap(); + } + let channel = state + .db + .create_channel( + community, + "deletion", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &admin.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + for key in [&author, &reader] { + state + .db + .add_member( + community, + channel, + &key.public_key().to_bytes(), + buzz_db::channel::MemberRole::Member, + None, + ) + .await + .unwrap(); + } + let message = EventBuilder::new(Kind::Custom(9), "unread message to delete") + .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) + .sign_with_keys(&author) + .unwrap(); + let mut tags = vec![Tag::parse(["e", &message.id.to_hex()]).unwrap()]; + let signer = if deletion_kind == 5 { + &author + } else { + tags.push(Tag::parse(["h", &channel.to_string()]).unwrap()); + &admin + }; + let deletion = EventBuilder::new(Kind::Custom(deletion_kind), "") + .tags(tags) + .sign_with_keys(signer) + .unwrap(); + let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}"); + for (event, key, count) in [(&message, &author, 1), (&deletion, signer, 0)] { + let body = serde_json::to_vec(event).unwrap(); + let auth = proof(key, &host, "/events", "POST", Some(&body)); + // /events has a different response contract from the accessory helper. + let response = crate::router::build_router(state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/events") + .header("host", &host) + .header("authorization", auth) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let result: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true, "{result}"); + assert_eq!(result["event_id"], event.id.to_hex(), "{result}"); + + // This distinct member never writes a frontier. Reads alone must not + // erase unread state; only the accepted signed deletion changes it. + let auth = proof(&reader, &host, &path, "GET", None); + let (status, sidebar) = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(status, StatusCode::OK, "{sidebar}"); + let channels = sidebar["channels"].as_array().unwrap(); + assert_eq!(channels.len(), 1, "{sidebar}"); + assert_eq!(channels[0]["channel_id"], channel.to_string()); + assert_eq!( + channels[0]["unread"], + json!({"status":"exact", "value":count}), + "kind {deletion_kind}, after kind {}: {sidebar}", + event.kind.as_u16() + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_signed_kind5_deletion_clears_another_readers_sidebar_count() { + signed_sidebar_deletion(5).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_signed_kind9005_deletion_clears_another_readers_sidebar_count() { + signed_sidebar_deletion(9005).await; +} From b8c1eb06f73a3f0939bfcf80daca18eb8b790735 Mon Sep 17 00:00:00 2001 From: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 18:34:02 -0400 Subject: [PATCH 10/18] refactor(db): share sidebar evidence completeness flag Apply Wren completeness simplification; both evidence flags had identical updates. Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> --- .../buzz-db/src/store/personal_read/projection.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index a1dfa73e5b7..0de57ffa10b 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -187,7 +187,6 @@ impl Db { let mut unread = 0; let mut attention = 0; let mut unread_complete = complete; - let mut attention_complete = complete; let mut threads: HashMap, ThreadEvidence> = HashMap::new(); for e in evidence { let n = e["n"] @@ -197,14 +196,12 @@ impl Db { as u32; let Some(facts) = e["facts"].as_object() else { unread_complete = false; - attention_complete = false; continue; }; // SQL's bounded ancestry fact is parity-tested against the shared // NIP-10 parser; no raw tag payload crosses the DB boundary. if facts.get("reply_marked") == Some(&Value::Bool(true)) && e["root"].is_null() { unread_complete = false; - attention_complete = false; continue; } // Roots are timeline messages; descendants belong exclusively @@ -222,7 +219,6 @@ impl Db { if is_reply { let Some(root) = e["root"].as_str().and_then(writes::event_id) else { unread_complete = false; - attention_complete = false; continue; }; let newest = ( @@ -252,7 +248,7 @@ impl Db { } } } - pending.push((threads, attention, unread_complete, attention_complete)); + pending.push((threads, attention, unread_complete)); channels.push(ChannelReadSummary { channel_id: row.try_get("id")?, name: row.try_get("name")?, @@ -260,7 +256,7 @@ impl Db { archived: row.try_get("archived")?, hidden: row.try_get("hidden")?, unread: ReadCount::from_evidence(unread, unread_complete), - attention: ReadCount::from_evidence(attention, attention_complete), + attention: ReadCount::from_evidence(attention, unread_complete), latest_message_id: row.try_get("latest_message_id")?, latest_message_at: row.try_get("latest_message_at")?, latest_message_complete: row.try_get("latest_message_complete")?, @@ -282,7 +278,7 @@ impl Db { .collect(); let participation = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; - for (channel, (threads, mut attention, unread_complete, evidence_complete)) in + for (channel, (threads, mut attention, evidence_complete)) in channels.iter_mut().zip(pending) { let mut complete = evidence_complete; @@ -307,7 +303,7 @@ impl Db { } items.push(ThreadReadSummary { root_id: hex::encode(root), - unread: ReadCount::from_evidence(thread.unread, unread_complete), + unread: ReadCount::from_evidence(thread.unread, evidence_complete), attention: ReadCount::from_evidence( thread_attention, evidence_complete && participating.is_some(), @@ -317,7 +313,7 @@ impl Db { }); } channel.attention = ReadCount::from_evidence(attention, complete); - channel.threads = summarize(items, unread_complete); + channel.threads = summarize(items, evidence_complete); } let next_cursor = if has_more { channels.last().map(|c| c.channel_id) From 644dbbdc35aa2360b41402716efe9a597386b65c Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Thu, 1 Oct 2026 15:25:16 -0400 Subject: [PATCH 11/18] docs(buzz-v1): state the rollout order and scope the NIP-RS sentence The contract said "NIP-RS clients see no change either way". That holds for NIP-RS state and request processing, which this branch does not touch. It does not hold for availability or shared limits, and the document said nothing about deploying the migration. State what an operator needs before rollout: migration 0056 comes before the relay serves even with the API off, there is no down migration, a pre-0056 relay restarted with auto-migration on does not start, and whole-community deletion is tied to the schema it was built or approved against. Say that enabled v1 traffic shares the signer's API-call quota and the database pool. Documentation only. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- docs/buzz-v1-read-state.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index 09d54550413..db1e4215b00 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -227,7 +227,20 @@ is no new public account export/reset endpoint. Operator-assisted erasure/export must use the established authenticated operational process and explicitly scope both community and actor; never equate the read-time horizon with data erasure. +Migration 0056 must be applied before this relay serves, enabled or not: started +without it and with auto-migration off, the relay stops before readiness. There +is no down migration, and disabling the API is not a rollback. A relay built +before 0056 that restarts with `BUZZ_AUTO_MIGRATE=true` (the Helm default) +refuses to start on the migrated schema. Whole-community deletion run from a +build before 0056 rejects the two new tables. A deletion approved on the +earlier schema and not yet fenced fails structural revalidation after 0056: +take pending approvals back through operator review before rollout, and do not +rewrite them. + Roll out disabled-by-default to controlled accounts after agent and human live acceptance. Disabling the API unmounts it and leaves both tables in place: v1 -clients lose read state and keep their unsent intents until it returns, and -NIP-RS clients see no change either way. +clients lose access to read state and keep their unsent intents until it +returns. Neither setting changes NIP-RS state or how NIP-RS requests are +processed. While enabled, v1 requests count against the signer's existing +API-call quota and share the existing writer database pool with other relay +work. From 4be404e5e1db8f9d7c740490b23a4048efe606a7 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Thu, 1 Oct 2026 16:10:31 -0400 Subject: [PATCH 12/18] fix(relay): keep the NIP-FI denial wire shape on /buzz/v1 When NIP-FI is not Off, the shared HTTP admission gate answers a failure with a fixed response: status, `text/plain; charset=utf-8`, a fixed body and, for 401, `WWW-Authenticate: Nostr` (docs/nips/NIP-FI.md). The bridge routes return that response as built. The /buzz/v1 adapter kept only the status and rewrote the rest as the v1 JSON error, so a key mismatch or a key in the deny map reached v1 clients in a shape NIP-FI does not allow. The deny map from #7977 makes that a live disconnect and deny path. The adapter now carries the gate's response through unchanged in every non-Off mode and adds only `Cache-Control: private, no-store`, the policy every v1 handler response already has. Admission order, the shared gate and the bridge are untouched. With NIP-FI Off, and for every error after admission (quota, replay, membership), the v1 JSON error and its Retry-After stay as they were. Three router tests, one each for sidebar, contexts and write, drive the real router with a P-256 assertion and the real deny map: an admitted control, a key mismatch, a denied key, missing and invalid evidence, the 401 challenge, and Off mode. Denials assert exact status, content type, headers and body bytes. All three fail without the adapter change, on `application/json` where `text/plain; charset=utf-8` is required. The contract document said every error is the JSON shape; it now states the NIP-FI exception and that outer router denials keep the shared middleware's policy. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/api/bridge.rs | 256 ++++++++++++++++++---- crates/buzz-relay/src/api/buzz_v1/auth.rs | 61 ++++-- docs/buzz-v1-read-state.md | 11 +- 3 files changed, 264 insertions(+), 64 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 4030240c535..6b3b058416f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -6046,59 +6046,235 @@ pub(crate) mod postgres_tests { ); } - // ── Caller key-pairing witness: moderation mismatched key → 403 ───────── - // - // Mirror of the GIF case through the moderation route. - // Falsifying mutation: remove key-pairing check → admission passes → 403 from - // moderation authz (not NIP-FI) with different JSON body. - // BFF admission must pair the asserted identity with the signed request. - #[test] + // All accessory methods must preserve the shared admission wire contract. + // Keep each route independent so the unfixed adapter fails all three tests. + #[tokio::test] #[ignore = "requires Postgres"] - fn nip_fi_enforce_buzz_v1_sidebar_pairs_request_identity() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("current_thread runtime"); - let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { - panic!("local Postgres not reachable"); - }; - let mut s = (*state).clone(); - std::sync::Arc::make_mut(&mut s.config).buzz_v1_enabled = true; - let state = std::sync::Arc::new(s); + async fn nip_fi_buzz_v1_sidebar_wire_contract() { + buzz_v1_wire_contract("GET", "/buzz/v1/me/sidebar?limit=1", b"").await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_buzz_v1_contexts_wire_contract() { + let targets = serde_json::json!([{"target":{"channel_id":uuid::Uuid::new_v4()}}]); + let encoded: String = targets + .to_string() + .bytes() + .map(|b| format!("%{b:02X}")) + .collect(); + buzz_v1_wire_contract( + "GET", + &format!("/buzz/v1/me/read-state?targets={encoded}"), + b"", + ) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_buzz_v1_write_wire_contract() { + let body = serde_json::to_vec(&serde_json::json!({"intents":[{ + "type":"mark_channel_read", "channel_id":uuid::Uuid::new_v4(), + "message_id":"ab".repeat(32) + }]})) + .expect("serialize intent"); + buzz_v1_wire_contract("POST", "/buzz/v1/me/read-state", &body).await; + } + + async fn buzz_v1_wire_contract(method: &str, path: &str, request_body: &[u8]) { + use axum::http::{header, StatusCode}; + use buzz_auth::{CrossPodMergeResult, IssuerCapacity, NipFiDenyMap, NipFiMode}; + + let fixture = nip_fi_enforce_test_state_with_verifier() + .await + .expect("local Postgres"); + let mut state = (*fixture).clone(); + Arc::make_mut(&mut state.config).buzz_v1_enabled = true; + let deny_map = Arc::new(NipFiDenyMap::new( + 10, + vec![IssuerCapacity { + issuer: HANDLER_TEST_ISSUER.to_owned(), + capacity: 10, + }], + )); + state.nip_fi_deny_map = Some(deny_map.clone()); + let state = Arc::new(state); let host = format!("bffv1-{}.local", uuid::Uuid::new_v4().simple()); - rt.block_on(state.db.ensure_configured_community(&host)) + state + .db + .ensure_configured_community(&host) + .await .expect("ensure community"); - let path = "/buzz/v1/me/sidebar?limit=1"; let url = format!("https://{host}{path}"); - let key_nip98 = Keys::generate(); - let key_assertion = Keys::generate(); - let mut results = Vec::new(); - for (label, akey) in [("paired", &key_nip98), ("mismatched", &key_assertion)] { - let assertion = signed_assertion_for_pubkey(&akey.public_key().to_hex()); - let mut headers = make_nip98_headers(&key_nip98, &url, "GET", b""); - headers.insert( - buzz_auth::CLIENT_ATTACHED_HEADER, - format!("Bearer {assertion}").parse().expect("valid header"), + let actor = Keys::generate(); + let other = Keys::generate(); + let paired = same_key_nip98_and_assertion_headers(&actor, &url, method, request_body); + let (status, headers, body) = oneshot_request_full( + state.clone(), + method, + path, + &host, + paired.clone(), + request_body, + ) + .await; + assert_eq!(status, StatusCode::OK, "admitted control: {body:?}"); + assert_eq!(headers[header::CONTENT_TYPE], "application/json"); + assert_eq!(headers[header::CACHE_CONTROL], "private, no-store"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("control JSON"); + if method == "POST" { + assert_eq!( + body.as_ref(), + br#"{"outcomes":[{"status":"blocked"}],"projection_status":"not_requested"}"# ); - let (status, _h, body) = rt.block_on(oneshot_request_full( + } else { + assert!(value["account"]["cutoff_ms"].is_i64()); + assert_eq!( + value["account"]["retention_seconds"], + state.config.buzz_v1_retention_seconds + ); + if path.contains("sidebar") { + assert_eq!(value["channels"], serde_json::json!([])); + assert_eq!(value["next_cursor"], serde_json::Value::Null); + } else { + assert_eq!( + value["contexts"], + serde_json::json!([{"status":"unavailable"}]) + ); + } + } + + for case in [ + "mismatch", + "denied", + "missing_nip98", + "invalid_nip98", + "missing_assertion", + "invalid_assertion", + ] { + let mut request_headers = paired.clone(); + match case { + "mismatch" => { + let assertion = signed_assertion_for_pubkey(&other.public_key().to_hex()); + request_headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}") + .parse() + .expect("assertion header"), + ); + } + "denied" => { + let now = chrono::Utc::now(); + assert_eq!( + deny_map.merge_cross_pod_deny( + HANDLER_TEST_ISSUER, + &actor.public_key(), + now + chrono::Duration::minutes(5), + now + ), + CrossPodMergeResult::Merged + ); + } + "missing_nip98" => { + request_headers.remove(header::AUTHORIZATION); + } + "invalid_nip98" => { + request_headers.insert(header::AUTHORIZATION, "Nostr invalid".parse().unwrap()); + } + "missing_assertion" => { + request_headers.remove(buzz_auth::CLIENT_ATTACHED_HEADER); + } + "invalid_assertion" => { + request_headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + "Bearer invalid".parse().unwrap(), + ); + } + _ => unreachable!(), + } + let (status, headers, body) = oneshot_request_full( state.clone(), - "GET", + method, path, &host, - headers, - b"", - )); - assert!(!body.is_empty(), "{label} response"); - results.push(status); + request_headers, + request_body, + ) + .await; + let missing = case.starts_with("missing_"); + assert_eq!( + status, + if missing { + StatusCode::UNAUTHORIZED + } else { + StatusCode::FORBIDDEN + }, + "{case}" + ); + assert_eq!( + headers[header::CONTENT_TYPE], + "text/plain; charset=utf-8", + "{case}" + ); + assert_eq!( + body.as_ref(), + if missing { + b"authentication required\n".as_slice() + } else if case.starts_with("invalid_") { + b"evidence rejected\n".as_slice() + } else { + b"authorization denied\n".as_slice() + }, + "{case}" + ); + assert_eq!( + headers + .get(header::WWW_AUTHENTICATE) + .map(|v| v.to_str().unwrap()), + missing.then_some("Nostr"), + "{case}" + ); + // Outer assertion middleware owns its own cache policy. + if !case.ends_with("assertion") { + assert_eq!( + headers[header::CACHE_CONTROL], + "private, no-store", + "{case}" + ); + } } - assert_eq!(results[0], axum::http::StatusCode::OK, "paired control"); + + // Off ignores the same real deny entry, and retains application JSON for + // missing request auth rather than leaking the bridge's error envelope. + let mut off = (*state).clone(); + Arc::make_mut(&mut off.config).nip_fi.mode = NipFiMode::Off; + let off = Arc::new(off); assert_eq!( - results[1], - axum::http::StatusCode::FORBIDDEN, - "mismatched assertion key must be denied on /buzz/v1" + oneshot_request_full(off.clone(), method, path, &host, paired, request_body) + .await + .0, + StatusCode::OK ); + let (status, headers, body) = oneshot_request_full( + off, + method, + path, + &host, + axum::http::HeaderMap::new(), + request_body, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(headers[header::CONTENT_TYPE], "application/json"); + assert_eq!(headers[header::CACHE_CONTROL], "private, no-store"); + assert!(!headers.contains_key(header::WWW_AUTHENTICATE)); + let error: serde_json::Value = serde_json::from_slice(&body).expect("Off JSON"); + assert_eq!(error["error"]["code"], "unauthorized"); + assert!(uuid::Uuid::parse_str(error["error"]["request_id"].as_str().unwrap()).is_ok()); } + // ── Caller key-pairing witness: moderation mismatched key → 403 ───────── #[test] #[ignore = "requires Postgres"] fn nip_fi_enforce_moderation_mismatched_key_is_403() { diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index 2879795031e..c9eaceaed89 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -11,22 +11,26 @@ use buzz_core::TenantContext; use serde_json::{json, Value}; use std::sync::Arc; -pub(super) struct Error { - status: StatusCode, - code: &'static str, +pub(super) enum Error { + Application { + status: StatusCode, + code: &'static str, + }, + Admission(Box), } impl Error { pub(super) fn new(status: StatusCode, code: &'static str) -> Self { - Self { status, code } + Self::Application { status, code } } pub(super) fn unavailable() -> Self { Self::new(StatusCode::SERVICE_UNAVAILABLE, "unavailable") } pub(super) fn terminal_denial(&self) -> bool { - matches!( - self.status, - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN - ) + let status = match self { + Self::Application { status, .. } => *status, + Self::Admission(response) => response.status(), + }; + matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) } pub(super) fn invalid() -> Self { Self::new(StatusCode::BAD_REQUEST, "invalid_request") @@ -34,23 +38,29 @@ impl Error { } impl IntoResponse for Error { fn into_response(self) -> Response { - let mut response = ( - self.status, - Json(json!({"error":{"code":self.code, - "request_id":uuid::Uuid::new_v4().to_string()}})), - ) - .into_response(); + let mut response = match self { + Self::Admission(response) => *response, + Self::Application { status, code } => { + let mut response = ( + status, + Json(json!({"error":{"code":code, + "request_id":uuid::Uuid::new_v4().to_string()}})), + ) + .into_response(); + if status == StatusCode::TOO_MANY_REQUESTS + || status == StatusCode::SERVICE_UNAVAILABLE + { + response + .headers_mut() + .insert(header::RETRY_AFTER, header::HeaderValue::from_static("60")); + } + response + } + }; response.headers_mut().insert( header::CACHE_CONTROL, header::HeaderValue::from_static("private, no-store"), ); - if self.status == StatusCode::TOO_MANY_REQUESTS - || self.status == StatusCode::SERVICE_UNAVAILABLE - { - response - .headers_mut() - .insert(header::RETRY_AFTER, header::HeaderValue::from_static("60")); - } response } } @@ -104,7 +114,14 @@ pub(super) async fn authorize( body.is_some(), ), ) - .map_err(|r| bridge_error((r.status(), Json(Value::Null))))?; + .map_err(|response| { + if state.config.nip_fi.mode == buzz_auth::NipFiMode::Off { + bridge_error((response.status(), Json(Value::Null))) + } else { + // Preserve the shared NIP-FI wire contract, not just its status. + Error::Admission(Box::new(response)) + } + })?; let actor = *admission.proven_pubkey(); let (event_id, signed_at) = admission.into_extra(); bridge::enforce_http_admission(state, &tenant, &actor) diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index db1e4215b00..dcd81b745b4 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -33,10 +33,17 @@ The host chooses the community; the signer chooses `/me`. NIP-OA admission does not grant access to the owner's personal state. Relay membership, bans and resource access are enforced; moderation timeouts do not prohibit reading. -All API responses are `Cache-Control: private, no-store`. Errors use +Responses produced by the v1 handlers are `Cache-Control: private, no-store`. +Application errors (including NIP-98 failures with NIP-FI Off) use `{"error":{"code":"invalid_request","request_id":"..."}}`, with 400 invalid, 401 unauthorized/replay, 403 forbidden, 404 unavailable capability/host/path, -429 rate limited or 503 temporarily unavailable. 429/503 include `Retry-After`. +429 rate limited or 503 temporarily unavailable. Application 429/503 errors +include `Retry-After`. When NIP-FI is not Off, admission failures instead preserve +the shared [NIP-FI HTTP denial contract](nips/NIP-FI.md): status, fixed plaintext +body, `Content-Type` and (for 401) `WWW-Authenticate: Nostr`. The v1 handler adds +`private, no-store` without changing those fields, unlike the bridge's direct +passthrough. Denials from the outer shared router middleware use its common +response policy, not the v1 handler's cache or JSON policy. Unknown request fields are rejected. Never turn a transport failure into read. ## Sidebar From 533de68d2f935ba924a1efcb854bcd8f95b7ce8c Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 2 Oct 2026 08:26:56 -0400 Subject: [PATCH 13/18] feat(buzz-v1): count a reply as unread only when it is directed at the reader Every unread reply used to count, so a busy thread the reader never joined lit up their sidebar. The relay now decides relevance itself, so clients need no conversation lookups of their own. A reply counts only when it has a reason, the first that holds: its channel is a DM (`direct`), it tags the reader (`mention`), it replies to a message the reader wrote or also replied to in the same channel (`conversation`), or it is a broadcast (`broadcast`). Top-level messages always count and carry no reason. Wire changes on /buzz/v1: - An unread message state reports `reason` instead of `attention`. - Thread summaries drop `attention`: every listed reply has a reason. Membership is looked up exactly by direct parent, scoped to the channel, for at most 1024 parents per request. When that lookup times out the reply is undecided, never absent: a context reports `unknown` and counts become lower bounds. A context decides eligibility, then the read frontier, then membership. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../src/store/personal_read/classification.rs | 26 +- .../src/store/personal_read/context.rs | 96 +- .../buzz-db/src/store/personal_read/model.rs | 44 +- .../src/store/personal_read/participation.rs | 59 +- .../participation_postgres_tests.rs | 821 ++++++++++++------ .../src/store/personal_read/postgres_tests.rs | 8 +- .../src/store/personal_read/projection.rs | 152 ++-- .../projection_postgres_tests.rs | 3 +- .../personal_read/threads_postgres_tests.rs | 53 +- docs/buzz-v1-read-state.md | 78 +- 10 files changed, 912 insertions(+), 428 deletions(-) diff --git a/crates/buzz-db/src/store/personal_read/classification.rs b/crates/buzz-db/src/store/personal_read/classification.rs index ef2635b06a9..dfc83abe357 100644 --- a/crates/buzz-db/src/store/personal_read/classification.rs +++ b/crates/buzz-db/src/store/personal_read/classification.rs @@ -1,6 +1,6 @@ -//! Selector eligibility and shared directed-attention rules. Aggregate SQL applies +//! Selector eligibility and shared directed-reason rules. Aggregate SQL applies //! the same eligibility before grouping, covered by the PostgreSQL parity test. -use super::model::ELIGIBLE_KINDS; +use super::model::{Reason, ELIGIBLE_KINDS}; pub(super) fn eligible( kind: i32, @@ -12,11 +12,19 @@ pub(super) fn eligible( ELIGIBLE_KINDS.contains(&kind) && !own && !deleted && created_ms >= cutoff_ms } -pub(super) fn directed(channel_type: &str, actor_hex: &str, tags: &[Vec]) -> bool { - channel_type == "dm" - || tags.iter().any(|tag| { - tag.len() >= 2 - && ((tag[0] == "p" && tag[1].eq_ignore_ascii_case(actor_hex)) - || (tag[0] == "broadcast" && tag[1] == "1")) - }) +/// Why a message is directed, before conversation membership is known. +pub(super) fn reason(channel_type: &str, actor_hex: &str, tags: &[Vec]) -> Option { + let tagged = |name: &str, matches: &dyn Fn(&str) -> bool| { + tags.iter() + .any(|tag| tag.len() >= 2 && tag[0] == name && matches(&tag[1])) + }; + if channel_type == "dm" { + Some(Reason::Direct) + } else if tagged("p", &|value| value.eq_ignore_ascii_case(actor_hex)) { + Some(Reason::Mention) + } else if tagged("broadcast", &|value| value == "1") { + Some(Reason::Broadcast) + } else { + None + } } diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs index 305a2f2f8d7..0b20e58e175 100644 --- a/crates/buzz-db/src/store/personal_read/context.rs +++ b/crates/buzz-db/src/store/personal_read/context.rs @@ -41,6 +41,8 @@ impl Db { let actor_bytes = actor.to_bytes(); let account = read_account(&mut tx, retention_seconds).await?; let mut contexts = Vec::with_capacity(queries.len()); + // Replies whose state turns on conversation membership, by position. + let mut pending = Vec::new(); for query in queries { let root = match writes::valid_target(&mut tx, community, &actor_bytes, &query.target).await { @@ -80,7 +82,7 @@ impl Db { "SELECT encode(e.id,'hex') AS id,e.kind,e.created_at, e.deleted_at IS NOT NULL AS deleted,e.pubkey=$3 AS own, CASE WHEN octet_length(e.tags::text)<=8192 THEN e.tags ELSE NULL END AS tags, - tm.root_event_id,c.channel_type::text AS channel_type + tm.root_event_id,tm.parent_event_id,c.channel_type::text AS channel_type FROM events e JOIN channels c ON c.community_id=e.community_id AND c.id=e.channel_id LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id AND tm.event_id=e.id AND tm.event_created_at=e.created_at AND tm.channel_id=e.channel_id @@ -91,7 +93,6 @@ impl Db { .into_iter() .map(|row| Ok((row.try_get::("id")?, row))) .collect::>()?; - let mut participation = None; let mut messages = Vec::with_capacity(ids.len()); for id in &query.message_ids { let state = if let Some(row) = by_id.get(&id.to_ascii_lowercase()) { @@ -129,32 +130,30 @@ impl Db { } else if prefix.is_some_and(|p| created.timestamp() <= p) { MessageReadState::Read } else { - let directed = classification::directed( + let reason = classification::reason( &row.try_get::("channel_type")?, &actor.to_hex(), &tags, ); - if !directed && is_reply && participation.is_none() { - participation = Some( - participation::resolve( - &mut tx, - community, - &actor_bytes, - &[(query.target.channel_id, root.clone())], - ) - .await? - .remove(&(query.target.channel_id, root.clone())) - .flatten(), - ); + // Membership outranks a broadcast and decides a + // plain reply. A DM or mention needs no lookup. + if is_reply + && !matches!(reason, Some(Reason::Direct | Reason::Mention)) + { + let parent: Option> = row.try_get("parent_event_id")?; + if let Some(parent) = parent { + pending.push(( + contexts.len(), + messages.len(), + (query.target.channel_id, parent), + )); + } } - MessageReadState::Unread { - attention: if directed { - Some(true) - } else if is_reply { - participation.flatten() - } else { - Some(false) - }, + // Provisional for a pending reply: see `settle`. + if is_reply && reason.is_none() { + MessageReadState::Unknown + } else { + MessageReadState::Unread { reason } } } } @@ -174,6 +173,13 @@ impl Db { messages, }); } + let targets: Vec<_> = pending.iter().map(|(.., key)| key.clone()).collect(); + let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; + for (context, message, key) in pending { + if let ContextState::Available { messages, .. } = &mut contexts[context] { + settle(&mut messages[message].state, members.get(&key).copied()); + } + } tx.commit().await?; Ok(ContextPage { account, contexts }) } @@ -207,3 +213,47 @@ impl Db { .await?) } } + +/// Apply conversation membership to a pending reply's provisional state: +/// `unknown` for a plain reply, `unread` with reason `broadcast` for a +/// broadcast. An undecided lookup (`None`) leaves it as it is. +fn settle(state: &mut MessageReadState, member: Option) { + match member { + Some(true) => { + *state = MessageReadState::Unread { + reason: Some(Reason::Conversation), + } + } + // A broadcast counts outside the actor's conversations too. + Some(false) if matches!(state, MessageReadState::Unknown) => { + *state = MessageReadState::NotCounted + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn membership_settles_a_pending_reply_and_an_undecided_lookup_fabricates_nothing() { + let broadcast = || MessageReadState::Unread { + reason: Some(Reason::Broadcast), + }; + for (provisional, member, expected) in [ + (MessageReadState::Unknown, Some(true), "conversation"), + (MessageReadState::Unknown, Some(false), "not_counted"), + (MessageReadState::Unknown, None, "unknown"), + (broadcast(), Some(true), "conversation"), + (broadcast(), Some(false), "broadcast"), + (broadcast(), None, "broadcast"), + ] { + let mut state = provisional; + settle(&mut state, member); + let wire = serde_json::to_value(&state).unwrap(); + let got = wire["reason"].as_str().or(wire["status"].as_str()); + assert_eq!(got, Some(expected), "{member:?}"); + } + } +} diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index e098e35f7ab..5839a848736 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -80,7 +80,7 @@ pub enum ReadCount { }, /// Incomplete evidence establishes no positive lower bound. No numeric value. Unknown, - /// More evidence exists or ancestry/participation could not be proved. + /// More evidence exists or ancestry/membership could not be proved. AtLeast { /// Proven lower bound, not a fabricated badge cap. value: u32, @@ -112,12 +112,12 @@ pub struct ChannelReadSummary { pub archived: bool, /// Existing DM visibility preference (not an authorization decision). pub hidden: bool, - /// Ordinary unread lower bound or exact count. + /// Unread messages that count: every top-level message, and a reply only + /// when it has a [`Reason`]. Other replies are not unread at all. pub unread: ReadCount, - /// Directed unread: DM, direct mention/broadcast, or a reply in a thread - /// with a live eligible message authored by this actor (including the root). - /// This is not Desktop notification eligibility: follows, mutes and prior - /// mentions elsewhere in a thread do not change this count. + /// The unread subset with a [`Reason`]: everything but ordinary top-level + /// messages. This is not Desktop notification eligibility: follows and + /// mutes do not change this count. pub attention: ReadCount, /// Latest eligible nondeleted event ID, independent of read progress, author, /// and the unread-tracking horizon. @@ -141,15 +141,14 @@ pub struct ThreadSummaries { pub complete: bool, } -/// Unread replies in one canonical thread. No conversation bytes. +/// Unread replies in one canonical thread; every one has a [`Reason`]. No +/// conversation bytes. #[derive(Debug, Serialize)] pub struct ThreadReadSummary { /// Canonical thread-root event ID. pub root_id: String, /// Unread replies in this thread (same definition as the row count). pub unread: ReadCount, - /// Directed subset, with the same definition as the row's attention. - pub attention: ReadCount, /// Newest observed unread reply: a valid thread mark_through anchor. pub latest_reply_id: String, /// Author time (Unix seconds) of latest_reply_id. @@ -183,22 +182,39 @@ pub struct ContextQuery { pub message_ids: Vec, } +/// Why an unread message is directed at the actor: the first that holds. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Reason { + /// Its channel is a DM. + Direct, + /// It tags the actor with `p`. + Mention, + /// It replies to a message the actor wrote, or to one the actor also + /// replied to, in the same channel. Only live eligible messages qualify. + Conversation, + /// It carries `broadcast=1`. + Broadcast, +} + /// Read progress and eligibility for one concrete message, not a public receipt. #[derive(Debug, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum MessageReadState { /// Missing, inaccessible, or outside the requested context. No existence oracle. Unavailable, - /// Evidence cannot safely establish ancestry or eligibility. + /// Evidence cannot safely establish ancestry, eligibility or membership. Unknown, - /// Ineligible for unread counts (own, deleted, auxiliary, or outside horizon). + /// Not unread: own, deleted, auxiliary, outside the horizon, or a reply + /// with no [`Reason`]. NotCounted, /// Covered by this context's frontier. Read, - /// Eligible and beyond this context's frontier. + /// Counts, and is beyond this context's frontier. Unread { - /// True for proven directed attention; null means participation unproved. - attention: Option, + /// Null only for an ordinary top-level message. A broadcast reply whose + /// membership is undecided reports `broadcast`. + reason: Option, }, } diff --git a/crates/buzz-db/src/store/personal_read/participation.rs b/crates/buzz-db/src/store/personal_read/participation.rs index f410cc092b7..682bca30489 100644 --- a/crates/buzz-db/src/store/personal_read/participation.rs +++ b/crates/buzz-db/src/store/personal_read/participation.rs @@ -1,4 +1,4 @@ -//! Bounded actor participation evidence from the existing conversation store. +//! Conversation membership from the existing thread store, bounded by time. use super::model::ELIGIBLE_KINDS; use crate::Result; use buzz_core::CommunityId; @@ -6,24 +6,26 @@ use sqlx::{Acquire, PgConnection, Row}; use std::collections::HashMap; use uuid::Uuid; -const MAX_THREAD_SCAN: i64 = 256; // Bound multiplicative work independently of the unread evidence window. -const MAX_ROOTS: usize = 1024; +const MAX_PARENTS: usize = 1024; -/// Positive evidence survives truncation; absence requires exhausting the thread. -/// Participation is independent of unread retention and read frontiers. +/// Whether each `(channel, parent)` is one of the actor's conversations: the +/// actor wrote the parent or has a reply to it. Only live eligible messages +/// qualify. An absent key is undecided: past the target cap, or the statement +/// deadline expired. Membership is independent of unread retention and read +/// frontiers. pub(super) async fn resolve( conn: &mut PgConnection, community: CommunityId, actor: &[u8], targets: &[(Uuid, Vec)], -) -> Result), Option>> { +) -> Result), bool>> { if targets.is_empty() { return Ok(HashMap::new()); } let targets = select_targets(targets); let channels: Vec<_> = targets.iter().map(|(channel, _)| *channel).collect(); - let roots: Vec<_> = targets.iter().map(|(_, root)| root.clone()).collect(); + let parents: Vec<_> = targets.iter().map(|(_, parent)| parent.clone()).collect(); // Optional inference must not abort authoritative unread/frontier reads. // A nested transaction is a savepoint; rollback also restores the caller's // statement timeout. No state is written in this read-only inference. @@ -34,33 +36,30 @@ pub(super) async fn resolve( sqlx::query("SET LOCAL jit = off") .execute(&mut *budget) .await?; + // Exact: a row cap would lose a real member behind a busy parent's other + // replies. LATERAL ... LIMIT 1 keeps the work on the replies under the + // requested parents; a plain EXISTS may be hashed over the whole tenant. let result = sqlx::query( - "SELECT t.channel_id,t.root_id, - CASE WHEN COALESCE(root.participated,false) OR COALESCE(replies.participated,false) - THEN true WHEN replies.candidates <= $5 THEN false ELSE NULL END AS participated - FROM unnest($3::uuid[],$4::bytea[]) t(channel_id,root_id) + "SELECT t.channel_id,t.parent_id,(own.hit OR replied.hit) IS TRUE AS member + FROM unnest($3::uuid[],$4::bytea[]) t(channel_id,parent_id) LEFT JOIN LATERAL ( - SELECT e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($6) AS participated - FROM events e WHERE e.community_id=$1 AND e.channel_id=t.channel_id AND e.id=t.root_id + SELECT e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($5) AS hit + FROM events e WHERE e.community_id=$1 AND e.channel_id=t.channel_id AND e.id=t.parent_id ORDER BY e.created_at DESC LIMIT 1 - ) root ON true + ) own ON true LEFT JOIN LATERAL ( - WITH candidates AS MATERIALIZED ( - SELECT event_created_at,event_id FROM thread_metadata - WHERE community_id=$1 AND root_event_id=t.root_id - ORDER BY event_created_at DESC,event_id LIMIT $5+1 - ) - SELECT count(*) AS candidates, - bool_or(e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($6)) AS participated - FROM candidates tm LEFT JOIN events e ON e.community_id=$1 + SELECT true AS hit FROM thread_metadata tm JOIN events e ON e.community_id=$1 AND e.channel_id=t.channel_id AND e.created_at=tm.event_created_at AND e.id=tm.event_id - ) replies ON true", + WHERE tm.community_id=$1 AND tm.channel_id=t.channel_id AND tm.parent_event_id=t.parent_id + AND e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($5) + AND own.hit IS NOT TRUE + LIMIT 1 + ) replied ON true", ) .bind(community.as_uuid()) .bind(actor) .bind(channels) - .bind(roots) - .bind(MAX_THREAD_SCAN) + .bind(parents) .bind(ELIGIBLE_KINDS.as_slice()) .fetch_all(&mut *budget) .await; @@ -77,8 +76,8 @@ pub(super) async fn resolve( rows.into_iter() .map(|row| { Ok(( - (row.try_get("channel_id")?, row.try_get("root_id")?), - row.try_get("participated")?, + (row.try_get("channel_id")?, row.try_get("parent_id")?), + row.try_get("member")?, )) }) .collect() @@ -90,7 +89,7 @@ fn select_targets(targets: &[(Uuid, Vec)]) -> Vec<(Uuid, Vec)> { let mut targets = targets.to_vec(); targets.sort_unstable(); targets.dedup(); - targets.truncate(MAX_ROOTS); + targets.truncate(MAX_PARENTS); targets } @@ -99,8 +98,8 @@ mod tests { use super::*; #[test] - fn target_selection_caps_unique_roots_independently_of_sql_timeout() { - // Literal contract boundaries deliberately do not derive from MAX_ROOTS. + fn target_selection_caps_unique_parents_independently_of_sql_timeout() { + // Literal contract boundaries deliberately do not derive from MAX_PARENTS. for count in [0_u32, 1, 1023, 1024, 1025] { let unique: Vec<_> = (0..count) .map(|i| (Uuid::nil(), i.to_be_bytes().to_vec())) diff --git a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs index b49d815fed5..682cb045af3 100644 --- a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs @@ -1,282 +1,635 @@ +//! Which replies count: the direct-parent conversation rule, its deletion and +//! channel edges, and the bounds on the membership lookup. use super::*; use crate::{ channel::{ChannelType, ChannelVisibility}, Db, }; use buzz_core::CommunityId; -use nostr::{EventBuilder, Keys, Kind, Timestamp}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use serde_json::{json, Value}; use sqlx::PgPool; use uuid::Uuid; -async fn event( - db: &Db, +const DAY: u64 = 86_400; + +/// One community with the reading actor and a peer. +struct World { + db: Db, + pool: PgPool, community: CommunityId, - channel: Uuid, - author: &Keys, - root: Option<&nostr::Event>, - kind: u16, - time: u64, -) -> nostr::Event { - let event = EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) - .custom_created_at(Timestamp::from(time)) - .sign_with_keys(author) - .unwrap(); - db.insert_event(community, &event, Some(channel)) - .await - .unwrap(); - if let Some(root) = root { - sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) - VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") - .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()).bind(time as f64) - .bind(channel).bind(root.id.as_bytes().as_slice()).execute(&db.pool).await.unwrap(); - } - event + actor: Keys, + peer: Keys, + now: u64, } -#[tokio::test] -#[ignore = "requires Postgres"] -async fn personal_read_attention_proves_participation_without_retention_or_frontier_inheritance() { - let pool = PgPool::connect(&crate::test_support::database_url()) - .await - .unwrap(); - let db = Db::from_pool(pool.clone()); - let community = db - .ensure_configured_community(&format!("attention-{}.local", Uuid::new_v4())) - .await - .unwrap() - .id; - let actor = Keys::generate(); - let other = Keys::generate(); - let now = Timestamp::now().as_secs(); - // Root author, old participant, absent, deleted participant, auxiliary author, - // overflowing thread without positive evidence, and overflowing own-root thread. - for case in 0..8 { - let channel = db +impl World { + async fn new() -> Self { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("conversation-{}.local", Uuid::new_v4())) + .await + .unwrap() + .id; + Self { + db, + pool, + community, + actor: Keys::generate(), + peer: Keys::generate(), + now: Timestamp::now().as_secs(), + } + } + + /// A channel the actor has joined. + async fn channel(&self) -> Uuid { + self.db .create_channel( - community, - &format!("case-{case}"), + self.community, + &Uuid::new_v4().to_string(), ChannelType::Stream, ChannelVisibility::Open, None, - &actor.public_key().to_bytes(), + &self.actor.public_key().to_bytes(), None, ) .await .unwrap() - .id; - let root_author = if case == 0 || case == 6 { - &actor - } else { - &other - }; - // Root and own reply predate the unread horizon. Participation must not - // expire with unread. - let root = event( - &db, - community, - channel, - root_author, - None, - 9, - now - 40 * 86400, - ) - .await; - if matches!(case, 1 | 3 | 4 | 7) { - let own = event( - &db, - community, - channel, - &actor, - Some(&root), - if case == 4 { 7 } else { 9 }, - now - 39 * 86400, - ) - .await; - if case == 3 { - sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") - .bind(community.as_uuid()) - .bind(own.id.as_bytes().as_slice()) - .execute(&pool) - .await - .unwrap(); - } - } - let count = if case >= 5 { 258 } else { 1 }; - let mut last = root.clone(); - for i in 0..count { - last = event(&db, community, channel, &other, Some(&root), 9, now + i).await; - } - let sidebar = db - .personal_read_sidebar( - community, - &actor.public_key(), + .id + } + + /// A top-level message. + async fn post(&self, channel: Uuid, author: &Keys, at: u64, tags: Vec) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(9), Uuid::new_v4().to_string()) + .tags(tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(author) + .unwrap(); + self.db + .insert_event(self.community, &event, Some(channel)) + .await + .unwrap(); + event + } + + /// A canonical reply to `parent` in `root`'s thread. `parent` need not be + /// stored in `channel`: thread metadata records what the reply claims. + async fn reply( + &self, + channel: Uuid, + author: &Keys, + root: &nostr::Event, + parent: Option<&nostr::Event>, + at: u64, + tags: Vec, + ) -> nostr::Event { + let event = self.post(channel, author, at, tags).await; + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$6,1)") + .bind(self.community.as_uuid()).bind(event.id.as_bytes().as_slice()).bind(at as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()) + .bind(parent.map(|parent| parent.id.as_bytes().as_slice())) + .execute(&self.pool).await.unwrap(); + event + } + + /// The storage write that author and staff deletions share. + async fn delete(&self, event: &nostr::Event) { + assert!(self + .db + .soft_delete_event_and_update_thread(self.community, event.id.as_bytes(), None, None) + .await + .unwrap()); + } + + async fn row(&self, channel: Uuid) -> ChannelReadSummary { + self.db + .personal_read_sidebar_channels( + self.community, + &self.actor.public_key(), DEFAULT_RETENTION_SECONDS, - 20, - None, + &[channel], ) .await - .unwrap(); - let summary = sidebar + .unwrap() .channels - .iter() - .find(|s| s.channel_id == channel) - .unwrap(); - assert!(matches!(summary.unread, ReadCount::Exact { value } if value == count as u32)); - let expected = match case { - 0 | 1 | 6 => Some(true), - 5 | 7 => None, - _ => Some(false), - }; - match expected { - Some(true) => assert!( - matches!(summary.attention, ReadCount::Exact { value } if value == count as u32), - "case {case}: {:?}", - summary.attention - ), - Some(false) => assert!( - matches!(summary.attention, ReadCount::Exact { value: 0 }), - "case {case}: {:?}", - summary.attention - ), - None => assert!( - matches!(summary.attention, ReadCount::Unknown), - "case {case}: {:?}", - summary.attention - ), - } - let context = db + .remove(0) + } + + /// The wire state of one message in the channel timeline or `root`'s thread. + async fn state( + &self, + channel: Uuid, + root: Option<&nostr::Event>, + message: &nostr::Event, + ) -> Value { + let page = self + .db .personal_read_contexts( - community, - &actor.public_key(), + self.community, + &self.actor.public_key(), DEFAULT_RETENTION_SECONDS, &[ContextQuery { target: ReadTarget { channel_id: channel, - root_id: Some(root.id.to_hex()), + root_id: root.map(|root| root.id.to_hex()), }, - message_ids: vec![last.id.to_hex()], + message_ids: vec![message.id.to_hex()], }], ) .await .unwrap(); - let wire = serde_json::to_value(context).unwrap(); - assert_eq!( - wire["contexts"][0]["messages"][0]["attention"], - serde_json::json!(expected), - "case {case}" - ); + let mut state = wire(&page)["contexts"][0]["messages"][0].take(); + state.as_object_mut().unwrap().remove("message_id"); + state + } + + fn mention(&self) -> Tag { + Tag::parse(["p", &self.actor.public_key().to_hex()]).unwrap() } } +fn wire(value: &T) -> Value { + serde_json::to_value(value).unwrap() +} + +fn exact(value: u32) -> Value { + json!({"status":"exact","value":value}) +} + +/// Unread with a reason; `Value::Null` for an ordinary top-level message. +fn unread(reason: impl Into) -> Value { + json!({"status":"unread","reason":reason.into()}) +} + +fn status(status: &str) -> Value { + json!({ "status": status }) +} + +fn broadcast() -> Tag { + Tag::parse(["broadcast", "1"]).unwrap() +} + +fn item(root: &nostr::Event, unread: u32, latest: &nostr::Event) -> Value { + json!({"root_id":root.id.to_hex(),"unread":exact(unread), + "latest_reply_id":latest.id.to_hex(),"latest_reply_at":latest.created_at.as_secs()}) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() { + let w = World::new().await; + // Every witness predates the unread horizon: membership does not expire. + let old = w.now - 40 * DAY; + + // The actor wrote the root. A peer answers it; another peer reply continues + // under that answer, where the actor has written nothing. + let c = w.channel().await; + let root = w.post(c, &w.actor, old, vec![]).await; + let answer = w + .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + .await; + let nested = w + .reply(c, &w.peer, &root, Some(&answer), w.now + 2, vec![]) + .await; + assert_eq!(w.state(c, None, &root).await, status("not_counted")); + assert_eq!( + w.state(c, Some(&root), &answer).await, + unread("conversation") + ); + assert_eq!( + w.state(c, Some(&root), &nested).await, + status("not_counted") + ); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!(wire(&row.attention), exact(1)); + // The newer reply that does not count is not the thread's preview. + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 1, &answer)],"complete":true}) + ); + + // Joining the nested conversation makes its earlier reply count. The + // actor's own reply adds nothing. + w.reply(c, &w.actor, &root, Some(&answer), w.now + 3, vec![]) + .await; + assert_eq!( + w.state(c, Some(&root), &nested).await, + unread("conversation") + ); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(2)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 2, &nested)],"complete":true}) + ); + + // A peer's thread. The actor replied to one of two parents, long ago. + let c = w.channel().await; + let root = w.post(c, &w.peer, old, vec![]).await; + let joined = w.reply(c, &w.peer, &root, Some(&root), old, vec![]).await; + let other = w.reply(c, &w.peer, &root, Some(&root), old, vec![]).await; + w.reply(c, &w.actor, &root, Some(&joined), old, vec![]) + .await; + let sibling = w + .reply(c, &w.peer, &root, Some(&joined), w.now + 1, vec![]) + .await; + let elsewhere = w + .reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![]) + .await; + assert_eq!( + w.state(c, Some(&root), &sibling).await, + unread("conversation") + ); + assert_eq!( + w.state(c, Some(&root), &elsewhere).await, + status("not_counted") + ); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 1, &sibling)],"complete":true}) + ); + + // A conversation the actor never joined: one unread top-level message, and + // a reply that is not unread even in its own thread. + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now, vec![]).await; + let reply = w + .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + .await; + assert_eq!(w.state(c, None, &root).await, unread(Value::Null)); + assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted")); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!(wire(&row.attention), exact(0)); + assert_eq!(wire(&row.threads), json!({"items":[],"complete":true})); + + // A reply whose parent was never recorded is undecided, not absent. + let orphan = w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await; + assert_eq!(w.state(c, Some(&root), &orphan).await, status("unknown")); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), json!({"status":"at_least","value":1})); + assert_eq!(wire(&row.attention), status("unknown")); + assert_eq!(wire(&row.threads), json!({"items":[],"complete":false})); +} + #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_attention_root_budget_does_not_fabricate_absence() { - let pool = PgPool::connect(&crate::test_support::database_url()) +async fn a_directed_reply_counts_outside_the_actors_conversations() { + let w = World::new().await; + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now, vec![]).await; + let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await; + let own = w + .reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![]) + .await; + let mention = w + .reply( + c, + &w.peer, + &root, + Some(&parent), + w.now + 2, + vec![w.mention()], + ) + .await; + let shout = w + .reply( + c, + &w.peer, + &root, + Some(&parent), + w.now + 3, + vec![broadcast()], + ) + .await; + let both = w + .reply( + c, + &w.peer, + &root, + Some(&parent), + w.now + 4, + vec![broadcast(), w.mention()], + ) + .await; + assert_eq!(w.state(c, Some(&root), &own).await, status("not_counted")); + assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention")); + assert_eq!(w.state(c, Some(&root), &shout).await, unread("broadcast")); + assert_eq!(w.state(c, Some(&root), &both).await, unread("mention")); + // The root, the actor's sibling `parent`, and the three directed replies. + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(5)); + assert_eq!(wire(&row.attention), exact(4)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 4, &both)],"complete":true}) + ); + + // Conversation outranks broadcast, never mention. Counts do not move. + w.reply(c, &w.actor, &root, Some(&parent), w.now + 5, vec![]) + .await; + assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention")); + assert_eq!( + w.state(c, Some(&root), &shout).await, + unread("conversation") + ); + assert_eq!(wire(&w.row(c).await.unread), exact(5)); + + // In a DM every peer message is direct, tagged or not, joined or not. + let dm = w.channel().await; + sqlx::query("UPDATE channels SET channel_type='dm' WHERE community_id=$1 AND id=$2") + .bind(w.community.as_uuid()) + .bind(dm) + .execute(&w.pool) .await .unwrap(); - let db = Db::from_pool(pool.clone()); - let community = db - .ensure_configured_community(&format!("root-budget-{}.local", Uuid::new_v4())) - .await - .unwrap() - .id; - let actor = Keys::generate(); - let other = Keys::generate(); - let channel = db - .create_channel( - community, - "many roots", - ChannelType::Stream, - ChannelVisibility::Open, - None, - &actor.public_key().to_bytes(), - None, + let root = w.post(dm, &w.peer, w.now, vec![w.mention()]).await; + let parent = w + .reply(dm, &w.peer, &root, Some(&root), w.now, vec![]) + .await; + let reply = w + .reply(dm, &w.peer, &root, Some(&parent), w.now + 1, vec![]) + .await; + assert_eq!(w.state(dm, None, &root).await, unread("direct")); + assert_eq!(w.state(dm, Some(&root), &reply).await, unread("direct")); + let row = w.row(dm).await; + assert_eq!(wire(&row.unread), exact(3)); + assert_eq!(wire(&row.attention), exact(3)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn eligibility_then_the_read_frontier_are_reported_before_membership() { + let w = World::new().await; + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now, vec![]).await; + let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await; + let reply = |author, at, tags| w.reply(c, author, &root, Some(&parent), w.now + at, tags); + // Peer replies to a parent the actor neither wrote nor replied to. + let outside = reply(&w.peer, 1, vec![]).await; + let deleted = reply(&w.peer, 2, vec![]).await; + w.delete(&deleted).await; + let own = w + .reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![]) + .await; + let anchor = reply(&w.peer, 4, vec![w.mention()]).await; + let later = reply(&w.peer, 5, vec![]).await; + assert_eq!( + w.state(c, Some(&root), &outside).await, + status("not_counted") + ); + + assert_eq!( + w.db.apply_personal_read_intent( + w.community, + &w.actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: c, + root_id: Some(root.id.to_hex()), + }, + message_id: anchor.id.to_hex(), + }, ) .await - .unwrap() - .id; - let now = Timestamp::now().as_secs(); + .unwrap(), + IntentOutcome::Applied + ); + for (message, expected) in [ + (&outside, "read"), + (&deleted, "not_counted"), + (&own, "not_counted"), + (&anchor, "read"), + (&later, "not_counted"), + ] { + assert_eq!(w.state(c, Some(&root), message).await, status(expected)); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() { + let w = World::new().await; + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now - 40 * DAY, vec![]).await; + let reply = |author, parent, at| w.reply(c, author, &root, Some(parent), w.now + at, vec![]); + + // The actor wrote the parent and never replied under it. + let wrote = reply(&w.actor, &root, 0).await; + let to_wrote = reply(&w.peer, &wrote, 10).await; + // The actor's only reply to a peer's parent. + let once = reply(&w.peer, &root, 0).await; + let only = reply(&w.actor, &once, 1).await; + let to_once = reply(&w.peer, &once, 11).await; + // Two replies by the actor to a peer's parent. + let twice = reply(&w.peer, &root, 0).await; + let first = reply(&w.actor, &twice, 1).await; + reply(&w.actor, &twice, 2).await; + let to_twice = reply(&w.peer, &twice, 12).await; + // The actor wrote the parent and also replied under it. + let both = reply(&w.actor, &root, 0).await; + let under = reply(&w.actor, &both, 1).await; + let to_both = reply(&w.peer, &both, 13).await; + + // Whether each counts: `unread` in a conversation, or else `not_counted`. + let counted = || async { + let mut counted = Vec::new(); + for message in [&to_wrote, &to_once, &to_twice, &to_both] { + let state = w.state(c, Some(&root), message).await; + let yes = state == unread("conversation"); + assert!(yes || state == status("not_counted"), "{state}"); + counted.push(yes); + } + counted + }; + let (yes, no) = (true, false); + // `once` and `twice` are peer replies to the root, which the actor has + // replied to (`wrote`, `both`), so they count as well. + assert_eq!(counted().await, [yes, yes, yes, yes]); + assert_eq!(wire(&w.row(c).await.unread), exact(6)); + + // Each deletion changes only its own parent's conversation. + w.delete(&wrote).await; + assert_eq!(counted().await, [no, yes, yes, yes]); + w.delete(&only).await; + assert_eq!(counted().await, [no, no, yes, yes]); + w.delete(&first).await; + assert_eq!(counted().await, [no, no, yes, yes]); + w.delete(&both).await; + assert_eq!(counted().await, [no, no, yes, yes]); + assert_eq!(wire(&w.row(c).await.unread), exact(2)); + w.delete(&under).await; + assert_eq!(counted().await, [no, no, yes, no]); + // With `wrote` and `both` gone the actor has no reply to the root either. + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 1, &to_twice)],"complete":true}) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn membership_is_scoped_to_the_replys_own_channel() { + let w = World::new().await; + let (a, b) = (w.channel().await, w.channel().await); + let old = w.now - 40 * DAY; + + // The actor replied to a peer's parent, but that reply is stored in B. + let theirs = w.post(a, &w.peer, old, vec![]).await; + w.reply(b, &w.actor, &theirs, Some(&theirs), old, vec![]) + .await; + let in_a = w + .reply(a, &w.peer, &theirs, Some(&theirs), w.now, vec![]) + .await; + assert_eq!( + w.state(a, Some(&theirs), &in_a).await, + status("not_counted") + ); + assert_eq!(wire(&w.row(a).await.unread), exact(0)); + + // The actor wrote a parent in A. A peer reply in B claims it as its parent. + let mine = w.post(a, &w.actor, old, vec![]).await; + w.reply(b, &w.peer, &mine, Some(&mine), w.now, vec![]).await; + let row = w.row(b).await; + assert_eq!(wire(&row.unread), exact(0)); + assert_eq!(wire(&row.threads), json!({"items":[],"complete":true})); + + // The same parents count once the actor is a member in the reply's channel. + w.reply(a, &w.actor, &theirs, Some(&theirs), old, vec![]) + .await; + let to_mine = w.reply(a, &w.peer, &mine, Some(&mine), w.now, vec![]).await; + assert_eq!( + w.state(a, Some(&theirs), &in_a).await, + unread("conversation") + ); + assert_eq!( + w.state(a, Some(&mine), &to_mine).await, + unread("conversation") + ); + assert_eq!(wire(&w.row(a).await.unread), exact(2)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn membership_is_exact_behind_a_busy_parent() { + let w = World::new().await; + let c = w.channel().await; + let old = w.now - 40 * DAY; + let root = w.post(c, &w.peer, old, vec![]).await; + // The actor's reply is the oldest of 259 under one parent: a 256-row + // window over the parent's replies would never reach it. + w.reply(c, &w.actor, &root, Some(&root), old, vec![]).await; + let mut last = root.clone(); + for i in 0..258 { + last = w + .reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) + .await; + } + assert_eq!(w.state(c, Some(&root), &last).await, unread("conversation")); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(258)); + assert_eq!(wire(&row.attention), exact(258)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 258, &last)],"complete":true}) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn threads_are_capped_after_replies_that_do_not_count_are_removed() { + let w = World::new().await; + let c = w.channel().await; + let old = w.now - 40 * DAY; + // The actor's thread has the oldest unread reply. Five unjoined threads + // are newer and would fill the list if the cap came first. + let mine = w.post(c, &w.actor, old, vec![]).await; + let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await; + for i in 1..=5 { + let root = w.post(c, &w.peer, old, vec![]).await; + w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) + .await; + } + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&mine, 1, &answer)],"complete":true}) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn the_parent_budget_leaves_replies_undecided_and_fabricates_no_absence() { + let w = World::new().await; + let c = w.channel().await; + // The actor is in the first conversation. Whether it is inside the budget + // depends on ID order, so only the bounds are asserted. for i in 0..1025 { - let root = event(&db, community, channel, &other, None, 9, now).await; - event(&db, community, channel, &other, Some(&root), 9, now + 1).await; + let author = if i == 0 { &w.actor } else { &w.peer }; + let root = w.post(c, author, w.now, vec![]).await; + w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + .await; if i == 1023 { - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::Exact { value: 2048 } - )); - // Root selection is tested deterministically at its production seam. - // The independent SQL deadline may withhold participation evidence. - assert!(matches!( - page.channels[0].attention, - ReadCount::Exact { value: 0 } | ReadCount::Unknown - )); + // 1024 parents fit: 1023 peer roots, and the reply to the actor's. + // The independent SQL deadline may still withhold the answer. + let row = w.row(c).await; + assert!( + [exact(1024), json!({"status":"at_least","value":1023})] + .contains(&wire(&row.unread)), + "{:?}", + row.unread + ); + assert!( + [exact(1), status("unknown")].contains(&wire(&row.attention)), + "{:?}", + row.attention + ); } } - let page = db - .personal_read_sidebar( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - 20, - None, - ) - .await - .unwrap(); - assert!(matches!( - page.channels[0].unread, - ReadCount::Exact { value: 2050 } - )); - assert!(matches!(page.channels[0].attention, ReadCount::Unknown)); + // 1025 parents do not: one reply is undecided, so nothing is exact. + let row = w.row(c).await; + assert!( + [1024, 1025] + .map(|value| json!({"status":"at_least","value":value})) + .contains(&wire(&row.unread)), + "{:?}", + row.unread + ); + assert!( + [json!({"status":"at_least","value":1}), status("unknown")].contains(&wire(&row.attention)), + "{:?}", + row.attention + ); + assert!(!row.threads.complete); } #[tokio::test] #[ignore = "requires Postgres"] -async fn personal_read_attention_timeout_preserves_sidebar_and_context_authority() { - let pool = PgPool::connect(&crate::test_support::database_url()) - .await - .unwrap(); - let db = Db::from_pool(pool.clone()); - let community = db - .ensure_configured_community(&format!("attention-timeout-{}.local", Uuid::new_v4())) - .await - .unwrap() - .id; - let actor = Keys::generate(); - let other = Keys::generate(); - let channel = db - .create_channel( - community, - "timeout", - ChannelType::Stream, - ChannelVisibility::Open, - None, - &actor.public_key().to_bytes(), - None, - ) - .await - .unwrap() - .id; - let now = Timestamp::now().as_secs(); - let root = event(&db, community, channel, &other, None, 9, now).await; - let reply = event(&db, community, channel, &other, Some(&root), 9, now + 1).await; - let mut held = pool.begin().await.unwrap(); +async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction() { + let w = World::new().await; + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now, vec![]).await; + let reply = w + .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + .await; + let mut held = w.pool.begin().await.unwrap(); // Force the real resolver to time out, then check that its outer snapshot // and original statement budget remain usable. sqlx::query("LOCK TABLE thread_metadata IN ACCESS EXCLUSIVE MODE") .execute(&mut *held) .await .unwrap(); - let mut reader = pool.begin().await.unwrap(); + let mut reader = w.pool.begin().await.unwrap(); sqlx::query("SET LOCAL statement_timeout='2000ms'") .execute(&mut *reader) .await @@ -293,9 +646,9 @@ async fn personal_read_attention_timeout_preserves_sidebar_and_context_authority .unwrap(); let result = participation::resolve( &mut reader, - community, - &actor.public_key().to_bytes(), - &[(channel, root.id.as_bytes().to_vec())], + w.community, + &w.actor.public_key().to_bytes(), + &[(c, root.id.as_bytes().to_vec())], ) .await .unwrap(); @@ -313,21 +666,5 @@ async fn personal_read_attention_timeout_preserves_sidebar_and_context_authority } reader.rollback().await.unwrap(); held.rollback().await.unwrap(); - let contexts = db - .personal_read_contexts( - community, - &actor.public_key(), - DEFAULT_RETENTION_SECONDS, - &[ContextQuery { - target: ReadTarget { - channel_id: channel, - root_id: Some(root.id.to_hex()), - }, - message_ids: vec![reply.id.to_hex()], - }], - ) - .await - .unwrap(); - let wire = serde_json::to_value(contexts).unwrap(); - assert_eq!(wire["contexts"][0]["messages"][0]["attention"], false); + assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted")); } diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index cd8c9aa51b4..9d9a47b5a1e 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -623,7 +623,9 @@ async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { async fn personal_read_channel_and_thread_never_inherit_each_other() { let (db, pool, community, channel, actor, root) = fixture().await; let base = root.created_at.as_secs(); + // Directed, so it counts outside the actor's conversations. let reply = EventBuilder::new(Kind::Custom(9), "unseen thread reply") + .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) .custom_created_at(nostr::Timestamp::from(base + 10)) .sign_with_keys(&Keys::generate()) .unwrap(); @@ -716,7 +718,9 @@ async fn personal_read_channel_and_thread_never_inherit_each_other() { async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers() { let (db, pool, community, channel, actor, root) = fixture().await; let base = root.created_at.as_secs(); + // A broadcast counts for every reader, in or out of the conversation. let reply = EventBuilder::new(Kind::Custom(9), "thread only") + .tags([nostr::Tag::parse(["broadcast", "1"]).unwrap()]) .custom_created_at(nostr::Timestamp::from(base + 1)) .sign_with_keys(&Keys::generate()) .unwrap(); @@ -758,7 +762,7 @@ async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers( assert_eq!(page["contexts"][0]["messages"][2]["status"], "unavailable"); assert_eq!(page["contexts"][1]["messages"][0]["status"], "unavailable"); assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); - assert_eq!(page["contexts"][1]["messages"][1]["attention"], false); + assert_eq!(page["contexts"][1]["messages"][1]["reason"], "broadcast"); let accounts: i64 = sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") .bind(community.as_uuid()) @@ -912,7 +916,7 @@ async fn personal_read_context_author_horizon_unknown_ancestry_and_deletion() { ) .unwrap(); assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); - assert_eq!(page["contexts"][0]["messages"][0]["attention"], true); + assert_eq!(page["contexts"][0]["messages"][0]["reason"], "mention"); assert_eq!(page["contexts"][0]["messages"][1]["status"], "unknown"); sqlx::query( "UPDATE events SET created_at=created_at-interval '31 days' WHERE community_id=$1 AND id=$2", diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index 0de57ffa10b..38e251b093c 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -6,7 +6,8 @@ use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use serde_json::Value; use sqlx::{Acquire, PgConnection, Row}; -use std::collections::HashMap; +use std::cmp::Reverse; +use std::collections::{hash_map::Entry, HashMap}; use uuid::Uuid; use crate::{observability, Db, DbError, Result}; @@ -123,7 +124,7 @@ impl Db { FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7 ORDER BY created_at DESC,id LIMIT $8 ), classified AS ( - SELECT e.*, tm.root_event_id AS root, + SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent, COALESCE(tm.root_event_id<>e.id,false) AS is_reply, COALESCE(extract(epoch FROM e.created_at)::bigint <= CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id @@ -138,6 +139,7 @@ impl Db { ), grouped AS ( SELECT CASE WHEN root IS NULL THEN NULL WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root, + CASE WHEN is_reply THEN encode(parent,'hex') END AS parent, is_reply, covered, -- ->>0 also selects scalar "p"/"e": reject nonarrays first. -- C collation matches Rust's ASCII case/hex rules. Reply @@ -161,7 +163,7 @@ impl Db { (array_agg(encode(id,'hex') ORDER BY created_at DESC,id))[1] AS newest_id FROM classified WHERE NOT covered OR root IS NULL - GROUP BY 1,2,3,4 + GROUP BY 1,2,3,4,5 ) SELECT (SELECT count(*) FROM candidates) AS scanned, jsonb_agg(to_jsonb(grouped)) AS evidence FROM grouped @@ -187,7 +189,8 @@ impl Db { let mut unread = 0; let mut attention = 0; let mut unread_complete = complete; - let mut threads: HashMap, ThreadEvidence> = HashMap::new(); + let mut threads: HashMap, Replies> = HashMap::new(); + let mut undirected = Vec::new(); for e in evidence { let n = e["n"] .as_u64() @@ -204,59 +207,54 @@ impl Db { unread_complete = false; continue; } - // Roots are timeline messages; descendants belong exclusively - // to their canonical thread. Never inherit the channel prefix. - let is_reply = e["is_reply"] == true; if e["covered"] == true { continue; } - unread += n; let directed = channel_type == "dm" || facts.get("directed") == Some(&Value::Bool(true)); - if directed { - attention += n; + // Roots are timeline messages; descendants belong exclusively + // to their canonical thread. Never inherit the channel prefix. + if e["is_reply"] != true { + unread += n; + if directed { + attention += n; + } + continue; } - if is_reply { - let Some(root) = e["root"].as_str().and_then(writes::event_id) else { - unread_complete = false; - continue; - }; - let newest = ( + let Some(root) = e["root"].as_str().and_then(writes::event_id) else { + unread_complete = false; + continue; + }; + let replies = Replies { + n, + newest: ( e["newest_at"].as_i64().ok_or_else(invalid_newest)?, e["newest_id"] .as_str() .ok_or_else(invalid_newest)? .to_owned(), - ); - let thread = threads.entry(root).or_insert_with(|| ThreadEvidence { - unread: 0, - directed: 0, - undirected: 0, - newest: newest.clone(), - }); - thread.unread += n; - if directed { - thread.directed += n; - } else { - thread.undirected += n; - } - // Newest first; equal author times break toward the smaller ID. - if (newest.0, std::cmp::Reverse(&newest.1)) - > (thread.newest.0, std::cmp::Reverse(&thread.newest.1)) - { - thread.newest = newest; - } + ), + }; + // A directed reply counts whatever its conversation. Any other + // reply counts only in one of the actor's conversations. + if directed { + count(&mut threads, root, replies); + } else if let Some(parent) = e["parent"].as_str().and_then(writes::event_id) { + undirected.push((root, parent, replies)); + } else { + unread_complete = false; } } - pending.push((threads, attention, unread_complete)); + pending.push((threads, undirected, unread, attention, unread_complete)); channels.push(ChannelReadSummary { channel_id: row.try_get("id")?, name: row.try_get("name")?, channel_type, archived: row.try_get("archived")?, hidden: row.try_get("hidden")?, - unread: ReadCount::from_evidence(unread, unread_complete), - attention: ReadCount::from_evidence(attention, unread_complete), + // Counts and threads wait for conversation membership below. + unread: ReadCount::Unknown, + attention: ReadCount::Unknown, latest_message_id: row.try_get("latest_message_id")?, latest_message_at: row.try_get("latest_message_at")?, latest_message_complete: row.try_get("latest_message_complete")?, @@ -269,51 +267,38 @@ impl Db { let targets: Vec<_> = channels .iter() .zip(&pending) - .flat_map(|(channel, (threads, ..))| { - threads + .flat_map(|(channel, (_, undirected, ..))| { + undirected .iter() - .filter(|(_, t)| t.undirected > 0) - .map(|(root, _)| (channel.channel_id, root.clone())) + .map(|(_, parent, _)| (channel.channel_id, parent.clone())) }) .collect(); - let participation = - participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; - for (channel, (threads, mut attention, evidence_complete)) in + let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; + for (channel, (mut threads, undirected, mut unread, mut attention, mut complete)) in channels.iter_mut().zip(pending) { - let mut complete = evidence_complete; - let mut items = Vec::with_capacity(threads.len()); - for (root, thread) in threads { - let participating = if thread.undirected == 0 { - Some(false) - } else { - participation - .get(&(channel.channel_id, root.clone())) - .copied() - .flatten() - }; - let thread_attention = match participating { - Some(true) => thread.directed + thread.undirected, - _ => thread.directed, - }; - match participating { - Some(true) => attention += thread.undirected, + // Relevance is decided before counts, previews and the thread cap. + for (root, parent, replies) in undirected { + match members.get(&(channel.channel_id, parent)) { + Some(true) => count(&mut threads, root, replies), Some(false) => {} None => complete = false, } + } + let mut items = Vec::with_capacity(threads.len()); + for (root, thread) in threads { + unread += thread.n; + attention += thread.n; items.push(ThreadReadSummary { root_id: hex::encode(root), - unread: ReadCount::from_evidence(thread.unread, evidence_complete), - attention: ReadCount::from_evidence( - thread_attention, - evidence_complete && participating.is_some(), - ), + unread: ReadCount::from_evidence(thread.n, complete), latest_reply_id: thread.newest.1, latest_reply_at: thread.newest.0, }); } + channel.unread = ReadCount::from_evidence(unread, complete); channel.attention = ReadCount::from_evidence(attention, complete); - channel.threads = summarize(items, evidence_complete); + channel.threads = summarize(items, complete); } let next_cursor = if has_more { channels.last().map(|c| c.channel_id) @@ -329,15 +314,33 @@ impl Db { } } -/// Uncovered reply evidence for one canonical thread within a channel scan. -struct ThreadEvidence { - unread: u32, - directed: u32, - undirected: u32, - /// Newest observed unread reply: (author seconds, lowercase hex ID). +/// Uncovered replies in one canonical thread: one evidence group, or the +/// thread's counted total. +struct Replies { + n: u32, + /// Newest of them: (author seconds, lowercase hex ID). newest: (i64, String), } +/// Add replies that count to their thread. +fn count(threads: &mut HashMap, Replies>, root: Vec, replies: Replies) { + match threads.entry(root) { + Entry::Vacant(slot) => { + slot.insert(replies); + } + Entry::Occupied(mut slot) => { + let thread = slot.get_mut(); + thread.n += replies.n; + // Newest first; equal author times break toward the smaller ID. + if (replies.newest.0, Reverse(&replies.newest.1)) + > (thread.newest.0, Reverse(&thread.newest.1)) + { + thread.newest = replies.newest; + } + } + } +} + fn invalid_newest() -> DbError { DbError::InvalidData("invalid thread evidence".into()) } @@ -383,7 +386,6 @@ mod tests { ThreadReadSummary { root_id: hex::encode([root; 32]), unread: ReadCount::Exact { value: 1 }, - attention: ReadCount::Exact { value: 0 }, latest_reply_id: hex::encode([root; 32]), latest_reply_at: at, } diff --git a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs index ec678cb465e..27fdc713a09 100644 --- a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs @@ -231,7 +231,8 @@ async fn sidebar_directed_fact_matches_selector_classifier() { .await .unwrap(); for tags in &cases { - let expected = u32::from(classification::directed(channel_type, &actor_hex, tags)); + let expected = + u32::from(classification::reason(channel_type, &actor_hex, tags).is_some()); sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") .bind(community.as_uuid()) .bind(json!(tags)) diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs index a6d8cea0e9d..76236056061 100644 --- a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -15,11 +15,22 @@ async fn post( channel: Uuid, at: u64, tags: Vec, +) -> nostr::Event { + post_as(db, community, channel, &Keys::generate(), at, tags).await +} + +async fn post_as( + db: &Db, + community: CommunityId, + channel: Uuid, + author: &Keys, + at: u64, + tags: Vec, ) -> nostr::Event { let event = EventBuilder::new(Kind::Custom(9), format!("message at {at}")) .tags(tags) .custom_created_at(nostr::Timestamp::from(at)) - .sign_with_keys(&Keys::generate()) + .sign_with_keys(author) .unwrap(); db.insert_event(community, &event, Some(channel)) .await @@ -27,7 +38,7 @@ async fn post( event } -/// A canonical reply: stored event plus its thread metadata. +/// A canonical reply to the root: stored event plus its thread metadata. async fn reply( db: &Db, pool: &PgPool, @@ -38,11 +49,37 @@ async fn reply( tags: Vec, ) -> nostr::Event { let event = post(db, community, channel, at, tags).await; + link(pool, community, channel, root, &event).await; + event +} + +async fn link( + pool: &PgPool, + community: CommunityId, + channel: Uuid, + root: &nostr::Event, + event: &nostr::Event, +) { sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") - .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()).bind(at as f64) + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) + .bind(event.created_at.as_secs() as f64) .bind(channel).bind(root.id.as_bytes().as_slice()).execute(pool).await.unwrap(); - event +} + +/// Put the actor in the root's conversation, so that plain replies to it +/// count. The actor's own reply is never unread. +async fn join( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + actor: &Keys, + root: &nostr::Event, +) { + let at = root.created_at.as_secs(); + let own = post_as(db, community, channel, actor, at, vec![]).await; + link(pool, community, channel, root, &own).await; } async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSummary { @@ -83,6 +120,7 @@ fn exact(count: &ReadCount) -> Option { #[ignore = "requires Postgres"] async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { let (db, pool, community, channel, actor, root) = fixture().await; + join(&db, &pool, community, channel, &actor, &root).await; let base = root.created_at.as_secs(); let first = reply(&db, &pool, community, channel, &root, base + 10, vec![]).await; post(&db, community, channel, base + 20, vec![]).await; @@ -284,6 +322,7 @@ async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independe #[ignore = "requires Postgres"] async fn absent_whole_channel_cut_never_covers_epoch_zero_replies() { let (db, pool, community, channel, actor, root) = fixture().await; + join(&db, &pool, community, channel, &actor, &root).await; reply(&db, &pool, community, channel, &root, 0, vec![]).await; // A channel row exists, with no whole-channel cut: NULL must stay NULL. apply( @@ -319,7 +358,9 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { let base = fixture_root.created_at.as_secs() + 1; let mut roots = Vec::new(); for i in 0..6 { - roots.push(post(&db, community, channel, base + i, vec![]).await); + let root = post(&db, community, channel, base + i, vec![]).await; + join(&db, &pool, community, channel, &actor, &root).await; + roots.push(root); } let newest_root = roots.last().unwrap().clone(); apply( @@ -386,7 +427,6 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { ); let third = &row.threads.items[2]; assert_eq!(exact(&third.unread), Some(3)); - assert_eq!(exact(&third.attention), Some(1)); assert_eq!(third.latest_reply_at, (base + 140) as i64); assert_eq!( third.latest_reply_id, @@ -435,6 +475,7 @@ impl ThreadReadSummary { #[ignore = "requires Postgres"] async fn thread_summaries_are_incomplete_when_tag_evidence_could_hide_a_root() { let (db, pool, community, channel, actor, root) = fixture().await; + join(&db, &pool, community, channel, &actor, &root).await; let listed = reply( &db, &pool, diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index dcd81b745b4..aa12a2f4529 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -68,19 +68,20 @@ Absence says nothing else about access to an open channel. ```json {"items":[{"root_id":"<64-hex>","unread":{"status":"exact","value":2}, - "attention":{"status":"exact","value":0},"latest_reply_id":"<64-hex>", - "latest_reply_at":1700000000}],"complete":true} + "latest_reply_id":"<64-hex>","latest_reply_at":1700000000}],"complete":true} ``` -Items are canonical roots with unread eligible replies, ordered by -`latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is the -newest observed unread reply (equal times prefer the smaller ID) and a valid -thread `mark_through` anchor. `unread` and `attention` use the row's -definitions. `complete=true` means the unread window was exhausted, no evidence -had unresolved ancestry or unusable tags, and no thread was omitted; then item -unread counts sum to the row's unread replies. Otherwise the list is a cut of -observed evidence and counts may be lower bounds or unknown. Participation -budget exhaustion affects only `attention`. No message bytes are included. +Items are canonical roots with unread replies that count (see below), ordered +by `latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is +the newest such reply (equal times prefer the smaller ID) and a valid thread +`mark_through` anchor. Replies that do not count are filtered out before the +count, the newest reply and the cap are chosen. `unread` uses the row's +definition; every counted reply is also attention, so items carry no separate +attention count. `complete=true` means the unread window was exhausted, no +evidence had unresolved ancestry, unusable tags or undecided membership, and no +thread was omitted; then item unread counts sum to the row's unread replies. +Otherwise the list is a cut of observed evidence and counts may be lower bounds +or unknown. No message bytes are included. Counts have exactly three representations: @@ -88,15 +89,33 @@ Counts have exactly three representations: [{"status":"exact","value":0},{"status":"at_least","value":7},{"status":"unknown"}] ``` -Only exact zero proves absence. Unknown has no numeric value. Ordinary unread -counts non-own, nondeleted messages of the advertised `eligible_kinds` beyond -the matching context frontier. The same kinds alone define latest activity, so -an edit, reaction or diff (40008) neither makes a channel unread nor moves it. -Classify live arrivals with the advertised set, not a client copy. Attention is the unread subset consisting -of DMs, direct actor mentions, `broadcast=1`, and replies in a thread containing -a live eligible message authored by the actor. Participation is independent of -read progress and retention. This is not Desktop notification policy: follows, -mutes and earlier mentions elsewhere in a thread do not affect this count. +Only exact zero proves absence. Unknown has no numeric value. A message is +eligible when it is non-own, nondeleted, of the advertised `eligible_kinds` and +inside the horizon. The same kinds alone define latest activity, so an edit, +reaction or diff (40008) neither makes a channel unread nor moves it. Classify +live arrivals with the advertised set, not a client copy. + +An eligible message beyond the matching context frontier counts as unread for +the first reason that holds: + +| `reason` | Holds when | +|---|---| +| `direct` | its channel is a DM | +| `mention` | it tags the actor with `p` | +| `conversation` | it is a reply, and the actor wrote its direct parent or has a reply to that same parent, in that channel | +| `broadcast` | it carries `broadcast=1` | +| null | it is top-level | + +A reply with no reason does not count: it is not unread and appears in no +thread list. `unread` counts the messages that count; `attention` is the subset +with a reason. Conversation membership uses only the actor's live eligible +messages (a deleted parent proves nothing; a surviving reply to it still does), +looks at the direct parent only (owning the root or replying elsewhere in the +thread proves nothing), and is independent of read progress and retention. A +reply whose membership is undecided is left out, and the row's counts become +lower bounds or unknown. The sidebar counts a broadcast reply without asking +which of the two reasons applies. This is not Desktop notification policy: +follows and mutes do not affect these counts. The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and, like every frontier, is measured in author time (`created_at`): a message counts @@ -131,9 +150,15 @@ Omitting `root_id` selects the channel timeline. The result contains `account` and one `contexts` entry per request entry, in order. Context status is `available` (with nullable `through_timestamp` and `messages`), `unknown`, or `unavailable`. A thread context's `through_timestamp` is its effective prefix, -including any whole-channel cut. Message status is `read`, `not_counted`, `unread` (with nullable -`attention`), `unknown`, or `unavailable`. Wrong-context, missing and forbidden -selectors share unavailable. Null attention means participation is unproved. +including any whole-channel cut. Message status is `read`, `not_counted`, +`unread` (with `reason`), `unknown`, or `unavailable`. Wrong-context, missing +and forbidden selectors share unavailable. Status is decided in this order: +ancestry and context; eligibility (`not_counted` for own, deleted, other kinds +and outside the horizon); the frontier (`read`, with no membership lookup); then +the reason. A reply past the frontier with no reason is `not_counted` when it is +proven outside the actor's conversations and `unknown` when membership is +undecided. A broadcast reply whose membership is undecided is `unread` with +reason `broadcast` and may report `conversation` on a later request. Conversation bytes must still come from the existing Nostr path. ## Fixed-operand writes @@ -186,9 +211,10 @@ remains device-local. leave latest incomplete even when unread is exact. - Tag documents over 8192 bytes or malformed relevant tags yield uncertainty. Compact boolean facts cross the database boundary, never raw tag payloads. -- Optional participation: at most 1024 unique roots and 257 metadata candidates - per root, with a 500 ms savepoint budget. Budget exhaustion preserves ordinary - counts and leaves unproved attention unknown/lower-bound. +- Conversation membership: at most 1024 unique parents per request, with a + 500 ms savepoint budget. The lookup is exact, so its work grows with the + replies under each parent. Past either bound a reply is undecided, never + absent: counts become lower bounds or unknown and a context reports `unknown`. - DB statement/lock deadlines and HTTP read deadlines bound work; writes use a shared eight-second intent-processing deadline after admission. Limits are containment, not a production capacity claim. From 9894641a8ed1b777b42497cb6efc90f4416a353b Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 2 Oct 2026 18:30:49 -0400 Subject: [PATCH 14/18] fix(buzz-v1): let a thread on a never-unread root be read A thread whose root is a kind that is never unread itself, such as a diff (40008), was listed in the sidebar but could not be opened or cleared. Reading its context returned `unavailable`, and `mark_through` with the sidebar's own `latest_reply_id` returned `blocked`, so the badge stayed until the whole channel was marked read. `valid_target` looked the root up through `message()`, which only finds kinds that can be unread. A root only has to exist in the channel and not be a reply itself, so that lookup no longer filters by kind. The `mark_through` anchor keeps the filter: a diff still never counts as unread and is never a valid anchor. The new Postgres test covers the sidebar, the context read and the write for such a thread, and fails without the writes.rs change. Reported in review on #7906 (writes.rs:166). Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../personal_read/threads_postgres_tests.rs | 73 +++++++++++++++++++ .../buzz-db/src/store/personal_read/writes.rs | 24 ++++-- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs index 76236056061..fad2feb8497 100644 --- a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -471,6 +471,79 @@ impl ThreadReadSummary { } } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + let mention = || vec![Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]; + // A diff is never unread, not even one addressed to the actor. + let diff = EventBuilder::new(Kind::Custom(40008), "a diff") + .tags(mention()) + .custom_created_at(nostr::Timestamp::from(base + 1)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &diff, Some(channel)) + .await + .unwrap(); + let on_diff = reply(&db, &pool, community, channel, &diff, base + 10, mention()).await; + let elsewhere = reply(&db, &pool, community, channel, &root, base + 20, mention()).await; + + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(3), "the root and two replies"); + assert_eq!(row.threads.items.len(), 2); + let listed = &row.threads.items[1]; + assert_eq!(listed.root_id, diff.id.to_hex()); + assert_eq!(listed.latest_reply_id, on_diff.id.to_hex()); + + // The listed thread is a usable context; the diff stays uncounted. + let thread = ReadTarget { + channel_id: channel, + root_id: Some(listed.root_id.clone()), + }; + let timeline = ReadTarget { + channel_id: channel, + root_id: None, + }; + let queries = [(&thread, &on_diff), (&timeline, &diff)].map(|(target, message)| ContextQuery { + target: target.clone(), + message_ids: vec![message.id.to_hex()], + }); + let page = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(); + let wire = serde_json::to_value(&page).unwrap(); + assert_eq!( + wire["contexts"][0]["messages"][0], + serde_json::json!({"message_id":on_diff.id.to_hex(),"status":"unread","reason":"mention"}) + ); + assert_eq!(wire["contexts"][1]["messages"][0]["status"], "not_counted"); + + // The diff is still no anchor; the listed reply reads its thread alone. + let through_diff = ReadIntent::MarkThrough { + target: thread, + message_id: diff.id.to_hex(), + }; + assert_eq!( + apply(&db, community, &actor, through_diff).await, + IntentOutcome::Blocked + ); + assert_eq!( + apply(&db, community, &actor, listed.clone_target(channel)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(2)); + assert_eq!(row.threads.items.len(), 1); + assert_eq!(row.threads.items[0].latest_reply_id, elsewhere.id.to_hex()); +} + #[tokio::test] #[ignore = "requires Postgres"] async fn thread_summaries_are_incomplete_when_tag_evidence_could_hide_a_root() { diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index e48333beb96..75aa09460b0 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -86,20 +86,23 @@ struct Message { root: Option>, } +/// A channel event and its canonical ancestry. `kinds` bounds the lookup: an +/// anchor must be a kind that can be unread; a thread root need not be. async fn message( conn: &mut PgConnection, community: CommunityId, channel: Uuid, id: &[u8], + kinds: Option<&[i32]>, ) -> Result> { let row = sqlx::query( "SELECT e.id, e.created_at, e.tags, tm.root_event_id FROM events e LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id AND tm.event_created_at=e.created_at AND tm.event_id=e.id AND tm.channel_id=e.channel_id WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=$3 - AND e.kind=ANY($4) + AND ($4::int4[] IS NULL OR e.kind=ANY($4)) LIMIT 1", - ).bind(community.as_uuid()).bind(channel).bind(id).bind(ELIGIBLE_KINDS.as_slice()).fetch_optional(&mut *conn).await?; + ).bind(community.as_uuid()).bind(channel).bind(id).bind(kinds).fetch_optional(&mut *conn).await?; row.map(|row| { let timestamp: DateTime = row.try_get("created_at")?; let id: Vec = row.try_get("id")?; @@ -160,9 +163,10 @@ pub(super) async fn valid_target( return Ok(None); } if !root.is_empty() { - // Deleted roots still own living replies. Validate actual ancestry, - // not absence of metadata: a missing index row is not a top-level proof. - let Some(msg) = message(conn, community, target.channel_id, &root).await? else { + // Deleted roots still own living replies, and so do roots of a kind + // that is never unread itself (a diff). Validate actual ancestry, not + // absence of metadata: a missing index row is not a top-level proof. + let Some(msg) = message(conn, community, target.channel_id, &root, None).await? else { return Ok(None); }; if msg.root.as_ref().is_some_and(|r| r != &msg.id) { @@ -204,7 +208,15 @@ pub(super) async fn apply( let Some(root) = valid_target(conn, community, actor, target).await? else { return Ok(IntentOutcome::Blocked); }; - let Some(msg) = message(conn, community, target.channel_id, &id).await? else { + let Some(msg) = message( + conn, + community, + target.channel_id, + &id, + Some(&ELIGIBLE_KINDS), + ) + .await? + else { return Ok(IntentOutcome::Blocked); }; let is_reply = msg.root.as_ref().is_some_and(|r| r != &msg.id); From ce9481aa622805fe6ff0208260b95cbf59e4c29c Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 2 Oct 2026 21:18:11 -0400 Subject: [PATCH 15/18] fix(buzz-v1): answer as Off in NIP-FI shadow mode, and record its verdict Main's shadow mode has one rule: a relay in Shadow answers every request exactly as it does in Off, and records what Enforce would have decided. /buzz/v1's admission adapter predates that mode and broke the rule twice once main was merged in: - It chose the error shape by comparing the mode to Off, so in Shadow a failed proof was answered with the NIP-FI response, not Off's unauthorized. - It bound the tenant directly, so a request on a Host that binds no community left no shadow record. Bind through nip_fi_shadow::bind_tenant like every other protected handler, and keep the NIP-FI wire contract only when the mode restricts. Off, Enforce and DenyProtected answer as before. accessory_shadow_matches_off pins both halves on /buzz/v1/me/sidebar: same status and code in Off and Shadow, exactly one shadow record in Shadow. It fails on the merge commit (unknown Host: no record) and with the bind fixed but the old mode check kept (seeded Host: code is lost). Main's nip_fi_mode_is_inspected_only_through_predicates also fails on the merge commit and passes here. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/api/buzz_v1/auth.rs | 14 ++-- .../src/api/buzz_v1/postgres_tests.rs | 71 +++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index c9eaceaed89..08434a253d1 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -88,13 +88,9 @@ pub(super) async fn authorize( method: &'static str, body: Option<&[u8]>, ) -> Result { - let host = headers - .get(header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, host) + let tenant = crate::nip_fi_shadow::bind_tenant(state, headers) .await - .map_err(|_| Error::new(StatusCode::NOT_FOUND, "not_found"))?; + .ok_or_else(|| Error::new(StatusCode::NOT_FOUND, "not_found"))?; let path = uri .path_and_query() .map(|p| p.as_str()) @@ -115,11 +111,11 @@ pub(super) async fn authorize( ), ) .map_err(|response| { - if state.config.nip_fi.mode == buzz_auth::NipFiMode::Off { - bridge_error((response.status(), Json(Value::Null))) - } else { + if state.config.nip_fi.mode.restricts() { // Preserve the shared NIP-FI wire contract, not just its status. Error::Admission(Box::new(response)) + } else { + bridge_error((response.status(), Json(Value::Null))) } })?; let actor = *admission.proven_pubkey(); diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index e4e27277dc3..58ed929bec2 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -466,6 +466,77 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { } } +// Pins Off parity under NIP-FI Shadow. These error bodies carry a per-request +// id, so they cannot ride the router's byte-for-byte Shadow rows: Shadow keeps +// Off's status and code and leaves exactly one verdict. +// Mutation: binding the tenant without `bind_tenant` leaves the unseeded row +// with no record; answering a failed proof with the NIP-FI response whenever +// the mode is not Off changes the seeded row's code. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_shadow_matches_off() { + use crate::nip_fi_core::tests::ScriptedVerifier; + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + for (seeded, status, code) in [ + (false, StatusCode::NOT_FOUND, "not_found"), + (true, StatusCode::UNAUTHORIZED, "unauthorized"), + ] { + let host = format!("bff-shadow-{}.local", uuid::Uuid::new_v4()); + if seeded { + fixture.db.ensure_configured_community(&host).await.unwrap(); + } + for (mode, records) in [ + (buzz_auth::NipFiMode::Off, 0), + (buzz_auth::NipFiMode::Shadow, 1), + ] { + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.buzz_v1_enabled = true; + config.nip_fi.mode = mode; + config.nip_fi.communities = crate::nip_fi_config::NipFiCommunities::for_test( + &format!("https://{host}"), + &["https://issuer.test"], + ); + state.nip_fi_verifier = Some(Arc::new(ScriptedVerifier::new(Ok(Some( + Keys::generate().public_key(), + ))))); + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + // An assertion the guard accepts, and no NIP-98 proof. + let req = Request::get("/buzz/v1/me/sidebar") + .header("host", &host) + .header(buzz_auth::CLIENT_ATTACHED_HEADER, "Bearer a.b.c") + .body(Body::empty()) + .unwrap(); + let response = crate::router::build_router(Arc::new(state)) + .oneshot(req) + .await + .unwrap(); + let got = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap_or_default(); + let recorded: u64 = snapshotter + .snapshot() + .into_vec() + .iter() + .filter(|(key, ..)| key.key().name() == "buzz_nip_fi_shadow_total") + .map(|(.., value)| match value { + metrics_util::debugging::DebugValue::Counter(n) => *n, + _ => 0, + }) + .sum(); + assert_eq!( + (got, &body["error"]["code"], recorded), + (status, &json!(code), records), + "seeded={seeded} {mode:?}" + ); + } + } +} + // Exercise the real signed HTTP ingest path, including deletion side effects, // rather than directly tombstoning an event in the database. async fn signed_sidebar_deletion(deletion_kind: u16) { From 8dbd87693714d091a7e5089999120192202d3e49 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Mon, 5 Oct 2026 13:54:06 -0400 Subject: [PATCH 16/18] Read frontiers follow relay arrival, not author time A read frontier was the author time (created_at) of the message a context was read through. The sender chooses that stamp and the relay accepts it up to 15 minutes either way, so it cannot order reads: - A message that arrives late with an older stamp landed already read. - Marking a future-dated message read everything else in that context until the clock caught up. A frontier is now the relay arrival time (events.received_at) of the anchor, compared at the column's microsecond resolution. Every event already carries it, so ingest is untouched. baxen raised this in review of #7906; numbering messages at ingest remains the end state and can land later without an app release, because no response exposes order. What changes: - Frontier columns in 0056 are timestamptz arrival values. 0056 is edited in place: it has not merged, so no deployment has applied it. - Context responses no longer carry through_timestamp. No field lets a client compute what a mark will cover. - latest_message_id and thread latest_reply_id are the last to arrive, so marking through them reads everything counted. Author-order anchors under arrival frontiers leave a badge that Mark as read cannot clear. - latest_message_id comes from the unread scan (4,096 events) and falls back to the 256-event probe only when the horizon holds no message. The probe alone misses a late arrival behind 256 newer-stamped events of any kind (Sami found this). - latest_message_at stays the greatest author time: display activity, no longer paired with the ID. latest_reply_at stays paired. Unchanged: routes, intents, count statuses, complete flags, and the 30-day horizon and scan windows, which still use author time. Named residuals, none of which strands a badge: arrival is the relay process clock rather than commit order, relay processes can skew, and messages with the identical microsecond stamp are read together. Tests: arrival_postgres_tests.rs (Sami) covers late arrival, future dating, both sidebar anchors and the probe boundary. Reverting the anchors to author order fails five personal_read tests; reverting to the probe-only lookup fails two. Co-authored-by: Sami Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../personal_read/arrival_postgres_tests.rs | 391 ++++++++++++++++++ .../src/store/personal_read/context.rs | 14 +- crates/buzz-db/src/store/personal_read/mod.rs | 8 +- .../buzz-db/src/store/personal_read/model.rs | 17 +- .../src/store/personal_read/postgres_tests.rs | 46 ++- .../src/store/personal_read/projection.rs | 46 ++- .../personal_read/threads_postgres_tests.rs | 52 ++- .../buzz-db/src/store/personal_read/writes.rs | 33 +- docs/buzz-v1-read-state.md | 91 ++-- migrations/0056_personal_read_state.sql | 13 +- schema/schema.sql | 12 +- 11 files changed, 592 insertions(+), 131 deletions(-) create mode 100644 crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs diff --git a/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs new file mode 100644 index 00000000000..e7288f59537 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs @@ -0,0 +1,391 @@ +//! Read progress follows relay arrival (`received_at`), never author time. +//! Each case sets every arrival explicitly: back-to-back inserts share a clock. +use super::{postgres_tests::fixture, *}; +use crate::Db; +use buzz_core::CommunityId; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use sqlx::PgPool; +use uuid::Uuid; + +/// Store `event` as having arrived at `arrived` (Unix seconds). +async fn arrive(pool: &PgPool, community: CommunityId, event: &nostr::Event, arrived: u64) { + let updated = sqlx::query( + "UPDATE events SET received_at=to_timestamp($3) WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(arrived as f64) + .execute(pool) + .await + .unwrap() + .rows_affected(); + assert_eq!(updated, 1, "arrival must land on exactly one stored event"); +} + +/// A message authored at `authored` that arrives at `arrived`. +async fn post( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + authored: u64, + arrived: u64, + tags: Vec, +) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(9), format!("authored {authored}")) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(authored)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + arrive(pool, community, &event, arrived).await; + event +} + +/// A reply to `root` that mentions the actor, so it counts without membership. +#[allow(clippy::too_many_arguments)] +async fn reply( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + root: &nostr::Event, + actor: &Keys, + authored: u64, + arrived: u64, +) -> nostr::Event { + let mention = Tag::public_key(actor.public_key()); + let event = post( + db, + pool, + community, + channel, + authored, + arrived, + vec![mention], + ) + .await; + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) + .bind(event.created_at.as_secs() as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(pool).await.unwrap(); + event +} + +async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSummary { + db.personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap() + .channels + .remove(0) +} + +async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) { + let outcome = db + .apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap(); + assert_eq!(outcome, IntentOutcome::Applied); +} + +fn mark_through(channel: Uuid, root: Option<&str>, message: &str) -> ReadIntent { + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: root.map(str::to_owned), + }, + message_id: message.to_owned(), + } +} + +/// Channel-timeline states of `messages`, in order. +async fn states( + db: &Db, + community: CommunityId, + actor: &Keys, + channel: Uuid, + messages: &[&nostr::Event], +) -> Vec { + let query = ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: messages.iter().map(|e| e.id.to_hex()).collect(), + }; + let page = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[query], + ) + .await + .unwrap(); + let page = serde_json::to_value(page).unwrap(); + page["contexts"][0]["messages"] + .as_array() + .unwrap() + .iter() + .map(|m| m["status"].as_str().unwrap().to_owned()) + .collect() +} + +fn exact(count: &ReadCount) -> Option { + match count { + ReadCount::Exact { value } => Some(*value), + _ => None, + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn late_arrival_with_old_author_time_is_unread() { + let (db, pool, community, channel, actor, read) = fixture().await; + let now = read.created_at.as_secs(); + arrive(&pool, community, &read, now - 60).await; + apply( + &db, + community, + &actor, + mark_through(channel, None, &read.id.to_hex()), + ) + .await; + + // Authored ten minutes before the read message, arriving after it was read. + let late = post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await; + + assert_eq!( + states(&db, community, &actor, channel, &[&read, &late]).await, + ["read", "unread"] + ); + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(1) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn future_dated_anchor_does_not_swallow_later_arrivals() { + let (db, pool, community, channel, actor, earlier) = fixture().await; + let now = earlier.created_at.as_secs(); + arrive(&pool, community, &earlier, now - 60).await; + // Stamped ten minutes ahead; the relay accepts up to fifteen. + let ahead = post(&db, &pool, community, channel, now + 600, now - 40, vec![]).await; + apply( + &db, + community, + &actor, + mark_through(channel, None, &ahead.id.to_hex()), + ) + .await; + + let later = post(&db, &pool, community, channel, now, now - 30, vec![]).await; + + assert_eq!( + states(&db, community, &actor, channel, &[&earlier, &ahead, &later]).await, + ["read", "read", "unread"] + ); + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(1) + ); +} + +/// Marking the sidebar's own latest message must clear the badge even when the +/// last arrival is not the newest by author time. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_as_read_with_the_sidebar_anchor_clears_a_late_arrival() { + let (db, pool, community, channel, actor, first) = fixture().await; + let now = first.created_at.as_secs(); + arrive(&pool, community, &first, now - 60).await; + post(&db, &pool, community, channel, now + 1, now - 40, vec![]).await; + post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await; + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(3) + ); + + let anchor = sidebar(&db, community, &actor) + .await + .latest_message_id + .expect("a channel with messages has a latest message"); + apply( + &db, + community, + &actor, + ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: anchor, + }, + ) + .await; + + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(0) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() { + let (db, pool, community, channel, actor, root) = fixture().await; + let now = root.created_at.as_secs(); + arrive(&pool, community, &root, now - 60).await; + apply( + &db, + community, + &actor, + mark_through(channel, None, &root.id.to_hex()), + ) + .await; + reply( + &db, + &pool, + community, + channel, + &root, + &actor, + now + 1, + now - 40, + ) + .await; + reply( + &db, + &pool, + community, + channel, + &root, + &actor, + now - 600, + now - 30, + ) + .await; + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(2)); + assert_eq!(row.threads.items.len(), 1); + + let root_id = root.id.to_hex(); + let anchor = row.threads.items[0].latest_reply_id.clone(); + apply( + &db, + community, + &actor, + mark_through(channel, Some(&root_id), &anchor), + ) + .await; + + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(0)); + assert!(row.threads.items.is_empty() && row.threads.complete); +} + +/// The shallow latest probe reads the newest `MAX_CHANNEL_SCAN + 1` events by +/// author time, of any kind. A late arrival with an older author time than +/// that many events is counted unread, so the unread scan must supply the +/// anchor or Mark as read with the sidebar anchor leaves it. +async fn mark_as_read_behind_fillers(fillers: u64) -> Option { + let (db, pool, community, channel, actor, first) = fixture().await; + let now = first.created_at.as_secs(); + arrive(&pool, community, &first, now - 60).await; + apply( + &db, + community, + &actor, + mark_through(channel, None, &first.id.to_hex()), + ) + .await; + for i in 0..fillers { + // Reactions are not counted, but the probe's LIMIT sees them. + let filler = EventBuilder::new(Kind::Custom(7), "+") + .custom_created_at(nostr::Timestamp::from(now - 500 + i)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &filler, Some(channel)) + .await + .unwrap(); + arrive(&pool, community, &filler, now - 50).await; + } + post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await; + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(1) + ); + + let anchor = sidebar(&db, community, &actor) + .await + .latest_message_id + .expect("a channel with messages has a latest message"); + apply( + &db, + community, + &actor, + ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: anchor, + }, + ) + .await; + exact(&sidebar(&db, community, &actor).await.unread) +} + +/// Control: with one slot to spare the late arrival is inside the probe. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_as_read_clears_a_late_arrival_inside_the_latest_probe() { + let fillers = MAX_CHANNEL_SCAN as u64 - 1; + assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_as_read_clears_a_late_arrival_outside_the_latest_probe() { + let fillers = MAX_CHANNEL_SCAN as u64; + assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0)); +} + +/// When the newest 257 events are all uncounted, the only message is still in +/// the unread scan: the summary reports it as found, as one coherent triple. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn latest_message_behind_a_full_probe_of_reactions_is_found() { + let (db, pool, community, channel, actor, first) = fixture().await; + let now = first.created_at.as_secs(); + let demoted = sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(first.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap() + .rows_affected(); + assert_eq!(demoted, 1); + let only = post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await; + for i in 0..MAX_CHANNEL_SCAN as u64 { + let filler = EventBuilder::new(Kind::Custom(7), "+") + .custom_created_at(nostr::Timestamp::from(now - 500 + i)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &filler, Some(channel)) + .await + .unwrap(); + arrive(&pool, community, &filler, now - 50).await; + } + + let row = sidebar(&db, community, &actor).await; + assert_eq!(row.latest_message_id, Some(only.id.to_hex())); + assert_eq!(row.latest_message_at, Some((now - 600) as i64)); + assert!(row.latest_message_complete); + assert_eq!(exact(&row.unread), Some(1)); +} diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs index 0b20e58e175..39a537575aa 100644 --- a/crates/buzz-db/src/store/personal_read/context.rs +++ b/crates/buzz-db/src/store/personal_read/context.rs @@ -59,7 +59,7 @@ impl Db { }; // A thread's effective prefix includes the channel's whole-channel cut, // exactly as the sidebar projection counts it. - let prefix: Option = sqlx::query_scalar( + let prefix: Option> = sqlx::query_scalar( "SELECT GREATEST( (SELECT through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=$4), @@ -79,7 +79,7 @@ impl Db { .filter_map(|id| writes::event_id(id)) .collect(); let rows = sqlx::query( - "SELECT encode(e.id,'hex') AS id,e.kind,e.created_at, + "SELECT encode(e.id,'hex') AS id,e.kind,e.created_at,e.received_at, e.deleted_at IS NOT NULL AS deleted,e.pubkey=$3 AS own, CASE WHEN octet_length(e.tags::text)<=8192 THEN e.tags ELSE NULL END AS tags, tm.root_event_id,tm.parent_event_id,c.channel_type::text AS channel_type @@ -118,6 +118,7 @@ impl Db { MessageReadState::Unavailable } else { let created: DateTime = row.try_get("created_at")?; + let received: DateTime = row.try_get("received_at")?; let kind: i32 = row.try_get("kind")?; if !classification::eligible( kind, @@ -127,7 +128,7 @@ impl Db { account.cutoff_ms, ) { MessageReadState::NotCounted - } else if prefix.is_some_and(|p| created.timestamp() <= p) { + } else if prefix.is_some_and(|p| received <= p) { MessageReadState::Read } else { let reason = classification::reason( @@ -168,15 +169,12 @@ impl Db { state, }); } - contexts.push(ContextState::Available { - through_timestamp: prefix, - messages, - }); + contexts.push(ContextState::Available { messages }); } let targets: Vec<_> = pending.iter().map(|(.., key)| key.clone()).collect(); let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; for (context, message, key) in pending { - if let ContextState::Available { messages, .. } = &mut contexts[context] { + if let ContextState::Available { messages } = &mut contexts[context] { settle(&mut messages[message].state, members.get(&key).copied()); } } diff --git a/crates/buzz-db/src/store/personal_read/mod.rs b/crates/buzz-db/src/store/personal_read/mod.rs index cf61addc9b7..fb056c600c7 100644 --- a/crates/buzz-db/src/store/personal_read/mod.rs +++ b/crates/buzz-db/src/store/personal_read/mod.rs @@ -1,7 +1,8 @@ //! Private, signer-owned accessory read progress, separate from NIP-RS events. //! -//! Timestamp prefixes and the unread horizon both use author time. Only fixed -//! context intents advance prefixes, never a query scan cap. +//! A frontier is the relay arrival time of the message a context was read +//! through; the unread horizon alone uses author time. Only fixed context +//! intents advance frontiers, never a query scan cap. mod classification; mod context; @@ -23,3 +24,6 @@ mod projection_postgres_tests; #[cfg(test)] mod threads_postgres_tests; + +#[cfg(test)] +mod arrival_postgres_tests; diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index 5839a848736..5786881ba87 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -20,7 +20,7 @@ pub struct ReadTarget { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum ReadIntent { - /// Advance a context through one fixed message, including equal times. + /// Advance a context through one fixed message, including equal arrivals. MarkThrough { /// Channel or canonical thread being marked. target: ReadTarget, @@ -28,7 +28,7 @@ pub enum ReadIntent { message_id: String, }, /// Advance the channel timeline and every thread in it through one fixed - /// message's author time. The anchor may be a reply; ancestry is irrelevant. + /// message's arrival time. The anchor may be a reply; ancestry is irrelevant. MarkChannelRead { /// Channel being marked, including all of its threads. channel_id: Uuid, @@ -119,11 +119,14 @@ pub struct ChannelReadSummary { /// messages. This is not Desktop notification eligibility: follows and /// mutes do not change this count. pub attention: ReadCount, - /// Latest eligible nondeleted event ID, independent of read progress, author, - /// and the unread-tracking horizon. + /// Last eligible nondeleted event to arrive, inside the unread horizon when + /// any is, whatever its author or read progress: marking through it reads + /// the row. /// None proves absence only when latest_message_complete is true. pub latest_message_id: Option, - /// Author time (Unix seconds) of latest_message_id; None exactly when it is None. + /// Display activity: the greatest author time (Unix seconds) among eligible + /// nondeleted events, not necessarily latest_message_id's own. None exactly + /// when it is None. pub latest_message_at: Option, /// Whether the latest lookup found a result or exhausted channel history. /// False means the bounded probe found none, but an unexamined tail remains. @@ -149,7 +152,7 @@ pub struct ThreadReadSummary { pub root_id: String, /// Unread replies in this thread (same definition as the row count). pub unread: ReadCount, - /// Newest observed unread reply: a valid thread mark_through anchor. + /// Last observed unread reply to arrive: marking through it reads the thread. pub latest_reply_id: String, /// Author time (Unix seconds) of latest_reply_id. pub latest_reply_at: i64, @@ -238,8 +241,6 @@ pub enum ContextState { Unknown, /// Context authority at the response snapshot. Available { - /// Fixed author-time prefix; null means no read progress. - through_timestamp: Option, /// Bounded explicit selectors, in request order. messages: Vec, }, diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index 9d9a47b5a1e..8339cb9a4ab 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -573,33 +573,41 @@ async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { IntentOutcome::Applied ); } - let earlier = EventBuilder::new(Kind::Custom(9), "earlier") + // Arrives after `event`; its earlier author time must not matter. + let later = EventBuilder::new(Kind::Custom(9), "later") .custom_created_at(nostr::Timestamp::from(event.created_at.as_secs() - 10)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &earlier, Some(channel)) + db.insert_event(community, &later, Some(channel)) .await .unwrap(); - let earlier = ReadIntent::MarkThrough { - target, - message_id: earlier.id.to_hex(), - }; - assert_eq!( - db.apply_personal_read_intent(community, &actor.public_key(), &earlier) - .await - .unwrap(), - IntentOutcome::Applied, - "an earlier anchor applies without moving the frontier back" - ); - let frontier: i64 = sqlx::query_scalar( - "SELECT through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", + for (anchor, why) in [ + (&later, "a later arrival advances the frontier"), + (&event, "an earlier anchor applies without moving it back"), + ] { + let intent = ReadIntent::MarkThrough { + target: target.clone(), + message_id: anchor.id.to_hex(), + }; + assert_eq!( + db.apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap(), + IntentOutcome::Applied, + "{why}" + ); + } + let at_later_arrival: bool = sqlx::query_scalar( + "SELECT f.through_timestamp=e.received_at FROM personal_read_frontiers f, events e + WHERE f.community_id=$1 AND f.actor=$2 AND e.community_id=$1 AND e.id=$3", ) .bind(community.as_uuid()) .bind(actor.public_key().to_bytes().as_slice()) + .bind(later.id.as_bytes().as_slice()) .fetch_one(&pool) .await .unwrap(); - assert_eq!(frontier, event.created_at.as_secs() as i64); + assert!(at_later_arrival); let stranger = Keys::generate(); let count: i64 = sqlx::query_scalar( "SELECT count(*) FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", @@ -998,9 +1006,9 @@ async fn personal_read_covered_corruption_requires_canonical_matching_frontier() .unwrap(); assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); sqlx::query("INSERT INTO personal_read_frontiers (community_id,actor,channel_id,root_id,through_timestamp) - VALUES ($1,$2,$3,$4,$5)") + VALUES ($1,$2,$3,$4,now())") .bind(community.as_uuid()).bind(actor.public_key().to_bytes().as_slice()).bind(channel) - .bind(root.id.as_bytes().as_slice()).bind(root.created_at.as_secs() as i64) + .bind(root.id.as_bytes().as_slice()) .execute(&pool).await.unwrap(); let page = db .personal_read_sidebar( @@ -1021,7 +1029,7 @@ async fn personal_read_covered_corruption_requires_canonical_matching_frontier() ReadCount::Exact { value: 0 } )); // Removing canonical metadata must not let a channel frontier hide unknown - // ancestry/corruption, even though both timestamp frontiers cover the row. + // ancestry/corruption, even though both frontiers cover the row. sqlx::query("DELETE FROM thread_metadata WHERE community_id=$1 AND event_id=$2") .bind(community.as_uuid()) .bind(reply.id.as_bytes().as_slice()) diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index 38e251b093c..b839ca7459f 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -89,6 +89,10 @@ impl Db { // Canonical timeline roots share an empty (present) root sentinel. // Tags are bounded before transfer; oversized/corrupt evidence stays // unknown, never falsely top-level/unmentioned/read. + // Latest comes from the unread scan, so its ID arrives no earlier than + // anything counted; the shallow probe answers only when the horizon + // holds no message. Both are newest-first by author time, so the deeper + // one finds the same greatest author time whenever the probe finds any. let rows = sqlx::query( r#"WITH roster AS MATERIALIZED ( SELECT c.id,c.name,c.channel_type::text AS channel_type, @@ -100,17 +104,19 @@ impl Db { AND ($9::uuid[] IS NULL OR c.id=ANY($9)) ORDER BY c.id LIMIT $4 ) - SELECT r.*, latest.latest_message_id, latest.latest_message_at, - (latest.latest_message_id IS NOT NULL OR latest.candidates <= $5-1) AS latest_message_complete, + SELECT r.*, COALESCE(e.latest_message_id, latest.latest_message_id) AS latest_message_id, + COALESCE(e.latest_message_at, latest.latest_message_at) AS latest_message_at, + (COALESCE(e.latest_message_id, latest.latest_message_id) IS NOT NULL + OR latest.candidates <= $5-1) AS latest_message_complete, e.scanned,COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r LEFT JOIN LATERAL ( WITH candidates AS MATERIALIZED ( - SELECT id,created_at,kind,deleted_at FROM events + SELECT id,created_at,received_at,kind,deleted_at FROM events WHERE community_id=$1 AND channel_id=r.id ORDER BY created_at DESC,id LIMIT $5 ) SELECT count(*) AS candidates, - (array_agg(encode(id,'hex') ORDER BY created_at DESC,id) + (array_agg(encode(id,'hex') ORDER BY received_at DESC,id) FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id, (array_agg(extract(epoch FROM created_at)::bigint ORDER BY created_at DESC,id) FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_at @@ -120,13 +126,13 @@ impl Db { AND cf.channel_id=r.id AND cf.root_id=''::bytea LEFT JOIN LATERAL ( WITH candidates AS MATERIALIZED ( - SELECT id,pubkey,created_at,deleted_at,kind,tags + SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7 ORDER BY created_at DESC,id LIMIT $8 ), classified AS ( SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent, COALESCE(tm.root_event_id<>e.id,false) AS is_reply, - COALESCE(extract(epoch FROM e.created_at)::bigint <= + COALESCE(e.received_at <= CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp) ELSE cf.through_timestamp END,false) AS covered @@ -159,14 +165,22 @@ impl Db { FROM jsonb_array_elements(tags) t(tag) WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts, count(*) AS n, - extract(epoch FROM max(created_at))::bigint AS newest_at, - (array_agg(encode(id,'hex') ORDER BY created_at DESC,id))[1] AS newest_id + (extract(epoch FROM max(received_at))*1000000)::bigint AS newest_arrival, + (array_agg(encode(id,'hex') ORDER BY received_at DESC,id))[1] AS newest_id, + (array_agg(extract(epoch FROM created_at)::bigint + ORDER BY received_at DESC,id))[1] AS newest_at FROM classified WHERE NOT covered OR root IS NULL GROUP BY 1,2,3,4,5 + ), scan AS ( + SELECT count(*) AS scanned, + (array_agg(encode(id,'hex') ORDER BY received_at DESC,id) + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id, + max(extract(epoch FROM created_at)::bigint) + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL) AS latest_message_at + FROM candidates ) - SELECT (SELECT count(*) FROM candidates) AS scanned, - jsonb_agg(to_jsonb(grouped)) AS evidence FROM grouped + SELECT scan.*, (SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence FROM scan ) e ON true ORDER BY r.id"#, ).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after) .bind((limit+1) as i64).bind((MAX_CHANNEL_SCAN+1) as i64) @@ -228,12 +242,13 @@ impl Db { let replies = Replies { n, newest: ( - e["newest_at"].as_i64().ok_or_else(invalid_newest)?, + e["newest_arrival"].as_i64().ok_or_else(invalid_newest)?, e["newest_id"] .as_str() .ok_or_else(invalid_newest)? .to_owned(), ), + newest_at: e["newest_at"].as_i64().ok_or_else(invalid_newest)?, }; // A directed reply counts whatever its conversation. Any other // reply counts only in one of the actor's conversations. @@ -293,7 +308,7 @@ impl Db { root_id: hex::encode(root), unread: ReadCount::from_evidence(thread.n, complete), latest_reply_id: thread.newest.1, - latest_reply_at: thread.newest.0, + latest_reply_at: thread.newest_at, }); } channel.unread = ReadCount::from_evidence(unread, complete); @@ -318,8 +333,10 @@ impl Db { /// thread's counted total. struct Replies { n: u32, - /// Newest of them: (author seconds, lowercase hex ID). + /// Last of them to arrive: (arrival microseconds, lowercase hex ID). newest: (i64, String), + /// Its author seconds. + newest_at: i64, } /// Add replies that count to their thread. @@ -331,11 +348,12 @@ fn count(threads: &mut HashMap, Replies>, root: Vec, replies: Replie Entry::Occupied(mut slot) => { let thread = slot.get_mut(); thread.n += replies.n; - // Newest first; equal author times break toward the smaller ID. + // Latest arrival first; equal arrivals break toward the smaller ID. if (replies.newest.0, Reverse(&replies.newest.1)) > (thread.newest.0, Reverse(&thread.newest.1)) { thread.newest = replies.newest; + thread.newest_at = replies.newest_at; } } } diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs index fad2feb8497..b8fc5248594 100644 --- a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -165,14 +165,14 @@ async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { ); assert!(row.threads.items.is_empty() && row.threads.complete); - // Late-arriving backdated reply is covered; a newer reply is not. - reply(&db, &pool, community, channel, &root, base + 5, vec![]).await; + // A reply that arrives after the cut is unread, however far backdated. + let late = reply(&db, &pool, community, channel, &root, base + 5, vec![]).await; let newer = reply(&db, &pool, community, channel, &root, base + 40, vec![]).await; let row = sidebar(&db, community, &actor).await; - assert_eq!(exact(&row.unread), Some(1)); + assert_eq!(exact(&row.unread), Some(2)); assert_eq!(row.threads.items[0].latest_reply_id, newer.id.to_hex()); - // Contexts report and apply the same effective thread prefix. + // Contexts apply the same effective thread frontier. let page = db .personal_read_contexts( community, @@ -183,15 +183,16 @@ async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { channel_id: channel, root_id: Some(root.id.to_hex()), }, - message_ids: vec![second.id.to_hex(), newer.id.to_hex()], + message_ids: vec![second.id.to_hex(), late.id.to_hex(), newer.id.to_hex()], }], ) .await .unwrap(); let wire = serde_json::to_value(&page).unwrap(); - assert_eq!(wire["contexts"][0]["through_timestamp"], (base + 30) as i64); + assert!(wire["contexts"][0].get("through_timestamp").is_none()); assert_eq!(wire["contexts"][0]["messages"][0]["status"], "read"); assert_eq!(wire["contexts"][0]["messages"][1]["status"], "unread"); + assert_eq!(wire["contexts"][0]["messages"][2]["status"], "unread"); } #[tokio::test] @@ -199,7 +200,7 @@ async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independent() { let (db, pool, community, channel, actor, root) = fixture().await; let base = root.created_at.as_secs(); - // Unresolved ancestry blocks ordinary marks, but not a time-only cut. + // Unresolved ancestry blocks ordinary marks, but not an arrival-only cut. let orphan_parent = "ab".repeat(32); let orphan = post( &db, @@ -274,7 +275,7 @@ async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independe .await, IntentOutcome::Applied ); - let cuts: Vec> = sqlx::query_scalar( + let cuts: Vec>> = sqlx::query_scalar( "SELECT threads_through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", ) .bind(community.as_uuid()) @@ -286,7 +287,7 @@ async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independe // The schema admits the cut on channel rows only. let thread_cut = sqlx::query( - "UPDATE personal_read_frontiers SET threads_through_timestamp=1 + "UPDATE personal_read_frontiers SET threads_through_timestamp=now() WHERE community_id=$1 AND actor=$2", ) .bind(community.as_uuid()) @@ -308,7 +309,7 @@ async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independe IntentOutcome::Applied ); assert!(sqlx::query( - "UPDATE personal_read_frontiers SET threads_through_timestamp=1 + "UPDATE personal_read_frontiers SET threads_through_timestamp=now() WHERE community_id=$1 AND actor=$2 AND root_id<>''::bytea", ) .bind(community.as_uuid()) @@ -376,33 +377,41 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { }, ) .await; - // Threads 0 and 1 tie on newest reply time; thread 2 has two equal-time - // replies and one directed reply. + // Threads 0 and 1 tie on newest reply time; thread 2 has one directed + // reply and two that arrive last, together. let mut anchors = Vec::new(); for (i, root) in roots.iter().enumerate() { let at = base + 100 + [50, 50, 40, 30, 20, 10][i]; anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await); } - let twin = reply( + reply( &db, &pool, community, channel, &roots[2], - base + 140, - vec![], + base + 101, + vec![Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()], ) .await; - reply( + let twin = reply( &db, &pool, community, channel, &roots[2], - base + 101, - vec![Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()], + base + 140, + vec![], ) .await; + sqlx::query( + "UPDATE events SET received_at=(SELECT received_at FROM events WHERE id=$1) WHERE id=$2", + ) + .bind(twin.id.as_bytes().as_slice()) + .bind(anchors[2].id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); let row = sidebar(&db, community, &actor).await; assert_eq!(exact(&row.unread), Some(8)); @@ -431,10 +440,11 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { assert_eq!( third.latest_reply_id, std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()), - "equal reply times break toward the smaller ID" + "equal arrivals break toward the smaller ID" ); - let newest = std::cmp::min(anchors[0].id.to_hex(), anchors[1].id.to_hex()); - assert_eq!(row.latest_message_id, Some(newest)); + // The row's anchor is its last arrival; its activity is the greatest + // author time, another message's. + assert_eq!(row.latest_message_id.as_ref(), Some(&third.latest_reply_id)); assert_eq!(row.latest_message_at, Some((base + 150) as i64)); // Reading one listed thread through its anchor completes the list. diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index 75aa09460b0..9947d2b7ab6 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -82,7 +82,7 @@ async fn access( struct Message { id: Vec, - timestamp: i64, + received_at: DateTime, root: Option>, } @@ -96,7 +96,7 @@ async fn message( kinds: Option<&[i32]>, ) -> Result> { let row = sqlx::query( - "SELECT e.id, e.created_at, e.tags, tm.root_event_id + "SELECT e.id, e.received_at, e.tags, tm.root_event_id FROM events e LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id AND tm.event_created_at=e.created_at AND tm.event_id=e.id AND tm.channel_id=e.channel_id WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=$3 @@ -104,7 +104,7 @@ async fn message( LIMIT 1", ).bind(community.as_uuid()).bind(channel).bind(id).bind(kinds).fetch_optional(&mut *conn).await?; row.map(|row| { - let timestamp: DateTime = row.try_get("created_at")?; + let received_at: DateTime = row.try_get("received_at")?; let id: Vec = row.try_get("id")?; let root: Option> = row.try_get("root_event_id")?; let tags: serde_json::Value = row.try_get("tags")?; @@ -119,22 +119,22 @@ async fn message( } Ok(Message { id, - timestamp: timestamp.timestamp(), + received_at, root, }) }) .transpose() } -/// An eligible-kind message's author time, deleted or not, without tags. -async fn anchor_timestamp( +/// An eligible-kind message's arrival time, deleted or not, without tags. +async fn anchor_received_at( conn: &mut PgConnection, community: CommunityId, channel: Uuid, id: &[u8], -) -> Result> { - let created: Option> = sqlx::query_scalar( - "SELECT created_at FROM events +) -> Result>> { + Ok(sqlx::query_scalar( + "SELECT received_at FROM events WHERE community_id=$1 AND channel_id=$2 AND id=$3 AND kind=ANY($4) LIMIT 1", ) .bind(community.as_uuid()) @@ -142,8 +142,7 @@ async fn anchor_timestamp( .bind(id) .bind(ELIGIBLE_KINDS.as_slice()) .fetch_optional(conn) - .await?; - Ok(created.map(|t| t.timestamp())) + .await?) } pub(super) async fn valid_target( @@ -182,14 +181,14 @@ async fn frontier( actor: &[u8], target: &ReadTarget, root: &[u8], - timestamp: i64, + through: DateTime, ) -> Result<()> { sqlx::query( "INSERT INTO personal_read_frontiers (community_id, actor, channel_id, root_id, through_timestamp) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp)", - ).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root).bind(timestamp) + ).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root).bind(through) .execute(&mut *conn).await?; Ok(()) } @@ -225,7 +224,7 @@ pub(super) async fn apply( { return Ok(IntentOutcome::Blocked); } - frontier(conn, community, actor, target, &root, msg.timestamp).await?; + frontier(conn, community, actor, target, &root, msg.received_at).await?; } ReadIntent::MarkChannelRead { channel_id, @@ -237,9 +236,9 @@ pub(super) async fn apply( if !access(conn, community, actor, *channel_id).await? { return Ok(IntentOutcome::Blocked); } - // Only the anchor's time matters: ancestry cannot change which + // Only the anchor's arrival matters: ancestry cannot change which // messages a whole-channel cut covers. - let Some(timestamp) = anchor_timestamp(conn, community, *channel_id, &id).await? else { + let Some(through) = anchor_received_at(conn, community, *channel_id, &id).await? else { return Ok(IntentOutcome::Blocked); }; sqlx::query( @@ -248,7 +247,7 @@ pub(super) async fn apply( ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, $4), threads_through_timestamp=GREATEST(personal_read_frontiers.threads_through_timestamp, $4)", - ).bind(community.as_uuid()).bind(actor).bind(channel_id).bind(timestamp) + ).bind(community.as_uuid()).bind(actor).bind(channel_id).bind(through) .execute(&mut *conn).await?; } } diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index aa12a2f4529..79368e834fb 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -51,12 +51,14 @@ Unknown request fields are rejected. Never turn a transport failure into read. `GET /buzz/v1/me/sidebar?limit=20&cursor=` returns `account`, `channels`, and `next_cursor`. Omit the cursor on the first request. Each channel includes identity/name/type, archived and hidden flags, `unread`, -`attention`, `latest_message_id`, `latest_message_at` (its author time in -seconds; null exactly when the ID is null), `latest_message_complete`, and -`threads`. Only joined, nondeleted channels are listed. Hidden/archived -presentation remains client-owned. Each page has a writer-consistent snapshot; -separate pages do not share a snapshot, and an unfinished traversal cannot prove -channel removal. +`attention`, `latest_message_id` (the last eligible message to arrive: the +row's read anchor, see [Order](#order)), `latest_message_at` (the greatest +author time among eligible messages, in seconds: display activity, not +necessarily that message's own time; null exactly when the ID is null), +`latest_message_complete`, and `threads`. Only joined, nondeleted channels are +listed. Hidden/archived presentation remains client-owned. Each page has a +writer-consistent snapshot; separate pages do not share a snapshot, and an +unfinished traversal cannot prove channel removal. `GET /buzz/v1/me/sidebar?channel_ids=,` refreshes 1–20 unique channels in one snapshot, ordered by ID with `next_cursor: null`. It cannot be @@ -73,9 +75,10 @@ Absence says nothing else about access to an open channel. Items are canonical roots with unread replies that count (see below), ordered by `latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is -the newest such reply (equal times prefer the smaller ID) and a valid thread -`mark_through` anchor. Replies that do not count are filtered out before the -count, the newest reply and the cap are chosen. `unread` uses the row's +the last such reply to arrive (equal arrivals prefer the smaller ID), so a +thread `mark_through` at it reads every reply listed; `latest_reply_at` is its +author time. Replies that do not count are filtered out before the count, the +latest reply and the cap are chosen. `unread` uses the row's definition; every counted reply is also attention, so items carry no separate attention count. `complete=true` means the unread window was exhausted, no evidence had unresolved ancestry, unusable tags or undecided membership, and no @@ -117,19 +120,20 @@ lower bounds or unknown. The sidebar counts a broadcast reply without asking which of the two reasons applies. This is not Desktop notification policy: follows and mutes do not affect these counts. -The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and, like -every frontier, is measured in author time (`created_at`): a message counts -while its author time is at or after `account.cutoff_ms`. It filters -unread/attention, not latest activity, event storage or frontier state. One -clock has three consequences: +The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and is +measured in author time (`created_at`): a message counts while its author time +is at or after `account.cutoff_ms`. It filters unread/attention, not latest +activity, event storage or frontier state. Frontiers use a different clock, +relay arrival (see [Order](#order)). Three consequences: - A message accepted late with an author time beyond the horizon (an import, a backfill, a long-offline sender) is excluded under the current horizon, - however recently the relay accepted it. It can still be latest. + however recently the relay accepted it. It can still be latest when the + horizon holds no message. - Unread expires at author time plus the horizon, so a future-dated author time extends how long a message counts. -- Horizon and frontier order messages identically, so a context's unread set - is one author-time range: after the frontier and at or after the cutoff. +- A context's unread set is two tests, not one range: arrived after the + frontier, and author time at or after the cutoff. A later configuration expansion can change counts without having lost progress. Latest activity is independent of actor and frontiers. A null latest ID proves @@ -148,9 +152,8 @@ global export of frontiers. Example decoded `targets`: Omitting `root_id` selects the channel timeline. The result contains `account` and one `contexts` entry per request entry, in order. Context status is -`available` (with nullable `through_timestamp` and `messages`), `unknown`, or -`unavailable`. A thread context's `through_timestamp` is its effective prefix, -including any whole-channel cut. Message status is `read`, `not_counted`, +`available` (with `messages`), `unknown`, or `unavailable`. A thread context's +frontier includes any whole-channel cut. Message status is `read`, `not_counted`, `unread` (with `reason`), `unknown`, or `unavailable`. Wrong-context, missing and forbidden selectors share unavailable. Status is decided in this order: ancestry and context; eligibility (`not_counted` for own, deleted, other kinds @@ -181,21 +184,49 @@ not assert a client has refreshed. Retry the same operands, never substitute latest. Keep pending intent durably on the client until its outcome is resolved. A mark-through validates a fixed message and advances its context's monotone -second-resolution author-time prefix. Equal-time messages and later-arriving -backdated messages at/below it are covered. Channel and thread frontiers never -inherit in either direction. Opening a view is not itself a reading action; -client dwell/focus policy determines when to send an actual observed anchor. -Old or deleted valid anchors may advance a frontier. +frontier to that message's relay arrival (see [Order](#order)). Channel and +thread frontiers never inherit in either direction. Opening a view is not +itself a reading action; client dwell/focus policy determines when to send an +actual observed anchor. Old or deleted valid anchors may advance a frontier. `mark_channel_read` is the one whole-channel cut: it advances the channel timeline and every thread in that channel, including unlisted ones, through the -anchor's author time. The anchor must be an accessible eligible-kind message in +anchor's arrival. The anchor must be an accessible eligible-kind message in the channel, top-level or reply, deleted or not; ancestry is not checked. A reply is read at or below the greater of its thread frontier and this cut. An -anchor that no longer exists is `blocked`. `latest_message_id` is a natural -anchor. A null ID with `latest_message_complete=false` does not prove empty -history; it only leaves the client without an anchor. Thread marks and channel `mark_through` -never set the cut. +anchor that no longer exists is `blocked`. `latest_message_id` is the anchor +that reads the whole row. A null ID with `latest_message_complete=false` does +not prove empty history; it only leaves the client without an anchor. Thread +marks and channel `mark_through` never set the cut. + +### Order + +A frontier is the relay arrival time (`events.received_at`) of the message a +context was read through, never its author time, which the sender chooses and +the relay accepts up to 15 minutes either way. Everything that arrived at or +before the anchor is read, whatever its author time. A message that arrives +later is unread even when backdated, and a future-dated anchor reads nothing +that arrives after it. + +The order is the relay's and is not exposed: no response carries a frontier, +and no field lets a client compute what a mark will cover. Send the anchors the +user actually saw, let the relay take the greatest, and ask a context which +messages are read. Do not compare author times, IDs or local receipt order to +drop one pending anchor in favor of another. + +Arrival is the accepting relay process's clock, read just before the insert, at +microsecond resolution. Three limits follow, none of which strands a badge: + +- It is not commit order. An insert that commits after a later-stamped message + was already marked read lands read. +- Relay processes with different clocks can stamp out of true order by their + skew. +- Messages with the identical stamp are read together. + +`latest_message_id` is the last message to arrive among those the unread count +examined: the 4,096 most recent events by author time inside the horizon. So +marking through it reads everything counted. When the horizon holds no message, +it is the last to arrive among the channel's 256 most recent events. There is no import of earlier client read state: an account starts with no frontiers, and the horizon bounds what that can show as unread. Manual unread diff --git a/migrations/0056_personal_read_state.sql b/migrations/0056_personal_read_state.sql index 4459b1a8403..41889e8890e 100644 --- a/migrations/0056_personal_read_state.sql +++ b/migrations/0056_personal_read_state.sql @@ -1,6 +1,8 @@ -- Private accessory read progress. Never included in Nostr event queries. --- Frontiers use signed event time. An empty root_id covers only the channel --- timeline; a root-specific frontier covers that thread, without inheritance. +-- A frontier is the relay arrival time (events.received_at) of the message a +-- context was read through, never signed event time, which the sender chooses. +-- An empty root_id covers only the channel timeline; a root-specific frontier +-- covers that thread, without inheritance. -- threads_through_timestamp is the only cross-context cut: an explicit -- whole-channel read that also covers every thread in that channel. CREATE TABLE personal_read_accounts ( @@ -14,11 +16,10 @@ CREATE TABLE personal_read_frontiers ( actor BYTEA NOT NULL, channel_id UUID NOT NULL, root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), - through_timestamp BIGINT NOT NULL CHECK (through_timestamp >= 0), + through_timestamp TIMESTAMPTZ NOT NULL, -- Whole-channel cut covering every thread; channel rows only. - threads_through_timestamp BIGINT - CHECK (threads_through_timestamp IS NULL - OR (threads_through_timestamp >= 0 AND root_id = ''::bytea)), + threads_through_timestamp TIMESTAMPTZ + CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea), PRIMARY KEY (community_id, actor, channel_id, root_id), FOREIGN KEY (community_id, actor) REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, diff --git a/schema/schema.sql b/schema/schema.sql index b73dcee4bf8..d1114885050 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -194,8 +194,9 @@ CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) WHERE okta_user_id IS NOT NULL; -- Private accessory read progress. Never included in Nostr event queries. --- Cutoffs and frontiers are both signed event time. An empty root_id denotes a --- channel frontier. +-- A frontier is the relay arrival time (events.received_at) of the message a +-- context was read through; the unread cutoff alone is signed event time. An +-- empty root_id denotes a channel frontier. CREATE TABLE personal_read_accounts ( community_id UUID NOT NULL REFERENCES communities(id), actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), @@ -207,11 +208,10 @@ CREATE TABLE personal_read_frontiers ( actor BYTEA NOT NULL, channel_id UUID NOT NULL, root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), - through_timestamp BIGINT NOT NULL CHECK (through_timestamp >= 0), + through_timestamp TIMESTAMPTZ NOT NULL, -- Whole-channel cut covering every thread; channel rows only. - threads_through_timestamp BIGINT - CHECK (threads_through_timestamp IS NULL - OR (threads_through_timestamp >= 0 AND root_id = ''::bytea)), + threads_through_timestamp TIMESTAMPTZ + CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea), PRIMARY KEY (community_id, actor, channel_id, root_id), FOREIGN KEY (community_id, actor) REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, From 6408a408fb556a063eca5301769d72b37203b46e Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Mon, 5 Oct 2026 14:16:46 -0400 Subject: [PATCH 17/18] Pin read frontiers to microsecond arrival order Two messages can arrive in the same second. The frontier must still order them: marking the earlier leaves the later unread, and two messages with the identical stamp are read together, as the Order section of docs/buzz-v1-read-state.md says. The existing tests catch a frontier truncated to whole seconds only when two of their real arrivals happen to share a second. These two set the stamps from integers, one microsecond apart and identical, so the result does not depend on timing. Tests only. With arrival floored to seconds on both the write and the read, arrivals_one_microsecond_apart_in_the_same_second_are_ordered fails with the later message read. Co-authored-by: Sami Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../personal_read/arrival_postgres_tests.rs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs index e7288f59537..0c578a34e2e 100644 --- a/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs @@ -389,3 +389,80 @@ async fn latest_message_behind_a_full_probe_of_reactions_is_found() { assert!(row.latest_message_complete); assert_eq!(exact(&row.unread), Some(1)); } + +/// Store `event` as having arrived at exactly `seconds` plus `micros`. Built +/// from integers, never a float, so microsecond order cannot hinge on rounding. +async fn arrive_exact( + pool: &PgPool, + community: CommunityId, + event: &nostr::Event, + seconds: i64, + micros: u32, +) { + let at = chrono::DateTime::::from_timestamp(seconds, micros * 1_000).unwrap(); + let updated = sqlx::query("UPDATE events SET received_at=$3 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(at) + .execute(pool) + .await + .unwrap() + .rows_affected(); + assert_eq!(updated, 1, "arrival must land on exactly one stored event"); + let stored: chrono::DateTime = + sqlx::query_scalar("SELECT received_at FROM events WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(stored, at, "received_at must keep microseconds"); +} + +/// Two messages with the same author time arriving within one second; marks +/// the one at `pick` and returns both channel states. +async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec, Option) { + let (db, pool, community, channel, actor, first) = fixture().await; + let now = first.created_at.as_secs(); + let second = post(&db, &pool, community, channel, now, now, vec![]).await; + let arrived = now as i64 - 30; + arrive_exact(&pool, community, &first, arrived, micros[0]).await; + arrive_exact(&pool, community, &second, arrived, micros[1]).await; + let both = [&first, &second]; + apply( + &db, + community, + &actor, + mark_through(channel, None, &both[pick].id.to_hex()), + ) + .await; + ( + states(&db, community, &actor, channel, &both).await, + exact(&sidebar(&db, community, &actor).await.unread), + ) +} + +/// Mid-second stamps, so truncating or rounding the frontier to whole seconds +/// either reads the later message or leaves the anchor unread. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn arrivals_one_microsecond_apart_in_the_same_second_are_ordered() { + assert_eq!( + mark_within_one_second([500_000, 500_001], 0).await, + (vec!["read".to_owned(), "unread".to_owned()], Some(1)) + ); +} + +/// The Order section: everything that arrived at or before the anchor is read, +/// so an identical stamp reads both, whichever is marked. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn marking_either_of_two_identical_arrivals_reads_both() { + for pick in [0, 1] { + assert_eq!( + mark_within_one_second([500_000, 500_000], pick).await, + (vec!["read".to_owned(), "read".to_owned()], Some(0)), + "marked index {pick}" + ); + } +} From 47dff0c70bafe7c9f6d83c6673fbe635bf8272f1 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Mon, 5 Oct 2026 16:16:15 -0400 Subject: [PATCH 18/18] Say which NIP-FI modes keep the v1 JSON error shape The read-state doc said admission failures keep the shared NIP-FI denial response "when NIP-FI is not Off". Shadow is not Off, but it does not restrict: a failed NIP-98 in Shadow takes the same v1 JSON error path as Off. The handler branches on mode.restricts(), which is Enforce and DenyProtected only, and accessory_shadow_matches_off pins it. Docs only. Raised in review on #7906. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- docs/buzz-v1-read-state.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index 79368e834fb..b6210a75b44 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -34,11 +34,12 @@ not grant access to the owner's personal state. Relay membership, bans and resource access are enforced; moderation timeouts do not prohibit reading. Responses produced by the v1 handlers are `Cache-Control: private, no-store`. -Application errors (including NIP-98 failures with NIP-FI Off) use +Application errors (including NIP-98 failures with NIP-FI Off or Shadow) use `{"error":{"code":"invalid_request","request_id":"..."}}`, with 400 invalid, 401 unauthorized/replay, 403 forbidden, 404 unavailable capability/host/path, 429 rate limited or 503 temporarily unavailable. Application 429/503 errors -include `Retry-After`. When NIP-FI is not Off, admission failures instead preserve +include `Retry-After`. When NIP-FI restricts (Enforce or DenyProtected), +admission failures instead preserve the shared [NIP-FI HTTP denial contract](nips/NIP-FI.md): status, fixed plaintext body, `Content-Type` and (for 401) `WWW-Authenticate: Nostr`. The v1 handler adds `private, no-store` without changing those fields, unlike the bridge's direct