Merge main and reconcile embedded migration count

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-09 13:11:09 -07:00
31 changed files with 1602 additions and 2761 deletions

View File

@@ -705,10 +705,9 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect(); let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version); migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 58); assert_eq!(migrations.len(), 59);
// 0058 is reserved by the independently reviewed trigger retirement. assert_eq!(migrations[58].version, 59);
assert_eq!(migrations[57].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled"));
assert!(migrations[57].sql.as_str().contains("buzz.push_enabled"));
assert_eq!(migrations[55].version, 56); assert_eq!(migrations[55].version, 56);
assert!(migrations[55] assert!(migrations[55]
.sql .sql
@@ -719,6 +718,11 @@ mod postgres_tests {
.sql .sql
.as_str() .as_str()
.contains("ALTER TABLE personal_read_accounts ADD COLUMN started_at")); .contains("ALTER TABLE personal_read_accounts ADD COLUMN started_at"));
assert_eq!(migrations[57].version, 58);
assert!(migrations[57]
.sql
.as_str()
.contains("ADD COLUMN through_message_id"));
assert_eq!(migrations[48].version, 49); assert_eq!(migrations[48].version, 49);
assert_eq!(migrations[49].version, 50); assert_eq!(migrations[49].version, 50);
assert_eq!(migrations[50].version, 51); assert_eq!(migrations[50].version, 51);
@@ -2232,6 +2236,18 @@ mod postgres_tests {
.get("personal_read_accounts") .get("personal_read_accounts")
.expect("schema.sql personal read accounts") .expect("schema.sql personal read accounts")
.contains(started_at_column)); .contains(started_at_column));
// 0058 replaces the frontier's whole-channel cut with its anchor ID.
let threads_through_column = "threads_through_timestamp timestamptz \
check (threads_through_timestamp is null or root_id = ''::bytea), ";
let through_message_column =
"through_message_id bytea check (octet_length(through_message_id) = 32), ";
let through_message = MIGRATOR
.iter()
.find(|m| m.version == 58)
.expect("personal read anchor migration")
.sql
.as_str();
assert!(through_message.contains("DROP COLUMN threads_through_timestamp"));
for (table, definition) in personal.tables { for (table, definition) in personal.tables {
let in_schema = schema.tables.get(&table).map(|schema_definition| { let in_schema = schema.tables.get(&table).map(|schema_definition| {
if table == "personal_read_accounts" { if table == "personal_read_accounts" {
@@ -2240,6 +2256,11 @@ mod postgres_tests {
schema_definition.clone() schema_definition.clone()
} }
}); });
let definition = if table == "personal_read_frontiers" {
definition.replacen(threads_through_column, through_message_column, 1)
} else {
definition
};
assert_eq!( assert_eq!(
in_schema.as_ref(), in_schema.as_ref(),
Some(&definition), Some(&definition),

View File

@@ -22,18 +22,19 @@ async fn arrive(pool: &PgPool, community: CommunityId, event: &nostr::Event, arr
assert_eq!(updated, 1, "arrival must land on exactly one stored event"); assert_eq!(updated, 1, "arrival must land on exactly one stored event");
} }
/// A message authored at `authored` that arrives at `arrived`. /// A message authored at `authored` that arrives at `arrived`. It mentions the
/// actor, so the channel's mention count is exactly its unread top-level count.
async fn post( async fn post(
db: &Db, db: &Db,
pool: &PgPool, pool: &PgPool,
community: CommunityId, community: CommunityId,
channel: Uuid, channel: Uuid,
actor: &Keys,
authored: u64, authored: u64,
arrived: u64, arrived: u64,
tags: Vec<Tag>,
) -> nostr::Event { ) -> nostr::Event {
let event = EventBuilder::new(Kind::Custom(9), format!("authored {authored}")) let event = EventBuilder::new(Kind::Custom(9), format!("authored {authored}"))
.tags(tags) .tags([Tag::public_key(actor.public_key())])
.custom_created_at(nostr::Timestamp::from(authored)) .custom_created_at(nostr::Timestamp::from(authored))
.sign_with_keys(&Keys::generate()) .sign_with_keys(&Keys::generate())
.unwrap(); .unwrap();
@@ -56,17 +57,7 @@ async fn reply(
authored: u64, authored: u64,
arrived: u64, arrived: u64,
) -> nostr::Event { ) -> nostr::Event {
let mention = Tag::public_key(actor.public_key()); let event = post(db, pool, community, channel, actor, authored, arrived).await;
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) 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)") VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)")
.bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice())
@@ -89,6 +80,12 @@ async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSu
.remove(0) .remove(0)
} }
/// The channel row's top-level state: (unread, mentions).
async fn counts(db: &Db, community: CommunityId, actor: &Keys) -> (bool, u32) {
let row = sidebar(db, community, actor).await;
(row.unread, row.mentions)
}
async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) { async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) {
let outcome = db let outcome = db
.apply_personal_read_intent(community, &actor.public_key(), &intent) .apply_personal_read_intent(community, &actor.public_key(), &intent)
@@ -107,46 +104,6 @@ fn mark_through(channel: Uuid, root: Option<&str>, message: &str) -> ReadIntent
} }
} }
/// Channel-timeline states of `messages`, in order.
async fn states(
db: &Db,
community: CommunityId,
actor: &Keys,
channel: Uuid,
messages: &[&nostr::Event],
) -> Vec<String> {
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<u32> {
match count {
ReadCount::Exact { value } => Some(*value),
_ => None,
}
}
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn late_arrival_with_old_author_time_is_unread() { async fn late_arrival_with_old_author_time_is_unread() {
@@ -160,18 +117,15 @@ async fn late_arrival_with_old_author_time_is_unread() {
mark_through(channel, None, &read.id.to_hex()), mark_through(channel, None, &read.id.to_hex()),
) )
.await; .await;
assert_eq!(counts(&db, community, &actor).await, (false, 0));
// Authored ten minutes before the read message, arriving after it was read. // Authored ten minutes before the read message, arriving after it was read.
let late = post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await; let late = post(&db, &pool, community, channel, &actor, now - 600, now - 30).await;
assert_eq!( let row = sidebar(&db, community, &actor).await;
states(&db, community, &actor, channel, &[&read, &late]).await, assert_eq!((row.unread, row.mentions), (true, 1));
["read", "unread"] assert_eq!(row.latest_id, Some(late.id.to_hex()));
); assert_eq!(row.read_through_id, Some(read.id.to_hex()));
assert_eq!(
exact(&sidebar(&db, community, &actor).await.unread),
Some(1)
);
} }
#[tokio::test] #[tokio::test]
@@ -181,7 +135,7 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() {
let now = earlier.created_at.as_secs(); let now = earlier.created_at.as_secs();
arrive(&pool, community, &earlier, now - 60).await; arrive(&pool, community, &earlier, now - 60).await;
// Stamped ten minutes ahead; the relay accepts up to fifteen. // Stamped ten minutes ahead; the relay accepts up to fifteen.
let ahead = post(&db, &pool, community, channel, now + 600, now - 40, vec![]).await; let ahead = post(&db, &pool, community, channel, &actor, now + 600, now - 40).await;
apply( apply(
&db, &db,
community, community,
@@ -189,17 +143,11 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() {
mark_through(channel, None, &ahead.id.to_hex()), mark_through(channel, None, &ahead.id.to_hex()),
) )
.await; .await;
assert_eq!(counts(&db, community, &actor).await, (false, 0));
let later = post(&db, &pool, community, channel, now, now - 30, vec![]).await; post(&db, &pool, community, channel, &actor, now, now - 30).await;
assert_eq!( assert_eq!(counts(&db, community, &actor).await, (true, 1));
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 /// Marking the sidebar's own latest message must clear the badge even when the
@@ -210,32 +158,19 @@ async fn mark_as_read_with_the_sidebar_anchor_clears_a_late_arrival() {
let (db, pool, community, channel, actor, first) = fixture().await; let (db, pool, community, channel, actor, first) = fixture().await;
let now = first.created_at.as_secs(); let now = first.created_at.as_secs();
arrive(&pool, community, &first, now - 60).await; arrive(&pool, community, &first, now - 60).await;
post(&db, &pool, community, channel, now + 1, now - 40, vec![]).await; post(&db, &pool, community, channel, &actor, now + 1, now - 40).await;
post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await; let last = post(&db, &pool, community, channel, &actor, now - 600, now - 30).await;
assert_eq!( let row = sidebar(&db, community, &actor).await;
exact(&sidebar(&db, community, &actor).await.unread), // The fixture message is unread too, but mentions no one.
Some(3) assert_eq!((row.unread, row.mentions), (true, 2));
); assert_eq!(row.latest_id, Some(last.id.to_hex()));
let anchor = sidebar(&db, community, &actor) let anchor = row
.await .latest_id
.latest_message_id
.expect("a channel with messages has a latest message"); .expect("a channel with messages has a latest message");
apply( apply(&db, community, &actor, mark_through(channel, None, &anchor)).await;
&db,
community,
&actor,
ReadIntent::MarkChannelRead {
channel_id: channel,
message_id: anchor,
},
)
.await;
assert_eq!( assert_eq!(counts(&db, community, &actor).await, (false, 0));
exact(&sidebar(&db, community, &actor).await.unread),
Some(0)
);
} }
#[tokio::test] #[tokio::test]
@@ -262,7 +197,7 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
now - 40, now - 40,
) )
.await; .await;
reply( let last = reply(
&db, &db,
&pool, &pool,
community, community,
@@ -274,11 +209,17 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
) )
.await; .await;
let row = sidebar(&db, community, &actor).await; let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(2)); assert_eq!(
assert_eq!(row.threads.items.len(), 1); (row.unread, row.mentions),
(false, 0),
"replies are not top-level"
);
assert_eq!(row.threads.len(), 1);
assert_eq!(row.threads[0].mentions, 2);
assert_eq!(row.threads[0].latest_id, last.id.to_hex());
let root_id = root.id.to_hex(); let root_id = root.id.to_hex();
let anchor = row.threads.items[0].latest_reply_id.clone(); let anchor = row.threads[0].latest_id.clone();
apply( apply(
&db, &db,
community, community,
@@ -288,15 +229,15 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
.await; .await;
let row = sidebar(&db, community, &actor).await; let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(0)); assert!(!row.unread);
assert!(row.threads.items.is_empty() && row.threads.complete); assert!(row.threads.is_empty());
} }
/// The shallow latest probe reads the newest `MAX_CHANNEL_SCAN + 1` events by /// 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 /// 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 /// that many events is counted unread, so the unread scan must supply the
/// anchor or Mark as read with the sidebar anchor leaves it. /// anchor or Mark as read with the sidebar anchor leaves it.
async fn mark_as_read_behind_fillers(fillers: u64) -> Option<u32> { async fn mark_as_read_behind_fillers(fillers: u64) -> (bool, u32) {
let (db, pool, community, channel, actor, first) = fixture().await; let (db, pool, community, channel, actor, first) = fixture().await;
let now = first.created_at.as_secs(); let now = first.created_at.as_secs();
arrive(&pool, community, &first, now - 60).await; arrive(&pool, community, &first, now - 60).await;
@@ -318,27 +259,15 @@ async fn mark_as_read_behind_fillers(fillers: u64) -> Option<u32> {
.unwrap(); .unwrap();
arrive(&pool, community, &filler, now - 50).await; arrive(&pool, community, &filler, now - 50).await;
} }
post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await; post(&db, &pool, community, channel, &actor, now - 600, now - 10).await;
assert_eq!( assert_eq!(counts(&db, community, &actor).await, (true, 1));
exact(&sidebar(&db, community, &actor).await.unread),
Some(1)
);
let anchor = sidebar(&db, community, &actor) let anchor = sidebar(&db, community, &actor)
.await .await
.latest_message_id .latest_id
.expect("a channel with messages has a latest message"); .expect("a channel with messages has a latest message");
apply( apply(&db, community, &actor, mark_through(channel, None, &anchor)).await;
&db, counts(&db, community, &actor).await
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. /// Control: with one slot to spare the late arrival is inside the probe.
@@ -346,18 +275,18 @@ async fn mark_as_read_behind_fillers(fillers: u64) -> Option<u32> {
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn mark_as_read_clears_a_late_arrival_inside_the_latest_probe() { async fn mark_as_read_clears_a_late_arrival_inside_the_latest_probe() {
let fillers = MAX_CHANNEL_SCAN as u64 - 1; let fillers = MAX_CHANNEL_SCAN as u64 - 1;
assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0)); assert_eq!(mark_as_read_behind_fillers(fillers).await, (false, 0));
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn mark_as_read_clears_a_late_arrival_outside_the_latest_probe() { async fn mark_as_read_clears_a_late_arrival_outside_the_latest_probe() {
let fillers = MAX_CHANNEL_SCAN as u64; let fillers = MAX_CHANNEL_SCAN as u64;
assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0)); assert_eq!(mark_as_read_behind_fillers(fillers).await, (false, 0));
} }
/// When the newest 257 events are all uncounted, the only message is still in /// When the newest 257 events are all uncounted, the only message is still in
/// the unread scan: the summary reports it as found, as one coherent triple. /// the unread scan: the summary reports it as latest and unread.
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn latest_message_behind_a_full_probe_of_reactions_is_found() { async fn latest_message_behind_a_full_probe_of_reactions_is_found() {
@@ -371,7 +300,7 @@ async fn latest_message_behind_a_full_probe_of_reactions_is_found() {
.unwrap() .unwrap()
.rows_affected(); .rows_affected();
assert_eq!(demoted, 1); assert_eq!(demoted, 1);
let only = post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await; let only = post(&db, &pool, community, channel, &actor, now - 600, now - 10).await;
for i in 0..MAX_CHANNEL_SCAN as u64 { for i in 0..MAX_CHANNEL_SCAN as u64 {
let filler = EventBuilder::new(Kind::Custom(7), "+") let filler = EventBuilder::new(Kind::Custom(7), "+")
.custom_created_at(nostr::Timestamp::from(now - 500 + i)) .custom_created_at(nostr::Timestamp::from(now - 500 + i))
@@ -384,10 +313,8 @@ async fn latest_message_behind_a_full_probe_of_reactions_is_found() {
} }
let row = sidebar(&db, community, &actor).await; let row = sidebar(&db, community, &actor).await;
assert_eq!(row.latest_message_id, Some(only.id.to_hex())); assert_eq!(row.latest_id, Some(only.id.to_hex()));
assert_eq!(row.latest_message_at, Some((now - 600) as i64)); assert_eq!((row.unread, row.mentions), (true, 1));
assert!(row.latest_message_complete);
assert_eq!(exact(&row.unread), Some(1));
} }
/// Store `event` as having arrived at exactly `seconds` plus `micros`. Built /// Store `event` as having arrived at exactly `seconds` plus `micros`. Built
@@ -419,13 +346,17 @@ async fn arrive_exact(
assert_eq!(stored, at, "received_at must keep microseconds"); assert_eq!(stored, at, "received_at must keep microseconds");
} }
/// Two messages with the same author time arriving within one second; marks /// Two mentioning messages with the same author time arriving within one
/// the one at `pick` and returns both channel states. /// second; marks the one at `pick`, then the other. Returns the mention count
async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec<String>, Option<u32>) { /// after the first mark, and the anchor (index) the frontier ends with.
let (db, pool, community, channel, actor, first) = fixture().await; async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (u32, usize) {
let now = first.created_at.as_secs(); let (db, pool, community, channel, actor, older) = fixture().await;
let second = post(&db, &pool, community, channel, now, now, vec![]).await; let now = older.created_at.as_secs();
let arrived = now as i64 - 30; let arrived = now as i64 - 30;
// The fixture message arrives first, so either mark covers it.
arrive_exact(&pool, community, &older, arrived - 1, 0).await;
let first = post(&db, &pool, community, channel, &actor, now, now).await;
let second = post(&db, &pool, community, channel, &actor, now, now).await;
arrive_exact(&pool, community, &first, arrived, micros[0]).await; arrive_exact(&pool, community, &first, arrived, micros[0]).await;
arrive_exact(&pool, community, &second, arrived, micros[1]).await; arrive_exact(&pool, community, &second, arrived, micros[1]).await;
let both = [&first, &second]; let both = [&first, &second];
@@ -436,32 +367,41 @@ async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec<String>,
mark_through(channel, None, &both[pick].id.to_hex()), mark_through(channel, None, &both[pick].id.to_hex()),
) )
.await; .await;
( let mentions = sidebar(&db, community, &actor).await.mentions;
states(&db, community, &actor, channel, &both).await, apply(
exact(&sidebar(&db, community, &actor).await.unread), &db,
community,
&actor,
mark_through(channel, None, &both[1 - pick].id.to_hex()),
) )
.await;
let anchor = sidebar(&db, community, &actor).await.read_through_id;
let anchor = both
.iter()
.position(|e| Some(e.id.to_hex()) == anchor)
.expect("the anchor is one of the marked messages");
(mentions, anchor)
} }
/// Mid-second stamps, so truncating or rounding the frontier to whole seconds /// Mid-second stamps, so truncating or rounding the frontier to whole seconds
/// either reads the later message or leaves the anchor unread. /// either reads the later message or leaves the anchor unread. The later
/// arrival then becomes the anchor.
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn arrivals_one_microsecond_apart_in_the_same_second_are_ordered() { async fn arrivals_one_microsecond_apart_in_the_same_second_are_ordered() {
assert_eq!( assert_eq!(mark_within_one_second([500_000, 500_001], 0).await, (1, 1));
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, /// The Order section: everything that arrived at or before the anchor is read,
/// so an identical stamp reads both, whichever is marked. /// so an identical stamp reads both, whichever is marked. An equal arrival
/// keeps the first anchor.
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn marking_either_of_two_identical_arrivals_reads_both() { async fn marking_either_of_two_identical_arrivals_reads_both() {
for pick in [0, 1] { for pick in [0, 1] {
assert_eq!( assert_eq!(
mark_within_one_second([500_000, 500_000], pick).await, mark_within_one_second([500_000, 500_000], pick).await,
(vec!["read".to_owned(), "read".to_owned()], Some(0)), (0, pick),
"marked index {pick}" "marked index {pick}"
); );
} }

View File

@@ -1,30 +0,0 @@
//! Selector eligibility and shared directed-reason rules. Aggregate SQL applies
//! the same eligibility before grouping, covered by the PostgreSQL parity test.
use super::model::{Reason, ELIGIBLE_KINDS};
pub(super) fn eligible(
kind: i32,
own: bool,
deleted: bool,
created_ms: i64,
cutoff_ms: i64,
) -> bool {
ELIGIBLE_KINDS.contains(&kind) && !own && !deleted && created_ms >= cutoff_ms
}
/// Why a message is directed, before conversation membership is known.
pub(super) fn reason(channel_type: &str, actor_hex: &str, tags: &[Vec<String>]) -> Option<Reason> {
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
}
}

View File

@@ -1,259 +0,0 @@
//! Explicit selectors over the same private frontier authority. No history API.
use super::{classification, model::*, participation, projection::read_account, writes};
use crate::{observability, Db, DbError, Result};
use buzz_core::CommunityId;
use chrono::{DateTime, Utc};
use sqlx::{Acquire, Row};
use std::collections::HashMap;
use uuid::Uuid;
impl Db {
/// Resolve bounded explicit contexts/messages in a single read-only snapshot.
/// Callers must recheck admission and resource access outside this snapshot.
pub async fn personal_read_contexts(
&self,
community: CommunityId,
actor: &nostr::PublicKey,
retention_seconds: u32,
queries: &[ContextQuery],
) -> Result<ContextPage> {
if queries.is_empty()
|| queries.len() > MAX_CONTEXTS
|| queries.iter().map(|q| q.message_ids.len()).sum::<usize>() > MAX_CONTEXT_MESSAGES
|| queries.iter().any(|q| {
q.message_ids
.iter()
.any(|id| writes::event_id(id).is_none())
})
{
return Err(DbError::InvalidData("invalid context selectors".into()));
}
let mut conn = observability::acquire_writer(
&self.pool,
observability::WriterOperation::SubscriptionHistory,
)
.await?;
let mut tx = conn.begin().await?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
.execute(&mut *tx)
.await?;
writes::deadlines(&mut tx).await?;
let actor_bytes = actor.to_bytes();
let account = read_account(&mut tx, community, &actor_bytes, retention_seconds).await?;
// Every frontier is floored at the account's start.
let floor = account.floor();
let mut contexts = Vec::with_capacity(queries.len());
// Replies whose state turns on conversation membership, by position.
let mut pending = Vec::new();
for query in queries {
let root =
match writes::valid_target(&mut tx, community, &actor_bytes, &query.target).await {
Ok(Some(root)) => root,
Ok(None) => {
contexts.push(ContextState::Unavailable);
continue;
}
Err(DbError::InvalidData(_)) => {
contexts.push(ContextState::Unknown);
continue;
}
Err(error) => return Err(error),
};
// A thread's effective prefix includes the channel's whole-channel cut,
// exactly as the sidebar projection counts it.
let prefix: Option<DateTime<Utc>> = sqlx::query_scalar(
"SELECT GREATEST(
(SELECT through_timestamp FROM personal_read_frontiers
WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=$4),
(SELECT threads_through_timestamp FROM personal_read_frontiers
WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=''::bytea
AND $4<>''::bytea))",
)
.bind(community.as_uuid())
.bind(actor_bytes.as_slice())
.bind(query.target.channel_id)
.bind(&root)
.fetch_one(&mut *tx)
.await?;
let ids: Vec<Vec<u8>> = query
.message_ids
.iter()
.filter_map(|id| writes::event_id(id))
.collect();
let rows = sqlx::query(
"SELECT encode(e.id,'hex') AS id,e.kind,e.created_at,e.received_at,
e.deleted_at IS NOT NULL AS deleted,e.pubkey=$3 AS own,
CASE WHEN octet_length(e.tags::text)<=8192 THEN e.tags ELSE NULL END AS tags,
tm.root_event_id,tm.parent_event_id,c.channel_type::text AS channel_type
FROM events e JOIN channels c ON c.community_id=e.community_id AND c.id=e.channel_id
LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id
AND tm.event_id=e.id AND tm.event_created_at=e.created_at AND tm.channel_id=e.channel_id
WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=ANY($4)",
).bind(community.as_uuid()).bind(query.target.channel_id)
.bind(actor_bytes.as_slice()).bind(&ids).fetch_all(&mut *tx).await?;
let by_id: HashMap<String, _> = rows
.into_iter()
.map(|row| Ok((row.try_get::<String, _>("id")?, row)))
.collect::<Result<_>>()?;
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<serde_json::Value> = row.try_get("tags")?;
let parsed =
tags.and_then(|v| serde_json::from_value::<Vec<Vec<String>>>(v).ok());
if let Some(tags) = parsed {
let canonical: Option<Vec<u8>> = 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<Utc> = row.try_get("created_at")?;
let received: DateTime<Utc> = row.try_get("received_at")?;
let kind: i32 = row.try_get("kind")?;
if !classification::eligible(
kind,
row.try_get("own")?,
row.try_get("deleted")?,
created.timestamp_millis(),
account.cutoff_ms,
) {
MessageReadState::NotCounted
} else if received <= prefix.map_or(floor, |p| p.max(floor)) {
MessageReadState::Read
} else {
let reason = classification::reason(
&row.try_get::<String, _>("channel_type")?,
&actor.to_hex(),
&tags,
);
// Membership outranks a broadcast and decides a
// plain reply. A DM or mention needs no lookup.
if is_reply
&& !matches!(reason, Some(Reason::Direct | Reason::Mention))
{
let parent: Option<Vec<u8>> = row.try_get("parent_event_id")?;
if let Some(parent) = parent {
pending.push((
contexts.len(),
messages.len(),
(query.target.channel_id, parent),
));
}
}
// Provisional for a pending reply: see `settle`.
if is_reply && reason.is_none() {
MessageReadState::Unknown
} else {
MessageReadState::Unread { reason }
}
}
}
} else {
MessageReadState::Unknown
}
} else {
MessageReadState::Unavailable
};
messages.push(ContextMessage {
message_id: id.clone(),
state,
});
}
contexts.push(ContextState::Available { messages });
}
let targets: Vec<_> = pending.iter().map(|(.., key)| key.clone()).collect();
let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?;
for (context, message, key) in pending {
if let ContextState::Available { messages } = &mut contexts[context] {
settle(&mut messages[message].state, members.get(&key).copied());
}
}
tx.commit().await?;
Ok(ContextPage { account, contexts })
}
/// Final bounded access check on the writer, outside a projection snapshot.
/// Open-channel access is independent of joined-sidebar membership.
pub async fn personal_read_accessible_contexts(
&self,
community: CommunityId,
actor: &nostr::PublicKey,
channels: &[Uuid],
) -> Result<Vec<Uuid>> {
if channels.len() > MAX_CONTEXTS {
return Err(DbError::InvalidData("too many context channels".into()));
}
let mut conn = observability::acquire_writer(
&self.pool,
observability::WriterOperation::Authorization,
)
.await?;
Ok(sqlx::query_scalar(
"SELECT c.id FROM channels c WHERE c.community_id=$1 AND c.id=ANY($2)
AND c.deleted_at IS NULL AND (c.visibility='open' OR EXISTS (
SELECT 1 FROM channel_members cm WHERE cm.community_id=$1
AND cm.channel_id=c.id AND cm.pubkey=$3 AND cm.removed_at IS NULL))",
)
.bind(community.as_uuid())
.bind(channels)
.bind(actor.to_bytes().as_slice())
.fetch_all(&mut *conn)
.await?)
}
}
/// Apply conversation membership to a pending reply's provisional state:
/// `unknown` for a plain reply, `unread` with reason `broadcast` for a
/// broadcast. An undecided lookup (`None`) leaves it as it is.
fn settle(state: &mut MessageReadState, member: Option<bool>) {
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:?}");
}
}
}

