mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(relay): reshape read-state API to the sidebar wire format
The counting implementation is unchanged; this changes what /buzz/v1 takes
and returns so later upgrades land in place.
- Sidebar rows are { channel_id, unread, mentions, read_through_id,
latest_id, threads }; thread rows are { root_id, unread, mentions,
read_through_id, latest_id }. Channel unread/mentions count top-level
messages only, and the channel latest_id is the last top-level message
(never a reply or an unresolved reply), so a channel mark_through can
always use it. Exactness (exact/at_least/unknown), channel metadata and
`account` are no longer on the wire.
- POST /me/read-state returns the updated rows of the channels it applied
to. mark_channel_read and the explicit-contexts read are removed.
- Migration 0058 records each frontier's anchor message id (reported as
read_through_id) and drops the whole-channel threads cut.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -705,7 +705,7 @@ mod postgres_tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 57);
|
||||
assert_eq!(migrations.len(), 58);
|
||||
assert_eq!(migrations[55].version, 56);
|
||||
assert!(migrations[55]
|
||||
.sql
|
||||
@@ -716,6 +716,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);
|
||||
@@ -2229,6 +2234,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" {
|
||||
@@ -2237,6 +2254,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),
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,68 @@ 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.
|
||||
/// Maximum thread rows per channel row.
|
||||
pub const MAX_THREAD_SUMMARIES: usize = 5;
|
||||
/// Bounded event evidence per channel; exhaustion is never inferred at this cap.
|
||||
/// 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 message the channel timeline was last marked through, if any.
|
||||
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. Every one is directed at the actor.
|
||||
pub mentions: u32,
|
||||
/// The message the thread was last marked through, if any.
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -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)])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -561,58 +442,42 @@ async fn threads_are_capped_after_replies_that_do_not_count_are_removed() {
|
||||
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 +486,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 +532,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
@@ -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,25 +130,37 @@ 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: never a
|
||||
-- timeline anchor, since a mark cannot validate it.
|
||||
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
|
||||
), 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
|
||||
@@ -172,29 +185,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 +212,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,14 +223,13 @@ 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 {
|
||||
@@ -235,14 +240,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 +259,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 +267,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 +307,6 @@ impl Db {
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(SidebarPage {
|
||||
account,
|
||||
channels,
|
||||
next_cursor,
|
||||
})
|
||||
@@ -343,6 +321,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 +349,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 +389,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,26 +398,24 @@ 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_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)))
|
||||
// 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
|
||||
@@ -442,14 +424,6 @@ mod tests {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,20 +156,14 @@ 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 })
|
||||
},
|
||||
"tags={tags:?}"
|
||||
);
|
||||
// A reply marker without recorded ancestry leaves the message out.
|
||||
assert_eq!(page.channels[0].unread, !reply, "tags={tags:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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 +171,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 +196,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 +214,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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,146 +131,69 @@ 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;
|
||||
@@ -368,13 +208,7 @@ 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
|
||||
@@ -414,18 +248,18 @@ 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()
|
||||
};
|
||||
// Six unread threads exceed the cap of five.
|
||||
assert_eq!(
|
||||
order,
|
||||
order(&row),
|
||||
vec![
|
||||
tied[0].clone(),
|
||||
tied[1].clone(),
|
||||
@@ -434,39 +268,33 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
|
||||
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);
|
||||
let third = &row.threads[2];
|
||||
assert_eq!(third.mentions, 3);
|
||||
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 sixth.
|
||||
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(),
|
||||
roots[2].id.to_hex(),
|
||||
roots[3].id.to_hex(),
|
||||
roots[4].id.to_hex(),
|
||||
roots[5].id.to_hex(),
|
||||
]
|
||||
);
|
||||
let mentions: Vec<_> = row.threads.iter().map(|t| t.mentions).collect();
|
||||
assert_eq!(mentions, [1, 3, 1, 1, 1]);
|
||||
}
|
||||
|
||||
impl ThreadReadSummary {
|
||||
@@ -476,7 +304,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 +328,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 +353,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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,29 @@ 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)
|
||||
})
|
||||
let mut body = json!({ "outcomes": outcomes });
|
||||
// Committed outcomes stand even when their rows cannot be read: omit them.
|
||||
if let Ok(Ok(channels)) = tokio::time::timeout(
|
||||
Duration::from_secs(8),
|
||||
refreshed(&state, &headers, &principal, &applied),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::unavailable())?
|
||||
{
|
||||
body["channels"] = json!(channels);
|
||||
}
|
||||
auth::response(body)
|
||||
}
|
||||
|
||||
// Preserve earlier committed outcomes while distinguishing a definite denial
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,61 @@ 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 message the timeline was last marked through, or null | the message the thread was last marked through, 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 5. 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 +127,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 +135,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 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 +169,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 +184,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 the
|
||||
horizon holds no top-level message, it is the last 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,31 +199,32 @@ 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; 5 thread rows per channel.
|
||||
- 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`.
|
||||
replies under each parent. Past either bound a reply is undecided and left
|
||||
out.
|
||||
- DB statement/lock deadlines and HTTP read deadlines bound work; writes use a
|
||||
shared eight-second intent-processing deadline after admission. Limits are
|
||||
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.
|
||||
|
||||
@@ -270,7 +232,7 @@ local seed is not a DAU/concurrency or p95/p99 production acceptance result.
|
||||
|
||||
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.
|
||||
fields do not authorize silently changing `mentions` or frontier semantics.
|
||||
Breaking changes require an explicitly negotiated contract or a new API version.
|
||||
Requests remain strict: send new parameters or intent types only after the relay
|
||||
advertises the corresponding capability. Missing optional data means unsupported
|
||||
|
||||
7
migrations/0058_personal_read_through_message.sql
Normal file
7
migrations/0058_personal_read_through_message.sql
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user