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();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 58);
// 0058 is reserved by the independently reviewed trigger retirement.
assert_eq!(migrations[57].version, 59);
assert!(migrations[57].sql.as_str().contains("buzz.push_enabled"));
assert_eq!(migrations.len(), 59);
assert_eq!(migrations[58].version, 59);
assert!(migrations[58].sql.as_str().contains("buzz.push_enabled"));
assert_eq!(migrations[55].version, 56);
assert!(migrations[55]
.sql
@@ -719,6 +718,11 @@ mod postgres_tests {
.sql
.as_str()
.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[49].version, 50);
assert_eq!(migrations[50].version, 51);
@@ -2232,6 +2236,18 @@ mod postgres_tests {
.get("personal_read_accounts")
.expect("schema.sql personal read accounts")
.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 {
let in_schema = schema.tables.get(&table).map(|schema_definition| {
if table == "personal_read_accounts" {
@@ -2240,6 +2256,11 @@ mod postgres_tests {
schema_definition.clone()
}
});
let definition = if table == "personal_read_frontiers" {
definition.replacen(threads_through_column, through_message_column, 1)
} else {
definition
};
assert_eq!(
in_schema.as_ref(),
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");
}
/// 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(
db: &Db,
pool: &PgPool,
community: CommunityId,
channel: Uuid,
actor: &Keys,
authored: u64,
arrived: u64,
tags: Vec<Tag>,
) -> nostr::Event {
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))
.sign_with_keys(&Keys::generate())
.unwrap();
@@ -56,17 +57,7 @@ async fn reply(
authored: u64,
arrived: u64,
) -> nostr::Event {
let mention = Tag::public_key(actor.public_key());
let event = post(
db,
pool,
community,
channel,
authored,
arrived,
vec![mention],
)
.await;
let event = post(db, pool, community, channel, actor, authored, arrived).await;
sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth)
VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)")
.bind(community.as_uuid()).bind(event.id.as_bytes().as_slice())
@@ -89,6 +80,12 @@ async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSu
.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) {
let outcome = db
.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]
#[ignore = "requires Postgres"]
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()),
)
.await;
assert_eq!(counts(&db, community, &actor).await, (false, 0));
// 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!(
states(&db, community, &actor, channel, &[&read, &late]).await,
["read", "unread"]
);
assert_eq!(
exact(&sidebar(&db, community, &actor).await.unread),
Some(1)
);
let row = sidebar(&db, community, &actor).await;
assert_eq!((row.unread, row.mentions), (true, 1));
assert_eq!(row.latest_id, Some(late.id.to_hex()));
assert_eq!(row.read_through_id, Some(read.id.to_hex()));
}
#[tokio::test]
@@ -181,7 +135,7 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() {
let now = earlier.created_at.as_secs();
arrive(&pool, community, &earlier, now - 60).await;
// Stamped ten minutes ahead; the relay accepts up to fifteen.
let ahead = post(&db, &pool, community, channel, now + 600, now - 40, vec![]).await;
let ahead = post(&db, &pool, community, channel, &actor, now + 600, now - 40).await;
apply(
&db,
community,
@@ -189,17 +143,11 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() {
mark_through(channel, None, &ahead.id.to_hex()),
)
.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!(
states(&db, community, &actor, channel, &[&earlier, &ahead, &later]).await,
["read", "read", "unread"]
);
assert_eq!(
exact(&sidebar(&db, community, &actor).await.unread),
Some(1)
);
assert_eq!(counts(&db, community, &actor).await, (true, 1));
}
/// 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 now = first.created_at.as_secs();
arrive(&pool, community, &first, now - 60).await;
post(&db, &pool, community, channel, now + 1, now - 40, vec![]).await;
post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await;
assert_eq!(
exact(&sidebar(&db, community, &actor).await.unread),
Some(3)
);
post(&db, &pool, community, channel, &actor, now + 1, now - 40).await;
let last = post(&db, &pool, community, channel, &actor, now - 600, now - 30).await;
let row = sidebar(&db, community, &actor).await;
// The fixture message is unread too, but mentions no one.
assert_eq!((row.unread, row.mentions), (true, 2));
assert_eq!(row.latest_id, Some(last.id.to_hex()));
let anchor = sidebar(&db, community, &actor)
.await
.latest_message_id
let anchor = row
.latest_id
.expect("a channel with messages has a latest message");
apply(
&db,
community,
&actor,
ReadIntent::MarkChannelRead {
channel_id: channel,
message_id: anchor,
},
)
.await;
apply(&db, community, &actor, mark_through(channel, None, &anchor)).await;
assert_eq!(
exact(&sidebar(&db, community, &actor).await.unread),
Some(0)
);
assert_eq!(counts(&db, community, &actor).await, (false, 0));
}
#[tokio::test]
@@ -262,7 +197,7 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
now - 40,
)
.await;
reply(
let last = reply(
&db,
&pool,
community,
@@ -274,11 +209,17 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
)
.await;
let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(2));
assert_eq!(row.threads.items.len(), 1);
assert_eq!(
(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 anchor = row.threads.items[0].latest_reply_id.clone();
let anchor = row.threads[0].latest_id.clone();
apply(
&db,
community,
@@ -288,15 +229,15 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
.await;
let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(0));
assert!(row.threads.items.is_empty() && row.threads.complete);
assert!(!row.unread);
assert!(row.threads.is_empty());
}
/// The shallow latest probe reads the newest `MAX_CHANNEL_SCAN + 1` events by
/// author time, of any kind. A late arrival with an older author time than
/// that many events is counted unread, so the unread scan must supply the
/// anchor or Mark as read with the sidebar anchor leaves it.
async fn mark_as_read_behind_fillers(fillers: u64) -> Option<u32> {
async fn mark_as_read_behind_fillers(fillers: u64) -> (bool, u32) {
let (db, pool, community, channel, actor, first) = fixture().await;
let now = first.created_at.as_secs();
arrive(&pool, community, &first, now - 60).await;
@@ -318,27 +259,15 @@ async fn mark_as_read_behind_fillers(fillers: u64) -> Option<u32> {
.unwrap();
arrive(&pool, community, &filler, now - 50).await;
}
post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await;
assert_eq!(
exact(&sidebar(&db, community, &actor).await.unread),
Some(1)
);
post(&db, &pool, community, channel, &actor, now - 600, now - 10).await;
assert_eq!(counts(&db, community, &actor).await, (true, 1));
let anchor = sidebar(&db, community, &actor)
.await
.latest_message_id
.latest_id
.expect("a channel with messages has a latest message");
apply(
&db,
community,
&actor,
ReadIntent::MarkChannelRead {
channel_id: channel,
message_id: anchor,
},
)
.await;
exact(&sidebar(&db, community, &actor).await.unread)
apply(&db, community, &actor, mark_through(channel, None, &anchor)).await;
counts(&db, community, &actor).await
}
/// 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"]
async fn mark_as_read_clears_a_late_arrival_inside_the_latest_probe() {
let fillers = MAX_CHANNEL_SCAN as u64 - 1;
assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0));
assert_eq!(mark_as_read_behind_fillers(fillers).await, (false, 0));
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn mark_as_read_clears_a_late_arrival_outside_the_latest_probe() {
let fillers = MAX_CHANNEL_SCAN as u64;
assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0));
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
/// 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]
#[ignore = "requires Postgres"]
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()
.rows_affected();
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 {
let filler = EventBuilder::new(Kind::Custom(7), "+")
.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;
assert_eq!(row.latest_message_id, Some(only.id.to_hex()));
assert_eq!(row.latest_message_at, Some((now - 600) as i64));
assert!(row.latest_message_complete);
assert_eq!(exact(&row.unread), Some(1));
assert_eq!(row.latest_id, Some(only.id.to_hex()));
assert_eq!((row.unread, row.mentions), (true, 1));
}
/// 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");
}
/// Two messages with the same author time arriving within one second; marks
/// the one at `pick` and returns both channel states.
async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec<String>, Option<u32>) {
let (db, pool, community, channel, actor, first) = fixture().await;
let now = first.created_at.as_secs();
let second = post(&db, &pool, community, channel, now, now, vec![]).await;
/// Two mentioning messages with the same author time arriving within one
/// second; marks the one at `pick`, then the other. Returns the mention count
/// after the first mark, and the anchor (index) the frontier ends with.
async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (u32, usize) {
let (db, pool, community, channel, actor, older) = fixture().await;
let now = older.created_at.as_secs();
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, &second, arrived, micros[1]).await;
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()),
)
.await;
(
states(&db, community, &actor, channel, &both).await,
exact(&sidebar(&db, community, &actor).await.unread),
let mentions = sidebar(&db, community, &actor).await.mentions;
apply(
&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
/// 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]
#[ignore = "requires Postgres"]
async fn arrivals_one_microsecond_apart_in_the_same_second_are_ordered() {
assert_eq!(
mark_within_one_second([500_000, 500_001], 0).await,
(vec!["read".to_owned(), "unread".to_owned()], Some(1))
);
assert_eq!(mark_within_one_second([500_000, 500_001], 0).await, (1, 1));
}
/// 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]
#[ignore = "requires Postgres"]
async fn marking_either_of_two_identical_arrivals_reads_both() {
for pick in [0, 1] {
assert_eq!(
mark_within_one_second([500_000, 500_000], pick).await,
(vec!["read".to_owned(), "read".to_owned()], Some(0)),
(0, 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
//! intents advance frontiers, never a query scan cap.
mod classification;
mod context;
mod model;
mod participation;
mod projection;

View File

@@ -28,14 +28,6 @@ pub enum ReadIntent {
/// Fixed anchor; retry must not substitute the latest message.
message_id: String,
},
/// Advance the channel timeline and every thread in it through one fixed
/// message's 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.
@@ -50,220 +42,72 @@ pub enum IntentOutcome {
Invalid,
}
/// The tracking boundary for the authenticated account.
#[derive(Clone, Debug, Serialize)]
pub struct ReadAccount {
/// Configured tracking duration in seconds.
pub retention_seconds: u32,
/// The tracking boundary for the authenticated account. Not on the wire.
#[derive(Clone, Debug)]
pub(super) struct ReadAccount {
/// 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
/// intent. `None` until then, and nothing counts as unread. Not on the wire.
#[serde(skip)]
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)
}
/// intent. `None` until then, and nothing counts as unread.
pub(super) started_at: Option<DateTime<Utc>>,
}
/// Maximum channel summaries in one sidebar page.
pub const MAX_CHANNELS: usize = 20;
/// Maximum unread-thread summaries per channel row.
pub const MAX_THREAD_SUMMARIES: usize = 5;
/// Bounded event evidence per channel; exhaustion is never inferred at this cap.
/// Maximum thread rows per channel row. A POST can return rows for
/// `MAX_INTENTS` channels, and they must fit the 1 MiB response limit.
pub const MAX_THREAD_SUMMARIES: usize = 25;
/// Latest-probe event budget per channel, before eligibility filtering.
pub const MAX_CHANNEL_SCAN: usize = 256;
/// Unread-window work budget per channel, before eligibility/ancestry joins.
pub const MAX_UNREAD_SCAN: usize = 4096;
/// Conversation kinds eligible for ordinary unread state (not edits/reactions).
pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003];
/// An honest aggregate: capped evidence cannot establish exact zero.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ReadCount {
/// Exhausted the authoritative candidate set.
Exact {
/// Total within the tracking horizon.
value: u32,
},
/// Incomplete evidence establishes no positive lower bound. No numeric value.
Unknown,
/// More evidence exists or ancestry/membership could not be proved.
AtLeast {
/// Proven lower bound, not a fabricated badge cap.
value: u32,
},
}
impl ReadCount {
pub(super) fn from_evidence(value: u32, complete: bool) -> Self {
if complete {
Self::Exact { value }
} else if value == 0 {
Self::Unknown
} else {
Self::AtLeast { value }
}
}
}
/// One joined-channel summary, not a second conversation/history API.
/// One joined channel's timeline read state. A channel and each of its
/// threads keep independent read positions.
#[derive(Debug, Serialize)]
pub struct ChannelReadSummary {
/// Joined channel UUID.
pub channel_id: Uuid,
/// Existing channel name.
pub name: String,
/// Existing channel type.
pub channel_type: String,
/// Archived channels stay in the roster; presentation remains client-owned.
pub archived: bool,
/// Existing DM visibility preference (not an authorization decision).
pub hidden: bool,
/// Unread messages that count: every top-level message, and a reply only
/// when it has a [`Reason`]. Other replies are not unread at all.
pub unread: ReadCount,
/// The unread subset with a [`Reason`]: everything but ordinary top-level
/// messages. This is not Desktop notification eligibility: follows and
/// mutes do not change this count.
pub attention: ReadCount,
/// Last eligible nondeleted event to arrive, inside the unread horizon when
/// any is, whatever its author or read progress: marking through it reads
/// the row.
/// None proves absence only when latest_message_complete is true.
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,
/// Whether an unread top-level message was found. Thread replies count
/// only on their thread row.
pub unread: bool,
/// Unread top-level messages directed at the actor: every one in a DM,
/// otherwise those that tag the actor with `p` or carry `broadcast=1`.
pub mentions: u32,
/// The anchor of the timeline's frontier: the marked message that arrived
/// last. None until the timeline is marked.
pub read_through_id: Option<String>,
/// Last eligible top-level message to arrive, whatever its author or read
/// progress: marking the timeline through it reads the timeline.
pub latest_id: Option<String>,
/// 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.
#[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.
/// One thread's read state within a channel row. No conversation bytes.
#[derive(Debug, Serialize)]
pub struct ThreadReadSummary {
/// Canonical thread-root event ID.
pub root_id: String,
/// Unread replies in this thread (same definition as the row count).
pub unread: ReadCount,
/// Last observed unread reply to arrive: marking through it reads the thread.
pub latest_reply_id: String,
/// Author time (Unix seconds) of latest_reply_id.
pub latest_reply_at: i64,
/// Whether an unread reply that counts was found.
pub unread: bool,
/// Unread replies that count: directed at the actor, or in one of the
/// actor's conversations.
pub mentions: u32,
/// 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.
#[derive(Debug, Serialize)]
pub struct SidebarPage {
/// Effective read-state lifecycle for this response.
pub account: ReadAccount,
/// Joined channels only, never every accessible public channel.
pub channels: Vec<ChannelReadSummary>,
/// Exclusive UUID roster cursor. None means this roster scan exhausted.
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)
}
/// The wire state of one message in the channel timeline or `root`'s thread.
async fn state(
&self,
channel: Uuid,
root: Option<&nostr::Event>,
message: &nostr::Event,
) -> Value {
let page = self
.db
.personal_read_contexts(
self.community,
&self.actor.public_key(),
DEFAULT_RETENTION_SECONDS,
&[ContextQuery {
target: ReadTarget {
channel_id: channel,
root_id: root.map(|root| root.id.to_hex()),
},
message_ids: vec![message.id.to_hex()],
}],
)
.await
.unwrap();
let mut state = wire(&page)["contexts"][0]["messages"][0].take();
state.as_object_mut().unwrap().remove("message_id");
state
/// The row's counts and thread rows: what the sidebar says counts.
async fn counts(&self, channel: Uuid) -> Value {
let row = self.row(channel).await;
json!({"unread":row.unread,"mentions":row.mentions,"threads":wire(&row.threads)})
}
fn mention(&self) -> Tag {
@@ -158,26 +136,20 @@ fn wire<T: serde::Serialize>(value: &T) -> Value {
serde_json::to_value(value).unwrap()
}
fn exact(value: u32) -> Value {
json!({"status":"exact","value":value})
}
/// Unread with a reason; `Value::Null` for an ordinary top-level message.
fn unread(reason: impl Into<Value>) -> Value {
json!({"status":"unread","reason":reason.into()})
}
fn status(status: &str) -> Value {
json!({ "status": status })
/// Expected `counts`: whether a top-level message is unread, how many of those
/// are directed, and the listed thread rows.
fn counts(unread: bool, mentions: u32, threads: Vec<Value>) -> Value {
json!({"unread":unread,"mentions":mentions,"threads":threads})
}
fn broadcast() -> Tag {
Tag::parse(["broadcast", "1"]).unwrap()
}
fn item(root: &nostr::Event, unread: u32, latest: &nostr::Event) -> Value {
json!({"root_id":root.id.to_hex(),"unread":exact(unread),
"latest_reply_id":latest.id.to_hex(),"latest_reply_at":latest.created_at.as_secs()})
/// A listed, never-marked thread with `mentions` counted unread replies.
fn item(root: &nostr::Event, mentions: u32, latest: &nostr::Event) -> Value {
json!({"root_id":root.id.to_hex(),"unread":true,"mentions":mentions,
"read_through_id":null,"latest_id":latest.id.to_hex()})
}
#[tokio::test]
@@ -197,22 +169,11 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() {
let nested = w
.reply(c, &w.peer, &root, Some(&answer), w.now + 2, vec![])
.await;
assert_eq!(w.state(c, None, &root).await, status("not_counted"));
// Only `answer` counts. The newer reply that does not count is not the
// thread's latest.
assert_eq!(
w.state(c, Some(&root), &answer).await,
unread("conversation")
);
assert_eq!(
w.state(c, Some(&root), &nested).await,
status("not_counted")
);
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(1));
assert_eq!(wire(&row.attention), exact(1));
// The newer reply that does not count is not the thread's preview.
assert_eq!(
wire(&row.threads),
json!({"items":[item(&root, 1, &answer)],"complete":true})
w.counts(c).await,
counts(false, 0, vec![item(&root, 1, &answer)])
);
// 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![])
.await;
assert_eq!(
w.state(c, Some(&root), &nested).await,
unread("conversation")
);
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(2));
assert_eq!(
wire(&row.threads),
json!({"items":[item(&root, 2, &nested)],"complete":true})
w.counts(c).await,
counts(false, 0, vec![item(&root, 2, &nested)])
);
// 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
.reply(c, &w.peer, &root, Some(&joined), w.now + 1, vec![])
.await;
let elsewhere = w
.reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![])
w.reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![])
.await;
assert_eq!(
w.state(c, Some(&root), &sibling).await,
unread("conversation")
);
assert_eq!(
w.state(c, Some(&root), &elsewhere).await,
status("not_counted")
);
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(1));
assert_eq!(
wire(&row.threads),
json!({"items":[item(&root, 1, &sibling)],"complete":true})
w.counts(c).await,
counts(false, 0, vec![item(&root, 1, &sibling)])
);
// A conversation the actor never joined: one unread top-level message, and
// a reply that is not unread even in its own thread.
let c = w.channel().await;
let root = w.post(c, &w.peer, w.now, vec![]).await;
let reply = w
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
.await;
assert_eq!(w.state(c, None, &root).await, unread(Value::Null));
assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted"));
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(1));
assert_eq!(wire(&row.attention), exact(0));
assert_eq!(wire(&row.threads), json!({"items":[],"complete":true}));
assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
// A reply whose parent was never recorded is undecided, not absent.
let orphan = w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await;
assert_eq!(w.state(c, Some(&root), &orphan).await, status("unknown"));
let row = w.row(c).await;
assert_eq!(wire(&row.unread), json!({"status":"at_least","value":1}));
assert_eq!(wire(&row.attention), status("unknown"));
assert_eq!(wire(&row.threads), json!({"items":[],"complete":false}));
// A reply whose parent was never recorded is undecided: left out.
w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await;
assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
}
#[tokio::test]
@@ -288,29 +222,12 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
let c = w.channel().await;
let root = w.post(c, &w.peer, w.now, vec![]).await;
let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await;
let own = w
.reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![])
.await;
let mention = w
.reply(
c,
&w.peer,
&root,
Some(&parent),
w.now + 2,
vec![w.mention()],
)
.await;
let shout = w
.reply(
c,
&w.peer,
&root,
Some(&parent),
w.now + 3,
vec![broadcast()],
)
w.reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![])
.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
.reply(
c,
@@ -321,28 +238,16 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
vec![broadcast(), w.mention()],
)
.await;
assert_eq!(w.state(c, Some(&root), &own).await, status("not_counted"));
assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention"));
assert_eq!(w.state(c, Some(&root), &shout).await, unread("broadcast"));
assert_eq!(w.state(c, Some(&root), &both).await, unread("mention"));
// The root, the actor's sibling `parent`, and the three directed replies.
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(5));
assert_eq!(wire(&row.attention), exact(4));
assert_eq!(
wire(&row.threads),
json!({"items":[item(&root, 4, &both)],"complete":true})
);
// The root is an undirected top-level message. The thread counts the
// actor's sibling `parent` and the three directed replies, not the
// actor's own.
let expected = counts(true, 0, vec![item(&root, 4, &both)]);
assert_eq!(w.counts(c).await, expected);
// 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![])
.await;
assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention"));
assert_eq!(
w.state(c, Some(&root), &shout).await,
unread("conversation")
);
assert_eq!(wire(&w.row(c).await.unread), exact(5));
assert_eq!(w.counts(c).await, expected);
// In a DM every peer message is direct, tagged or not, joined or not.
let dm = w.channel().await;
@@ -359,33 +264,34 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
let reply = w
.reply(dm, &w.peer, &root, Some(&parent), w.now + 1, vec![])
.await;
assert_eq!(w.state(dm, None, &root).await, unread("direct"));
assert_eq!(w.state(dm, Some(&root), &reply).await, unread("direct"));
let row = w.row(dm).await;
assert_eq!(wire(&row.unread), exact(3));
assert_eq!(wire(&row.attention), exact(3));
assert_eq!(
w.counts(dm).await,
counts(true, 1, vec![item(&root, 2, &reply)])
);
}
#[tokio::test]
#[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 c = w.channel().await;
let root = w.post(c, &w.peer, w.now, vec![]).await;
let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await;
let reply = |author, at, tags| w.reply(c, author, &root, Some(&parent), w.now + at, tags);
// Peer replies to a parent the actor neither wrote nor replied to.
let outside = reply(&w.peer, 1, vec![]).await;
let deleted = reply(&w.peer, 2, vec![]).await;
reply(&w.peer, 1, vec![]).await;
let deleted = reply(&w.peer, 2, vec![w.mention()]).await;
w.delete(&deleted).await;
let own = w
.reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![])
w.reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![])
.await;
let anchor = reply(&w.peer, 4, vec![w.mention()]).await;
let later = reply(&w.peer, 5, vec![]).await;
// 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!(
w.state(c, Some(&root), &outside).await,
status("not_counted")
w.counts(c).await,
counts(true, 0, vec![item(&root, 2, &anchor)])
);
assert_eq!(
@@ -404,15 +310,14 @@ async fn eligibility_then_the_read_frontier_are_reported_before_membership() {
.unwrap(),
IntentOutcome::Applied
);
for (message, expected) in [
(&outside, "read"),
(&deleted, "not_counted"),
(&own, "not_counted"),
(&anchor, "read"),
(&later, "not_counted"),
] {
assert_eq!(w.state(c, Some(&root), message).await, status(expected));
}
assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
// A later directed reply is unread past the thread's anchor; the channel
// timeline was never marked.
let after = reply(&w.peer, 6, vec![w.mention()]).await;
let mut thread = item(&root, 1, &after);
thread["read_through_id"] = json!(anchor.id.to_hex());
assert_eq!(w.counts(c).await, counts(true, 0, vec![thread]));
assert_eq!(w.row(c).await.read_through_id, None);
}
#[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.
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.
let once = reply(&w.peer, &root, 0).await;
let only = reply(&w.actor, &once, 1).await;
let to_once = reply(&w.peer, &once, 11).await;
reply(&w.peer, &once, 11).await;
// Two replies by the actor to a peer's parent.
let twice = reply(&w.peer, &root, 0).await;
let first = reply(&w.actor, &twice, 1).await;
@@ -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 to_both = reply(&w.peer, &both, 13).await;
// Whether each counts: `unread` in a conversation, or else `not_counted`.
let counted = || async {
let mut counted = Vec::new();
for message in [&to_wrote, &to_once, &to_twice, &to_both] {
let state = w.state(c, Some(&root), message).await;
let yes = state == unread("conversation");
assert!(yes || state == status("not_counted"), "{state}");
counted.push(yes);
}
counted
// How many replies the thread counts, the latest of them `to_both` until
// it stops counting.
let w = &w;
let thread = |mentions: u32| async move {
let row = w.row(c).await;
assert!(!row.unread, "the root predates the horizon");
assert_eq!(row.threads.len(), 1);
assert_eq!(row.threads[0].mentions, mentions);
row.threads[0].latest_id.clone()
};
let (yes, no) = (true, false);
// `once` and `twice` are peer replies to the root, which the actor has
// replied to (`wrote`, `both`), so they count as well.
assert_eq!(counted().await, [yes, yes, yes, yes]);
assert_eq!(wire(&w.row(c).await.unread), exact(6));
// The four peer replies to the actor's conversations count. So do `once`
// and `twice`: peer replies to the root, which the actor has replied to
// (`wrote`, `both`).
assert_eq!(thread(6).await, to_both.id.to_hex());
// Each deletion changes only its own parent's conversation.
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;
assert_eq!(counted().await, [no, no, yes, yes]);
assert_eq!(thread(4).await, to_both.id.to_hex());
w.delete(&first).await;
assert_eq!(counted().await, [no, no, yes, yes]);
w.delete(&both).await;
assert_eq!(counted().await, [no, no, yes, yes]);
assert_eq!(wire(&w.row(c).await.unread), exact(2));
w.delete(&under).await;
assert_eq!(counted().await, [no, no, yes, no]);
assert_eq!(thread(4).await, to_both.id.to_hex());
// With `wrote` and `both` gone the actor has no reply to the root either.
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(1));
assert_eq!(
wire(&row.threads),
json!({"items":[item(&root, 1, &to_twice)],"complete":true})
);
w.delete(&both).await;
assert_eq!(thread(2).await, to_both.id.to_hex());
w.delete(&under).await;
assert_eq!(thread(1).await, to_twice.id.to_hex());
}
#[tokio::test]
@@ -492,32 +388,21 @@ async fn membership_is_scoped_to_the_replys_own_channel() {
let in_a = w
.reply(a, &w.peer, &theirs, Some(&theirs), w.now, vec![])
.await;
assert_eq!(
w.state(a, Some(&theirs), &in_a).await,
status("not_counted")
);
assert_eq!(wire(&w.row(a).await.unread), exact(0));
assert_eq!(w.counts(a).await, counts(false, 0, vec![]));
// The actor wrote a parent in A. A peer reply in B claims it as its parent.
let mine = w.post(a, &w.actor, old, vec![]).await;
w.reply(b, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
let row = w.row(b).await;
assert_eq!(wire(&row.unread), exact(0));
assert_eq!(wire(&row.threads), json!({"items":[],"complete":true}));
assert_eq!(w.counts(b).await, counts(false, 0, vec![]));
// The same parents count once the actor is a member in the reply's channel.
w.reply(a, &w.actor, &theirs, Some(&theirs), old, vec![])
.await;
let to_mine = w.reply(a, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
assert_eq!(
w.state(a, Some(&theirs), &in_a).await,
unread("conversation")
);
assert_eq!(
w.state(a, Some(&mine), &to_mine).await,
unread("conversation")
);
assert_eq!(wire(&w.row(a).await.unread), exact(2));
// Equal reply times: root ID order.
let mut threads = [item(&theirs, 1, &in_a), item(&mine, 1, &to_mine)];
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()));
}
#[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![])
.await;
}
assert_eq!(w.state(c, Some(&root), &last).await, unread("conversation"));
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(258));
assert_eq!(wire(&row.attention), exact(258));
assert_eq!(
wire(&row.threads),
json!({"items":[item(&root, 258, &last)],"complete":true})
w.counts(c).await,
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 c = w.channel().await;
let old = w.now - 40 * DAY;
// The actor's thread has the oldest unread reply. Five unjoined threads
// are newer and would fill the list if the cap came first.
// The actor's thread has the oldest unread reply. A cap's worth of
// unjoined threads are newer and would fill the list if the cap came
// first.
let mine = w.post(c, &w.actor, old, vec![]).await;
let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
for i in 1..=5 {
for i in 1..=MAX_THREAD_SUMMARIES as u64 {
let root = w.post(c, &w.peer, old, vec![]).await;
w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![])
.await;
}
let row = w.row(c).await;
assert_eq!(wire(&row.unread), exact(1));
assert_eq!(
wire(&row.threads),
json!({"items":[item(&mine, 1, &answer)],"complete":true})
w.counts(c).await,
counts(false, 0, vec![item(&mine, 1, &answer)])
);
}
#[tokio::test]
#[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 c = w.channel().await;
// The actor is in the first conversation. Whether it is inside the budget
// depends on ID order, so only the bounds are asserted.
let mut mine = None;
for i in 0..1025 {
let author = if i == 0 { &w.actor } else { &w.peer };
let root = w.post(c, author, w.now, vec![]).await;
w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
let reply = w
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
.await;
if i == 1023 {
// 1024 parents fit: 1023 peer roots, and the reply to the actor's.
// The independent SQL deadline may still withhold the answer.
let row = w.row(c).await;
if i == 0 {
mine = Some(item(&root, 1, &reply));
}
// 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!(
[exact(1024), json!({"status":"at_least","value":1023})]
.contains(&wire(&row.unread)),
"{:?}",
row.unread
);
assert!(
[exact(1), status("unknown")].contains(&wire(&row.attention)),
"{:?}",
row.attention
[vec![], vec![mine.clone().unwrap()]]
.map(|threads| counts(true, 0, threads))
.contains(&counted),
"{i}: {counted}"
);
}
}
// 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]
@@ -621,8 +487,7 @@ async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction(
let w = World::new().await;
let c = w.channel().await;
let root = w.post(c, &w.peer, w.now, vec![]).await;
let reply = w
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
.await;
let mut held = w.pool.begin().await.unwrap();
// Force the real resolver to time out, then check that its outer snapshot
@@ -668,5 +533,5 @@ async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction(
}
reader.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
//! unread: an unexamined tail yields a positive lower bound or an unknown count.
//! unread: an unexamined tail goes uncounted, so counts may undercount.
use super::{model::*, participation, writes};
use buzz_core::CommunityId;
@@ -81,22 +81,19 @@ impl Db {
// The inner event LIMIT is deliberately before eligibility filtering.
// This bounds rows/joins even with long deleted or self-authored runs.
// Aggregate equivalent eligible evidence before transfer. Multiplicity
// preserves counts; raw scan count (not group count) proves exhaustion.
// SQL eligibility mirrors classification::eligible (PostgreSQL parity test).
// Canonical covered rows need no tags; missing ancestry stays unknown.
// preserves counts.
// Validate relevant tag parts before compacting directed/ancestry facts.
// PostgreSQL scalar "p" ->> 0 is "p": the type check must reject it too.
// Canonical timeline roots share an empty (present) root sentinel.
// Tags are bounded before transfer; oversized/corrupt evidence stays
// unknown, never falsely top-level/unmentioned/read.
// Tags are bounded before transfer; oversized/corrupt evidence is
// skipped, never falsely top-level/unmentioned/read.
// Latest comes from the unread scan, so its ID arrives no earlier than
// anything counted; the shallow probe answers only when the horizon
// holds no message. Both are newest-first by author time, so the deeper
// one finds the same greatest author time whenever the probe finds any.
// holds no top-level message. Thread replies are never the timeline's
// latest: a timeline mark cannot use one as its anchor.
let rows = sqlx::query(
r#"WITH roster AS MATERIALIZED (
SELECT c.id,c.name,c.channel_type::text AS channel_type,
c.archived_at IS NOT NULL AS archived, cm.hidden_at IS NOT NULL AS hidden
SELECT c.id,c.channel_type::text AS channel_type
FROM channel_members cm JOIN channels c
ON c.community_id=cm.community_id AND c.id=cm.channel_id
WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL
@@ -104,23 +101,27 @@ impl Db {
AND ($9::uuid[] IS NULL OR c.id=ANY($9))
ORDER BY c.id LIMIT $4
)
SELECT r.*, COALESCE(e.latest_message_id, latest.latest_message_id) AS latest_message_id,
COALESCE(e.latest_message_at, latest.latest_message_at) AS latest_message_at,
(COALESCE(e.latest_message_id, latest.latest_message_id) IS NOT NULL
OR latest.candidates <= $5-1) AS latest_message_complete,
e.scanned,COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r
SELECT r.*, COALESCE(e.latest_id, latest.latest_id) AS latest_id,
encode(cf.through_message_id,'hex') AS read_through_id,
COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r
LEFT JOIN LATERAL (
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
ORDER BY created_at DESC,id LIMIT $5
)
SELECT count(*) AS candidates,
(array_agg(encode(id,'hex') ORDER BY received_at DESC,id)
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id,
(array_agg(extract(epoch FROM created_at)::bigint ORDER BY created_at DESC,id)
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_at
FROM candidates
SELECT (array_agg(encode(c.id,'hex') ORDER BY c.received_at DESC,c.id)
FILTER (WHERE c.kind=ANY($6) AND c.deleted_at IS NULL
AND NOT COALESCE(tm.root_event_id<>c.id,false)
-- Same unresolved-reply test as `threaded` below.
AND NOT (tm.root_event_id IS NULL AND jsonb_typeof(c.tags)='array'
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
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
@@ -129,29 +130,40 @@ impl Db {
SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags
FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7
ORDER BY created_at DESC,id LIMIT $8
), classified AS (
), threaded AS MATERIALIZED (
SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent,
COALESCE(tm.root_event_id<>e.id,false) AS is_reply,
-- Every frontier is floored at the account's start.
COALESCE(e.received_at <= GREATEST(COALESCE($10::timestamptz,'infinity'),
CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id
THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp)
ELSE cf.through_timestamp END),false) AS covered
FROM (SELECT * FROM candidates ORDER BY created_at DESC,id LIMIT $8-1) e
-- A NIP-10 reply marker without canonical ancestry: neither
-- counted nor a timeline anchor, since a mark cannot validate
-- it. Parity-tested against the shared NIP-10 parser.
tm.root_event_id IS NULL AND jsonb_typeof(e.tags)='array'
AND EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) t(tag)
WHERE jsonb_typeof(tag)='array' AND tag->>0='e'
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
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
AND tf.channel_id=r.id AND tf.root_id=tm.root_event_id AND tm.root_event_id<>e.id
WHERE e.kind=ANY($6) AND e.pubkey<>$2 AND e.deleted_at IS NULL
AND tf.channel_id=r.id AND tf.root_id=e.root AND e.is_reply
WHERE e.pubkey<>$2 AND NOT e.unresolved
), grouped AS (
SELECT CASE WHEN root IS NULL THEN NULL
WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root,
CASE WHEN is_reply THEN encode(parent,'hex') END AS parent,
is_reply, covered,
is_reply, covered, read_through_id,
-- ->>0 also selects scalar "p"/"e": reject nonarrays first.
-- C collation matches Rust's ASCII case/hex rules. Reply
-- markers matter only without canonical ancestry; otherwise
-- metadata, not tag spelling, owns the context.
-- C collation matches Rust's ASCII case rules.
CASE WHEN octet_length(tags::text)<=8192
AND jsonb_typeof(tags)='array' THEN
(SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array'
@@ -159,10 +171,7 @@ impl Db {
THEN NULL ELSE jsonb_build_object(
'directed',COALESCE(bool_or(
(tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex'))
OR (tag->>0='broadcast' AND tag->>1='1')),false),
'reply_marked',root IS NULL AND COALESCE(bool_or(tag->>0='e'
AND tag->>3='reply'
AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'),false)) END
OR (tag->>0='broadcast' AND tag->>1='1')),false)) END
FROM jsonb_array_elements(tags) t(tag)
WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts,
count(*) AS n,
@@ -172,29 +181,24 @@ impl Db {
ORDER BY received_at DESC,id))[1] AS newest_at
FROM classified
WHERE NOT covered OR root IS NULL
GROUP BY 1,2,3,4,5
), scan AS (
SELECT count(*) AS scanned,
(array_agg(encode(id,'hex') ORDER BY received_at DESC,id)
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id,
max(extract(epoch FROM created_at)::bigint)
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL) AS latest_message_at
FROM candidates
GROUP BY 1,2,3,4,5,6
)
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"#,
).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(DateTime::from_timestamp_millis(account.cutoff_ms)
.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(account.started_at)
.fetch_all(&mut *tx).await?;
let has_more = rows.len() > limit;
// An unstarted account has read every arrival, scanned or not, so its
// counts are exactly zero whatever the scan cap left unexamined.
// An unstarted account has read every arrival, so nothing is unread.
let started = account.started_at.is_some();
let mut channels = Vec::new();
let mut pending = Vec::new();
@@ -204,11 +208,9 @@ impl Db {
.as_array()
.ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?;
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 mut unread = 0;
let mut attention = 0;
let mut unread_complete = complete;
let mut unread = false;
let mut mentions = 0;
let mut threads: HashMap<Vec<u8>, Replies> = HashMap::new();
let mut undirected = Vec::new();
for e in evidence {
@@ -217,16 +219,10 @@ impl Db {
.filter(|n| *n <= MAX_UNREAD_SCAN as u64)
.ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))?
as u32;
// Unusable tags prove nothing: the message is left out.
let Some(facts) = e["facts"].as_object() else {
unread_complete = false;
continue;
};
// SQL's bounded ancestry fact is parity-tested against the shared
// NIP-10 parser; no raw tag payload crosses the DB boundary.
if facts.get("reply_marked") == Some(&Value::Bool(true)) && e["root"].is_null() {
unread_complete = false;
continue;
}
if e["covered"] == true {
continue;
}
@@ -235,14 +231,13 @@ impl Db {
// Roots are timeline messages; descendants belong exclusively
// to their canonical thread. Never inherit the channel prefix.
if e["is_reply"] != true {
unread += n;
unread = true;
if directed {
attention += n;
mentions += n;
}
continue;
}
let Some(root) = e["root"].as_str().and_then(writes::event_id) else {
unread_complete = false;
continue;
};
let replies = Replies {
@@ -255,6 +250,7 @@ impl Db {
.to_owned(),
),
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
// reply counts only in one of the actor's conversations.
@@ -262,64 +258,38 @@ impl Db {
count(&mut threads, root, replies);
} else if let Some(parent) = e["parent"].as_str().and_then(writes::event_id) {
undirected.push((root, parent, replies));
} else {
unread_complete = false;
}
}
pending.push((threads, undirected, unread, attention, unread_complete));
pending.push((threads, undirected));
channels.push(ChannelReadSummary {
channel_id: row.try_get("id")?,
name: row.try_get("name")?,
channel_type,
archived: row.try_get("archived")?,
hidden: row.try_get("hidden")?,
// Counts and threads wait for conversation membership below.
unread: ReadCount::Unknown,
attention: ReadCount::Unknown,
latest_message_id: row.try_get("latest_message_id")?,
latest_message_at: row.try_get("latest_message_at")?,
latest_message_complete: row.try_get("latest_message_complete")?,
threads: ThreadSummaries {
items: Vec::new(),
complete: false,
},
unread,
mentions,
read_through_id: row.try_get("read_through_id")?,
latest_id: row.try_get("latest_id")?,
// Threads wait for conversation membership below.
threads: Vec::new(),
});
}
let targets: Vec<_> = channels
.iter()
.zip(&pending)
.flat_map(|(channel, (_, undirected, ..))| {
.flat_map(|(channel, (_, undirected))| {
undirected
.iter()
.map(|(_, parent, _)| (channel.channel_id, parent.clone()))
})
.collect();
let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?;
for (channel, (mut threads, undirected, mut unread, mut attention, mut complete)) in
channels.iter_mut().zip(pending)
{
// Relevance is decided before counts, previews and the thread cap.
for (channel, (mut threads, undirected)) in channels.iter_mut().zip(pending) {
// Relevance is decided before the thread cap. An undecided
// membership leaves the reply out.
for (root, parent, replies) in undirected {
match members.get(&(channel.channel_id, parent)) {
Some(true) => count(&mut threads, root, replies),
Some(false) => {}
None => complete = false,
if members.get(&(channel.channel_id, parent)) == Some(&true) {
count(&mut threads, root, replies);
}
}
let mut items = Vec::with_capacity(threads.len());
for (root, thread) in threads {
unread += thread.n;
attention += thread.n;
items.push(ThreadReadSummary {
root_id: hex::encode(root),
unread: ReadCount::from_evidence(thread.n, complete),
latest_reply_id: thread.newest.1,
latest_reply_at: thread.newest_at,
});
}
channel.unread = ReadCount::from_evidence(unread, complete);
channel.attention = ReadCount::from_evidence(attention, complete);
channel.threads = summarize(items, complete);
channel.threads = summarize(threads);
}
let next_cursor = if has_more {
channels.last().map(|c| c.channel_id)
@@ -328,7 +298,6 @@ impl Db {
};
tx.commit().await?;
Ok(SidebarPage {
account,
channels,
next_cursor,
})
@@ -343,6 +312,8 @@ struct Replies {
newest: (i64, String),
/// Its author seconds.
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.
@@ -369,20 +340,25 @@ fn invalid_newest() -> DbError {
DbError::InvalidData("invalid thread evidence".into())
}
/// Order newest unread reply first (root ID breaks ties) and cap the list. The
/// list is complete only when evidence was exhausted and nothing was omitted.
pub(super) fn summarize(
mut items: Vec<ThreadReadSummary>,
evidence_complete: bool,
) -> ThreadSummaries {
items.sort_unstable_by(|a, b| {
b.latest_reply_at
.cmp(&a.latest_reply_at)
.then_with(|| a.root_id.cmp(&b.root_id))
/// Order newest unread reply first (root ID breaks ties) and cap the list.
fn summarize(threads: HashMap<Vec<u8>, Replies>) -> Vec<ThreadReadSummary> {
let mut threads: Vec<_> = threads.into_iter().collect();
threads.sort_unstable_by(|(a_root, a), (b_root, b)| {
b.newest_at
.cmp(&a.newest_at)
.then_with(|| a_root.cmp(b_root))
});
let complete = evidence_complete && items.len() <= MAX_THREAD_SUMMARIES;
items.truncate(MAX_THREAD_SUMMARIES);
ThreadSummaries { items, complete }
threads
.into_iter()
.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.
@@ -404,7 +380,6 @@ pub(super) async fn read_account(
.await?;
let cutoff: DateTime<Utc> = row.try_get("cutoff")?;
Ok(ReadAccount {
retention_seconds,
cutoff_ms: cutoff.timestamp_millis(),
started_at: row.try_get("started_at")?,
})
@@ -414,42 +389,32 @@ pub(super) async fn read_account(
mod tests {
use super::*;
fn item(root: u8, at: i64) -> ThreadReadSummary {
ThreadReadSummary {
root_id: hex::encode([root; 32]),
unread: ReadCount::Exact { value: 1 },
latest_reply_id: hex::encode([root; 32]),
latest_reply_at: at,
fn replies(at: i64) -> Replies {
Replies {
n: 1,
newest: (at, String::new()),
newest_at: at,
read_through_id: None,
}
}
#[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.
for count in [0_u8, 1, 4, 5, 6, 7] {
// Descending roots with pairwise-equal times exercise the tie-break.
let items: Vec<_> = (0..count)
.rev()
.map(|i| item(i, i64::from(i / 2)))
for count in [0_u8, 1, 24, 25, 26, 27] {
// Pairwise-equal times exercise the tie-break.
let threads = (0..count)
.map(|i| (vec![i; 32], replies(i64::from(i / 2))))
.collect();
let summaries = summarize(items, true);
let roots: Vec<_> = summaries.items.iter().map(|t| t.root_id.clone()).collect();
let roots: Vec<_> = summarize(threads).into_iter().map(|t| t.root_id).collect();
let mut expected: Vec<_> = (0..count).map(|i| (i64::from(i / 2), i)).collect();
expected.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let expected: Vec<_> = expected
.into_iter()
.take(5)
.take(25)
.map(|(_, i)| hex::encode([i; 32]))
.collect();
assert_eq!(roots, expected, "count {count}");
assert_eq!(summaries.complete, count <= 5, "count {count}");
}
}
#[test]
fn thread_summaries_are_incomplete_when_evidence_is() {
let summaries = summarize(vec![item(1, 1)], false);
assert_eq!(summaries.items.len(), 1);
assert!(!summaries.complete);
}
}

View File

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

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 crate::{
channel::{ChannelType, ChannelVisibility},
@@ -102,105 +102,22 @@ async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent
.unwrap()
}
fn channel_read(channel: Uuid, message: &nostr::Event) -> ReadIntent {
ReadIntent::MarkChannelRead {
channel_id: channel,
message_id: message.id.to_hex(),
}
}
fn exact(count: &ReadCount) -> Option<u32> {
match count {
ReadCount::Exact { value } => Some(*value),
_ => None,
fn channel_mark(channel: Uuid, message_id: String) -> ReadIntent {
ReadIntent::MarkThrough {
target: ReadTarget {
channel_id: channel,
root_id: None,
},
message_id,
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() {
let (db, pool, community, channel, actor, root) = fixture().await;
join(&db, &pool, community, channel, &actor, &root).await;
async fn mark_through_rejects_unresolved_missing_auxiliary_and_malformed_anchors() {
let (db, _pool, community, channel, actor, root) = fixture().await;
let base = root.created_at.as_secs();
let first = reply(&db, &pool, community, channel, &root, base + 10, vec![]).await;
post(&db, community, channel, base + 20, vec![]).await;
let second = reply(&db, &pool, community, channel, &root, base + 30, vec![]).await;
// A reply anchor is valid for the whole channel, unlike channel mark_through.
let channel_only = ReadTarget {
channel_id: channel,
root_id: None,
};
let through_reply = ReadIntent::MarkThrough {
target: channel_only,
message_id: first.id.to_hex(),
};
assert_eq!(
apply(&db, community, &actor, through_reply).await,
IntentOutcome::Blocked
);
assert_eq!(
apply(&db, community, &actor, channel_read(channel, &first)).await,
IntentOutcome::Applied
);
let row = sidebar(&db, community, &actor).await;
assert_eq!(
exact(&row.unread),
Some(2),
"top at +20 and reply at +30 remain"
);
assert_eq!(row.threads.items.len(), 1);
assert_eq!(row.threads.items[0].latest_reply_id, second.id.to_hex());
assert!(row.threads.complete);
assert_eq!(
apply(&db, community, &actor, channel_read(channel, &second)).await,
IntentOutcome::Applied
);
let row = sidebar(&db, community, &actor).await;
assert_eq!(
exact(&row.unread),
Some(0),
"every thread is covered by the cut"
);
assert!(row.threads.items.is_empty() && row.threads.complete);
// 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.
// Unresolved ancestry cannot be classified as top-level or reply.
let orphan_parent = "ab".repeat(32);
let orphan = post(
&db,
@@ -214,151 +131,76 @@ async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independe
.apply_personal_read_intent(
community,
&actor.public_key(),
&ReadIntent::MarkThrough {
target: ReadTarget {
channel_id: channel,
root_id: None
},
message_id: orphan.id.to_hex(),
},
&channel_mark(channel, orphan.id.to_hex()),
)
.await
.is_err());
assert_eq!(
apply(&db, community, &actor, channel_read(channel, &orphan)).await,
IntentOutcome::Applied
);
// Missing, auxiliary and malformed anchors.
let missing = ReadIntent::MarkChannelRead {
channel_id: channel,
message_id: "cd".repeat(32),
};
assert_eq!(
apply(&db, community, &actor, missing).await,
IntentOutcome::Blocked
);
let malformed = ReadIntent::MarkChannelRead {
channel_id: channel,
message_id: "zz".into(),
};
assert_eq!(
apply(&db, community, &actor, malformed).await,
IntentOutcome::Invalid
);
let reaction = EventBuilder::new(Kind::Custom(7), "+")
.sign_with_keys(&Keys::generate())
.unwrap();
db.insert_event(community, &reaction, Some(channel))
.await
.unwrap();
assert_eq!(
apply(&db, community, &actor, channel_read(channel, &reaction)).await,
IntentOutcome::Blocked
);
// Ordinary channel marks never set the whole-channel cut.
let other = Keys::generate();
assert_eq!(
apply(
&db,
community,
&other,
ReadIntent::MarkThrough {
target: ReadTarget {
channel_id: channel,
root_id: None
},
message_id: root.id.to_hex(),
},
)
.await,
IntentOutcome::Applied
);
let cuts: Vec<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());
for (anchor, outcome) in [
("cd".repeat(32), IntentOutcome::Blocked),
(reaction.id.to_hex(), IntentOutcome::Blocked),
("zz".into(), IntentOutcome::Invalid),
] {
assert_eq!(
apply(
&db,
community,
&actor,
channel_mark(channel, anchor.clone())
)
.await,
outcome,
"{anchor}"
);
}
let row = sidebar(&db, community, &actor).await;
assert_eq!(row.read_through_id, None, "nothing was marked");
}
#[tokio::test]
#[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;
join(&db, &pool, community, channel, &actor, &root).await;
reply(&db, &pool, community, channel, &root, 0, vec![]).await;
// A channel row exists, with no whole-channel cut: NULL must stay NULL.
let ancient = reply(&db, &pool, community, channel, &root, 0, vec![]).await;
// A channel frontier exists, with no thread frontier: absent must not
// read as epoch zero or as the channel's.
apply(
&db,
community,
&actor,
ReadIntent::MarkThrough {
target: ReadTarget {
channel_id: channel,
root_id: None,
},
message_id: root.id.to_hex(),
},
channel_mark(channel, root.id.to_hex()),
)
.await;
// Widen the horizon to reach the epoch, so that only a NULL cut read as
// zero could hide this reply.
// Widen the horizon to reach the epoch, so that only a misread absent
// frontier could hide this reply.
let row = db
.personal_read_sidebar(community, &actor.public_key(), u32::MAX, 20, None)
.await
.unwrap()
.channels
.remove(0);
assert_eq!(exact(&row.unread), Some(1), "epoch-zero reply stays unread");
assert_eq!(row.threads.items[0].latest_reply_at, 0);
assert!(!row.unread);
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]
#[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;
// After the fixture root, so marking the newest root covers every root.
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();
for i in 0..6 {
for i in 0..n {
let root = post(&db, community, channel, base + i, vec![]).await;
join(&db, &pool, community, channel, &actor, &root).await;
roots.push(root);
@@ -368,20 +210,16 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
&db,
community,
&actor,
ReadIntent::MarkThrough {
target: ReadTarget {
channel_id: channel,
root_id: None,
},
message_id: newest_root.id.to_hex(),
},
channel_mark(channel, newest_root.id.to_hex()),
)
.await;
// Threads 0 and 1 tie on newest reply time; thread 2 has one directed
// reply and two that arrive last, together.
// Threads 0 and 1 tie on newest reply time and the rest follow, newest
// 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();
for (i, root) in roots.iter().enumerate() {
let at = base + 100 + [50, 50, 40, 30, 20, 10][i];
for (i, root) in (0..).zip(&roots) {
let at = base + 100 + offset(i);
anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await);
}
reply(
@@ -400,7 +238,7 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
community,
channel,
&roots[2],
base + 140,
base + 100 + offset(2),
vec![],
)
.await;
@@ -414,59 +252,42 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
.unwrap();
let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(8));
assert!(!row.threads.complete, "six unread threads exceed the cap");
// Every root is read; the eight unread replies count only on threads.
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()];
tied.sort();
let order: Vec<_> = row
.threads
.items
.iter()
.map(|t| t.root_id.clone())
.collect();
let order = |row: &ChannelReadSummary| -> Vec<_> {
row.threads.iter().map(|t| t.root_id.clone()).collect()
};
let ids = |roots: &[nostr::Event]| roots.iter().map(|r| r.id.to_hex()).collect::<Vec<_>>();
let last = roots.len() - 1;
// 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!(
order,
vec![
tied[0].clone(),
tied[1].clone(),
roots[2].id.to_hex(),
roots[3].id.to_hex(),
roots[4].id.to_hex(),
]
);
let third = &row.threads.items[2];
assert_eq!(exact(&third.unread), Some(3));
assert_eq!(third.latest_reply_at, (base + 140) as i64);
assert_eq!(
third.latest_reply_id,
third.latest_id,
std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()),
"equal arrivals break toward the smaller ID"
);
// The row's anchor is its last arrival; its activity is the greatest
// author time, another message's.
assert_eq!(row.latest_message_id.as_ref(), Some(&third.latest_reply_id));
assert_eq!(row.latest_message_at, Some((base + 150) as i64));
// Reading one listed thread through its anchor completes the list.
let first = row.threads.items[0].clone_target(channel);
// Reading one listed thread through its anchor lists the oldest.
let first = row.threads[0].clone_target(channel);
assert_eq!(
apply(&db, community, &actor, first).await,
IntentOutcome::Applied
);
let row = sidebar(&db, community, &actor).await;
assert!(row.threads.complete);
assert_eq!(row.threads.items.len(), 5);
let sum: u32 = row
.threads
.items
.iter()
.map(|t| exact(&t.unread).unwrap())
.sum();
assert_eq!(
Some(sum),
exact(&row.unread),
"complete summaries account for every reply"
order(&row),
[vec![tied[1].clone()], ids(&roots[2..])].concat()
);
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 {
@@ -476,7 +297,7 @@ impl ThreadReadSummary {
channel_id: channel,
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 row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(3), "the root and two replies");
assert_eq!(row.threads.items.len(), 2);
let listed = &row.threads.items[1];
// The undirected fixture root is unread; the directed diff is not counted.
assert_eq!((row.unread, row.mentions), (true, 0));
assert_eq!(row.threads.len(), 2);
let listed = &row.threads[1];
assert_eq!(listed.root_id, diff.id.to_hex());
assert_eq!(listed.latest_reply_id, on_diff.id.to_hex());
// 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");
assert_eq!(listed.latest_id, on_diff.id.to_hex());
assert_eq!(listed.mentions, 1);
// The diff is still no anchor; the listed reply reads its thread alone.
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(),
};
assert_eq!(
@@ -549,49 +346,9 @@ async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() {
IntentOutcome::Applied
);
let row = sidebar(&db, community, &actor).await;
assert_eq!(exact(&row.unread), Some(2));
assert_eq!(row.threads.items.len(), 1);
assert_eq!(row.threads.items[0].latest_reply_id, elsewhere.id.to_hex());
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn thread_summaries_are_incomplete_when_tag_evidence_could_hide_a_root() {
let (db, pool, community, channel, actor, root) = fixture().await;
join(&db, &pool, community, channel, &actor, &root).await;
let listed = reply(
&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 { .. }
));
assert_eq!((row.unread, row.mentions), (true, 0));
assert_eq!(row.threads.len(), 1);
assert_eq!(row.threads[0].latest_id, elsewhere.id.to_hex());
}
#[tokio::test]

View File

@@ -128,25 +128,6 @@ async fn message(
.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(
conn: &mut PgConnection,
community: CommunityId,
@@ -177,20 +158,27 @@ pub(super) async fn valid_target(
Ok(Some(root))
}
/// Advance a monotone frontier. The anchor ID follows the greatest arrival;
/// an equal arrival keeps the existing anchor.
async fn frontier(
conn: &mut PgConnection,
community: CommunityId,
actor: &[u8],
target: &ReadTarget,
root: &[u8],
through: DateTime<Utc>,
msg: &Message,
) -> Result<()> {
sqlx::query(
"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
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)
SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp),
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?;
Ok(())
}
@@ -226,31 +214,7 @@ pub(super) async fn apply(
{
return Ok(IntentOutcome::Blocked);
}
frontier(conn, community, actor, target, &root, msg.received_at).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?;
frontier(conn, community, actor, target, &root, &msg).await?;
}
}
Ok(IntentOutcome::Applied)

View File

@@ -6068,35 +6068,18 @@ pub(crate) mod postgres_tests {
}
// 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]
#[ignore = "requires Postgres"]
async fn nip_fi_buzz_v1_sidebar_wire_contract() {
buzz_v1_wire_contract("GET", "/buzz/v1/me/sidebar?limit=1", b"").await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn nip_fi_buzz_v1_contexts_wire_contract() {
let targets = serde_json::json!([{"target":{"channel_id":uuid::Uuid::new_v4()}}]);
let encoded: String = targets
.to_string()
.bytes()
.map(|b| format!("%{b:02X}"))
.collect();
buzz_v1_wire_contract(
"GET",
&format!("/buzz/v1/me/read-state?targets={encoded}"),
b"",
)
.await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn nip_fi_buzz_v1_write_wire_contract() {
let body = serde_json::to_vec(&serde_json::json!({"intents":[{
"type":"mark_channel_read", "channel_id":uuid::Uuid::new_v4(),
"type":"mark_through", "target":{"channel_id":uuid::Uuid::new_v4()},
"message_id":"ab".repeat(32)
}]}))
.expect("serialize intent");
@@ -6145,25 +6128,13 @@ pub(crate) mod postgres_tests {
assert_eq!(headers[header::CACHE_CONTROL], "private, no-store");
let value: serde_json::Value = serde_json::from_slice(&body).expect("control JSON");
if method == "POST" {
// A blocked intent applies to no channel, so no row is refreshed.
assert_eq!(
body.as_ref(),
br#"{"outcomes":[{"status":"blocked"}],"projection_status":"not_requested"}"#
br#"{"channels":[],"outcomes":[{"status":"blocked"}]}"#
);
} else {
assert!(value["account"]["cutoff_ms"].is_i64());
assert_eq!(
value["account"]["retention_seconds"],
state.config.buzz_v1_retention_seconds
);
if path.contains("sidebar") {
assert_eq!(value["channels"], serde_json::json!([]));
assert_eq!(value["next_cursor"], serde_json::Value::Null);
} else {
assert_eq!(
value["contexts"],
serde_json::json!([{"status":"unavailable"}])
);
}
assert_eq!(value, serde_json::json!({"channels":[],"next_cursor":null}));
}
for case in [

View File

@@ -161,9 +161,12 @@ pub(super) async fn recheck(
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> {
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());
}
Ok((

View File

@@ -6,10 +6,12 @@ use axum::{
http::HeaderMap,
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_json::{json, Value};
use std::{sync::Arc, time::Duration};
use std::{collections::BTreeSet, sync::Arc, time::Duration};
use uuid::Uuid;
/// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid.
@@ -72,26 +74,63 @@ pub(super) async fn sidebar(
}
}
.map_err(|_| Error::unavailable())?;
auth::recheck(&state, &headers, &principal).await?;
let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect();
let memberships = state
.db
.membership_pairs(
principal.tenant.community(),
&channels,
&[principal.actor.to_bytes().to_vec()],
)
.await
.map_err(|_| Error::unavailable())?;
if memberships.len() != channels.len() {
return Err(Error::unavailable());
}
auth::response(page)
auth::response(release(&state, &headers, &principal, page).await?)
})
.await
.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)]
#[serde(deny_unknown_fields)]
struct Batch {
@@ -114,6 +153,7 @@ pub(super) async fn write(
}
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
let mut outcomes = Vec::with_capacity(batch.intents.len());
let mut applied = BTreeSet::new();
for item in batch.intents {
let Ok(intent) = serde_json::from_value::<ReadIntent>(item) else {
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.
// Earlier acknowledged commits survive all later projection/item failures.
outcomes.push(
tokio::time::timeout_at(
deadline,
write_intent(&state, &headers, &principal, &intent),
)
.await
.unwrap_or_else(|_| json!({"status":"unknown","retryable":true})),
);
let outcome = tokio::time::timeout_at(
deadline,
write_intent(&state, &headers, &principal, &intent),
)
.await
.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"}))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ContextQuery {
// JSON array carried as one URL-encoded, signed query parameter.
targets: String,
}
pub(super) async fn contexts(
State(state): State<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())?
let mut body = json!({ "outcomes": outcomes });
// Committed outcomes stand even when their rows cannot be read in the
// remaining budget: omit them.
if let Ok(Ok(channels)) =
tokio::time::timeout_at(deadline, refreshed(&state, &headers, &principal, &applied)).await
{
body["channels"] = json!(channels);
}
auth::response(body)
}
// 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}),
}
}
#[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;
use crate::state::AppState;
use axum::{routing::get, Router};
use axum::{
routing::{get, post},
Router,
};
use std::sync::Arc;
/// 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 {
Router::new()
.route("/me/sidebar", get(handlers::sidebar))
.route(
"/me/read-state",
get(handlers::contexts).post(handlers::write),
)
.route("/me/read-state", post(handlers::write))
.fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") })
.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))
.await
.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 body = serde_json::to_vec(&json!({"intents":[{"type":"mark_channel_read",
"channel_id":channel,"message_id":event.id.to_hex()}]}))
let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_through",
"target":{"channel_id":channel},"message_id":event.id.to_hex()}]}))
.unwrap();
let missing_hash = proof(&actor, &host, write_path, "POST", None);
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.1["outcomes"][0]["status"], "applied");
// The frontier belongs to the signer alone.
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]);
let targets: String = targets
.to_string()
.bytes()
.map(|b| format!("%{b:02X}"))
.collect();
let path = format!("/buzz/v1/me/read-state?targets={targets}");
let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}");
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 page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
assert_eq!(page.0, StatusCode::OK, "{}", page.1);
assert_eq!(page.1["contexts"][0]["messages"][0]["status"], status);
assert_eq!(page.1["channels"][0]["unread"], unread, "{}", page.1);
assert_eq!(page.1["channels"][0]["read_through_id"], read_through);
}
}
#[tokio::test]
#[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()
.await
.unwrap();
let mut state = (*fixture).clone();
Arc::make_mut(&mut state.config).buzz_v1_enabled = true;
let state = Arc::new(state);
let host = format!("bff-context-{}.local", uuid::Uuid::new_v4());
let host = format!("bff-batch-{}.local", uuid::Uuid::new_v4());
let community = state
.db
.ensure_configured_community(&host)
@@ -261,76 +270,95 @@ async fn accessory_context_get_signed_query_and_independent_batch_outcomes() {
.unwrap()
.id;
let actor = Keys::generate();
let channel = state
.db
.create_channel(
community,
"context",
buzz_db::channel::ChannelType::Stream,
buzz_db::channel::ChannelVisibility::Open,
None,
&actor.public_key().to_bytes(),
None,
)
.await
.unwrap()
.id;
let event = EventBuilder::new(Kind::Custom(9), "message selector")
.sign_with_keys(&Keys::generate())
.unwrap();
state
.db
.insert_event(community, &event, Some(channel))
.await
.unwrap();
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]);
let encode = |value: &str| {
value
.bytes()
.map(|b| format!("%{b:02X}"))
.collect::<String>()
let create = |name: &'static str, owner: &Keys| {
let (state, owner) = (state.clone(), owner.public_key().to_bytes());
async move {
state
.db
.create_channel(
community,
name,
buzz_db::channel::ChannelType::Stream,
buzz_db::channel::ChannelVisibility::Open,
None,
&owner,
None,
)
.await
.unwrap()
.id
}
};
let path = format!(
"/buzz/v1/me/read-state?targets={}",
encode(&targets.to_string())
);
let channel = create("joined", &actor).await;
// Open, so marking it applies, but never joined.
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;
let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}");
let auth = proof(&actor, &host, &path, "GET", None);
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
assert_eq!(result.0, StatusCode::OK, "{}", result.1);
assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "unread");
let auth = proof(&actor, &host, "/buzz/v1/me/read-state", "GET", None);
assert_eq!(
request(state.clone(), &host, &path, "GET", Some(&auth), b"")
.await
.0,
StatusCode::UNAUTHORIZED
);
let bad_path = "/buzz/v1/me/read-state?targets=invalid";
let auth = proof(&actor, &host, bad_path, "GET", None);
assert_eq!(
request(state.clone(), &host, bad_path, "GET", Some(&auth), b"")
.await
.0,
StatusCode::BAD_REQUEST
);
let unread = json!({"channel_id":channel,"unread":true,"mentions":0,
"read_through_id":null,"latest_id":event,"threads":[]});
assert_eq!(result.1["channels"], json!([unread]));
let write_path = "/buzz/v1/me/read-state";
let body = serde_json::to_vec(&json!({"intents":[
{"type":"unknown"},
{"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event.id.to_hex()},
{"type":"mark_through","target":{"channel_id":channel},"message_id":event.id.to_hex()},
{"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"}
]})).unwrap();
{"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event},
{"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":event},
{"type":"mark_through","target":{"channel_id":unjoined},"message_id":elsewhere}
]}))
.unwrap();
let auth = proof(&actor, &host, write_path, "POST", Some(&body));
let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await;
assert_eq!(result.0, StatusCode::OK, "{}", result.1);
assert_eq!(
result.1["outcomes"],
json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},{"status":"invalid"}])
json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},
{"status":"invalid"},{"status":"applied"},{"status":"applied"}])
);
let auth = proof(&actor, &host, &path, "GET", None);
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "read");
// One updated row per applied channel; the blocked and the unjoined
// channels have none.
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]
@@ -379,8 +407,11 @@ async fn accessory_write_revocation_is_terminal_before_persistence() {
.insert_event(community, &event, Some(channel))
.await
.unwrap();
let intent = buzz_db::personal_read::ReadIntent::MarkChannelRead {
channel_id: channel,
let intent = buzz_db::personal_read::ReadIntent::MarkThrough {
target: buzz_db::personal_read::ReadTarget {
channel_id: channel,
root_id: None,
},
message_id: event.id.to_hex(),
};
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}"
);
if enabled && known_host {
let d = &doc["buzz_v1"];
assert_eq!(d["version"], 1);
assert_eq!(d["base_path"], "/buzz/v1");
assert_eq!(d["retention_seconds"], 1234);
assert_eq!(d["max_channels"], buzz_db::personal_read::MAX_CHANNELS);
assert_eq!(d["max_intents"], buzz_db::personal_read::MAX_INTENTS);
assert_eq!(d["max_contexts"], buzz_db::personal_read::MAX_CONTEXTS);
// Exactly these fields: context and thread-cap limits are gone.
assert_eq!(
d["max_context_messages"],
buzz_db::personal_read::MAX_CONTEXT_MESSAGES
doc["buzz_v1"],
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
.unwrap();
}
// Addressed to the reader, so its mention count pins the exact number.
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)
.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[0]["channel_id"], channel.to_string());
assert_eq!(
channels[0]["unread"],
json!({"status":"exact", "value":count}),
(&channels[0]["unread"], &channels[0]["mentions"]),
(&json!(count == 1), &json!(count)),
"kind {deletion_kind}, after kind {}: {sidebar}",
event.kind.as_u16()
);