View File

@@ -4,8 +4,6 @@
//! through; the unread horizon alone uses author time. Only fixed context //! through; the unread horizon alone uses author time. Only fixed context
//! intents advance frontiers, never a query scan cap. //! intents advance frontiers, never a query scan cap.
mod classification;
mod context;
mod model; mod model;
mod participation; mod participation;
mod projection; mod projection;

View File

@@ -28,14 +28,6 @@ pub enum ReadIntent {
/// Fixed anchor; retry must not substitute the latest message. /// Fixed anchor; retry must not substitute the latest message.
message_id: String, message_id: String,
}, },
/// Advance the channel timeline and every thread in it through one fixed
/// message's arrival time. The anchor may be a reply; ancestry is irrelevant.
MarkChannelRead {
/// Channel being marked, including all of its threads.
channel_id: Uuid,
/// Fixed anchor; retry must not substitute the latest message.
message_id: String,
},
} }
/// Outcome for one independent transaction, never acknowledged before commit. /// Outcome for one independent transaction, never acknowledged before commit.
@@ -50,220 +42,72 @@ pub enum IntentOutcome {
Invalid, Invalid,
} }
/// The tracking boundary for the authenticated account. /// The tracking boundary for the authenticated account. Not on the wire.
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug)]
pub struct ReadAccount { pub(super) struct ReadAccount {
/// Configured tracking duration in seconds.
pub retention_seconds: u32,
/// Read-time author-time cutoff (Unix milliseconds), not a discard boundary. /// Read-time author-time cutoff (Unix milliseconds), not a discard boundary.
pub cutoff_ms: i64, pub(super) cutoff_ms: i64,
/// Arrival floor of every frontier: the account's first applied read /// Arrival floor of every frontier: the account's first applied read
/// intent. `None` until then, and nothing counts as unread. Not on the wire. /// intent. `None` until then, and nothing counts as unread.
#[serde(skip)] pub(super) started_at: Option<DateTime<Utc>>,
pub started_at: Option<DateTime<Utc>>,
}
impl ReadAccount {
/// The arrival every frontier is floored at: the start, or the end of time
/// for an account that has not started.
pub(super) fn floor(&self) -> DateTime<Utc> {
self.started_at.unwrap_or(DateTime::<Utc>::MAX_UTC)
}
} }
/// Maximum channel summaries in one sidebar page. /// Maximum channel summaries in one sidebar page.
pub const MAX_CHANNELS: usize = 20; pub const MAX_CHANNELS: usize = 20;
/// Maximum unread-thread summaries per channel row. /// Maximum thread rows per channel row. A POST can return rows for
pub const MAX_THREAD_SUMMARIES: usize = 5; /// `MAX_INTENTS` channels, and they must fit the 1 MiB response limit.
/// Bounded event evidence per channel; exhaustion is never inferred at this cap. pub const MAX_THREAD_SUMMARIES: usize = 25;
/// Latest-probe event budget per channel, before eligibility filtering.
pub const MAX_CHANNEL_SCAN: usize = 256; pub const MAX_CHANNEL_SCAN: usize = 256;
/// Unread-window work budget per channel, before eligibility/ancestry joins. /// Unread-window work budget per channel, before eligibility/ancestry joins.
pub const MAX_UNREAD_SCAN: usize = 4096; pub const MAX_UNREAD_SCAN: usize = 4096;
/// Conversation kinds eligible for ordinary unread state (not edits/reactions). /// Conversation kinds eligible for ordinary unread state (not edits/reactions).
pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003]; pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003];
/// An honest aggregate: capped evidence cannot establish exact zero. /// One joined channel's timeline read state. A channel and each of its
#[derive(Clone, Debug, Serialize)] /// threads keep independent read positions.
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ReadCount {
/// Exhausted the authoritative candidate set.
Exact {
/// Total within the tracking horizon.
value: u32,
},
/// Incomplete evidence establishes no positive lower bound. No numeric value.
Unknown,
/// More evidence exists or ancestry/membership could not be proved.
AtLeast {
/// Proven lower bound, not a fabricated badge cap.
value: u32,
},
}
impl ReadCount {
pub(super) fn from_evidence(value: u32, complete: bool) -> Self {
if complete {
Self::Exact { value }
} else if value == 0 {
Self::Unknown
} else {
Self::AtLeast { value }
}
}
}
/// One joined-channel summary, not a second conversation/history API.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ChannelReadSummary { pub struct ChannelReadSummary {
/// Joined channel UUID. /// Joined channel UUID.
pub channel_id: Uuid, pub channel_id: Uuid,
/// Existing channel name. /// Whether an unread top-level message was found. Thread replies count
pub name: String, /// only on their thread row.
/// Existing channel type. pub unread: bool,
pub channel_type: String, /// Unread top-level messages directed at the actor: every one in a DM,
/// Archived channels stay in the roster; presentation remains client-owned. /// otherwise those that tag the actor with `p` or carry `broadcast=1`.
pub archived: bool, pub mentions: u32,
/// Existing DM visibility preference (not an authorization decision). /// The anchor of the timeline's frontier: the marked message that arrived
pub hidden: bool, /// last. None until the timeline is marked.
/// Unread messages that count: every top-level message, and a reply only pub read_through_id: Option<String>,
/// when it has a [`Reason`]. Other replies are not unread at all. /// Last eligible top-level message to arrive, whatever its author or read
pub unread: ReadCount, /// progress: marking the timeline through it reads the timeline.
/// The unread subset with a [`Reason`]: everything but ordinary top-level pub latest_id: Option<String>,
/// messages. This is not Desktop notification eligibility: follows and
/// mutes do not change this count.
pub attention: ReadCount,
/// Last eligible nondeleted event to arrive, inside the unread horizon when
/// any is, whatever its author or read progress: marking through it reads
/// the row.
/// None proves absence only when latest_message_complete is true.
pub latest_message_id: Option<String>,
/// 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<i64>,
/// 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. /// Threads with unread replies, newest unread reply first.
pub threads: ThreadSummaries, pub threads: Vec<ThreadReadSummary>,
} }
/// A bounded, ordered list of unread threads within one channel row. /// One thread's read state within a channel row. No conversation bytes.
#[derive(Debug, Serialize)]
pub struct ThreadSummaries {
/// At most MAX_THREAD_SUMMARIES, by latest_reply_at DESC then root_id ASC.
pub items: Vec<ThreadReadSummary>,
/// True only when evidence was exhausted and no thread was omitted.
pub complete: bool,
}
/// Unread replies in one canonical thread; every one has a [`Reason`]. No
/// conversation bytes.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ThreadReadSummary { pub struct ThreadReadSummary {
/// Canonical thread-root event ID. /// Canonical thread-root event ID.
pub root_id: String, pub root_id: String,
/// Unread replies in this thread (same definition as the row count). /// Whether an unread reply that counts was found.
pub unread: ReadCount, pub unread: bool,
/// Last observed unread reply to arrive: marking through it reads the thread. /// Unread replies that count: directed at the actor, or in one of the
pub latest_reply_id: String, /// actor's conversations.
/// Author time (Unix seconds) of latest_reply_id. pub mentions: u32,
pub latest_reply_at: i64, /// The anchor of the thread's frontier: the marked message that arrived
/// last. None until the thread is marked.
pub read_through_id: Option<String>,
/// Last counted reply to arrive: marking through it reads the thread.
pub latest_id: String,
} }
/// A bounded roster page, with no cross-page snapshot or removal inference. /// A bounded roster page, with no cross-page snapshot or removal inference.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct SidebarPage { pub struct SidebarPage {
/// Effective read-state lifecycle for this response.
pub account: ReadAccount,
/// Joined channels only, never every accessible public channel. /// Joined channels only, never every accessible public channel.
pub channels: Vec<ChannelReadSummary>, pub channels: Vec<ChannelReadSummary>,
/// Exclusive UUID roster cursor. None means this roster scan exhausted. /// Exclusive UUID roster cursor. None means this roster scan exhausted.
pub next_cursor: Option<Uuid>, pub next_cursor: Option<Uuid>,
} }
/// 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<String>,
}
/// Why an unread message is directed at the actor: the first that holds.
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Reason {
/// Its channel is a DM.
Direct,
/// It tags the actor with `p`.
Mention,
/// It replies to a message the actor wrote, or to one the actor also
/// replied to, in the same channel. Only live eligible messages qualify.
Conversation,
/// It carries `broadcast=1`.
Broadcast,
}
/// Read progress and eligibility for one concrete message, not a public receipt.
#[derive(Debug, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum MessageReadState {
/// Missing, inaccessible, or outside the requested context. No existence oracle.
Unavailable,
/// Evidence cannot safely establish ancestry, eligibility or membership.
Unknown,
/// Not unread: own, deleted, auxiliary, outside the horizon, or a reply
/// with no [`Reason`].
NotCounted,
/// Covered by this context's frontier.
Read,
/// Counts, and is beyond this context's frontier.
Unread {
/// Null only for an ordinary top-level message. A broadcast reply whose
/// membership is undecided reports `broadcast`.
reason: Option<Reason>,
},
}
/// An explicit message result, in request order.
#[derive(Debug, Serialize)]
pub struct ContextMessage {
/// Requested ID, not an independently disclosed event ID.
pub message_id: String,
/// Actor-private state within the requested context.
#[serde(flatten)]
pub state: MessageReadState,
}
/// A context result. Denied and missing resources share an indistinguishable shape.
#[derive(Debug, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ContextState {
/// Missing or inaccessible context.
Unavailable,
/// Canonical context could not be proved.
Unknown,
/// Context authority at the response snapshot.
Available {
/// Bounded explicit selectors, in request order.
messages: Vec<ContextMessage>,
},
}
/// Actor-private bounded context response; no cross-request snapshot guarantee.
#[derive(Debug, Serialize)]
pub struct ContextPage {
/// Effective read-state lifecycle for this response.
pub account: ReadAccount,
/// One result per requested context, in request order.
pub contexts: Vec<ContextState>,
}

View File

