diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index ef10d5ec39..8fb8f71f99 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -75,7 +75,8 @@ pub struct ChannelReadSummary { /// Unread top-level messages directed at the actor: every one in a DM, /// otherwise those that tag the actor with `p` or carry `broadcast=1`. pub mentions: u32, - /// The message the channel timeline was last marked through, if any. + /// The anchor of the timeline's frontier: the marked message that arrived + /// last. None until the timeline is marked. pub read_through_id: Option, /// Last eligible top-level message to arrive, whatever its author or read /// progress: marking the timeline through it reads the timeline. @@ -91,9 +92,11 @@ pub struct ThreadReadSummary { pub root_id: String, /// Whether an unread reply that counts was found. pub unread: bool, - /// Unread replies that count. Every one is directed at the actor. + /// Unread replies that count: directed at the actor, or in one of the + /// actor's conversations. pub mentions: u32, - /// The message the thread was last marked through, if any. + /// The anchor of the thread's frontier: the marked message that arrived + /// last. None until the thread is marked. pub read_through_id: Option, /// Last counted reply to arrive: marking through it reads the thread. pub latest_id: String, diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs index 5e38d85515..d7d7e66fc7 100644 --- a/crates/buzz-db/src/store/personal_read/projection.rs +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -1,5 +1,5 @@ //! Bounded evidence projection. The cap limits evidence, not the definition of -//! unread: an unexamined tail yields a positive lower bound or an unknown count. +//! unread: an unexamined tail goes uncounted, so counts may undercount. use super::{model::*, participation, writes}; use buzz_core::CommunityId; @@ -133,8 +133,9 @@ impl Db { ), threaded AS MATERIALIZED ( SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent, COALESCE(tm.root_event_id<>e.id,false) AS is_reply, - -- A NIP-10 reply marker without canonical ancestry: never a - -- timeline anchor, since a mark cannot validate it. + -- A NIP-10 reply marker without canonical ancestry: neither + -- counted nor a timeline anchor, since a mark cannot validate + -- it. Parity-tested against the shared NIP-10 parser. tm.root_event_id IS NULL AND jsonb_typeof(e.tags)='array' AND EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) t(tag) WHERE jsonb_typeof(tag)='array' AND tag->>0='e' @@ -155,16 +156,14 @@ impl Db { FROM threaded e LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 AND tf.channel_id=r.id AND tf.root_id=e.root AND e.is_reply - WHERE e.pubkey<>$2 + WHERE e.pubkey<>$2 AND NOT e.unresolved ), grouped AS ( SELECT CASE WHEN root IS NULL THEN NULL WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root, CASE WHEN is_reply THEN encode(parent,'hex') END AS parent, is_reply, covered, read_through_id, -- ->>0 also selects scalar "p"/"e": reject nonarrays first. - -- C collation matches Rust's ASCII case/hex rules. Reply - -- markers matter only without canonical ancestry; otherwise - -- metadata, not tag spelling, owns the context. + -- C collation matches Rust's ASCII case rules. CASE WHEN octet_length(tags::text)<=8192 AND jsonb_typeof(tags)='array' THEN (SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array' @@ -172,10 +171,7 @@ impl Db { THEN NULL ELSE jsonb_build_object( 'directed',COALESCE(bool_or( (tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex')) - OR (tag->>0='broadcast' AND tag->>1='1')),false), - 'reply_marked',root IS NULL AND COALESCE(bool_or(tag->>0='e' - AND tag->>3='reply' - AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'),false)) END + OR (tag->>0='broadcast' AND tag->>1='1')),false)) END FROM jsonb_array_elements(tags) t(tag) WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts, count(*) AS n, @@ -227,11 +223,6 @@ impl Db { let Some(facts) = e["facts"].as_object() else { 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() { - continue; - } if e["covered"] == true { continue; } 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 2b4979515f..3dc5c131b3 100644 --- a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs @@ -158,6 +158,12 @@ async fn sidebar_ancestry_fact_matches_shared_nip10_parser() { .unwrap(); // A reply marker without recorded ancestry leaves the message out. assert_eq!(page.channels[0].unread, !reply, "tags={tags:?}"); + // ...and it is never the timeline's anchor. + assert_eq!( + page.channels[0].latest_id.is_some(), + !reply, + "tags={tags:?}" + ); } } diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index b627677742..fe2fc2d9f0 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -174,12 +174,10 @@ pub(super) async fn write( outcomes.push(outcome); } let mut body = json!({ "outcomes": outcomes }); - // Committed outcomes stand even when their rows cannot be read: omit them. - if let Ok(Ok(channels)) = tokio::time::timeout( - Duration::from_secs(8), - refreshed(&state, &headers, &principal, &applied), - ) - .await + // Committed outcomes stand even when their rows cannot be read in the + // remaining budget: omit them. + if let Ok(Ok(channels)) = + tokio::time::timeout_at(deadline, refreshed(&state, &headers, &principal, &applied)).await { body["channels"] = json!(channels); } diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index b71720b6c9..4b4eb8bf57 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -76,7 +76,7 @@ marking one never moves another. |---|---|---| | `unread` | an unread top-level message counts | an unread reply counts | | `mentions` | unread top-level messages directed at you | unread replies that count | -| `read_through_id` | the message the timeline was last marked through, or null | the message the thread was last marked through, or null | +| `read_through_id` | the anchor of the timeline's frontier, or null | the anchor of the thread's frontier, or null | | `latest_id` | the last eligible top-level message to arrive, or null | the last counted unread reply to arrive | A thread reply never makes the channel row unread and never becomes its @@ -89,8 +89,10 @@ bounded scans found no top-level message, not that the channel is empty. `read_through_id` may name a message that has since been deleted. `threads` lists threads with unread replies that count, newest unread reply -first by author time (then `root_id`), at most 5. A thread row's `unread` is -always true in this version. No message bytes are included. +first by author time (then `root_id`), at most 5. Threads past the fifth are +omitted with their mentions, and nothing signals the omission: after marking +the listed threads read, a refresh can list the next ones. A thread row's +`unread` is always true in this version. No message bytes are included. 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 @@ -146,8 +148,8 @@ request order. An ambiguous timeout/storage failure returns later failures. `channels` holds the updated sidebar row of each distinct channel with an applied intent, in the sidebar's row shape and ordered by ID; a channel you have not joined has no row. Replace those rows rather than -re-deriving them. `channels` is omitted when the rows could not be read after -the writes committed: refresh them with `?channel_ids=`. Retry the same +re-deriving them. `channels` is omitted when the rows could not be read within +the request's deadline after the writes committed: refresh them with `?channel_ids=`. Retry the same operands, never substitute latest. Keep pending intent durably on the client until its outcome is resolved. @@ -186,9 +188,9 @@ microsecond resolution. Three limits follow, none of which strands a badge: A channel's `latest_id` is the last top-level message to arrive among those the unread count examined: the 4,096 most recent events by author time inside the -horizon. So marking through it reads every top-level message counted. When the -horizon holds no top-level message, it is the last to arrive among the -channel's 256 most recent events. +horizon. So marking through it reads every top-level message counted. When +that scan holds no top-level message, it is the last top-level message to +arrive among the channel's 256 most recent events. There is no import of earlier client read state. The relay records the account's `started_at` at its first applied read intent and never moves it. @@ -211,8 +213,8 @@ device-local. 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 and left out. -- DB statement/lock deadlines and HTTP read deadlines bound work; writes use a - shared eight-second intent-processing deadline after admission. Limits are +- DB statement/lock deadlines and HTTP read deadlines bound work; a write's intents + and its row refresh share one eight-second deadline after admission. Limits are containment, not a production capacity claim. Apply migrations 0056 through 0058 (or the equivalent desired schema). 0056