View File

@@ -94,12 +94,6 @@ pub struct BuzzV1Descriptor {
pub max_channels: usize,
/// Maximum independent write intents per request.
pub max_intents: usize,
/// Maximum explicit contexts per read request.
pub max_contexts: usize,
/// Maximum message selectors across one context request.
pub max_context_messages: usize,
/// Maximum unread-thread summaries per sidebar channel row.
pub max_thread_summaries: usize,
/// Message kinds that count as unread and as latest activity. Clients
/// classify live arrivals with this set instead of keeping a copy.
pub eligible_kinds: [i32; 4],
@@ -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 state.config.buzz_v1_enabled {
use buzz_db::personal_read::{
ELIGIBLE_KINDS, MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS,
MAX_THREAD_SUMMARIES,
};
use buzz_db::personal_read::{ELIGIBLE_KINDS, MAX_CHANNELS, MAX_INTENTS};
info.buzz_v1 = Some(BuzzV1Descriptor {
version: 1,
base_path: crate::api::buzz_v1::BASE_PATH.to_owned(),
retention_seconds: state.config.buzz_v1_retention_seconds,
max_channels: MAX_CHANNELS,
max_intents: MAX_INTENTS,
max_contexts: MAX_CONTEXTS,
max_context_messages: MAX_CONTEXT_MESSAGES,
max_thread_summaries: MAX_THREAD_SUMMARIES,
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 };
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) => {
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,
requestPermission,
} 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
// 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") {
// 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;
}

View File

@@ -109,8 +109,9 @@ const PERSON_OUTLINE_OFFSETS = [
// 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
// 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.
const MEDIAPIPE_WASM_BASE =
// stay under that prefix — `src/app/contentSecurityPolicy.test.mjs` checks
// that it does.
export const MEDIAPIPE_WASM_BASE =
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
const SELFIE_SEGMENTER_MODEL_URL =
"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:
* - macOS: Command (Meta)

View File

@@ -3,12 +3,12 @@
Design guidance for follow-up work, not implemented wire capabilities.
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;
fetching detail separately remains valid. Reuse canonical classification and
read frontiers, not a second definition of unread. Historical-mention eligibility
or mute-aware attention requires an explicit policy contract, not an unnoticed
change to today's `attention` field.
or mute-aware mentions requires an explicit policy contract, not an unnoticed
change to today's `mentions` field.
Channel-content revisions, personal-state revisions and synchronized preferences
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
{"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000,
"max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100,
"max_thread_summaries":5,"eligible_kinds":[9,40002,45001,45003]}}
"max_channels":20,"max_intents":100,"eligible_kinds":[9,40002,45001,45003]}}
```
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
`GET /buzz/v1/me/sidebar?limit=20&cursor=<exclusive-channel-uuid>` returns
`account`, `channels`, and `next_cursor`. Omit the cursor on the first request.
Each channel includes identity/name/type, archived and hidden flags, `unread`,
`attention`, `latest_message_id` (the last eligible message to arrive: the
row's read anchor, see [Order](#order)), `latest_message_at` (the greatest
author time among eligible messages, in seconds: display activity, not
necessarily that message's own time; null exactly when the ID is null),
`latest_message_complete`, and `threads`. Only joined, nondeleted channels are
listed. Hidden/archived presentation remains client-owned. Each page has a
`channels` and `next_cursor`. Omit the cursor on the first request. Only
joined, nondeleted channels are listed; names, types, archived and hidden state
come from Nostr channel metadata, not this API. Each page has a
writer-consistent snapshot; separate pages do not share a snapshot, and an
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.
Absence says nothing else about access to an open channel.
`threads` lists unread threads in the row, newest unread reply first:
```json
{"items":[{"root_id":"<64-hex>","unread":{"status":"exact","value":2},
"latest_reply_id":"<64-hex>","latest_reply_at":1700000000}],"complete":true}
{"channels":[{"channel_id":"<uuid>","unread":true,"mentions":1,
"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
by `latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is
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.
A channel's timeline and each of its threads keep independent read positions;
marking one never moves another.
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
[{"status":"exact","value":0},{"status":"at_least","value":7},{"status":"unknown"}]
```
A thread reply never makes the channel row unread and never becomes its
`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
eligible when it is non-own, nondeleted, of the advertised `eligible_kinds` and
inside the horizon. The same kinds alone define latest activity, so an edit,
reaction or diff (40008) neither makes a channel unread nor moves it. Classify
live arrivals with the advertised set, not a client copy.
`threads` lists threads with unread replies that count, newest unread reply
first by author time (then `root_id`), at most 25. Threads past the 25th are
omitted with their mentions, and nothing signals the omission: after marking
the listed threads read, a refresh can list the next ones. A thread row's
`unread` is always true in this version. No message bytes are included.
An eligible message beyond the matching context frontier counts as unread for
the first reason that holds:
A message is eligible when it is non-own, nondeleted, of the advertised
`eligible_kinds` and inside the horizon. The same kinds alone define latest
activity, so an edit, reaction or diff (40008) neither makes a channel unread
nor moves it. Classify live arrivals with the advertised set, not a client copy.
| `reason` | Holds when |
|---|---|
| `direct` | its channel is a DM |
| `mention` | it tags the actor with `p` |
| `conversation` | it is a reply, and the actor wrote its direct parent or has a reply to that same parent, in that channel |
| `broadcast` | it carries `broadcast=1` |
| null | it is top-level |
A 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
in the same way, or it is in one of your conversations: you wrote its direct
parent or have a reply to that same parent, in that channel. Conversation
membership uses only your live eligible messages (a deleted parent proves
nothing; a surviving reply to it still does), looks at the direct parent only
(owning the root or replying elsewhere in the thread proves nothing), and is
independent of read progress and retention. 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
thread list. `unread` counts the messages that count; `attention` is the subset
with a reason. Conversation membership uses only the actor's live eligible
messages (a deleted parent proves nothing; a surviving reply to it still does),
looks at the direct parent only (owning the root or replying elsewhere in the
thread proves nothing), and is independent of read progress and retention. A
reply whose membership is undecided is left out, and the row's counts become
lower bounds or unknown. The sidebar counts a broadcast reply without asking
which of the two reasons applies. This is not Desktop notification policy:
follows and mutes do not affect these counts.
Counts are what the bounded scans could establish, and may undercount. A message
whose tags are unusable, whose ancestry is unresolved, or whose conversation
membership is undecided is left out, as are messages beyond the scan bounds
(see [Bounds](#bounds-and-deployment)).
The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and is
measured in author time (`created_at`): a message counts while its author time
is at or after `account.cutoff_ms`. It filters unread/attention, not latest
activity, event storage or frontier state. Frontiers use a different clock,
relay arrival (see [Order](#order)). Three consequences:
measured in author time (`created_at`). It filters unread and mentions, not
latest activity, event storage or frontier state. Frontiers use a different
clock, relay arrival (see [Order](#order)). Three consequences:
- A message accepted late with an author time beyond the horizon (an import, a
backfill, a long-offline sender) is excluded under the current horizon,
@@ -137,33 +129,6 @@ relay arrival (see [Order](#order)). Three consequences:
frontier, and author time at or after the cutoff.
A later configuration expansion can change counts without having lost progress.
Latest activity is independent of actor and frontiers. A null latest ID proves
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
@@ -172,33 +137,30 @@ Conversation bytes must still come from the existing Nostr path.
```json
{"intents":[
{"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_channel_read","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>"}
]}
```
Each intent commits atomically and returns its own `applied`, `blocked` or
`invalid` outcome. An ambiguous timeout/storage failure returns
The response is `{"outcomes":[...],"channels":[...]}`. Each intent commits
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
later failures. `projection_status` is `not_requested`; a successful write does
not assert a client has refreshed. Retry the same operands, never substitute
latest. Keep pending intent durably on the client until its outcome is resolved.
later failures. `channels` holds the updated sidebar row of each distinct
channel with an applied intent, in the sidebar's row shape and ordered by ID;
a channel you have not joined has no row. Replace those rows rather than
re-deriving them. `channels` is omitted when the rows could not be read 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
frontier to that message's relay arrival (see [Order](#order)). Channel and
thread frontiers never inherit in either direction. Opening a view is not
itself a reading action; client dwell/focus policy determines when to send an
actual observed anchor. Old or deleted valid anchors may advance a frontier.
`mark_channel_read` is the one whole-channel cut: it advances the channel
timeline and every thread in that channel, including unlisted ones, through the
anchor's 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.
frontier to that message's relay arrival (see [Order](#order)). A channel
anchor must be a top-level message; a thread anchor must be the thread's root
or one of its replies. The frontier records the anchor as `read_through_id`
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
dwell/focus policy determines when to send an actual observed anchor. Old or
deleted valid anchors may advance a frontier.
### 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
that arrives after it.
The order is the relay's and is not exposed: no response carries a frontier,
and no field lets a client compute what a mark will cover. Send the anchors the
user actually saw, let the relay take the greatest, and ask a context which
messages are read. Do not compare author times, IDs or local receipt order to
drop one pending anchor in favor of another.
The order is the relay's and is not exposed: a response carries the anchor
message (`read_through_id`), never its arrival time, and no field lets a client
compute what a mark will cover. Send the anchors the user actually saw and let
the relay take the greatest. Do not compare author times, IDs or local receipt
order to drop one pending anchor in favor of another.
Arrival is the accepting relay process's clock, read just before the insert, at
microsecond resolution. Three limits follow, none of which strands a badge:
@@ -224,10 +186,11 @@ microsecond resolution. Three limits follow, none of which strands a badge:
skew.
- Messages with the identical stamp are read together.
`latest_message_id` is the last message to arrive among those the unread count
examined: the 4,096 most recent events by author time inside the horizon. So
marking through it reads everything counted. When the horizon holds no message,
it is the last to arrive among the channel's 256 most recent events.
A channel's `latest_id` is the last top-level message to arrive among those the
unread count examined: the 4,096 most recent events by author time inside the
horizon. So marking through it reads every top-level message counted. When
that scan holds no top-level message, it is the last top-level message to
arrive among the channel's 256 most recent events.
There is no import of earlier client read state. The relay records the
account's `started_at` at its first applied read intent and never moves it.
@@ -238,40 +201,45 @@ device-local.
## Bounds and deployment
- 20 sidebar rows, 100 intents, 20 contexts / 100 selectors per request.
- 64 KiB write body; 16 KiB context URL; 1 MiB serialized API response.
- 4096 raw events per channel inside the horizon plus one exhaustion sentinel,
before eligibility.
- Latest activity probes 256 events plus a sentinel; long ineligible tails may
leave latest incomplete even when unread is exact.
- Tag documents over 8192 bytes or malformed relevant tags yield uncertainty.
Compact boolean facts cross the database boundary, never raw tag payloads.
- 20 sidebar rows and 100 intents per request; 25 thread rows per channel, so
a POST's rows for 100 channels fit the response limit.
- 64 KiB write body; 1 MiB serialized API response.
- 4096 raw events per channel inside the horizon, before eligibility.
- The latest fallback probes 256 events; a long ineligible or reply-only tail
can leave `latest_id` null.
- Tag documents over 8192 bytes or malformed relevant tags leave the message
out. Compact boolean facts cross the database boundary, never raw tag
payloads.
- Conversation membership: at most 1024 unique parents per request, with a
500 ms savepoint budget. The lookup is exact, so its work grows with the
replies under each parent. Past either bound a reply is undecided, never
absent: counts become lower bounds or unknown and a context reports `unknown`.
- DB statement/lock deadlines and HTTP read deadlines bound work; writes use a
shared eight-second intent-processing deadline after admission. Limits are
replies under each parent. Past either bound a reply is undecided and left
out.
- DB statement/lock deadlines and HTTP read deadlines bound work; a write's intents
and its row refresh share one eight-second deadline after admission. Limits are
containment, not a production capacity claim.
Apply migrations 0056 and 0057 (or the equivalent desired schema). 0056
creates two empty private tables and no index on `events`, and 0057 adds the
nullable `personal_read_accounts.started_at` column. Both sidebar scans are
Apply migrations 0056 through 0058 (or the equivalent desired schema). 0056
creates two empty private tables and no index on `events`, 0057 adds the
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
per-message ingest write path or stored unread counters are introduced.
Use existing HTTP route/status/latency metrics for `/buzz/v1/me/sidebar` and
`/buzz/v1/me/read-state`, plus database pool/statement metrics. Inspect exact /
lower-bound / unknown proportions in controlled acceptance captures; no payload,
`/buzz/v1/me/read-state`, plus database pool/statement metrics. No payload,
actor, channel or frontier values should become metric labels. The measured
local seed is not a DAU/concurrency or p95/p99 production acceptance result.
## Compatibility and extension rules
Within `/buzz/v1`, clients must ignore unknown response object fields. Existing
required fields, status variants and their meanings remain stable; additive
fields do not authorize silently changing `attention` or frontier semantics.
Breaking changes require an explicitly negotiated contract or a new API version.
Within `/buzz/v1`, clients must ignore unknown response object fields. The
shape is stable: existing fields, their types, status variants and what a
`mark_through` does remain. What the counts include is not yet: which messages
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
advertises the corresponding capability. Missing optional data means unsupported
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 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())
.where(
(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,
root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)),
through_timestamp TIMESTAMPTZ NOT NULL,
-- Whole-channel cut covering every thread; channel rows only.
threads_through_timestamp TIMESTAMPTZ
CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea),
-- The message whose arrival is through_timestamp; NULL before 0058.
through_message_id BYTEA CHECK (octet_length(through_message_id) = 32),
PRIMARY KEY (community_id, actor, channel_id, root_id),
FOREIGN KEY (community_id, actor)
REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,