@@ -121,32 +121,10 @@ impl World {
.remove(0) .remove(0)
} }
/// The wire state of one message in the channel timeline or `root`'s thread. /// The row's counts and thread rows: what the sidebar says counts.
async fn state( async fn counts(&self, channel: Uuid) -> Value {
&self, let row = self.row(channel).await;
channel: Uuid, json!({"unread":row.unread,"mentions":row.mentions,"threads":wire(&row.threads)})
root: Option<&nostr::Event>,
message: &nostr::Event,
) -> Value {
let page = self
.db
.personal_read_contexts(
self.community,
&self.actor.public_key(),
DEFAULT_RETENTION_SECONDS,
&[ContextQuery {
target: ReadTarget {
channel_id: channel,
root_id: root.map(|root| root.id.to_hex()),
},
message_ids: vec![message.id.to_hex()],
}],
)
.await
.unwrap();
let mut state = wire(&page)["contexts"][0]["messages"][0].take();
state.as_object_mut().unwrap().remove("message_id");
state
} }
fn mention(&self) -> Tag { fn mention(&self) -> Tag {
@@ -158,26 +136,20 @@ fn wire<T: serde::Serialize>(value: &T) -> Value {
serde_json::to_value(value).unwrap() serde_json::to_value(value).unwrap()
} }
fn exact(value: u32) -> Value { /// Expected `counts`: whether a top-level message is unread, how many of those
json!({"status":"exact","value":value}) /// are directed, and the listed thread rows.
} fn counts(unread: bool, mentions: u32, threads: Vec<Value>) -> Value {
json!({"unread":unread,"mentions":mentions,"threads":threads})
/// Unread with a reason; `Value::Null` for an ordinary top-level message.
fn unread(reason: impl Into<Value>) -> Value {
json!({"status":"unread","reason":reason.into()})
}
fn status(status: &str) -> Value {
json!({ "status": status })
} }
fn broadcast() -> Tag { fn broadcast() -> Tag {
Tag::parse(["broadcast", "1"]).unwrap() Tag::parse(["broadcast", "1"]).unwrap()
} }
fn item(root: &nostr::Event, unread: u32, latest: &nostr::Event) -> Value { /// A listed, never-marked thread with `mentions` counted unread replies.
json!({"root_id":root.id.to_hex(),"unread":exact(unread), fn item(root: &nostr::Event, mentions: u32, latest: &nostr::Event) -> Value {
"latest_reply_id":latest.id.to_hex(),"latest_reply_at":latest.created_at.as_secs()}) json!({"root_id":root.id.to_hex(),"unread":true,"mentions":mentions,
"read_through_id":null,"latest_id":latest.id.to_hex()})
} }
#[tokio::test] #[tokio::test]
@@ -197,22 +169,11 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() {
let nested = w let nested = w
.reply(c, &w.peer, &root, Some(&answer), w.now + 2, vec![]) .reply(c, &w.peer, &root, Some(&answer), w.now + 2, vec![])
.await; .await;
assert_eq!(w.state(c, None, &root).await, status("not_counted")); // Only `answer` counts. The newer reply that does not count is not the
// thread's latest.
assert_eq!( assert_eq!(
w.state(c, Some(&root), &answer).await, w.counts(c).await,
unread("conversation") counts(false, 0, vec![item(&root, 1, &answer)])
);
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 // Joining the nested conversation makes its earlier reply count. The
@@ -220,14 +181,8 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() {
w.reply(c, &w.actor, &root, Some(&answer), w.now + 3, vec![]) w.reply(c, &w.actor, &root, Some(&answer), w.now + 3, vec![])
.await; .await;
assert_eq!( assert_eq!(
w.state(c, Some(&root), &nested).await, w.counts(c).await,
unread("conversation") counts(false, 0, vec![item(&root, 2, &nested)])
);
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. // A peer's thread. The actor replied to one of two parents, long ago.
@@ -240,45 +195,24 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() {
let sibling = w let sibling = w
.reply(c, &w.peer, &root, Some(&joined), w.now + 1, vec![]) .reply(c, &w.peer, &root, Some(&joined), w.now + 1, vec![])
.await; .await;
let elsewhere = w w.reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![])
.reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![])
.await; .await;
assert_eq!( assert_eq!(
w.state(c, Some(&root), &sibling).await, w.counts(c).await,
unread("conversation") counts(false, 0, vec![item(&root, 1, &sibling)])
);
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 conversation the actor never joined: one unread top-level message, and
// a reply that is not unread even in its own thread. // a reply that is not unread even in its own thread.
let c = w.channel().await; let c = w.channel().await;
let root = w.post(c, &w.peer, w.now, vec![]).await; let root = w.post(c, &w.peer, w.now, vec![]).await;
let reply = w w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
.await; .await;
assert_eq!(w.state(c, None, &root).await, unread(Value::Null)); assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
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. // A reply whose parent was never recorded is undecided: left out.
let orphan = w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await; w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await;
assert_eq!(w.state(c, Some(&root), &orphan).await, status("unknown")); assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
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] #[tokio::test]
@@ -288,29 +222,12 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
let c = w.channel().await; let c = w.channel().await;
let root = w.post(c, &w.peer, w.now, vec![]).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 parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await;
let own = w w.reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![])
.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; .await;
for (at, tags) in [(2, vec![w.mention()]), (3, vec![broadcast()])] {
w.reply(c, &w.peer, &root, Some(&parent), w.now + at, tags)
.await;
}
let both = w let both = w
.reply( .reply(
c, c,
@@ -321,28 +238,16 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
vec![broadcast(), w.mention()], vec![broadcast(), w.mention()],
) )
.await; .await;
assert_eq!(w.state(c, Some(&root), &own).await, status("not_counted")); // The root is an undirected top-level message. The thread counts the
assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention")); // actor's sibling `parent` and the three directed replies, not the
assert_eq!(w.state(c, Some(&root), &shout).await, unread("broadcast")); // actor's own.
assert_eq!(w.state(c, Some(&root), &both).await, unread("mention")); let expected = counts(true, 0, vec![item(&root, 4, &both)]);
// The root, the actor's sibling `parent`, and the three directed replies. assert_eq!(w.counts(c).await, expected);
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. // Joining the directed replies' conversation changes no count.
w.reply(c, &w.actor, &root, Some(&parent), w.now + 5, vec![]) w.reply(c, &w.actor, &root, Some(&parent), w.now + 5, vec![])
.await; .await;
assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention")); assert_eq!(w.counts(c).await, expected);
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. // In a DM every peer message is direct, tagged or not, joined or not.
let dm = w.channel().await; let dm = w.channel().await;
@@ -359,33 +264,34 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
let reply = w let reply = w
.reply(dm, &w.peer, &root, Some(&parent), w.now + 1, vec![]) .reply(dm, &w.peer, &root, Some(&parent), w.now + 1, vec![])
.await; .await;
assert_eq!(w.state(dm, None, &root).await, unread("direct")); assert_eq!(
assert_eq!(w.state(dm, Some(&root), &reply).await, unread("direct")); w.counts(dm).await,
let row = w.row(dm).await; counts(true, 1, vec![item(&root, 2, &reply)])
assert_eq!(wire(&row.unread), exact(3)); );
assert_eq!(wire(&row.attention), exact(3));
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn eligibility_then_the_read_frontier_are_reported_before_membership() { async fn eligibility_frontier_and_membership_each_leave_replies_out() {
let w = World::new().await; let w = World::new().await;
let c = w.channel().await; let c = w.channel().await;
let root = w.post(c, &w.peer, w.now, vec![]).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 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); 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. // Peer replies to a parent the actor neither wrote nor replied to.
let outside = reply(&w.peer, 1, vec![]).await; reply(&w.peer, 1, vec![]).await;
let deleted = reply(&w.peer, 2, vec![]).await; let deleted = reply(&w.peer, 2, vec![w.mention()]).await;
w.delete(&deleted).await; w.delete(&deleted).await;
let own = w w.reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![])
.reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![])
.await; .await;
let anchor = reply(&w.peer, 4, vec![w.mention()]).await; let anchor = reply(&w.peer, 4, vec![w.mention()]).await;
let later = reply(&w.peer, 5, vec![]).await; // Undirected, outside the actor's conversations: never counts.
reply(&w.peer, 5, vec![]).await;
// `parent` (a reply to the root, which the actor answered) and `anchor`
// count: not the deleted, own or undirected replies.
assert_eq!( assert_eq!(
w.state(c, Some(&root), &outside).await, w.counts(c).await,
status("not_counted") counts(true, 0, vec![item(&root, 2, &anchor)])
); );
assert_eq!( assert_eq!(
@@ -404,15 +310,14 @@ async fn eligibility_then_the_read_frontier_are_reported_before_membership() {
.unwrap(), .unwrap(),
IntentOutcome::Applied IntentOutcome::Applied
); );
for (message, expected) in [ assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
(&outside, "read"), // A later directed reply is unread past the thread's anchor; the channel
(&deleted, "not_counted"), // timeline was never marked.
(&own, "not_counted"), let after = reply(&w.peer, 6, vec![w.mention()]).await;
(&anchor, "read"), let mut thread = item(&root, 1, &after);
(&later, "not_counted"), thread["read_through_id"] = json!(anchor.id.to_hex());
] { assert_eq!(w.counts(c).await, counts(true, 0, vec![thread]));
assert_eq!(w.state(c, Some(&root), message).await, status(expected)); assert_eq!(w.row(c).await.read_through_id, None);
}
} }
#[tokio::test] #[tokio::test]
@@ -425,11 +330,11 @@ async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() {
// The actor wrote the parent and never replied under it. // The actor wrote the parent and never replied under it.
let wrote = reply(&w.actor, &root, 0).await; let wrote = reply(&w.actor, &root, 0).await;
let to_wrote = reply(&w.peer, &wrote, 10).await; reply(&w.peer, &wrote, 10).await;
// The actor's only reply to a peer's parent. // The actor's only reply to a peer's parent.
let once = reply(&w.peer, &root, 0).await; let once = reply(&w.peer, &root, 0).await;
let only = reply(&w.actor, &once, 1).await; let only = reply(&w.actor, &once, 1).await;
let to_once = reply(&w.peer, &once, 11).await; reply(&w.peer, &once, 11).await;
// Two replies by the actor to a peer's parent. // Two replies by the actor to a peer's parent.
let twice = reply(&w.peer, &root, 0).await; let twice = reply(&w.peer, &root, 0).await;
let first = reply(&w.actor, &twice, 1).await; let first = reply(&w.actor, &twice, 1).await;
@@ -440,42 +345,33 @@ async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() {
let under = reply(&w.actor, &both, 1).await; let under = reply(&w.actor, &both, 1).await;
let to_both = reply(&w.peer, &both, 13).await; let to_both = reply(&w.peer, &both, 13).await;
// Whether each counts: `unread` in a conversation, or else `not_counted`. // How many replies the thread counts, the latest of them `to_both` until
let counted = || async { // it stops counting.
let mut counted = Vec::new(); let w = &w;
for message in [&to_wrote, &to_once, &to_twice, &to_both] { let thread = |mentions: u32| async move {
let state = w.state(c, Some(&root), message).await; let row = w.row(c).await;
let yes = state == unread("conversation"); assert!(!row.unread, "the root predates the horizon");
assert!(yes || state == status("not_counted"), "{state}"); assert_eq!(row.threads.len(), 1);
counted.push(yes); assert_eq!(row.threads[0].mentions, mentions);
} row.threads[0].latest_id.clone()
counted
}; };
let (yes, no) = (true, false); // The four peer replies to the actor's conversations count. So do `once`
// `once` and `twice` are peer replies to the root, which the actor has // and `twice`: peer replies to the root, which the actor has replied to
// replied to (`wrote`, `both`), so they count as well. // (`wrote`, `both`).
assert_eq!(counted().await, [yes, yes, yes, yes]); assert_eq!(thread(6).await, to_both.id.to_hex());
assert_eq!(wire(&w.row(c).await.unread), exact(6));
// Each deletion changes only its own parent's conversation. // Each deletion changes only its own parent's conversation.
w.delete(&wrote).await; w.delete(&wrote).await;
assert_eq!(counted().await, [no, yes, yes, yes]); assert_eq!(thread(5).await, to_both.id.to_hex());
w.delete(&only).await; w.delete(&only).await;
assert_eq!(counted().await, [no, no, yes, yes]); assert_eq!(thread(4).await, to_both.id.to_hex());
w.delete(&first).await; w.delete(&first).await;
assert_eq!(counted().await, [no, no, yes, yes]); assert_eq!(thread(4).await, to_both.id.to_hex());
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. // With `wrote` and `both` gone the actor has no reply to the root either.
let row = w.row(c).await; w.delete(&both).await;
assert_eq!(wire(&row.unread), exact(1)); assert_eq!(thread(2).await, to_both.id.to_hex());
assert_eq!( w.delete(&under).await;
wire(&row.threads), assert_eq!(thread(1).await, to_twice.id.to_hex());
json!({"items":[item(&root, 1, &to_twice)],"complete":true})
);
} }
#[tokio::test] #[tokio::test]
@@ -492,32 +388,21 @@ async fn membership_is_scoped_to_the_replys_own_channel() {
let in_a = w let in_a = w
.reply(a, &w.peer, &theirs, Some(&theirs), w.now, vec![]) .reply(a, &w.peer, &theirs, Some(&theirs), w.now, vec![])
.await; .await;
assert_eq!( assert_eq!(w.counts(a).await, counts(false, 0, vec![]));
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. // 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; let mine = w.post(a, &w.actor, old, vec![]).await;
w.reply(b, &w.peer, &mine, Some(&mine), w.now, vec![]).await; w.reply(b, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
let row = w.row(b).await; assert_eq!(w.counts(b).await, counts(false, 0, vec![]));
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. // 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![]) w.reply(a, &w.actor, &theirs, Some(&theirs), old, vec![])
.await; .await;
let to_mine = w.reply(a, &w.peer, &mine, Some(&mine), w.now, vec![]).await; let to_mine = w.reply(a, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
assert_eq!( // Equal reply times: root ID order.
w.state(a, Some(&theirs), &in_a).await, let mut threads = [item(&theirs, 1, &in_a), item(&mine, 1, &to_mine)];
unread("conversation") threads.sort_by(|x, y| x["root_id"].as_str().cmp(&y["root_id"].as_str()));
); assert_eq!(w.counts(a).await, counts(false, 0, threads.to_vec()));
assert_eq!(
w.state(a, Some(&mine), &to_mine).await,
unread("conversation")
);
assert_eq!(wire(&w.row(a).await.unread), exact(2));
} }
#[tokio::test] #[tokio::test]
@@ -536,13 +421,9 @@ async fn membership_is_exact_behind_a_busy_parent() {
.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) .reply(c, &w.peer, &root, Some(&root), w.now + i, vec![])
.await; .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!( assert_eq!(
wire(&row.threads), w.counts(c).await,
json!({"items":[item(&root, 258, &last)],"complete":true}) counts(false, 0, vec![item(&root, 258, &last)])
); );
} }
@@ -552,67 +433,52 @@ async fn threads_are_capped_after_replies_that_do_not_count_are_removed() {
let w = World::new().await; let w = World::new().await;
let c = w.channel().await; let c = w.channel().await;
let old = w.now - 40 * DAY; let old = w.now - 40 * DAY;
// The actor's thread has the oldest unread reply. Five unjoined threads // The actor's thread has the oldest unread reply. A cap's worth of
// are newer and would fill the list if the cap came first. // 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 mine = w.post(c, &w.actor, old, vec![]).await;
let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await; let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
for i in 1..=5 { for i in 1..=MAX_THREAD_SUMMARIES as u64 {
let root = w.post(c, &w.peer, old, vec![]).await; let root = w.post(c, &w.peer, old, vec![]).await;
w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![])
.await; .await;
} }
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(1));
assert_eq!( assert_eq!(
wire(&row.threads), w.counts(c).await,
json!({"items":[item(&mine, 1, &answer)],"complete":true}) counts(false, 0, vec![item(&mine, 1, &answer)])
); );
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn the_parent_budget_leaves_replies_undecided_and_fabricates_no_absence() { async fn the_parent_budget_leaves_undecided_replies_out() {
let w = World::new().await; let w = World::new().await;
let c = w.channel().await; let c = w.channel().await;
// The actor is in the first conversation. Whether it is inside the budget // The actor is in the first conversation. Whether it is inside the budget
// depends on ID order, so only the bounds are asserted. // depends on ID order, so only the bounds are asserted.
let mut mine = None;
for i in 0..1025 { for i in 0..1025 {
let author = if i == 0 { &w.actor } else { &w.peer }; let author = if i == 0 { &w.actor } else { &w.peer };
let root = w.post(c, author, w.now, vec![]).await; let root = w.post(c, author, w.now, vec![]).await;
w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) let reply = w
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
.await; .await;
if i == 1023 { if i == 0 {
// 1024 parents fit: 1023 peer roots, and the reply to the actor's. mine = Some(item(&root, 1, &reply));
// The independent SQL deadline may still withhold the answer. }
let row = w.row(c).await; // 1024 parents fit: 1023 peer roots, and the reply to the actor's.
// The independent SQL deadline may still withhold the answer. Past
// 1024, one reply is undecided. An undecided reply is left out.
if i >= 1023 {
let counted = w.counts(c).await;
assert!( assert!(
[exact(1024), json!({"status":"at_least","value":1023})] [vec![], vec![mine.clone().unwrap()]]
.contains(&wire(&row.unread)), .map(|threads| counts(true, 0, threads))
"{:?}", .contains(&counted),
row.unread "{i}: {counted}"
);
assert!(
[exact(1), status("unknown")].contains(&wire(&row.attention)),
"{:?}",
row.attention
); );
} }
} }
// 1025 parents do not: one reply is undecided, so nothing is exact.
let row = w.row(c).await;
assert!(
[1024, 1025]
.map(|value| json!({"status":"at_least","value":value}))
.contains(&wire(&row.unread)),
"{:?}",
row.unread
);
assert!(
[json!({"status":"at_least","value":1}), status("unknown")].contains(&wire(&row.attention)),
"{:?}",
row.attention
);
assert!(!row.threads.complete);
} }
#[tokio::test] #[tokio::test]
@@ -621,8 +487,7 @@ async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction(
let w = World::new().await; let w = World::new().await;
let c = w.channel().await; let c = w.channel().await;
let root = w.post(c, &w.peer, w.now, vec![]).await; let root = w.post(c, &w.peer, w.now, vec![]).await;
let reply = w w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
.await; .await;
let mut held = w.pool.begin().await.unwrap(); let mut held = w.pool.begin().await.unwrap();
// Force the real resolver to time out, then check that its outer snapshot // Force the real resolver to time out, then check that its outer snapshot
@@ -668,5 +533,5 @@ async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction(
} }
reader.rollback().await.unwrap(); reader.rollback().await.unwrap();
held.rollback().await.unwrap(); held.rollback().await.unwrap();
assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted")); assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
//! Bounded evidence projection. The cap limits evidence, not the definition of //! 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 super::{model::*, participation, writes};
use buzz_core::CommunityId; use buzz_core::CommunityId;
@@ -81,22 +81,19 @@ impl Db {
// The inner event LIMIT is deliberately before eligibility filtering. // The inner event LIMIT is deliberately before eligibility filtering.
// This bounds rows/joins even with long deleted or self-authored runs. // This bounds rows/joins even with long deleted or self-authored runs.
// Aggregate equivalent eligible evidence before transfer. Multiplicity // Aggregate equivalent eligible evidence before transfer. Multiplicity
// preserves counts; raw scan count (not group count) proves exhaustion. // preserves counts.
// 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. // Validate relevant tag parts before compacting directed/ancestry facts.
// PostgreSQL scalar "p" ->> 0 is "p": the type check must reject it too. // PostgreSQL scalar "p" ->> 0 is "p": the type check must reject it too.
// Canonical timeline roots share an empty (present) root sentinel. // Canonical timeline roots share an empty (present) root sentinel.
// Tags are bounded before transfer; oversized/corrupt evidence stays // Tags are bounded before transfer; oversized/corrupt evidence is
// unknown, never falsely top-level/unmentioned/read. // skipped, never falsely top-level/unmentioned/read.
// Latest comes from the unread scan, so its ID arrives no earlier than // Latest comes from the unread scan, so its ID arrives no earlier than
// anything counted; the shallow probe answers only when the horizon // anything counted; the shallow probe answers only when the horizon
// holds no message. Both are newest-first by author time, so the deeper // holds no top-level message. Thread replies are never the timeline's
// one finds the same greatest author time whenever the probe finds any. // latest: a timeline mark cannot use one as its anchor.
let rows = sqlx::query( let rows = sqlx::query(
r#"WITH roster AS MATERIALIZED ( r#"WITH roster AS MATERIALIZED (
SELECT c.id,c.name,c.channel_type::text AS channel_type, SELECT c.id,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 FROM channel_members cm JOIN channels c
ON c.community_id=cm.community_id AND c.id=cm.channel_id 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 WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL
@@ -104,23 +101,27 @@ impl Db {
AND ($9::uuid[] IS NULL OR c.id=ANY($9)) AND ($9::uuid[] IS NULL OR c.id=ANY($9))
ORDER BY c.id LIMIT $4 ORDER BY c.id LIMIT $4
) )
SELECT r.*, COALESCE(e.latest_message_id, latest.latest_message_id) AS latest_message_id, SELECT r.*, COALESCE(e.latest_id, latest.latest_id) AS latest_id,
COALESCE(e.latest_message_at, latest.latest_message_at) AS latest_message_at, encode(cf.through_message_id,'hex') AS read_through_id,
(COALESCE(e.latest_message_id, latest.latest_message_id) IS NOT NULL COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r
OR latest.candidates <= $5-1) AS latest_message_complete,
e.scanned,COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
WITH candidates AS MATERIALIZED ( WITH candidates AS MATERIALIZED (
SELECT id,created_at,received_at,kind,deleted_at FROM events SELECT id,created_at,received_at,kind,deleted_at,tags FROM events
WHERE community_id=$1 AND channel_id=r.id WHERE community_id=$1 AND channel_id=r.id
ORDER BY created_at DESC,id LIMIT $5 ORDER BY created_at DESC,id LIMIT $5
) )
SELECT count(*) AS candidates, SELECT (array_agg(encode(c.id,'hex') ORDER BY c.received_at DESC,c.id)
(array_agg(encode(id,'hex') ORDER BY received_at DESC,id) FILTER (WHERE c.kind=ANY($6) AND c.deleted_at IS NULL
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id, AND NOT COALESCE(tm.root_event_id<>c.id,false)
(array_agg(extract(epoch FROM created_at)::bigint ORDER BY created_at DESC,id) -- Same unresolved-reply test as `threaded` below.
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_at AND NOT (tm.root_event_id IS NULL AND jsonb_typeof(c.tags)='array'
FROM candidates AND EXISTS (SELECT 1 FROM jsonb_array_elements(c.tags) t(tag)
WHERE jsonb_typeof(tag)='array' AND tag->>0='e'
AND tag->>3='reply'
AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'))
))[1] AS latest_id
FROM candidates c LEFT JOIN thread_metadata tm ON tm.community_id=$1
AND tm.channel_id=r.id AND tm.event_created_at=c.created_at AND tm.event_id=c.id
) latest ON true ) latest ON true
LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2 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 AND cf.channel_id=r.id AND cf.root_id=''::bytea
@@ -129,29 +130,40 @@ impl Db {
SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags 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 FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7
ORDER BY created_at DESC,id LIMIT $8 ORDER BY created_at DESC,id LIMIT $8
), classified AS ( ), threaded AS MATERIALIZED (
SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent, 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(tm.root_event_id<>e.id,false) AS is_reply,
-- Every frontier is floored at the account's start. -- A NIP-10 reply marker without canonical ancestry: neither
COALESCE(e.received_at <= GREATEST(COALESCE($10::timestamptz,'infinity'), -- counted nor a timeline anchor, since a mark cannot validate
CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id -- it. Parity-tested against the shared NIP-10 parser.
THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp) tm.root_event_id IS NULL AND jsonb_typeof(e.tags)='array'
ELSE cf.through_timestamp END),false) AS covered AND EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) t(tag)
FROM (SELECT * FROM candidates ORDER BY created_at DESC,id LIMIT $8-1) e WHERE jsonb_typeof(tag)='array' AND tag->>0='e'
AND tag->>3='reply'
AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$')
AS unresolved
FROM candidates e
LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id 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 AND tm.event_created_at=e.created_at AND tm.event_id=e.id
WHERE e.kind=ANY($6) AND e.deleted_at IS NULL
), classified AS (
SELECT e.*,
-- Every frontier is floored at the account's start.
COALESCE(e.received_at <= GREATEST(COALESCE($10::timestamptz,'infinity'),
CASE WHEN e.is_reply THEN tf.through_timestamp
ELSE cf.through_timestamp END),false) AS covered,
encode(tf.through_message_id,'hex') AS read_through_id
FROM threaded e
LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 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 AND tf.channel_id=r.id AND tf.root_id=e.root AND e.is_reply
WHERE e.kind=ANY($6) AND e.pubkey<>$2 AND e.deleted_at IS NULL WHERE e.pubkey<>$2 AND NOT e.unresolved
), grouped AS ( ), grouped AS (
SELECT CASE WHEN root IS NULL THEN NULL SELECT CASE WHEN root IS NULL THEN NULL
WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root, WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root,
CASE WHEN is_reply THEN encode(parent,'hex') END AS parent, CASE WHEN is_reply THEN encode(parent,'hex') END AS parent,
is_reply, covered, is_reply, covered, read_through_id,
-- ->>0 also selects scalar "p"/"e": reject nonarrays first. -- ->>0 also selects scalar "p"/"e": reject nonarrays first.
-- C collation matches Rust's ASCII case/hex rules. Reply -- C collation matches Rust's ASCII case rules.
-- markers matter only without canonical ancestry; otherwise
-- metadata, not tag spelling, owns the context.
CASE WHEN octet_length(tags::text)<=8192 CASE WHEN octet_length(tags::text)<=8192
AND jsonb_typeof(tags)='array' THEN AND jsonb_typeof(tags)='array' THEN
(SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array' (SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array'
@@ -159,10 +171,7 @@ impl Db {
THEN NULL ELSE jsonb_build_object( THEN NULL ELSE jsonb_build_object(
'directed',COALESCE(bool_or( 'directed',COALESCE(bool_or(
(tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex')) (tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex'))
OR (tag->>0='broadcast' AND tag->>1='1')),false), OR (tag->>0='broadcast' AND tag->>1='1')),false)) END
'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) FROM jsonb_array_elements(tags) t(tag)
WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts, WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts,
count(*) AS n, count(*) AS n,
@@ -172,29 +181,24 @@ impl Db {
ORDER BY received_at DESC,id))[1] AS newest_at ORDER BY received_at DESC,id))[1] AS newest_at
FROM classified FROM classified
WHERE NOT covered OR root IS NULL WHERE NOT covered OR root IS NULL
GROUP BY 1,2,3,4,5 GROUP BY 1,2,3,4,5,6
), scan AS (
SELECT count(*) AS scanned,
(array_agg(encode(id,'hex') ORDER BY received_at DESC,id)
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id,
max(extract(epoch FROM created_at)::bigint)
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL) AS latest_message_at
FROM candidates
) )
SELECT scan.*, (SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence FROM scan SELECT (array_agg(encode(id,'hex') ORDER BY received_at DESC,id)
FILTER (WHERE NOT is_reply AND NOT unresolved))[1] AS latest_id,
(SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence
FROM threaded
) e ON true ORDER BY r.id"#, ) e ON true ORDER BY r.id"#,
).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after) ).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after)
.bind((limit+1) as i64).bind((MAX_CHANNEL_SCAN+1) as i64) .bind((limit+1) as i64).bind(MAX_CHANNEL_SCAN as i64)
.bind(ELIGIBLE_KINDS.as_slice()) .bind(ELIGIBLE_KINDS.as_slice())
.bind(DateTime::from_timestamp_millis(account.cutoff_ms) .bind(DateTime::from_timestamp_millis(account.cutoff_ms)
.ok_or_else(|| DbError::InvalidData("invalid unread cutoff".into()))?) .ok_or_else(|| DbError::InvalidData("invalid unread cutoff".into()))?)
.bind((MAX_UNREAD_SCAN+1) as i64) .bind(MAX_UNREAD_SCAN as i64)
.bind(only) .bind(only)
.bind(account.started_at) .bind(account.started_at)
.fetch_all(&mut *tx).await?; .fetch_all(&mut *tx).await?;
let has_more = rows.len() > limit; let has_more = rows.len() > limit;
// An unstarted account has read every arrival, scanned or not, so its // An unstarted account has read every arrival, so nothing is unread.
// counts are exactly zero whatever the scan cap left unexamined.
let started = account.started_at.is_some(); let started = account.started_at.is_some();
let mut channels = Vec::new(); let mut channels = Vec::new();
let mut pending = Vec::new(); let mut pending = Vec::new();
@@ -204,11 +208,9 @@ impl Db {
.as_array() .as_array()
.ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?; .ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?;
let evidence = if started { evidence.as_slice() } else { &[] }; let evidence = if started { evidence.as_slice() } else { &[] };
let complete = !started || row.try_get::<i64, _>("scanned")? <= MAX_UNREAD_SCAN as i64;
let channel_type: String = row.try_get("channel_type")?; let channel_type: String = row.try_get("channel_type")?;
let mut unread = 0; let mut unread = false;
let mut attention = 0; let mut mentions = 0;
let mut unread_complete = complete;
let mut threads: HashMap<Vec<u8>, Replies> = HashMap::new(); let mut threads: HashMap<Vec<u8>, Replies> = HashMap::new();
let mut undirected = Vec::new(); let mut undirected = Vec::new();
for e in evidence { for e in evidence {
@@ -217,16 +219,10 @@ impl Db {
.filter(|n| *n <= MAX_UNREAD_SCAN as u64) .filter(|n| *n <= MAX_UNREAD_SCAN as u64)
.ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))? .ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))?
as u32; as u32;
// Unusable tags prove nothing: the message is left out.
let Some(facts) = e["facts"].as_object() else { let Some(facts) = e["facts"].as_object() else {
unread_complete = false;
continue; continue;
}; };
// SQL's bounded ancestry fact is parity-tested against the shared
// NIP-10 parser; no raw tag payload crosses the DB boundary.
if facts.get("reply_marked") == Some(&Value::Bool(true)) && e["root"].is_null() {
unread_complete = false;
continue;
}
if e["covered"] == true { if e["covered"] == true {
continue; continue;
} }
@@ -235,14 +231,13 @@ impl Db {
// Roots are timeline messages; descendants belong exclusively // Roots are timeline messages; descendants belong exclusively
// to their canonical thread. Never inherit the channel prefix. // to their canonical thread. Never inherit the channel prefix.
if e["is_reply"] != true { if e["is_reply"] != true {
unread += n; unread = true;
if directed { if directed {
attention += n; mentions += n;
} }
continue; continue;
} }
let Some(root) = e["root"].as_str().and_then(writes::event_id) else { let Some(root) = e["root"].as_str().and_then(writes::event_id) else {
unread_complete = false;
continue; continue;
}; };
let replies = Replies { let replies = Replies {
@@ -255,6 +250,7 @@ impl Db {
.to_owned(), .to_owned(),
), ),
newest_at: e["newest_at"].as_i64().ok_or_else(invalid_newest)?, newest_at: e["newest_at"].as_i64().ok_or_else(invalid_newest)?,
read_through_id: e["read_through_id"].as_str().map(str::to_owned),
}; };
// A directed reply counts whatever its conversation. Any other // A directed reply counts whatever its conversation. Any other
// reply counts only in one of the actor's conversations. // reply counts only in one of the actor's conversations.
@@ -262,64 +258,38 @@ impl Db {
count(&mut threads, root, replies); count(&mut threads, root, replies);
} else if let Some(parent) = e["parent"].as_str().and_then(writes::event_id) { } else if let Some(parent) = e["parent"].as_str().and_then(writes::event_id) {
undirected.push((root, parent, replies)); undirected.push((root, parent, replies));
} else {
unread_complete = false;
} }
} }
pending.push((threads, undirected, unread, attention, unread_complete)); pending.push((threads, undirected));
channels.push(ChannelReadSummary { channels.push(ChannelReadSummary {
channel_id: row.try_get("id")?, channel_id: row.try_get("id")?,
name: row.try_get("name")?, unread,
channel_type, mentions,
archived: row.try_get("archived")?, read_through_id: row.try_get("read_through_id")?,
hidden: row.try_get("hidden")?, latest_id: row.try_get("latest_id")?,
// Counts and threads wait for conversation membership below. // Threads wait for conversation membership below.
unread: ReadCount::Unknown, threads: Vec::new(),
attention: ReadCount::Unknown,
latest_message_id: row.try_get("latest_message_id")?,
latest_message_at: row.try_get("latest_message_at")?,
latest_message_complete: row.try_get("latest_message_complete")?,
threads: ThreadSummaries {
items: Vec::new(),
complete: false,
},
}); });
} }
let targets: Vec<_> = channels let targets: Vec<_> = channels
.iter() .iter()
.zip(&pending) .zip(&pending)
.flat_map(|(channel, (_, undirected, ..))| { .flat_map(|(channel, (_, undirected))| {
undirected undirected
.iter() .iter()
.map(|(_, parent, _)| (channel.channel_id, parent.clone())) .map(|(_, parent, _)| (channel.channel_id, parent.clone()))
}) })
.collect(); .collect();
let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?;
for (channel, (mut threads, undirected, mut unread, mut attention, mut complete)) in for (channel, (mut threads, undirected)) in channels.iter_mut().zip(pending) {
channels.iter_mut().zip(pending) // Relevance is decided before the thread cap. An undecided
{ // membership leaves the reply out.
// Relevance is decided before counts, previews and the thread cap.
for (root, parent, replies) in undirected { for (root, parent, replies) in undirected {
match members.get(&(channel.channel_id, parent)) { if members.get(&(channel.channel_id, parent)) == Some(&true) {
Some(true) => count(&mut threads, root, replies), count(&mut threads, root, replies);
Some(false) => {}
None => complete = false,
} }
} }
let mut items = Vec::with_capacity(threads.len()); channel.threads = summarize(threads);
for (root, thread) in threads {
unread += thread.n;
attention += thread.n;
items.push(ThreadReadSummary {
root_id: hex::encode(root),
unread: ReadCount::from_evidence(thread.n, complete),
latest_reply_id: thread.newest.1,
latest_reply_at: thread.newest_at,
});
}
channel.unread = ReadCount::from_evidence(unread, complete);
channel.attention = ReadCount::from_evidence(attention, complete);
channel.threads = summarize(items, complete);
} }
let next_cursor = if has_more { let next_cursor = if has_more {
channels.last().map(|c| c.channel_id) channels.last().map(|c| c.channel_id)
@@ -328,7 +298,6 @@ impl Db {
}; };
tx.commit().await?; tx.commit().await?;
Ok(SidebarPage { Ok(SidebarPage {
account,
channels, channels,
next_cursor, next_cursor,
}) })
@@ -343,6 +312,8 @@ struct Replies {
newest: (i64, String), newest: (i64, String),
/// Its author seconds. /// Its author seconds.
newest_at: i64, newest_at: i64,
/// The thread's frontier anchor, shared by every group of the thread.
read_through_id: Option<String>,
} }
/// Add replies that count to their thread. /// Add replies that count to their thread.
@@ -369,20 +340,25 @@ fn invalid_newest() -> DbError {
DbError::InvalidData("invalid thread evidence".into()) DbError::InvalidData("invalid thread evidence".into())
} }
/// Order newest unread reply first (root ID breaks ties) and cap the list. The /// Order newest unread reply first (root ID breaks ties) and cap the list.
/// list is complete only when evidence was exhausted and nothing was omitted. fn summarize(threads: HashMap<Vec<u8>, Replies>) -> Vec<ThreadReadSummary> {
pub(super) fn summarize( let mut threads: Vec<_> = threads.into_iter().collect();
mut items: Vec<ThreadReadSummary>, threads.sort_unstable_by(|(a_root, a), (b_root, b)| {
evidence_complete: bool, b.newest_at
) -> ThreadSummaries { .cmp(&a.newest_at)
items.sort_unstable_by(|a, b| { .then_with(|| a_root.cmp(b_root))
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; threads
items.truncate(MAX_THREAD_SUMMARIES); .into_iter()
ThreadSummaries { items, complete } .take(MAX_THREAD_SUMMARIES)
.map(|(root, thread)| ThreadReadSummary {
root_id: hex::encode(root),
unread: true,
mentions: thread.n,
read_through_id: thread.read_through_id,
latest_id: thread.newest.1,
})
.collect()
} }
/// Read-time horizon and the account's start, in the caller's snapshot. /// Read-time horizon and the account's start, in the caller's snapshot.
@@ -404,7 +380,6 @@ pub(super) async fn read_account(
.await?; .await?;
let cutoff: DateTime<Utc> = row.try_get("cutoff")?; let cutoff: DateTime<Utc> = row.try_get("cutoff")?;
Ok(ReadAccount { Ok(ReadAccount {
retention_seconds,
cutoff_ms: cutoff.timestamp_millis(), cutoff_ms: cutoff.timestamp_millis(),
started_at: row.try_get("started_at")?, started_at: row.try_get("started_at")?,
}) })
@@ -414,42 +389,32 @@ pub(super) async fn read_account(
mod tests { mod tests {
use super::*; use super::*;
fn item(root: u8, at: i64) -> ThreadReadSummary { fn replies(at: i64) -> Replies {
ThreadReadSummary { Replies {
root_id: hex::encode([root; 32]), n: 1,
unread: ReadCount::Exact { value: 1 }, newest: (at, String::new()),
latest_reply_id: hex::encode([root; 32]), newest_at: at,
latest_reply_at: at, read_through_id: None,
} }
} }
#[test] #[test]
fn thread_summaries_order_newest_first_break_ties_by_root_and_cap_at_five() { fn thread_rows_order_newest_first_break_ties_by_root_and_cap_at_twenty_five() {
// Literal contract boundaries deliberately do not derive from the constant. // Literal contract boundaries deliberately do not derive from the constant.
for count in [0_u8, 1, 4, 5, 6, 7] { for count in [0_u8, 1, 24, 25, 26, 27] {
// Descending roots with pairwise-equal times exercise the tie-break. // Pairwise-equal times exercise the tie-break.
let items: Vec<_> = (0..count) let threads = (0..count)
.rev() .map(|i| (vec![i; 32], replies(i64::from(i / 2))))
.map(|i| item(i, i64::from(i / 2)))
.collect(); .collect();
let summaries = summarize(items, true); let roots: Vec<_> = summarize(threads).into_iter().map(|t| t.root_id).collect();
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(); 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))); expected.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let expected: Vec<_> = expected let expected: Vec<_> = expected
.into_iter() .into_iter()
.take(5) .take(25)
.map(|(_, i)| hex::encode([i; 32])) .map(|(_, i)| hex::encode([i; 32]))
.collect(); .collect();
assert_eq!(roots, expected, "count {count}"); 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);
}
} }

View File

@@ -1,39 +1,37 @@
use super::{classification, postgres_tests::fixture, *}; use super::{postgres_tests::fixture, *};
use serde_json::json; 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] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn sidebar_sql_eligibility_matches_selector_classifier() { async fn sidebar_sql_eligibility_follows_kind_author_deletion_and_horizon() {
let (db, pool, community, channel, actor, event) = fixture().await; let (db, pool, community, _, 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 now = chrono::Utc::now().timestamp_millis();
let horizon = i64::from(DEFAULT_RETENTION_SECONDS) * 1000; let horizon = i64::from(DEFAULT_RETENTION_SECONDS) * 1000;
for kind in [9, 40002, 45001, 45003, 1, 7, 39002, 40008] { // Addressed to the actor, so a counted message is also exactly one mention.
let tags = json!([["p", actor.public_key().to_hex()]]);
for (kind, eligible_kind) in [
(9, true),
(40002, true),
(45001, true),
(45003, true),
(1, false),
(7, false),
(39002, false),
(40008, false),
] {
for own in [false, true] { for own in [false, true] {
for deleted in [false, true] { for deleted in [false, true] {
// Now, then one minute inside and one minute outside the horizon. // Now, then one minute inside and one minute outside the horizon.
for age in [0, horizon - 60_000, horizon + 60_000] { for (age, inside) in [
(0, true),
(horizon - 60_000, true),
(horizon + 60_000, false),
] {
let created = now - age; let created = now - age;
sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,created_at=to_timestamp($5::double precision/1000) WHERE community_id=$1") sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,created_at=to_timestamp($5::double precision/1000),tags=$6 WHERE community_id=$1")
.bind(community.as_uuid()).bind(kind) .bind(community.as_uuid()).bind(kind)
.bind(if own { actor.public_key().to_bytes() } else { event.pubkey.to_bytes() }.as_slice()) .bind(if own { actor.public_key().to_bytes() } else { event.pubkey.to_bytes() }.as_slice())
.bind(deleted).bind(created as f64).execute(&pool).await.unwrap(); .bind(deleted).bind(created as f64).bind(&tags).execute(&pool).await.unwrap();
let page = db let page = db
.personal_read_sidebar( .personal_read_sidebar(
community, community,
@@ -44,31 +42,11 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() {
) )
.await .await
.unwrap(); .unwrap();
let expected = u32::from(classification::eligible( let expected = eligible_kind && !own && !deleted && inside;
kind, let row = &page.channels[0];
own,
deleted,
created,
page.account.cutoff_ms,
));
assert!(
matches!(page.channels[0].unread, ReadCount::Exact { value } if value == expected),
"kind={kind} own={own} deleted={deleted} age={age}"
);
// The per-message selector must agree with the aggregate.
let contexts = db
.personal_read_contexts(
community,
&actor.public_key(),
DEFAULT_RETENTION_SECONDS,
&query,
)
.await
.unwrap();
assert_eq!( assert_eq!(
serde_json::to_value(&contexts).unwrap()["contexts"][0]["messages"][0] (row.unread, row.mentions),
["status"], (expected, u32::from(expected)),
["not_counted", "unread"][expected as usize],
"kind={kind} own={own} deleted={deleted} age={age}" "kind={kind} own={own} deleted={deleted} age={age}"
); );
} }
@@ -82,27 +60,25 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() {
async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() { async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() {
let (db, pool, community, _, actor, _) = fixture().await; let (db, pool, community, _, actor, _) = fixture().await;
let actor_hex = actor.public_key().to_hex().to_uppercase(); let actor_hex = actor.public_key().to_hex().to_uppercase();
for (tags, unread, attention) in [ // Unusable tags prove nothing: that message is left out, neither unread
(json!([]), Some(1), Some(0)), // nor a mention.
(json!(["p"]), None, None), for (tags, unread, mentions) in [
(json!(["e"]), None, None), (json!([]), true, 0),
(json!(["broadcast"]), None, None), (json!(["p"]), false, 0),
( (json!(["e"]), false, 0),
json!([["p", actor_hex, "relay", "petname"]]), (json!(["broadcast"]), false, 0),
Some(1), (json!([["p", actor_hex, "relay", "petname"]]), true, 1),
Some(1), (json!([["p", "00".repeat(32)]]), true, 0),
), (json!([["broadcast", "1", "extra"]]), true, 1),
(json!([["p", "00".repeat(32)]]), Some(1), Some(0)), (json!([["broadcast", "0"]]), true, 0),
(json!([["broadcast", "1", "extra"]]), Some(1), Some(1)), (json!([["p", "00".repeat(32), 42]]), false, 0),
(json!([["broadcast", "0"]]), Some(1), Some(0)), (json!([["broadcast", "0", 42]]), false, 0),
(json!([["p", "00".repeat(32), 42]]), None, None), (json!([["e", "00".repeat(32), "", "root", 42]]), false, 0),
(json!([["broadcast", "0", 42]]), None, None), (json!([["p", actor_hex], ["p", "other", 42]]), false, 0),
(json!([["e", "00".repeat(32), "", "root", 42]]), None, None), (json!([["e", "00".repeat(32), "", "reply"]]), false, 0),
(json!([["p", actor_hex], ["p", "other", 42]]), None, None), (json!([["x", 42]]), true, 0),
(json!([["e", "00".repeat(32), "", "reply"]]), None, None), (json!({"p":actor_hex}), false, 0),
(json!([["x", 42]]), Some(1), Some(0)), (json!([["p", "x".repeat(8193)]]), false, 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") sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1")
.bind(community.as_uuid()) .bind(community.as_uuid())
@@ -120,19 +96,12 @@ async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() {
) )
.await .await
.unwrap(); .unwrap();
for (actual, expected) in [ let row = &page.channels[0];
(&page.channels[0].unread, unread), assert_eq!(
(&page.channels[0].attention, attention), (row.unread, row.mentions),
] { (unread, mentions),
assert!( "tags={tags}"
match (actual, expected) { );
(ReadCount::Exact { value }, Some(n)) => *value == n,
(ReadCount::Unknown, None) => true,
_ => false,
},
"tags={tags} actual={actual:?} expected={expected:?}"
);
}
} }
} }
@@ -187,12 +156,12 @@ async fn sidebar_ancestry_fact_matches_shared_nip10_parser() {
) )
.await .await
.unwrap(); .unwrap();
assert!( // A reply marker without recorded ancestry leaves the message out.
if reply { assert_eq!(page.channels[0].unread, !reply, "tags={tags:?}");
matches!(page.channels[0].unread, ReadCount::Unknown) // ...and it is never the timeline's anchor.
} else { assert_eq!(
matches!(page.channels[0].unread, ReadCount::Exact { value: 1 }) page.channels[0].latest_id.is_some(),
}, !reply,
"tags={tags:?}" "tags={tags:?}"
); );
} }
@@ -200,7 +169,7 @@ async fn sidebar_ancestry_fact_matches_shared_nip10_parser() {
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn sidebar_directed_fact_matches_selector_classifier() { async fn sidebar_directed_fact_follows_dm_mention_and_broadcast_rules() {
let (db, pool, community, channel, actor, _) = fixture().await; let (db, pool, community, channel, actor, _) = fixture().await;
let actor_hex = actor.public_key().to_hex(); let actor_hex = actor.public_key().to_hex();
let fullwidth: String = actor_hex let fullwidth: String = actor_hex
@@ -208,18 +177,21 @@ async fn sidebar_directed_fact_matches_selector_classifier() {
.map(|c| if c.is_ascii_alphabetic() { 'A' } else { c }) .map(|c| if c.is_ascii_alphabetic() { 'A' } else { c })
.collect(); .collect();
assert_ne!(fullwidth, actor_hex); assert_ne!(fullwidth, actor_hex);
let cases: Vec<Vec<Vec<String>>> = serde_json::from_value(json!([ // Whether each tag set is directed in a stream; in a DM every one is.
[], let cases = [
[["p", actor_hex]], (json!([]), false),
[["p", actor_hex.to_uppercase()]], (json!([["p", actor_hex]]), true),
[["p", fullwidth]], (json!([["p", actor_hex.to_uppercase()]]), true),
[["p"]], (json!([["p", fullwidth]]), false),
[["broadcast", "1"]], (json!([["p"]]), false),
[["broadcast", "true"]], (json!([["broadcast", "1"]]), true),
[["p", "00".repeat(32)]], (json!([["broadcast", "true"]]), false),
[["broadcast", "0"], ["p", actor_hex.to_uppercase()]] (json!([["p", "00".repeat(32)]]), false),
])) (
.unwrap(); json!([["broadcast", "0"], ["p", actor_hex.to_uppercase()]]),
true,
),
];
for channel_type in ["stream", "dm"] { for channel_type in ["stream", "dm"] {
sqlx::query( sqlx::query(
"UPDATE channels SET channel_type=$3::channel_type WHERE community_id=$1 AND id=$2", "UPDATE channels SET channel_type=$3::channel_type WHERE community_id=$1 AND id=$2",
@@ -230,12 +202,11 @@ async fn sidebar_directed_fact_matches_selector_classifier() {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
for tags in &cases { for (tags, in_stream) in &cases {
let expected = let expected = u32::from(channel_type == "dm" || *in_stream);
u32::from(classification::reason(channel_type, &actor_hex, tags).is_some());
sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1")
.bind(community.as_uuid()) .bind(community.as_uuid())
.bind(json!(tags)) .bind(tags)
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
@@ -249,13 +220,10 @@ async fn sidebar_directed_fact_matches_selector_classifier() {
) )
.await .await
.unwrap(); .unwrap();
assert!(matches!( assert!(page.channels[0].unread);
page.channels[0].unread, assert_eq!(
ReadCount::Exact { value: 1 } page.channels[0].mentions, expected,
)); "channel_type={channel_type} tags={tags}"
assert!(
matches!(page.channels[0].attention, ReadCount::Exact { value } if value == expected),
"channel_type={channel_type} tags={tags:?} expected={expected}"
); );
} }
} }

View File

@@ -1,4 +1,4 @@
//! Whole-channel reads, thread summaries and targeted refresh. //! Thread summaries, anchor validation and targeted refresh.
use super::{postgres_tests::fixture, *}; use super::{postgres_tests::fixture, *};
use crate::{ use crate::{
channel::{ChannelType, ChannelVisibility}, channel::{ChannelType, ChannelVisibility},
@@ -102,105 +102,22 @@ async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent
.unwrap() .unwrap()
} }
fn channel_read(channel: Uuid, message: &nostr::Event) -> ReadIntent { fn channel_mark(channel: Uuid, message_id: String) -> ReadIntent {
ReadIntent::MarkChannelRead { ReadIntent::MarkThrough {
channel_id: channel, target: ReadTarget {
message_id: message.id.to_hex(), channel_id: channel,
} root_id: None,
} },
message_id,
fn exact(count: &ReadCount) -> Option<u32> {
match count {
ReadCount::Exact { value } => Some(*value),
_ => None,
} }
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { async fn mark_through_rejects_unresolved_missing_auxiliary_and_malformed_anchors() {
let (db, pool, community, channel, actor, root) = fixture().await; 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 base = root.created_at.as_secs();
let first = reply(&db, &pool, community, channel, &root, base + 10, vec![]).await; // Unresolved ancestry cannot be classified as top-level or reply.
post(&db, community, channel, base + 20, vec![]).await;
let second = reply(&db, &pool, community, channel, &root, base + 30, vec![]).await;
// A reply anchor is valid for the whole channel, unlike channel mark_through.
let channel_only = ReadTarget {
channel_id: channel,
root_id: None,
};
let through_reply = ReadIntent::MarkThrough {
target: channel_only,
message_id: first.id.to_hex(),
};
assert_eq!(
apply(&db, community, &actor, through_reply).await,
IntentOutcome::Blocked
);
assert_eq!(
apply(&db, community, &actor, channel_read(channel, &first)).await,
IntentOutcome::Applied
);
let row = sidebar(&db, community, &actor).await;
assert_eq!(
exact(&row.unread),
Some(2),
"top at +20 and reply at +30 remain"
);
assert_eq!(row.threads.items.len(), 1);
assert_eq!(row.threads.items[0].latest_reply_id, second.id.to_hex());
assert!(row.threads.complete);
assert_eq!(
apply(&db, community, &actor, channel_read(channel, &second)).await,
IntentOutcome::Applied
);
let row = sidebar(&db, community, &actor).await;
assert_eq!(
exact(&row.unread),
Some(0),
"every thread is covered by the cut"
);
assert!(row.threads.items.is_empty() && row.threads.complete);
// A reply that arrives after the cut is unread, however far backdated.
let late = reply(&db, &pool, community, channel, &root, base + 5, vec![]).await;
let newer = reply(&db, &pool, community, channel, &root, base + 40, vec![]).await;
let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(2));
assert_eq!(row.threads.items[0].latest_reply_id, newer.id.to_hex());
// Contexts apply the same effective thread frontier.
let page = db
.personal_read_contexts(
community,
&actor.public_key(),
DEFAULT_RETENTION_SECONDS,
&[ContextQuery {
target: ReadTarget {
channel_id: channel,
root_id: Some(root.id.to_hex()),
},
message_ids: vec![second.id.to_hex(), late.id.to_hex(), newer.id.to_hex()],
}],
)
.await
.unwrap();
let wire = serde_json::to_value(&page).unwrap();
assert!(wire["contexts"][0].get("through_timestamp").is_none());
assert_eq!(wire["contexts"][0]["messages"][0]["status"], "read");
assert_eq!(wire["contexts"][0]["messages"][1]["status"], "unread");
assert_eq!(wire["contexts"][0]["messages"][2]["status"], "unread");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independent() {
let (db, pool, community, channel, actor, root) = fixture().await;
let base = root.created_at.as_secs();
// Unresolved ancestry blocks ordinary marks, but not an arrival-only cut.
let orphan_parent = "ab".repeat(32); let orphan_parent = "ab".repeat(32);
let orphan = post( let orphan = post(
&db, &db,
@@ -214,151 +131,76 @@ async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independe
.apply_personal_read_intent( .apply_personal_read_intent(
community, community,
&actor.public_key(), &actor.public_key(),
&ReadIntent::MarkThrough { &channel_mark(channel, orphan.id.to_hex()),
target: ReadTarget {
channel_id: channel,
root_id: None
},
message_id: orphan.id.to_hex(),
},
) )
.await .await
.is_err()); .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), "+") let reaction = EventBuilder::new(Kind::Custom(7), "+")
.sign_with_keys(&Keys::generate()) .sign_with_keys(&Keys::generate())
.unwrap(); .unwrap();
db.insert_event(community, &reaction, Some(channel)) db.insert_event(community, &reaction, Some(channel))
.await .await
.unwrap(); .unwrap();
assert_eq!( for (anchor, outcome) in [
apply(&db, community, &actor, channel_read(channel, &reaction)).await, ("cd".repeat(32), IntentOutcome::Blocked),
IntentOutcome::Blocked (reaction.id.to_hex(), IntentOutcome::Blocked),
); ("zz".into(), IntentOutcome::Invalid),
] {
// Ordinary channel marks never set the whole-channel cut. assert_eq!(
let other = Keys::generate(); apply(
assert_eq!( &db,
apply( community,
&db, &actor,
community, channel_mark(channel, anchor.clone())
&other, )
ReadIntent::MarkThrough { .await,
target: ReadTarget { outcome,
channel_id: channel, "{anchor}"
root_id: None );
}, }
message_id: root.id.to_hex(), let row = sidebar(&db, community, &actor).await;
}, assert_eq!(row.read_through_id, None, "nothing was marked");
)
.await,
IntentOutcome::Applied
);
let cuts: Vec<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
"SELECT threads_through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2",
)
.bind(community.as_uuid())
.bind(other.public_key().to_bytes().as_slice())
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(cuts, vec![None]);
// The schema admits the cut on channel rows only.
let thread_cut = sqlx::query(
"UPDATE personal_read_frontiers SET threads_through_timestamp=now()
WHERE community_id=$1 AND actor=$2",
)
.bind(community.as_uuid())
.bind(actor.public_key().to_bytes().as_slice())
.execute(&pool)
.await;
assert!(thread_cut.is_ok(), "channel row accepts the cut");
let root_id = root.id.to_hex();
let thread = ReadTarget {
channel_id: channel,
root_id: Some(root_id),
};
let mark = ReadIntent::MarkThrough {
target: thread,
message_id: root.id.to_hex(),
};
assert_eq!(
apply(&db, community, &actor, mark).await,
IntentOutcome::Applied
);
assert!(sqlx::query(
"UPDATE personal_read_frontiers SET threads_through_timestamp=now()
WHERE community_id=$1 AND actor=$2 AND root_id<>''::bytea",
)
.bind(community.as_uuid())
.bind(actor.public_key().to_bytes().as_slice())
.execute(&pool)
.await
.is_err());
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn absent_whole_channel_cut_never_covers_epoch_zero_replies() { async fn channel_frontier_never_covers_an_epoch_zero_reply() {
let (db, pool, community, channel, actor, root) = fixture().await; let (db, pool, community, channel, actor, root) = fixture().await;
join(&db, &pool, community, channel, &actor, &root).await; join(&db, &pool, community, channel, &actor, &root).await;
reply(&db, &pool, community, channel, &root, 0, vec![]).await; let ancient = reply(&db, &pool, community, channel, &root, 0, vec![]).await;
// A channel row exists, with no whole-channel cut: NULL must stay NULL. // A channel frontier exists, with no thread frontier: absent must not
// read as epoch zero or as the channel's.
apply( apply(
&db, &db,
community, community,
&actor, &actor,
ReadIntent::MarkThrough { channel_mark(channel, root.id.to_hex()),
target: ReadTarget {
channel_id: channel,
root_id: None,
},
message_id: root.id.to_hex(),
},
) )
.await; .await;
// Widen the horizon to reach the epoch, so that only a NULL cut read as // Widen the horizon to reach the epoch, so that only a misread absent
// zero could hide this reply. // frontier could hide this reply.
let row = db let row = db
.personal_read_sidebar(community, &actor.public_key(), u32::MAX, 20, None) .personal_read_sidebar(community, &actor.public_key(), u32::MAX, 20, None)
.await .await
.unwrap() .unwrap()
.channels .channels
.remove(0); .remove(0);
assert_eq!(exact(&row.unread), Some(1), "epoch-zero reply stays unread"); assert!(!row.unread);
assert_eq!(row.threads.items[0].latest_reply_at, 0); assert_eq!(row.threads.len(), 1, "epoch-zero reply stays unread");
assert_eq!(row.threads[0].mentions, 1);
assert_eq!(row.threads[0].latest_id, ancient.id.to_hex());
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { async fn thread_summaries_order_cap_and_anchor() {
let (db, pool, community, channel, actor, fixture_root) = fixture().await; let (db, pool, community, channel, actor, fixture_root) = fixture().await;
// After the fixture root, so marking the newest root covers every root. // After the fixture root, so marking the newest root covers every root.
let base = fixture_root.created_at.as_secs() + 1; let base = fixture_root.created_at.as_secs() + 1;
// One more thread than the cap.
let n = MAX_THREAD_SUMMARIES as u64 + 1;
let mut roots = Vec::new(); let mut roots = Vec::new();
for i in 0..6 { for i in 0..n {
let root = post(&db, community, channel, base + i, vec![]).await; let root = post(&db, community, channel, base + i, vec![]).await;
join(&db, &pool, community, channel, &actor, &root).await; join(&db, &pool, community, channel, &actor, &root).await;
roots.push(root); roots.push(root);
@@ -368,20 +210,16 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
&db, &db,
community, community,
&actor, &actor,
ReadIntent::MarkThrough { channel_mark(channel, newest_root.id.to_hex()),
target: ReadTarget {
channel_id: channel,
root_id: None,
},
message_id: newest_root.id.to_hex(),
},
) )
.await; .await;
// Threads 0 and 1 tie on newest reply time; thread 2 has one directed // Threads 0 and 1 tie on newest reply time and the rest follow, newest
// reply and two that arrive last, together. // first. Thread 2 has one directed reply and two that arrive last,
// together.
let offset = |i: u64| if i < 2 { n * 10 } else { (n - i) * 10 };
let mut anchors = Vec::new(); let mut anchors = Vec::new();
for (i, root) in roots.iter().enumerate() { for (i, root) in (0..).zip(&roots) {
let at = base + 100 + [50, 50, 40, 30, 20, 10][i]; let at = base + 100 + offset(i);
anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await); anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await);
} }
reply( reply(
@@ -400,7 +238,7 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
community, community,
channel, channel,
&roots[2], &roots[2],
base + 140, base + 100 + offset(2),
vec![], vec![],
) )
.await; .await;
@@ -414,59 +252,42 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
.unwrap(); .unwrap();
let row = sidebar(&db, community, &actor).await; let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(8)); // Every root is read; the eight unread replies count only on threads.
assert!(!row.threads.complete, "six unread threads exceed the cap"); assert_eq!((row.unread, row.mentions), (false, 0));
// The timeline's latest is its last top-level arrival, never a reply.
assert_eq!(row.latest_id, Some(newest_root.id.to_hex()));
let mut tied = [roots[0].id.to_hex(), roots[1].id.to_hex()]; let mut tied = [roots[0].id.to_hex(), roots[1].id.to_hex()];
tied.sort(); tied.sort();
let order: Vec<_> = row let order = |row: &ChannelReadSummary| -> Vec<_> {
.threads row.threads.iter().map(|t| t.root_id.clone()).collect()
.items };
.iter() let ids = |roots: &[nostr::Event]| roots.iter().map(|r| r.id.to_hex()).collect::<Vec<_>>();
.map(|t| t.root_id.clone()) let last = roots.len() - 1;
.collect(); // The oldest unread thread is past the cap.
assert_eq!(order(&row), [tied.to_vec(), ids(&roots[2..last])].concat());
let third = &row.threads[2];
assert_eq!(third.mentions, 3);
assert_eq!( assert_eq!(
order, third.latest_id,
vec![
tied[0].clone(),
tied[1].clone(),
roots[2].id.to_hex(),
roots[3].id.to_hex(),
roots[4].id.to_hex(),
]
);
let third = &row.threads.items[2];
assert_eq!(exact(&third.unread), Some(3));
assert_eq!(third.latest_reply_at, (base + 140) as i64);
assert_eq!(
third.latest_reply_id,
std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()), std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()),
"equal arrivals break toward the smaller ID" "equal arrivals break toward the smaller ID"
); );
// The row's anchor is its last arrival; its activity is the greatest
// author time, another message's.
assert_eq!(row.latest_message_id.as_ref(), Some(&third.latest_reply_id));
assert_eq!(row.latest_message_at, Some((base + 150) as i64));
// Reading one listed thread through its anchor completes the list. // Reading one listed thread through its anchor lists the oldest.
let first = row.threads.items[0].clone_target(channel); let first = row.threads[0].clone_target(channel);
assert_eq!( assert_eq!(
apply(&db, community, &actor, first).await, apply(&db, community, &actor, first).await,
IntentOutcome::Applied IntentOutcome::Applied
); );
let row = sidebar(&db, community, &actor).await; 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!( assert_eq!(
Some(sum), order(&row),
exact(&row.unread), [vec![tied[1].clone()], ids(&roots[2..])].concat()
"complete summaries account for every reply"
); );
let mentions: Vec<_> = row.threads.iter().map(|t| t.mentions).collect();
let mut expected = vec![1; MAX_THREAD_SUMMARIES];
expected[1] = 3;
assert_eq!(mentions, expected);
} }
impl ThreadReadSummary { impl ThreadReadSummary {
@@ -476,7 +297,7 @@ impl ThreadReadSummary {
channel_id: channel, channel_id: channel,
root_id: Some(self.root_id.clone()), root_id: Some(self.root_id.clone()),
}, },
message_id: self.latest_reply_id.clone(), message_id: self.latest_id.clone(),
} }
} }
} }
@@ -500,44 +321,20 @@ async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() {
let elsewhere = reply(&db, &pool, community, channel, &root, base + 20, mention()).await; let elsewhere = reply(&db, &pool, community, channel, &root, base + 20, mention()).await;
let row = sidebar(&db, community, &actor).await; let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(3), "the root and two replies"); // The undirected fixture root is unread; the directed diff is not counted.
assert_eq!(row.threads.items.len(), 2); assert_eq!((row.unread, row.mentions), (true, 0));
let listed = &row.threads.items[1]; assert_eq!(row.threads.len(), 2);
let listed = &row.threads[1];
assert_eq!(listed.root_id, diff.id.to_hex()); assert_eq!(listed.root_id, diff.id.to_hex());
assert_eq!(listed.latest_reply_id, on_diff.id.to_hex()); assert_eq!(listed.latest_id, on_diff.id.to_hex());
assert_eq!(listed.mentions, 1);
// 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. // The diff is still no anchor; the listed reply reads its thread alone.
let through_diff = ReadIntent::MarkThrough { let through_diff = ReadIntent::MarkThrough {
target: thread, target: ReadTarget {
channel_id: channel,
root_id: Some(listed.root_id.clone()),
},
message_id: diff.id.to_hex(), message_id: diff.id.to_hex(),
}; };
assert_eq!( assert_eq!(
@@ -549,49 +346,9 @@ async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() {
IntentOutcome::Applied IntentOutcome::Applied
); );
let row = sidebar(&db, community, &actor).await; let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(2)); assert_eq!((row.unread, row.mentions), (true, 0));
assert_eq!(row.threads.items.len(), 1); assert_eq!(row.threads.len(), 1);
assert_eq!(row.threads.items[0].latest_reply_id, elsewhere.id.to_hex()); assert_eq!(row.threads[0].latest_id, elsewhere.id.to_hex());
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn thread_summaries_are_incomplete_when_tag_evidence_could_hide_a_root() {
let (db, pool, community, channel, actor, root) = fixture().await;
join(&db, &pool, community, channel, &actor, &root).await;
let listed = reply(
&db,
&pool,
community,
channel,
&root,
root.created_at.as_secs() + 1,
vec![],
)
.await;
let hidden = post(
&db,
community,
channel,
root.created_at.as_secs() + 2,
vec![],
)
.await;
sqlx::query("UPDATE events SET tags=$3 WHERE community_id=$1 AND id=$2")
.bind(community.as_uuid())
.bind(hidden.id.as_bytes().as_slice())
.bind(serde_json::json!([["e", "x".repeat(9000)]]))
.execute(&pool)
.await
.unwrap();
let row = sidebar(&db, community, &actor).await;
assert_eq!(row.threads.items.len(), 1);
assert_eq!(row.threads.items[0].latest_reply_id, listed.id.to_hex());
assert!(!row.threads.complete);
assert!(!matches!(
row.threads.items[0].unread,
ReadCount::Exact { .. }
));
} }
#[tokio::test] #[tokio::test]

View File

@@ -128,25 +128,6 @@ async fn message(
.transpose() .transpose()
} }
/// An eligible-kind message's arrival time, deleted or not, without tags.
async fn anchor_received_at(
conn: &mut PgConnection,
community: CommunityId,
channel: Uuid,
id: &[u8],
) -> Result<Option<DateTime<Utc>>> {
Ok(sqlx::query_scalar(
"SELECT received_at FROM events
WHERE community_id=$1 AND channel_id=$2 AND id=$3 AND kind=ANY($4) LIMIT 1",
)
.bind(community.as_uuid())
.bind(channel)
.bind(id)
.bind(ELIGIBLE_KINDS.as_slice())
.fetch_optional(conn)
.await?)
}
pub(super) async fn valid_target( pub(super) async fn valid_target(
conn: &mut PgConnection, conn: &mut PgConnection,
community: CommunityId, community: CommunityId,
@@ -177,20 +158,27 @@ pub(super) async fn valid_target(
Ok(Some(root)) Ok(Some(root))
} }
/// Advance a monotone frontier. The anchor ID follows the greatest arrival;
/// an equal arrival keeps the existing anchor.
async fn frontier( async fn frontier(
conn: &mut PgConnection, conn: &mut PgConnection,
community: CommunityId, community: CommunityId,
actor: &[u8], actor: &[u8],
target: &ReadTarget, target: &ReadTarget,
root: &[u8], root: &[u8],
through: DateTime<Utc>, msg: &Message,
) -> Result<()> { ) -> Result<()> {
sqlx::query( sqlx::query(
"INSERT INTO personal_read_frontiers "INSERT INTO personal_read_frontiers
(community_id, actor, channel_id, root_id, through_timestamp) VALUES ($1,$2,$3,$4,$5) (community_id, actor, channel_id, root_id, through_timestamp, through_message_id)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE
SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp)", SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp),
).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root).bind(through) through_message_id=CASE
WHEN EXCLUDED.through_timestamp > personal_read_frontiers.through_timestamp
THEN EXCLUDED.through_message_id ELSE personal_read_frontiers.through_message_id END",
).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root)
.bind(msg.received_at).bind(&msg.id)
.execute(&mut *conn).await?; .execute(&mut *conn).await?;
Ok(()) Ok(())
} }
@@ -226,31 +214,7 @@ pub(super) async fn apply(
{ {
return Ok(IntentOutcome::Blocked); return Ok(IntentOutcome::Blocked);
} }
frontier(conn, community, actor, target, &root, msg.received_at).await?; frontier(conn, community, actor, target, &root, &msg).await?;
}
ReadIntent::MarkChannelRead {
channel_id,
message_id,
} => {
let Some(id) = event_id(message_id) else {
return Ok(IntentOutcome::Invalid);
};
if !access(conn, community, actor, *channel_id).await? {
return Ok(IntentOutcome::Blocked);
}
// Only the anchor's arrival matters: ancestry cannot change which
// messages a whole-channel cut covers.
let Some(through) = anchor_received_at(conn, community, *channel_id, &id).await? else {
return Ok(IntentOutcome::Blocked);
};
sqlx::query(
"INSERT INTO personal_read_frontiers (community_id, actor, channel_id, root_id,
through_timestamp, threads_through_timestamp) VALUES ($1,$2,$3,''::bytea,$4,$4)
ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE
SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, $4),
threads_through_timestamp=GREATEST(personal_read_frontiers.threads_through_timestamp, $4)",
).bind(community.as_uuid()).bind(actor).bind(channel_id).bind(through)
.execute(&mut *conn).await?;
} }
} }
Ok(IntentOutcome::Applied) Ok(IntentOutcome::Applied)

View File

@@ -6068,35 +6068,18 @@ pub(crate) mod postgres_tests {
} }
// All accessory methods must preserve the shared admission wire contract. // All accessory methods must preserve the shared admission wire contract.
// Keep each route independent so the unfixed adapter fails all three tests. // Keep each route independent so the unfixed adapter fails both tests.
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn nip_fi_buzz_v1_sidebar_wire_contract() { async fn nip_fi_buzz_v1_sidebar_wire_contract() {
buzz_v1_wire_contract("GET", "/buzz/v1/me/sidebar?limit=1", b"").await; 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] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn nip_fi_buzz_v1_write_wire_contract() { async fn nip_fi_buzz_v1_write_wire_contract() {
let body = serde_json::to_vec(&serde_json::json!({"intents":[{ let body = serde_json::to_vec(&serde_json::json!({"intents":[{
"type":"mark_channel_read", "channel_id":uuid::Uuid::new_v4(), "type":"mark_through", "target":{"channel_id":uuid::Uuid::new_v4()},
"message_id":"ab".repeat(32) "message_id":"ab".repeat(32)
}]})) }]}))
.expect("serialize intent"); .expect("serialize intent");
@@ -6145,25 +6128,13 @@ pub(crate) mod postgres_tests {
assert_eq!(headers[header::CACHE_CONTROL], "private, no-store"); assert_eq!(headers[header::CACHE_CONTROL], "private, no-store");
let value: serde_json::Value = serde_json::from_slice(&body).expect("control JSON"); let value: serde_json::Value = serde_json::from_slice(&body).expect("control JSON");
if method == "POST" { if method == "POST" {
// A blocked intent applies to no channel, so no row is refreshed.
assert_eq!( assert_eq!(
body.as_ref(), body.as_ref(),
br#"{"outcomes":[{"status":"blocked"}],"projection_status":"not_requested"}"# br#"{"channels":[],"outcomes":[{"status":"blocked"}]}"#
); );
} else { } else {
assert!(value["account"]["cutoff_ms"].is_i64()); assert_eq!(value, serde_json::json!({"channels":[],"next_cursor":null}));
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 [ for case in [

View File

@@ -161,9 +161,12 @@ pub(super) async fn recheck(
Ok(()) Ok(())
} }
/// Largest serialized API response; a larger one is a 503.
pub(super) const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
pub(super) fn response(value: impl serde::Serialize) -> Result<Response, Error> { pub(super) fn response(value: impl serde::Serialize) -> Result<Response, Error> {
let bytes = serde_json::to_vec(&value).map_err(|_| Error::unavailable())?; let bytes = serde_json::to_vec(&value).map_err(|_| Error::unavailable())?;
if bytes.len() > 1024 * 1024 { if bytes.len() > MAX_RESPONSE_BYTES {
return Err(Error::unavailable()); return Err(Error::unavailable());
} }
Ok(( Ok((

View File

@@ -6,10 +6,12 @@ use axum::{
http::HeaderMap, http::HeaderMap,
response::Response, response::Response,
}; };
use buzz_db::personal_read::{ReadIntent, MAX_CHANNELS, MAX_INTENTS}; use buzz_db::personal_read::{
ChannelReadSummary, ReadIntent, SidebarPage, MAX_CHANNELS, MAX_INTENTS,
};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::{sync::Arc, time::Duration}; use std::{collections::BTreeSet, sync::Arc, time::Duration};
use uuid::Uuid; use uuid::Uuid;
/// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid. /// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid.
@@ -72,26 +74,63 @@ pub(super) async fn sidebar(
} }
} }
.map_err(|_| Error::unavailable())?; .map_err(|_| Error::unavailable())?;
auth::recheck(&state, &headers, &principal).await?; auth::response(release(&state, &headers, &principal, page).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 .await
.map_err(|_| Error::unavailable())? .map_err(|_| Error::unavailable())?
} }
/// Release projected rows only after admission and the actor's membership of
/// every row are rechecked outside the projection snapshot.
async fn release(
state: &AppState,
headers: &HeaderMap,
principal: &auth::Principal,
page: SidebarPage,
) -> Result<SidebarPage, Error> {
auth::recheck(state, headers, principal).await?;
let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect();
let memberships = state
.db
.membership_pairs(
principal.tenant.community(),
&channels,
&[principal.actor.to_bytes().to_vec()],
)
.await
.map_err(|_| Error::unavailable())?;
if memberships.len() != channels.len() {
return Err(Error::unavailable());
}
Ok(page)
}
/// Updated rows for the channels a batch applied to. A channel the actor has
/// not joined has no row.
async fn refreshed(
state: &AppState,
headers: &HeaderMap,
principal: &auth::Principal,
channels: &BTreeSet<Uuid>,
) -> Result<Vec<ChannelReadSummary>, Error> {
let channels: Vec<_> = channels.iter().copied().collect();
let mut rows = Vec::with_capacity(channels.len());
for chunk in channels.chunks(MAX_CHANNELS) {
let page = state
.db
.personal_read_sidebar_channels(
principal.tenant.community(),
&principal.actor,
state.config.buzz_v1_retention_seconds,
chunk,
)
.await
.map_err(|_| Error::unavailable())?;
rows.extend(release(state, headers, principal, page).await?.channels);
}
Ok(rows)
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct Batch { struct Batch {
@@ -114,6 +153,7 @@ pub(super) async fn write(
} }
let deadline = tokio::time::Instant::now() + Duration::from_secs(8); let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
let mut outcomes = Vec::with_capacity(batch.intents.len()); let mut outcomes = Vec::with_capacity(batch.intents.len());
let mut applied = BTreeSet::new();
for item in batch.intents { for item in batch.intents {
let Ok(intent) = serde_json::from_value::<ReadIntent>(item) else { let Ok(intent) = serde_json::from_value::<ReadIntent>(item) else {
outcomes.push(json!({"status":"invalid"})); outcomes.push(json!({"status":"invalid"}));
@@ -121,83 +161,27 @@ pub(super) async fn write(
}; };
// A deadline/DB failure after commit is ambiguous, not a false failure. // A deadline/DB failure after commit is ambiguous, not a false failure.
// Earlier acknowledged commits survive all later projection/item failures. // Earlier acknowledged commits survive all later projection/item failures.
outcomes.push( let outcome = tokio::time::timeout_at(
tokio::time::timeout_at( deadline,
deadline, write_intent(&state, &headers, &principal, &intent),
write_intent(&state, &headers, &principal, &intent), )
) .await
.await .unwrap_or_else(|_| json!({"status":"unknown","retryable":true}));
.unwrap_or_else(|_| json!({"status":"unknown","retryable":true})), if outcome["status"] == "applied" {
); let ReadIntent::MarkThrough { target, .. } = &intent;
applied.insert(target.channel_id);
}
outcomes.push(outcome);
} }
auth::response(json!({"outcomes":outcomes,"projection_status":"not_requested"})) let mut body = json!({ "outcomes": outcomes });
} // Committed outcomes stand even when their rows cannot be read in the
// remaining budget: omit them.
#[derive(Deserialize)] if let Ok(Ok(channels)) =
#[serde(deny_unknown_fields)] tokio::time::timeout_at(deadline, refreshed(&state, &headers, &principal, &applied)).await
pub(super) struct ContextQuery { {
// JSON array carried as one URL-encoded, signed query parameter. body["channels"] = json!(channels);
targets: String, }
} auth::response(body)
pub(super) async fn contexts(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
OriginalUri(uri): OriginalUri,
query: Result<Query<ContextQuery>, axum::extract::rejection::QueryRejection>,
) -> Result<Response, Error> {
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<Target> =
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::<usize>() > 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())?
} }
// Preserve earlier committed outcomes while distinguishing a definite denial // Preserve earlier committed outcomes while distinguishing a definite denial
@@ -224,3 +208,39 @@ pub(super) async fn write_intent(
Err(_) => json!({"status":"unknown","retryable":true}), Err(_) => json!({"status":"unknown","retryable":true}),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use buzz_db::personal_read::{ThreadReadSummary, MAX_THREAD_SUMMARIES};
/// A POST that applied to `MAX_INTENTS` channels, each with a full thread
/// list, must still fit: an oversized body is a 503 that hides committed
/// outcomes.
#[test]
fn largest_write_response_fits_the_response_limit() {
let id = || Some("f".repeat(64));
let channels: Vec<_> = (0..MAX_INTENTS)
.map(|_| ChannelReadSummary {
channel_id: Uuid::max(),
unread: true,
mentions: u32::MAX,
read_through_id: id(),
latest_id: id(),
threads: (0..MAX_THREAD_SUMMARIES)
.map(|_| ThreadReadSummary {
root_id: "f".repeat(64),
unread: true,
mentions: u32::MAX,
read_through_id: id(),
latest_id: "f".repeat(64),
})
.collect(),
})
.collect();
let outcomes = vec![json!({"status":"unknown","retryable":true}); MAX_INTENTS];
let body = json!({ "outcomes": outcomes, "channels": channels });
let bytes = serde_json::to_vec(&body).unwrap().len();
assert!(bytes <= auth::MAX_RESPONSE_BYTES, "{bytes} bytes");
}
}

View File

@@ -4,7 +4,10 @@ mod auth;
mod handlers; mod handlers;
use crate::state::AppState; use crate::state::AppState;
use axum::{routing::get, Router}; use axum::{
routing::{get, post},
Router,
};
use std::sync::Arc; use std::sync::Arc;
/// Public accessory prefix, shared by routing and capability discovery. /// Public accessory prefix, shared by routing and capability discovery.
@@ -14,10 +17,7 @@ pub const BASE_PATH: &str = "/buzz/v1";
pub fn router(state: Arc<AppState>) -> Router { pub fn router(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.route("/me/sidebar", get(handlers::sidebar)) .route("/me/sidebar", get(handlers::sidebar))
.route( .route("/me/read-state", post(handlers::write))
"/me/read-state",
get(handlers::contexts).post(handlers::write),
)
.fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") }) .fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") })
.with_state(state) .with_state(state)
} }

View File

@@ -191,9 +191,20 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() {
.insert_event(community, &event, Some(channel)) .insert_event(community, &event, Some(channel))
.await .await
.unwrap(); .unwrap();
state
.db
.add_member(
community,
channel,
&other.public_key().to_bytes(),
buzz_db::channel::MemberRole::Member,
None,
)
.await
.unwrap();
let write_path = "/buzz/v1/me/read-state"; let write_path = "/buzz/v1/me/read-state";
let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_channel_read", let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_through",
"channel_id":channel,"message_id":event.id.to_hex()}]})) "target":{"channel_id":channel},"message_id":event.id.to_hex()}]}))
.unwrap(); .unwrap();
let missing_hash = proof(&actor, &host, write_path, "POST", None); let missing_hash = proof(&actor, &host, write_path, "POST", None);
assert_eq!( assert_eq!(
@@ -228,32 +239,30 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() {
assert_eq!(applied.0, StatusCode::OK, "{}", applied.1); assert_eq!(applied.0, StatusCode::OK, "{}", applied.1);
assert_eq!(applied.1["outcomes"][0]["status"], "applied"); assert_eq!(applied.1["outcomes"][0]["status"], "applied");
// The frontier belongs to the signer alone. // The frontier belongs to the signer alone.
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]); let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}");
let targets: String = targets
.to_string()
.bytes()
.map(|b| format!("%{b:02X}"))
.collect();
let path = format!("/buzz/v1/me/read-state?targets={targets}");
start_before_everything(&state, community, &other).await; start_before_everything(&state, community, &other).await;
for (key, status) in [(&actor, "read"), (&other, "unread")] { for (key, unread, read_through) in [
(&actor, false, json!(event.id.to_hex())),
(&other, true, Value::Null),
] {
let auth = proof(key, &host, &path, "GET", None); let auth = proof(key, &host, &path, "GET", None);
let page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; let page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
assert_eq!(page.0, StatusCode::OK, "{}", page.1); assert_eq!(page.0, StatusCode::OK, "{}", page.1);
assert_eq!(page.1["contexts"][0]["messages"][0]["status"], status); assert_eq!(page.1["channels"][0]["unread"], unread, "{}", page.1);
assert_eq!(page.1["channels"][0]["read_through_id"], read_through);
} }
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn accessory_context_get_signed_query_and_independent_batch_outcomes() { async fn accessory_batch_outcomes_are_independent_and_return_applied_rows() {
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
.await .await
.unwrap(); .unwrap();
let mut state = (*fixture).clone(); let mut state = (*fixture).clone();
Arc::make_mut(&mut state.config).buzz_v1_enabled = true; Arc::make_mut(&mut state.config).buzz_v1_enabled = true;
let state = Arc::new(state); let state = Arc::new(state);
let host = format!("bff-context-{}.local", uuid::Uuid::new_v4()); let host = format!("bff-batch-{}.local", uuid::Uuid::new_v4());
let community = state let community = state
.db .db
.ensure_configured_community(&host) .ensure_configured_community(&host)
@@ -261,76 +270,95 @@ async fn accessory_context_get_signed_query_and_independent_batch_outcomes() {
.unwrap() .unwrap()
.id; .id;
let actor = Keys::generate(); let actor = Keys::generate();
let channel = state let create = |name: &'static str, owner: &Keys| {
.db let (state, owner) = (state.clone(), owner.public_key().to_bytes());
.create_channel( async move {
community, state
"context", .db
buzz_db::channel::ChannelType::Stream, .create_channel(
buzz_db::channel::ChannelVisibility::Open, community,
None, name,
&actor.public_key().to_bytes(), buzz_db::channel::ChannelType::Stream,
None, buzz_db::channel::ChannelVisibility::Open,
) None,
.await &owner,
.unwrap() None,
.id; )
let event = EventBuilder::new(Kind::Custom(9), "message selector") .await
.sign_with_keys(&Keys::generate()) .unwrap()
.unwrap(); .id
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::<String>()
}; };
let path = format!( let channel = create("joined", &actor).await;
"/buzz/v1/me/read-state?targets={}", // Open, so marking it applies, but never joined.
encode(&targets.to_string()) let unjoined = create("open, not joined", &Keys::generate()).await;
); let post = |channel| {
let state = state.clone();
async move {
let event = EventBuilder::new(Kind::Custom(9), "read state")
.sign_with_keys(&Keys::generate())
.unwrap();
state
.db
.insert_event(community, &event, Some(channel))
.await
.unwrap();
event.id.to_hex()
}
};
let (event, elsewhere) = (post(channel).await, post(unjoined).await);
start_before_everything(&state, community, &actor).await; start_before_everything(&state, community, &actor).await;
let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}");
let auth = proof(&actor, &host, &path, "GET", None); let auth = proof(&actor, &host, &path, "GET", None);
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
assert_eq!(result.0, StatusCode::OK, "{}", result.1); assert_eq!(result.0, StatusCode::OK, "{}", result.1);
assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "unread"); let unread = json!({"channel_id":channel,"unread":true,"mentions":0,
let auth = proof(&actor, &host, "/buzz/v1/me/read-state", "GET", None); "read_through_id":null,"latest_id":event,"threads":[]});
assert_eq!( assert_eq!(result.1["channels"], json!([unread]));
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 write_path = "/buzz/v1/me/read-state";
let body = serde_json::to_vec(&json!({"intents":[ let body = serde_json::to_vec(&json!({"intents":[
{"type":"unknown"}, {"type":"unknown"},
{"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event.id.to_hex()}, {"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event},
{"type":"mark_through","target":{"channel_id":channel},"message_id":event.id.to_hex()}, {"type":"mark_through","target":{"channel_id":channel},"message_id":event},
{"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"} {"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"},
]})).unwrap(); {"type":"mark_through","target":{"channel_id":channel},"message_id":event},
{"type":"mark_through","target":{"channel_id":unjoined},"message_id":elsewhere}
]}))
.unwrap();
let auth = proof(&actor, &host, write_path, "POST", Some(&body)); let auth = proof(&actor, &host, write_path, "POST", Some(&body));
let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await; let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await;
assert_eq!(result.0, StatusCode::OK, "{}", result.1); assert_eq!(result.0, StatusCode::OK, "{}", result.1);
assert_eq!( assert_eq!(
result.1["outcomes"], result.1["outcomes"],
json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},{"status":"invalid"}]) json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},
{"status":"invalid"},{"status":"applied"},{"status":"applied"}])
); );
let auth = proof(&actor, &host, &path, "GET", None); // One updated row per applied channel; the blocked and the unjoined
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; // channels have none.
assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "read"); let read = json!({"channel_id":channel,"unread":false,"mentions":0,
"read_through_id":event,"latest_id":event,"threads":[]});
assert_eq!(result.1["channels"], json!([read]));
// The removed context read is not served, however well signed.
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event]}]);
let targets: String = targets
.to_string()
.bytes()
.map(|b| format!("%{b:02X}"))
.collect();
let path = format!("/buzz/v1/me/read-state?targets={targets}");
let response = crate::router::build_router(state.clone())
.oneshot(
Request::builder()
.uri(&path)
.header("host", &host)
.header("authorization", proof(&actor, &host, &path, "GET", None))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_ne!(response.status(), StatusCode::OK);
} }
#[tokio::test] #[tokio::test]
@@ -379,8 +407,11 @@ async fn accessory_write_revocation_is_terminal_before_persistence() {
.insert_event(community, &event, Some(channel)) .insert_event(community, &event, Some(channel))
.await .await
.unwrap(); .unwrap();
let intent = buzz_db::personal_read::ReadIntent::MarkChannelRead { let intent = buzz_db::personal_read::ReadIntent::MarkThrough {
channel_id: channel, target: buzz_db::personal_read::ReadTarget {
channel_id: channel,
root_id: None,
},
message_id: event.id.to_hex(), message_id: event.id.to_hex(),
}; };
let path = "/buzz/v1/me/read-state"; let path = "/buzz/v1/me/read-state";
@@ -456,19 +487,13 @@ async fn accessory_discovery_is_host_bound_and_opt_in() {
"enabled={enabled} known_host={known_host} path={path}" "enabled={enabled} known_host={known_host} path={path}"
); );
if enabled && known_host { if enabled && known_host {
let d = &doc["buzz_v1"]; // Exactly these fields: context and thread-cap limits are gone.
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!( assert_eq!(
d["max_context_messages"], doc["buzz_v1"],
buzz_db::personal_read::MAX_CONTEXT_MESSAGES json!({"version":1,"base_path":"/buzz/v1","retention_seconds":1234,
"max_channels":20,"max_intents":100,
"eligible_kinds":[9, 40002, 45001, 45003]})
); );
assert_eq!(d["max_thread_summaries"], 5);
assert_eq!(d["eligible_kinds"], json!([9, 40002, 45001, 45003]));
} }
} }
} }
@@ -613,8 +638,12 @@ async fn signed_sidebar_deletion(deletion_kind: u16) {
.await .await
.unwrap(); .unwrap();
} }
// Addressed to the reader, so its mention count pins the exact number.
let message = EventBuilder::new(Kind::Custom(9), "unread message to delete") let message = EventBuilder::new(Kind::Custom(9), "unread message to delete")
.tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) .tags([
Tag::parse(["h", &channel.to_string()]).unwrap(),
Tag::public_key(reader.public_key()),
])
.sign_with_keys(&author) .sign_with_keys(&author)
.unwrap(); .unwrap();
let mut tags = vec![Tag::parse(["e", &message.id.to_hex()]).unwrap()]; let mut tags = vec![Tag::parse(["e", &message.id.to_hex()]).unwrap()];
@@ -662,8 +691,8 @@ async fn signed_sidebar_deletion(deletion_kind: u16) {
assert_eq!(channels.len(), 1, "{sidebar}"); assert_eq!(channels.len(), 1, "{sidebar}");
assert_eq!(channels[0]["channel_id"], channel.to_string()); assert_eq!(channels[0]["channel_id"], channel.to_string());
assert_eq!( assert_eq!(
channels[0]["unread"], (&channels[0]["unread"], &channels[0]["mentions"]),
json!({"status":"exact", "value":count}), (&json!(count == 1), &json!(count)),
"kind {deletion_kind}, after kind {}: {sidebar}", "kind {deletion_kind}, after kind {}: {sidebar}",
event.kind.as_u16() event.kind.as_u16()
); );

View File

@@ -94,12 +94,6 @@ pub struct BuzzV1Descriptor {
pub max_channels: usize, pub max_channels: usize,
/// Maximum independent write intents per request. /// Maximum independent write intents per request.
pub max_intents: usize, pub max_intents: usize,
/// Maximum explicit contexts per read request.
pub max_contexts: usize,
/// Maximum message selectors across one context request.
pub max_context_messages: usize,
/// Maximum unread-thread summaries per sidebar channel row.
pub max_thread_summaries: usize,
/// Message kinds that count as unread and as latest activity. Clients /// Message kinds that count as unread and as latest activity. Clients
/// classify live arrivals with this set instead of keeping a copy. /// classify live arrivals with this set instead of keeping a copy.
pub eligible_kinds: [i32; 4], pub eligible_kinds: [i32; 4],
@@ -389,19 +383,13 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st
); );
if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await {
if state.config.buzz_v1_enabled { if state.config.buzz_v1_enabled {
use buzz_db::personal_read::{ use buzz_db::personal_read::{ELIGIBLE_KINDS, MAX_CHANNELS, MAX_INTENTS};
ELIGIBLE_KINDS, MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS,
MAX_THREAD_SUMMARIES,
};
info.buzz_v1 = Some(BuzzV1Descriptor { info.buzz_v1 = Some(BuzzV1Descriptor {
version: 1, version: 1,
base_path: crate::api::buzz_v1::BASE_PATH.to_owned(), base_path: crate::api::buzz_v1::BASE_PATH.to_owned(),
retention_seconds: state.config.buzz_v1_retention_seconds, retention_seconds: state.config.buzz_v1_retention_seconds,
max_channels: MAX_CHANNELS, max_channels: MAX_CHANNELS,
max_intents: MAX_INTENTS, max_intents: MAX_INTENTS,
max_contexts: MAX_CONTEXTS,
max_context_messages: MAX_CONTEXT_MESSAGES,
max_thread_summaries: MAX_THREAD_SUMMARIES,
eligible_kinds: ELIGIBLE_KINDS, eligible_kinds: ELIGIBLE_KINDS,
}); });
} }

View File

@@ -1,204 +0,0 @@
//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`.
//!
//! The CSP is only enforced on assets Tauri itself serves, so neither
//! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under
//! `vite preview`) can catch a policy that breaks the app. These tests pin the
//! non-obvious sources the frontend actually needs, so a future tightening
//! fails here instead of in a signed build.
//!
//! Kept as an integration test so the policy can be checked without the app
//! crate having to declare a test-only module.
use std::collections::HashMap;
const TAURI_CONF: &str = include_str!("../tauri.conf.json");
fn csp_directives() -> HashMap<String, Vec<String>> {
let conf: serde_json::Value =
serde_json::from_str(TAURI_CONF).expect("tauri.conf.json is valid JSON");
let csp = conf["app"]["security"]["csp"]
.as_str()
.expect("app.security.csp is set as a policy string");
csp.split(';')
.filter_map(|directive| {
let mut parts = directive.split_whitespace();
let name = parts.next()?;
Some((name.to_owned(), parts.map(str::to_owned).collect()))
})
.collect()
}
fn sources(directive: &str) -> Vec<String> {
csp_directives()
.remove(directive)
.unwrap_or_else(|| panic!("csp is missing the {directive} directive"))
}
#[test]
fn script_src_allows_wasm_instantiation() {
// Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for
// every code block; MediaPipe selfie segmentation does the same. Without
// this token both silently degrade — highlighting drops to plain text and
// animated avatars keep their background.
assert!(sources("script-src").contains(&"'wasm-unsafe-eval'".to_owned()));
}
/// The `MEDIAPIPE_WASM_BASE` literal the frontend hands to `FilesetResolver`.
fn mediapipe_wasm_base() -> String {
const CAPTURE: &str = include_str!("../../src/features/profile/lib/animatedAvatarCapture.ts");
let after = CAPTURE
.split_once("const MEDIAPIPE_WASM_BASE =")
.expect("animatedAvatarCapture.ts declares MEDIAPIPE_WASM_BASE")
.1;
let url = after
.split_once('"')
.expect("MEDIAPIPE_WASM_BASE is a double-quoted string literal")
.1;
url.split_once('"')
.expect("MEDIAPIPE_WASM_BASE literal is terminated")
.0
.to_owned()
}
/// The npm scope the MediaPipe loader must come from. A CSP source ending in
/// `/` is a path *prefix* — paths can't be wildcarded — so this admits any
/// `@mediapipe` package while excluding the rest of what jsDelivr serves.
const MEDIAPIPE_SCOPE: &str = "https://cdn.jsdelivr.net/npm/@mediapipe/";
#[test]
fn script_src_scopes_the_mediapipe_loader() {
// `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js`
// via a `<script>` tag (which of the two depends on a runtime SIMD probe),
// so the loader URL the frontend builds has to fall inside the allowlisted
// prefix — checked here rather than discovered in a signed build.
let allowed = sources("script-src");
assert!(
allowed.contains(&MEDIAPIPE_SCOPE.to_owned()),
"script-src must allow {MEDIAPIPE_SCOPE}"
);
let base = mediapipe_wasm_base();
assert!(
base.starts_with(MEDIAPIPE_SCOPE),
"MEDIAPIPE_WASM_BASE ({base}) must sit under the allowlisted {MEDIAPIPE_SCOPE}"
);
}
/// How many path segments a source narrows to past its origin.
fn path_depth(source: &str) -> usize {
let Some((_scheme, authority)) = source.split_once("://") else {
return 0;
};
match authority.split_once('/') {
Some((_host, path)) => path.split('/').filter(|part| !part.is_empty()).count(),
None => 0,
}
}
/// Whether a `script-src` source is narrow enough to be worth allowing. CSP
/// keywords (`'self'`, `'wasm-unsafe-eval'`) pass. A remote source must name a
/// non-wildcard host *and* a path reaching at least a publisher scope — a bare
/// origin, a scheme, or a registry root would put arbitrary third-party code
/// one injected `<script>` away.
fn is_pinned_script_source(source: &str) -> bool {
if source.starts_with('\'') {
return true;
}
!source.contains('*') && path_depth(source) >= 2
}
#[test]
fn script_src_trusts_no_bare_origins() {
for source in sources("script-src") {
assert!(
is_pinned_script_source(&source),
"script-src must stay scoped, found broad source `{source}`"
);
}
}
#[test]
fn pinned_script_source_rejects_broad_sources() {
// Guards the guard: the check above is only worth having if it fails on the
// shapes that reopen the allowlist.
for allowed in [
"'self'",
"'wasm-unsafe-eval'",
MEDIAPIPE_SCOPE,
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm/vision_wasm_internal.js",
] {
assert!(is_pinned_script_source(allowed), "{allowed} should pass");
}
for rejected in [
"https://cdn.jsdelivr.net",
"https://cdn.jsdelivr.net/",
"https://cdn.jsdelivr.net/npm/",
"https://cdn.jsdelivr.net/gh/",
"https://*.jsdelivr.net/npm/@mediapipe/",
"https:",
"*",
] {
assert!(
!is_pinned_script_source(rejected),
"{rejected} should be rejected"
);
}
}
#[test]
fn media_directives_allow_the_buzz_media_scheme() {
// `rewriteRelayUrl` emits `buzz-media://localhost/...` until the loopback
// proxy port resolves, so cold-start media renders through the custom
// scheme (mapped to `http://buzz-media.localhost` on Windows).
for directive in ["img-src", "media-src", "connect-src"] {
let allowed = sources(directive);
assert!(
allowed.contains(&"buzz-media:".to_owned()),
"{directive} must allow buzz-media:"
);
assert!(
allowed.contains(&"http://buzz-media.localhost".to_owned()),
"{directive} must allow http://buzz-media.localhost"
);
}
}
#[test]
fn connect_src_allows_ipc_and_cleartext_relays() {
// `ipc:` / `http://ipc.localhost` carry every Tauri command. Cleartext
// `http:`/`ws:` stay allowed because a relay URL is user-supplied and the
// app accepts plain `ws://` on any host (`communityStorage::normalizeRelayUrl`,
// the community edit form): `relayProbe` opens a browser WebSocket to it,
// so narrowing this to loopback would report reachable relays as dead —
// while the real connection, which runs through tauri-plugin-websocket in
// Rust, is not governed by CSP at all. Blanket `https:` is already allowed,
// so restricting the cleartext schemes would close no exfiltration path.
let allowed = sources("connect-src");
for source in [
"ipc:",
"http://ipc.localhost",
"https:",
"http:",
"wss:",
"ws:",
] {
assert!(
allowed.contains(&source.to_owned()),
"connect-src must allow {source}"
);
}
}
#[test]
fn script_src_stays_free_of_unsafe_inline_and_eval() {
let allowed = sources("script-src");
// The inline boot script in index.html is covered by Tauri's build-time
// SHA-256 hashing (scripts only — Tauri nonces inline <style> elements via
// a different path), so neither escape hatch is ever needed here. The boot
// background style was moved to public/boot.css to avoid the nonce path for
// style-src; see boot.css for the full rationale.
assert!(!allowed.contains(&"'unsafe-inline'".to_owned()));
assert!(!allowed.contains(&"'unsafe-eval'".to_owned()));
}

View File

@@ -0,0 +1,181 @@
// Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`.
//
// The CSP is only enforced on assets Tauri itself serves, so neither
// `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under
// `vite preview`) can catch a policy that breaks the app. These tests pin the
// non-obvious sources the frontend actually needs, so a future tightening
// fails here instead of in a signed build.
//
// Lives on the frontend side because it checks the policy against frontend
// code (`MEDIAPIPE_WASM_BASE`), and the `desktop/**` CI filter covers both
// that code and `src-tauri/tauri.conf.json`.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { MEDIAPIPE_WASM_BASE } from "../features/profile/lib/animatedAvatarCapture.ts";
const TAURI_CONF = JSON.parse(
readFileSync(
new URL("../../src-tauri/tauri.conf.json", import.meta.url),
"utf8",
),
);
function cspDirectives() {
const csp = TAURI_CONF.app?.security?.csp;
assert.equal(
typeof csp,
"string",
"app.security.csp is set as a policy string",
);
const directives = new Map();
for (const directive of csp.split(";")) {
const [name, ...sources] = directive.split(/\s+/).filter(Boolean);
if (name) directives.set(name, sources);
}
return directives;
}
function sources(directive) {
const allowed = cspDirectives().get(directive);
assert.ok(allowed, `csp is missing the ${directive} directive`);
return allowed;
}
/** The npm scope the MediaPipe loader must come from. A CSP source ending in
* `/` is a path *prefix* — paths can't be wildcarded — so this admits any
* `@mediapipe` package while excluding the rest of what jsDelivr serves. */
const MEDIAPIPE_SCOPE = "https://cdn.jsdelivr.net/npm/@mediapipe/";
/** How many path segments a source narrows to past its origin. */
function pathDepth(source) {
const authority = source.split("://")[1];
if (authority === undefined) return 0;
const slash = authority.indexOf("/");
if (slash === -1) return 0;
return authority
.slice(slash + 1)
.split("/")
.filter(Boolean).length;
}
/** Whether a `script-src` source is narrow enough to be worth allowing. CSP
* keywords (`'self'`, `'wasm-unsafe-eval'`) pass. A remote source must name a
* non-wildcard host *and* a path reaching at least a publisher scope — a bare
* origin, a scheme, or a registry root would put arbitrary third-party code
* one injected `<script>` away. */
function isPinnedScriptSource(source) {
if (source.startsWith("'")) return true;
return !source.includes("*") && pathDepth(source) >= 2;
}
test("script-src allows wasm instantiation", () => {
// Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for
// every code block; MediaPipe selfie segmentation does the same. Without
// this token both silently degrade — highlighting drops to plain text and
// animated avatars keep their background.
assert.ok(sources("script-src").includes("'wasm-unsafe-eval'"));
});
test("script-src scopes the MediaPipe loader", () => {
// `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js`
// via a `<script>` tag (which of the two depends on a runtime SIMD probe),
// so the loader URL the frontend builds has to fall inside the allowlisted
// prefix — checked here rather than discovered in a signed build.
assert.ok(
sources("script-src").includes(MEDIAPIPE_SCOPE),
`script-src must allow ${MEDIAPIPE_SCOPE}`,
);
assert.ok(
MEDIAPIPE_WASM_BASE.startsWith(MEDIAPIPE_SCOPE),
`MEDIAPIPE_WASM_BASE (${MEDIAPIPE_WASM_BASE}) must sit under the allowlisted ${MEDIAPIPE_SCOPE}`,
);
});
test("script-src trusts no bare origins", () => {
for (const source of sources("script-src")) {
assert.ok(
isPinnedScriptSource(source),
`script-src must stay scoped, found broad source \`${source}\``,
);
}
});
test("pinned script source check rejects broad sources", () => {
// Guards the guard: the check above is only worth having if it fails on the
// shapes that reopen the allowlist.
for (const allowed of [
"'self'",
"'wasm-unsafe-eval'",
MEDIAPIPE_SCOPE,
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm/vision_wasm_internal.js",
]) {
assert.ok(isPinnedScriptSource(allowed), `${allowed} should pass`);
}
for (const rejected of [
"https://cdn.jsdelivr.net",
"https://cdn.jsdelivr.net/",
"https://cdn.jsdelivr.net/npm/",
"https://cdn.jsdelivr.net/gh/",
"https://*.jsdelivr.net/npm/@mediapipe/",
"https:",
"*",
]) {
assert.ok(
!isPinnedScriptSource(rejected),
`${rejected} should be rejected`,
);
}
});
test("media directives allow the buzz-media scheme", () => {
// `rewriteRelayUrl` emits `buzz-media://localhost/...` until the loopback
// proxy port resolves, so cold-start media renders through the custom
// scheme (mapped to `http://buzz-media.localhost` on Windows).
for (const directive of ["img-src", "media-src", "connect-src"]) {
const allowed = sources(directive);
assert.ok(
allowed.includes("buzz-media:"),
`${directive} must allow buzz-media:`,
);
assert.ok(
allowed.includes("http://buzz-media.localhost"),
`${directive} must allow http://buzz-media.localhost`,
);
}
});
test("connect-src allows IPC and cleartext relays", () => {
// `ipc:` / `http://ipc.localhost` carry every Tauri command. Cleartext
// `http:`/`ws:` stay allowed because a relay URL is user-supplied and the
// app accepts plain `ws://` on any host (`communityStorage::normalizeRelayUrl`,
// the community edit form): `relayProbe` opens a browser WebSocket to it,
// so narrowing this to loopback would report reachable relays as dead —
// while the real connection, which runs through tauri-plugin-websocket in
// Rust, is not governed by CSP at all. Blanket `https:` is already allowed,
// so restricting the cleartext schemes would close no exfiltration path.
const allowed = sources("connect-src");
for (const source of [
"ipc:",
"http://ipc.localhost",
"https:",
"http:",
"wss:",
"ws:",
]) {
assert.ok(allowed.includes(source), `connect-src must allow ${source}`);
}
});
test("script-src stays free of unsafe-inline and unsafe-eval", () => {
const allowed = sources("script-src");
// The inline boot script in index.html is covered by Tauri's build-time
// SHA-256 hashing (scripts only — Tauri nonces inline <style> elements via
// a different path), so neither escape hatch is ever needed here. The boot
// background style was moved to public/boot.css to avoid the nonce path for
// style-src; see boot.css for the full rationale.
assert.ok(!allowed.includes("'unsafe-inline'"));
assert.ok(!allowed.includes("'unsafe-eval'"));
});

View File

@@ -23,7 +23,55 @@ class ThrowingNotification {
globalThis.window = { Notification: ThrowingNotification }; globalThis.window = { Notification: ThrowingNotification };
const { sendDesktopNotification } = await import("./desktop.ts"); const { sendDesktopNotification, getDesktopNotificationPermissionState } =
await import("./desktop.ts");
// Stands in for the plugin's injected shim: `permission` is a cached value and
// only `requestPermission()` reaches the backend and rewrites it.
function shimNotification(initialPermission, grantedPermission = "granted") {
class ShimNotification {
static permission = initialPermission;
static requestCount = 0;
static async requestPermission() {
ShimNotification.requestCount += 1;
ShimNotification.permission = grantedPermission;
return grantedPermission;
}
close() {}
}
return ShimNotification;
}
async function withEnvironment({ platform, isTauri, notification }, callback) {
const originalNavigator = Object.getOwnPropertyDescriptor(
globalThis,
"navigator",
);
const originalNotification = window.Notification;
const originalIsTauri = globalThis.isTauri;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { platform, userAgent: "" },
});
window.Notification = notification;
globalThis.isTauri = isTauri;
try {
return await callback();
} finally {
if (originalNavigator) {
Object.defineProperty(globalThis, "navigator", originalNavigator);
} else {
delete globalThis.navigator;
}
window.Notification = originalNotification;
globalThis.isTauri = originalIsTauri;
}
}
test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => { test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => {
const warnings = []; const warnings = [];
@@ -50,3 +98,72 @@ test("constructor failure is a delivery miss and does not prevent a later notifi
}, },
]); ]);
}); });
test("Windows Tauri repairs the shim's false denied state on read", async () => {
const Notification = shimNotification("denied");
const permission = await withEnvironment(
{ platform: "Win32", isTauri: true, notification: Notification },
() => getDesktopNotificationPermissionState(),
);
assert.equal(permission, "granted");
assert.equal(Notification.requestCount, 1);
});
test("the repaired state is cached, so later reads do not request again", async () => {
const Notification = shimNotification("denied");
await withEnvironment(
{ platform: "Win32", isTauri: true, notification: Notification },
async () => {
await getDesktopNotificationPermissionState();
const second = await getDesktopNotificationPermissionState();
assert.equal(second, "granted");
},
);
assert.equal(Notification.requestCount, 1);
});
test("a denial that survives the request is reported as denied", async () => {
const Notification = shimNotification("denied", "denied");
const permission = await withEnvironment(
{ platform: "Win32", isTauri: true, notification: Notification },
() => getDesktopNotificationPermissionState(),
);
assert.equal(permission, "denied");
assert.equal(Notification.requestCount, 1);
});
test("denied stays terminal outside the Windows Tauri app", async () => {
for (const environment of [
{ label: "Windows web", platform: "Win32", isTauri: false },
{ label: "Linux Tauri", platform: "Linux x86_64", isTauri: true },
{ label: "Linux web", platform: "Linux x86_64", isTauri: false },
]) {
const Notification = shimNotification("denied");
const permission = await withEnvironment(
{ ...environment, notification: Notification },
() => getDesktopNotificationPermissionState(),
);
assert.equal(permission, "denied", environment.label);
assert.equal(Notification.requestCount, 0, environment.label);
}
});
test("granted is returned untouched and never triggers a request", async () => {
const Notification = shimNotification("granted");
const permission = await withEnvironment(
{ platform: "Win32", isTauri: true, notification: Notification },
() => getDesktopNotificationPermissionState(),
);
assert.equal(permission, "granted");
assert.equal(Notification.requestCount, 0);
});

View File

@@ -6,7 +6,11 @@ import {
onAction, onAction,
requestPermission, requestPermission,
} from "@tauri-apps/plugin-notification"; } from "@tauri-apps/plugin-notification";
import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; import {
isLinuxPlatform,
isMacPlatform,
isWindowsPlatform,
} from "@/shared/lib/platform";
// Backend event emitted when a native Linux notification is clicked or a // Backend event emitted when a native Linux notification is clicked or a
// queued macOS activation becomes available. See src-tauri notification code. // queued macOS activation becomes available. See src-tauri notification code.
@@ -147,6 +151,25 @@ export async function getDesktopNotificationPermissionState(): Promise<DesktopNo
} }
if (window.Notification.permission !== "default") { if (window.Notification.permission !== "default") {
// On Windows the notification plugin's injected shim stamps `denied` at
// startup without ever consulting the backend — `permission_state()`
// returns `Granted` unconditionally on desktop — so a `denied` reading
// inside the Windows Tauri app is never a real denial. Repair it by
// requesting once: on Windows `request_permission()` raises no prompt,
// returns `Granted`, and rewrites the shim's cached value, so subsequent
// reads short-circuit here as `granted`.
if (
window.Notification.permission === "denied" &&
isTauri() &&
isWindowsPlatform()
) {
try {
return await requestDesktopNotificationAccess();
} catch {
return window.Notification.permission;
}
}
return window.Notification.permission; return window.Notification.permission;
} }

View File

@@ -109,8 +109,9 @@ const PERSON_OUTLINE_OFFSETS = [
// always matches the JS API. `script-src` in tauri.conf.json allowlists the // always matches the JS API. `script-src` in tauri.conf.json allowlists the
// jsDelivr path prefix for the @mediapipe npm scope rather than the whole // jsDelivr path prefix for the @mediapipe npm scope rather than the whole
// origin, which also serves arbitrary npm and GitHub scripts; this URL must // origin, which also serves arbitrary npm and GitHub scripts; this URL must
// stay under that prefix — `src-tauri/tests/csp.rs` checks that it does. // stay under that prefix — `src/app/contentSecurityPolicy.test.mjs` checks
const MEDIAPIPE_WASM_BASE = // that it does.
export const MEDIAPIPE_WASM_BASE =
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm"; "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
const SELFIE_SEGMENTER_MODEL_URL = const SELFIE_SEGMENTER_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite"; "https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isWindowsPlatform } from "./platform.ts";
function withNavigator(navigator, callback) {
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: navigator,
});
try {
callback();
} finally {
if (original) {
Object.defineProperty(globalThis, "navigator", original);
} else {
delete globalThis.navigator;
}
}
}
test("Windows detection accepts the platform strings WebView2 reports", () => {
for (const platform of ["Win32", "Win64", "Windows"]) {
withNavigator({ platform, userAgent: "" }, () => {
assert.equal(isWindowsPlatform(), true, platform);
});
}
});
test("Windows detection rejects the other desktop platforms", () => {
for (const platform of ["MacIntel", "Darwin", "Linux x86_64"]) {
withNavigator({ platform, userAgent: "" }, () => {
assert.equal(isWindowsPlatform(), false, platform);
});
}
});

View File

@@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean {
); );
} }
/** Returns true on Windows desktops. */
export function isWindowsPlatform(): boolean {
if (typeof navigator === "undefined") {
return false;
}
return /^win/i.test(navigator.platform);
}
/** /**
* The platform's normal application-shortcut modifier: * The platform's normal application-shortcut modifier:
* - macOS: Command (Meta) * - macOS: Command (Meta)

View File

@@ -3,12 +3,12 @@
Design guidance for follow-up work, not implemented wire capabilities. Design guidance for follow-up work, not implemented wire capabilities.
The current [API contract](buzz-v1-read-state.md) remains authoritative. The current [API contract](buzz-v1-read-state.md) remains authoritative.
Thread summaries are implemented (see `threads` in the API contract). Richer Thread rows 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; 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 fetching detail separately remains valid. Reuse canonical classification and
read frontiers, not a second definition of unread. Historical-mention eligibility read frontiers, not a second definition of unread. Historical-mention eligibility
or mute-aware attention requires an explicit policy contract, not an unnoticed or mute-aware mentions requires an explicit policy contract, not an unnoticed
change to today's `attention` field. change to today's `mentions` field.
Channel-content revisions, personal-state revisions and synchronized preferences Channel-content revisions, personal-state revisions and synchronized preferences
are separate from read frontiers. Future revisions must describe the same are separate from read frontiers. Future revisions must describe the same

View File

@@ -12,8 +12,7 @@ application/nostr+json`, or `GET /info`) includes `buzz_v1` only when enabled:
```json ```json
{"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000, {"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,"eligible_kinds":[9,40002,45001,45003]}}
"max_thread_summaries":5,"eligible_kinds":[9,40002,45001,45003]}}
``` ```
Use the requesting origin plus this relative prefix. Discovery is a configured Use the requesting origin plus this relative prefix. Discovery is a configured
@@ -50,14 +49,9 @@ Unknown request fields are rejected. Never turn a transport failure into read.
## Sidebar ## Sidebar
`GET /buzz/v1/me/sidebar?limit=20&cursor=<exclusive-channel-uuid>` returns `GET /buzz/v1/me/sidebar?limit=20&cursor=<exclusive-channel-uuid>` returns
`account`, `channels`, and `next_cursor`. Omit the cursor on the first request. `channels` and `next_cursor`. Omit the cursor on the first request. Only
Each channel includes identity/name/type, archived and hidden flags, `unread`, joined, nondeleted channels are listed; names, types, archived and hidden state
`attention`, `latest_message_id` (the last eligible message to arrive: the come from Nostr channel metadata, not this API. Each page has a
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 writer-consistent snapshot; separate pages do not share a snapshot, and an
unfinished traversal cannot prove channel removal. unfinished traversal cannot prove channel removal.
@@ -67,65 +61,63 @@ 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. a joined, nondeleted, accessible sidebar row at that snapshot: remove its row.
Absence says nothing else about access to an open channel. Absence says nothing else about access to an open channel.
`threads` lists unread threads in the row, newest unread reply first:
```json ```json
{"items":[{"root_id":"<64-hex>","unread":{"status":"exact","value":2}, {"channels":[{"channel_id":"<uuid>","unread":true,"mentions":1,
"latest_reply_id":"<64-hex>","latest_reply_at":1700000000}],"complete":true} "read_through_id":"<64-hex>","latest_id":"<64-hex>",
"threads":[{"root_id":"<64-hex>","unread":true,"mentions":2,
"read_through_id":null,"latest_id":"<64-hex>"}]}],
"next_cursor":null}
``` ```
Items are canonical roots with unread replies that count (see below), ordered A channel's timeline and each of its threads keep independent read positions;
by `latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is marking one never moves another.
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
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: | Field | Channel row | Thread row |
|---|---|---|
| `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 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 |
```json A thread reply never makes the channel row unread and never becomes its
[{"status":"exact","value":0},{"status":"at_least","value":7},{"status":"unknown"}] `latest_id`, including a reply carrying `broadcast=1`: it shows on its thread
``` row. `latest_id` is the anchor that reads its scope: "mark channel read" sends a
channel `mark_through` at the channel's `latest_id`, plus one thread
`mark_through` at each listed thread's `latest_id` to clear those too. The
channel `latest_id` counts any author and ignores read progress. Null means the
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.
Only exact zero proves absence. Unknown has no numeric value. A message is `threads` lists threads with unread replies that count, newest unread reply
eligible when it is non-own, nondeleted, of the advertised `eligible_kinds` and first by author time (then `root_id`), at most 25. Threads past the 25th are
inside the horizon. The same kinds alone define latest activity, so an edit, omitted with their mentions, and nothing signals the omission: after marking
reaction or diff (40008) neither makes a channel unread nor moves it. Classify the listed threads read, a refresh can list the next ones. A thread row's
live arrivals with the advertised set, not a client copy. `unread` is always true in this version. No message bytes are included.
An eligible message beyond the matching context frontier counts as unread for A message is eligible when it is non-own, nondeleted, of the advertised
the first reason that holds: `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.
| `reason` | Holds when | A top-level message is directed at you when its channel is a DM, it tags you
|---|---| with `p`, or it carries `broadcast=1`. A reply counts only when it is directed
| `direct` | its channel is a DM | in the same way, or it is in one of your conversations: you wrote its direct
| `mention` | it tags the actor with `p` | parent or have a reply to that same parent, in that channel. Conversation
| `conversation` | it is a reply, and the actor wrote its direct parent or has a reply to that same parent, in that channel | membership uses only your live eligible messages (a deleted parent proves
| `broadcast` | it carries `broadcast=1` | nothing; a surviving reply to it still does), looks at the direct parent only
| null | it is top-level | (owning the root or replying elsewhere in the thread proves nothing), and is
independent of read progress and retention. This is not Desktop notification
policy: follows and mutes do not affect these counts.
A reply with no reason does not count: it is not unread and appears in no Counts are what the bounded scans could establish, and may undercount. A message
thread list. `unread` counts the messages that count; `attention` is the subset whose tags are unusable, whose ancestry is unresolved, or whose conversation
with a reason. Conversation membership uses only the actor's live eligible membership is undecided is left out, as are messages beyond the scan bounds
messages (a deleted parent proves nothing; a surviving reply to it still does), (see [Bounds](#bounds-and-deployment)).
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 is 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 measured in author time (`created_at`). It filters unread and mentions, not
is at or after `account.cutoff_ms`. It filters unread/attention, not latest latest activity, event storage or frontier state. Frontiers use a different
activity, event storage or frontier state. Frontiers use a different clock, clock, relay arrival (see [Order](#order)). Three consequences:
relay arrival (see [Order](#order)). Three consequences:
- A message accepted late with an author time beyond the horizon (an import, a - 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, backfill, a long-offline sender) is excluded under the current horizon,
@@ -137,33 +129,6 @@ relay arrival (see [Order](#order)). Three consequences:
frontier, and author time at or after the cutoff. frontier, and author time at or after the cutoff.
A later configuration expansion can change counts without having lost progress. 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=<URL-encoded-JSON-array>` 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":"<uuid>","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 `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
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 ## Fixed-operand writes
@@ -172,33 +137,30 @@ Conversation bytes must still come from the existing Nostr path.
```json ```json
{"intents":[ {"intents":[
{"type":"mark_through","target":{"channel_id":"<uuid>"},"message_id":"<64-hex-event>"}, {"type":"mark_through","target":{"channel_id":"<uuid>"},"message_id":"<64-hex-event>"},
{"type":"mark_through","target":{"channel_id":"<uuid>","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"}, {"type":"mark_through","target":{"channel_id":"<uuid>","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"}
{"type":"mark_channel_read","channel_id":"<uuid>","message_id":"<64-hex-event>"}
]} ]}
``` ```
Each intent commits atomically and returns its own `applied`, `blocked` or The response is `{"outcomes":[...],"channels":[...]}`. Each intent commits
`invalid` outcome. An ambiguous timeout/storage failure returns atomically and returns its own `applied`, `blocked` or `invalid` outcome, in
request order. An ambiguous timeout/storage failure returns
`{"status":"unknown","retryable":true}`. Earlier committed outcomes survive `{"status":"unknown","retryable":true}`. Earlier committed outcomes survive
later failures. `projection_status` is `not_requested`; a successful write does later failures. `channels` holds the updated sidebar row of each distinct
not assert a client has refreshed. Retry the same operands, never substitute channel with an applied intent, in the sidebar's row shape and ordered by ID;
latest. Keep pending intent durably on the client until its outcome is resolved. 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 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.
A mark-through validates a fixed message and advances its context's monotone A mark-through validates a fixed message and advances its context's monotone
frontier to that message's relay arrival (see [Order](#order)). Channel and frontier to that message's relay arrival (see [Order](#order)). A channel
thread frontiers never inherit in either direction. Opening a view is not anchor must be a top-level message; a thread anchor must be the thread's root
itself a reading action; client dwell/focus policy determines when to send an or one of its replies. The frontier records the anchor as `read_through_id`
actual observed anchor. Old or deleted valid anchors may advance a frontier. when it advances; an anchor that arrived at or before the current frontier
changes nothing. Opening a view is not itself a reading action; client
`mark_channel_read` is the one whole-channel cut: it advances the channel dwell/focus policy determines when to send an actual observed anchor. Old or
timeline and every thread in that channel, including unlisted ones, through the deleted valid anchors may advance a frontier.
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 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 ### Order
@@ -209,11 +171,11 @@ 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 later is unread even when backdated, and a future-dated anchor reads nothing
that arrives after it. that arrives after it.
The order is the relay's and is not exposed: no response carries a frontier, The order is the relay's and is not exposed: a response carries the anchor
and no field lets a client compute what a mark will cover. Send the anchors the message (`read_through_id`), never its arrival time, and no field lets a client
user actually saw, let the relay take the greatest, and ask a context which compute what a mark will cover. Send the anchors the user actually saw and let
messages are read. Do not compare author times, IDs or local receipt order to the relay take the greatest. Do not compare author times, IDs or local receipt
drop one pending anchor in favor of another. order to drop one pending anchor in favor of another.
Arrival is the accepting relay process's clock, read just before the insert, at 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: microsecond resolution. Three limits follow, none of which strands a badge:
@@ -224,10 +186,11 @@ microsecond resolution. Three limits follow, none of which strands a badge:
skew. skew.
- Messages with the identical stamp are read together. - Messages with the identical stamp are read together.
`latest_message_id` is the last message to arrive among those the unread count A channel's `latest_id` is the last top-level message to arrive among those the
examined: the 4,096 most recent events by author time inside the horizon. So unread count examined: the 4,096 most recent events by author time inside the
marking through it reads everything counted. When the horizon holds no message, horizon. So marking through it reads every top-level message counted. When
it is the last to arrive among the channel's 256 most recent events. 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 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. account's `started_at` at its first applied read intent and never moves it.
@@ -238,40 +201,45 @@ device-local.
## Bounds and deployment ## Bounds and deployment
- 20 sidebar rows, 100 intents, 20 contexts / 100 selectors per request. - 20 sidebar rows and 100 intents per request; 25 thread rows per channel, so
- 64 KiB write body; 16 KiB context URL; 1 MiB serialized API response. a POST's rows for 100 channels fit the response limit.
- 4096 raw events per channel inside the horizon plus one exhaustion sentinel, - 64 KiB write body; 1 MiB serialized API response.
before eligibility. - 4096 raw events per channel inside the horizon, before eligibility.
- Latest activity probes 256 events plus a sentinel; long ineligible tails may - The latest fallback probes 256 events; a long ineligible or reply-only tail
leave latest incomplete even when unread is exact. can leave `latest_id` null.
- Tag documents over 8192 bytes or malformed relevant tags yield uncertainty. - Tag documents over 8192 bytes or malformed relevant tags leave the message
Compact boolean facts cross the database boundary, never raw tag payloads. out. Compact boolean facts cross the database boundary, never raw tag
payloads.
- Conversation membership: at most 1024 unique parents per request, with a - 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 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 replies under each parent. Past either bound a reply is undecided and left
absent: counts become lower bounds or unknown and a context reports `unknown`. out.
- DB statement/lock deadlines and HTTP read deadlines bound work; writes use a - DB statement/lock deadlines and HTTP read deadlines bound work; a write's intents
shared eight-second intent-processing deadline after admission. Limits are and its row refresh share one eight-second deadline after admission. Limits are
containment, not a production capacity claim. containment, not a production capacity claim.
Apply migrations 0056 and 0057 (or the equivalent desired schema). 0056 Apply migrations 0056 through 0058 (or the equivalent desired schema). 0056
creates two empty private tables and no index on `events`, and 0057 adds the creates two empty private tables and no index on `events`, 0057 adds the
nullable `personal_read_accounts.started_at` column. Both sidebar scans are nullable `personal_read_accounts.started_at` column, and 0058 adds the nullable
`personal_read_frontiers.through_message_id` column and drops
`threads_through_timestamp`. Both sidebar scans are
served by the existing `idx_events_community_channel_created`. No new served by the existing `idx_events_community_channel_created`. No new
per-message ingest write path or stored unread counters are introduced. per-message ingest write path or stored unread counters are introduced.
Use existing HTTP route/status/latency metrics for `/buzz/v1/me/sidebar` and 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 / `/buzz/v1/me/read-state`, plus database pool/statement metrics. No payload,
lower-bound / unknown proportions in controlled acceptance captures; no payload,
actor, channel or frontier values should become metric labels. The measured actor, channel or frontier values should become metric labels. The measured
local seed is not a DAU/concurrency or p95/p99 production acceptance result. local seed is not a DAU/concurrency or p95/p99 production acceptance result.
## Compatibility and extension rules ## Compatibility and extension rules
Within `/buzz/v1`, clients must ignore unknown response object fields. Existing Within `/buzz/v1`, clients must ignore unknown response object fields. The
required fields, status variants and their meanings remain stable; additive shape is stable: existing fields, their types, status variants and what a
fields do not authorize silently changing `attention` or frontier semantics. `mark_through` does remain. What the counts include is not yet: which messages
Breaking changes require an explicitly negotiated contract or a new API version. are unread or directed, which threads are listed, and the bounds may change
while v1 is pre-release. A client therefore replaces its rows with each
response rather than reconciling them with its own counts. Breaking shape
changes require an explicitly negotiated contract or a new API version.
Requests remain strict: send new parameters or intent types only after the relay Requests remain strict: send new parameters or intent types only after the relay
advertises the corresponding capability. Missing optional data means unsupported advertises the corresponding capability. Missing optional data means unsupported
or not requested, never an empty list, zero count or unchanged revision. or not requested, never an empty list, zero count or unchanged revision.

View File

@@ -0,0 +1,7 @@
-- A frontier records the message it was read through, so the sidebar can
-- report it. NULL for frontiers written before this column. Channel and thread
-- frontiers are independent: the whole-channel cut covering every thread is
-- gone.
ALTER TABLE personal_read_frontiers
ADD COLUMN through_message_id BYTEA CHECK (octet_length(through_message_id) = 32),
DROP COLUMN threads_through_timestamp;

View File

@@ -130,7 +130,9 @@ Future<void> _deactivateCommunityPushLease(
} }
final decoded = nostr.Nip19.decode(payload: nsec); final decoded = nostr.Nip19.decode(payload: nsec);
final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public;
final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); final descriptor = await fetchBuzzPushLeaseDescriptor(
canonicalBuzzPushRelayHttpUrl(community.relayUrl),
);
final matchingGrant = (await readBuzzPushEndpointGrants()) final matchingGrant = (await readBuzzPushEndpointGrants())
.where( .where(
(grant) => (grant) =>

View File

@@ -0,0 +1,56 @@
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
for (final scheme in ['ws', 'wss', 'http', 'https']) {
test(
'opt-out fetches relay metadata over HTTP for $scheme community',
() async {
final container = ProviderContainer();
addTearDown(container.dispose);
final community = Community.create(
name: 'Test',
relayUrl: '$scheme://relay.example:8443',
nsec: nostr.Keys.generate().nsec,
);
final requests = <http.Request>[];
final client = MockClient((request) async {
requests.add(request);
// Stop at the HTTP boundary, before native grants or publication.
return http.Response('test unavailable', 503);
});
addTearDown(client.close);
await http.runWithClient(() async {
await expectLater(
container.read(communityPushLeaseDeactivatorProvider)(
community,
generation: 2,
),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('NIP-11 request failed with HTTP 503'),
),
),
);
}, () => client);
final httpScheme = scheme == 'ws' || scheme == 'http'
? 'http'
: 'https';
expect(
requests.single.url,
Uri.parse('$httpScheme://relay.example:8443/'),
);
expect(requests.single.headers['Accept'], 'application/nostr+json');
},
);
}
}

View File

@@ -4,9 +4,8 @@ CREATE TABLE personal_read_frontiers (
channel_id UUID NOT NULL, channel_id UUID NOT NULL,
root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)),
through_timestamp TIMESTAMPTZ NOT NULL, through_timestamp TIMESTAMPTZ NOT NULL,
-- Whole-channel cut covering every thread; channel rows only. -- The message whose arrival is through_timestamp; NULL before 0058.
threads_through_timestamp TIMESTAMPTZ through_message_id BYTEA CHECK (octet_length(through_message_id) = 32),
CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea),
PRIMARY KEY (community_id, actor, channel_id, root_id), PRIMARY KEY (community_id, actor, channel_id, root_id),
FOREIGN KEY (community_id, actor) FOREIGN KEY (community_id, actor)
REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,