mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Merge main and reconcile embedded migration count
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -705,10 +705,9 @@ mod postgres_tests {
|
|||||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||||
migrations.sort_by_key(|migration| migration.version);
|
migrations.sort_by_key(|migration| migration.version);
|
||||||
|
|
||||||
assert_eq!(migrations.len(), 58);
|
assert_eq!(migrations.len(), 59);
|
||||||
// 0058 is reserved by the independently reviewed trigger retirement.
|
assert_eq!(migrations[58].version, 59);
|
||||||
assert_eq!(migrations[57].version, 59);
|
assert!(migrations[58].sql.as_str().contains("buzz.push_enabled"));
|
||||||
assert!(migrations[57].sql.as_str().contains("buzz.push_enabled"));
|
|
||||||
assert_eq!(migrations[55].version, 56);
|
assert_eq!(migrations[55].version, 56);
|
||||||
assert!(migrations[55]
|
assert!(migrations[55]
|
||||||
.sql
|
.sql
|
||||||
@@ -719,6 +718,11 @@ mod postgres_tests {
|
|||||||
.sql
|
.sql
|
||||||
.as_str()
|
.as_str()
|
||||||
.contains("ALTER TABLE personal_read_accounts ADD COLUMN started_at"));
|
.contains("ALTER TABLE personal_read_accounts ADD COLUMN started_at"));
|
||||||
|
assert_eq!(migrations[57].version, 58);
|
||||||
|
assert!(migrations[57]
|
||||||
|
.sql
|
||||||
|
.as_str()
|
||||||
|
.contains("ADD COLUMN through_message_id"));
|
||||||
assert_eq!(migrations[48].version, 49);
|
assert_eq!(migrations[48].version, 49);
|
||||||
assert_eq!(migrations[49].version, 50);
|
assert_eq!(migrations[49].version, 50);
|
||||||
assert_eq!(migrations[50].version, 51);
|
assert_eq!(migrations[50].version, 51);
|
||||||
@@ -2232,6 +2236,18 @@ mod postgres_tests {
|
|||||||
.get("personal_read_accounts")
|
.get("personal_read_accounts")
|
||||||
.expect("schema.sql personal read accounts")
|
.expect("schema.sql personal read accounts")
|
||||||
.contains(started_at_column));
|
.contains(started_at_column));
|
||||||
|
// 0058 replaces the frontier's whole-channel cut with its anchor ID.
|
||||||
|
let threads_through_column = "threads_through_timestamp timestamptz \
|
||||||
|
check (threads_through_timestamp is null or root_id = ''::bytea), ";
|
||||||
|
let through_message_column =
|
||||||
|
"through_message_id bytea check (octet_length(through_message_id) = 32), ";
|
||||||
|
let through_message = MIGRATOR
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.version == 58)
|
||||||
|
.expect("personal read anchor migration")
|
||||||
|
.sql
|
||||||
|
.as_str();
|
||||||
|
assert!(through_message.contains("DROP COLUMN threads_through_timestamp"));
|
||||||
for (table, definition) in personal.tables {
|
for (table, definition) in personal.tables {
|
||||||
let in_schema = schema.tables.get(&table).map(|schema_definition| {
|
let in_schema = schema.tables.get(&table).map(|schema_definition| {
|
||||||
if table == "personal_read_accounts" {
|
if table == "personal_read_accounts" {
|
||||||
@@ -2240,6 +2256,11 @@ mod postgres_tests {
|
|||||||
schema_definition.clone()
|
schema_definition.clone()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let definition = if table == "personal_read_frontiers" {
|
||||||
|
definition.replacen(threads_through_column, through_message_column, 1)
|
||||||
|
} else {
|
||||||
|
definition
|
||||||
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
in_schema.as_ref(),
|
in_schema.as_ref(),
|
||||||
Some(&definition),
|
Some(&definition),
|
||||||
|
|||||||
@@ -22,18 +22,19 @@ async fn arrive(pool: &PgPool, community: CommunityId, event: &nostr::Event, arr
|
|||||||
assert_eq!(updated, 1, "arrival must land on exactly one stored event");
|
assert_eq!(updated, 1, "arrival must land on exactly one stored event");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A message authored at `authored` that arrives at `arrived`.
|
/// A message authored at `authored` that arrives at `arrived`. It mentions the
|
||||||
|
/// actor, so the channel's mention count is exactly its unread top-level count.
|
||||||
async fn post(
|
async fn post(
|
||||||
db: &Db,
|
db: &Db,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
community: CommunityId,
|
community: CommunityId,
|
||||||
channel: Uuid,
|
channel: Uuid,
|
||||||
|
actor: &Keys,
|
||||||
authored: u64,
|
authored: u64,
|
||||||
arrived: u64,
|
arrived: u64,
|
||||||
tags: Vec<Tag>,
|
|
||||||
) -> nostr::Event {
|
) -> nostr::Event {
|
||||||
let event = EventBuilder::new(Kind::Custom(9), format!("authored {authored}"))
|
let event = EventBuilder::new(Kind::Custom(9), format!("authored {authored}"))
|
||||||
.tags(tags)
|
.tags([Tag::public_key(actor.public_key())])
|
||||||
.custom_created_at(nostr::Timestamp::from(authored))
|
.custom_created_at(nostr::Timestamp::from(authored))
|
||||||
.sign_with_keys(&Keys::generate())
|
.sign_with_keys(&Keys::generate())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -56,17 +57,7 @@ async fn reply(
|
|||||||
authored: u64,
|
authored: u64,
|
||||||
arrived: u64,
|
arrived: u64,
|
||||||
) -> nostr::Event {
|
) -> nostr::Event {
|
||||||
let mention = Tag::public_key(actor.public_key());
|
let event = post(db, pool, community, channel, actor, authored, arrived).await;
|
||||||
let event = post(
|
|
||||||
db,
|
|
||||||
pool,
|
|
||||||
community,
|
|
||||||
channel,
|
|
||||||
authored,
|
|
||||||
arrived,
|
|
||||||
vec![mention],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth)
|
sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth)
|
||||||
VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)")
|
VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)")
|
||||||
.bind(community.as_uuid()).bind(event.id.as_bytes().as_slice())
|
.bind(community.as_uuid()).bind(event.id.as_bytes().as_slice())
|
||||||
@@ -89,6 +80,12 @@ async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSu
|
|||||||
.remove(0)
|
.remove(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The channel row's top-level state: (unread, mentions).
|
||||||
|
async fn counts(db: &Db, community: CommunityId, actor: &Keys) -> (bool, u32) {
|
||||||
|
let row = sidebar(db, community, actor).await;
|
||||||
|
(row.unread, row.mentions)
|
||||||
|
}
|
||||||
|
|
||||||
async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) {
|
async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) {
|
||||||
let outcome = db
|
let outcome = db
|
||||||
.apply_personal_read_intent(community, &actor.public_key(), &intent)
|
.apply_personal_read_intent(community, &actor.public_key(), &intent)
|
||||||
@@ -107,46 +104,6 @@ fn mark_through(channel: Uuid, root: Option<&str>, message: &str) -> ReadIntent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel-timeline states of `messages`, in order.
|
|
||||||
async fn states(
|
|
||||||
db: &Db,
|
|
||||||
community: CommunityId,
|
|
||||||
actor: &Keys,
|
|
||||||
channel: Uuid,
|
|
||||||
messages: &[&nostr::Event],
|
|
||||||
) -> Vec<String> {
|
|
||||||
let query = ContextQuery {
|
|
||||||
target: ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: None,
|
|
||||||
},
|
|
||||||
message_ids: messages.iter().map(|e| e.id.to_hex()).collect(),
|
|
||||||
};
|
|
||||||
let page = db
|
|
||||||
.personal_read_contexts(
|
|
||||||
community,
|
|
||||||
&actor.public_key(),
|
|
||||||
DEFAULT_RETENTION_SECONDS,
|
|
||||||
&[query],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let page = serde_json::to_value(page).unwrap();
|
|
||||||
page["contexts"][0]["messages"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.map(|m| m["status"].as_str().unwrap().to_owned())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exact(count: &ReadCount) -> Option<u32> {
|
|
||||||
match count {
|
|
||||||
ReadCount::Exact { value } => Some(*value),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn late_arrival_with_old_author_time_is_unread() {
|
async fn late_arrival_with_old_author_time_is_unread() {
|
||||||
@@ -160,18 +117,15 @@ async fn late_arrival_with_old_author_time_is_unread() {
|
|||||||
mark_through(channel, None, &read.id.to_hex()),
|
mark_through(channel, None, &read.id.to_hex()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
assert_eq!(counts(&db, community, &actor).await, (false, 0));
|
||||||
|
|
||||||
// Authored ten minutes before the read message, arriving after it was read.
|
// Authored ten minutes before the read message, arriving after it was read.
|
||||||
let late = post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await;
|
let late = post(&db, &pool, community, channel, &actor, now - 600, now - 30).await;
|
||||||
|
|
||||||
assert_eq!(
|
let row = sidebar(&db, community, &actor).await;
|
||||||
states(&db, community, &actor, channel, &[&read, &late]).await,
|
assert_eq!((row.unread, row.mentions), (true, 1));
|
||||||
["read", "unread"]
|
assert_eq!(row.latest_id, Some(late.id.to_hex()));
|
||||||
);
|
assert_eq!(row.read_through_id, Some(read.id.to_hex()));
|
||||||
assert_eq!(
|
|
||||||
exact(&sidebar(&db, community, &actor).await.unread),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -181,7 +135,7 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() {
|
|||||||
let now = earlier.created_at.as_secs();
|
let now = earlier.created_at.as_secs();
|
||||||
arrive(&pool, community, &earlier, now - 60).await;
|
arrive(&pool, community, &earlier, now - 60).await;
|
||||||
// Stamped ten minutes ahead; the relay accepts up to fifteen.
|
// Stamped ten minutes ahead; the relay accepts up to fifteen.
|
||||||
let ahead = post(&db, &pool, community, channel, now + 600, now - 40, vec![]).await;
|
let ahead = post(&db, &pool, community, channel, &actor, now + 600, now - 40).await;
|
||||||
apply(
|
apply(
|
||||||
&db,
|
&db,
|
||||||
community,
|
community,
|
||||||
@@ -189,17 +143,11 @@ async fn future_dated_anchor_does_not_swallow_later_arrivals() {
|
|||||||
mark_through(channel, None, &ahead.id.to_hex()),
|
mark_through(channel, None, &ahead.id.to_hex()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
assert_eq!(counts(&db, community, &actor).await, (false, 0));
|
||||||
|
|
||||||
let later = post(&db, &pool, community, channel, now, now - 30, vec![]).await;
|
post(&db, &pool, community, channel, &actor, now, now - 30).await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(counts(&db, community, &actor).await, (true, 1));
|
||||||
states(&db, community, &actor, channel, &[&earlier, &ahead, &later]).await,
|
|
||||||
["read", "read", "unread"]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
exact(&sidebar(&db, community, &actor).await.unread),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marking the sidebar's own latest message must clear the badge even when the
|
/// Marking the sidebar's own latest message must clear the badge even when the
|
||||||
@@ -210,32 +158,19 @@ async fn mark_as_read_with_the_sidebar_anchor_clears_a_late_arrival() {
|
|||||||
let (db, pool, community, channel, actor, first) = fixture().await;
|
let (db, pool, community, channel, actor, first) = fixture().await;
|
||||||
let now = first.created_at.as_secs();
|
let now = first.created_at.as_secs();
|
||||||
arrive(&pool, community, &first, now - 60).await;
|
arrive(&pool, community, &first, now - 60).await;
|
||||||
post(&db, &pool, community, channel, now + 1, now - 40, vec![]).await;
|
post(&db, &pool, community, channel, &actor, now + 1, now - 40).await;
|
||||||
post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await;
|
let last = post(&db, &pool, community, channel, &actor, now - 600, now - 30).await;
|
||||||
assert_eq!(
|
let row = sidebar(&db, community, &actor).await;
|
||||||
exact(&sidebar(&db, community, &actor).await.unread),
|
// The fixture message is unread too, but mentions no one.
|
||||||
Some(3)
|
assert_eq!((row.unread, row.mentions), (true, 2));
|
||||||
);
|
assert_eq!(row.latest_id, Some(last.id.to_hex()));
|
||||||
|
|
||||||
let anchor = sidebar(&db, community, &actor)
|
let anchor = row
|
||||||
.await
|
.latest_id
|
||||||
.latest_message_id
|
|
||||||
.expect("a channel with messages has a latest message");
|
.expect("a channel with messages has a latest message");
|
||||||
apply(
|
apply(&db, community, &actor, mark_through(channel, None, &anchor)).await;
|
||||||
&db,
|
|
||||||
community,
|
|
||||||
&actor,
|
|
||||||
ReadIntent::MarkChannelRead {
|
|
||||||
channel_id: channel,
|
|
||||||
message_id: anchor,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(counts(&db, community, &actor).await, (false, 0));
|
||||||
exact(&sidebar(&db, community, &actor).await.unread),
|
|
||||||
Some(0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -262,7 +197,7 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
|
|||||||
now - 40,
|
now - 40,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
reply(
|
let last = reply(
|
||||||
&db,
|
&db,
|
||||||
&pool,
|
&pool,
|
||||||
community,
|
community,
|
||||||
@@ -274,11 +209,17 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let row = sidebar(&db, community, &actor).await;
|
let row = sidebar(&db, community, &actor).await;
|
||||||
assert_eq!(exact(&row.unread), Some(2));
|
assert_eq!(
|
||||||
assert_eq!(row.threads.items.len(), 1);
|
(row.unread, row.mentions),
|
||||||
|
(false, 0),
|
||||||
|
"replies are not top-level"
|
||||||
|
);
|
||||||
|
assert_eq!(row.threads.len(), 1);
|
||||||
|
assert_eq!(row.threads[0].mentions, 2);
|
||||||
|
assert_eq!(row.threads[0].latest_id, last.id.to_hex());
|
||||||
|
|
||||||
let root_id = root.id.to_hex();
|
let root_id = root.id.to_hex();
|
||||||
let anchor = row.threads.items[0].latest_reply_id.clone();
|
let anchor = row.threads[0].latest_id.clone();
|
||||||
apply(
|
apply(
|
||||||
&db,
|
&db,
|
||||||
community,
|
community,
|
||||||
@@ -288,15 +229,15 @@ async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let row = sidebar(&db, community, &actor).await;
|
let row = sidebar(&db, community, &actor).await;
|
||||||
assert_eq!(exact(&row.unread), Some(0));
|
assert!(!row.unread);
|
||||||
assert!(row.threads.items.is_empty() && row.threads.complete);
|
assert!(row.threads.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shallow latest probe reads the newest `MAX_CHANNEL_SCAN + 1` events by
|
/// The shallow latest probe reads the newest `MAX_CHANNEL_SCAN + 1` events by
|
||||||
/// author time, of any kind. A late arrival with an older author time than
|
/// author time, of any kind. A late arrival with an older author time than
|
||||||
/// that many events is counted unread, so the unread scan must supply the
|
/// that many events is counted unread, so the unread scan must supply the
|
||||||
/// anchor or Mark as read with the sidebar anchor leaves it.
|
/// anchor or Mark as read with the sidebar anchor leaves it.
|
||||||
async fn mark_as_read_behind_fillers(fillers: u64) -> Option<u32> {
|
async fn mark_as_read_behind_fillers(fillers: u64) -> (bool, u32) {
|
||||||
let (db, pool, community, channel, actor, first) = fixture().await;
|
let (db, pool, community, channel, actor, first) = fixture().await;
|
||||||
let now = first.created_at.as_secs();
|
let now = first.created_at.as_secs();
|
||||||
arrive(&pool, community, &first, now - 60).await;
|
arrive(&pool, community, &first, now - 60).await;
|
||||||
@@ -318,27 +259,15 @@ async fn mark_as_read_behind_fillers(fillers: u64) -> Option<u32> {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
arrive(&pool, community, &filler, now - 50).await;
|
arrive(&pool, community, &filler, now - 50).await;
|
||||||
}
|
}
|
||||||
post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await;
|
post(&db, &pool, community, channel, &actor, now - 600, now - 10).await;
|
||||||
assert_eq!(
|
assert_eq!(counts(&db, community, &actor).await, (true, 1));
|
||||||
exact(&sidebar(&db, community, &actor).await.unread),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
|
|
||||||
let anchor = sidebar(&db, community, &actor)
|
let anchor = sidebar(&db, community, &actor)
|
||||||
.await
|
.await
|
||||||
.latest_message_id
|
.latest_id
|
||||||
.expect("a channel with messages has a latest message");
|
.expect("a channel with messages has a latest message");
|
||||||
apply(
|
apply(&db, community, &actor, mark_through(channel, None, &anchor)).await;
|
||||||
&db,
|
counts(&db, community, &actor).await
|
||||||
community,
|
|
||||||
&actor,
|
|
||||||
ReadIntent::MarkChannelRead {
|
|
||||||
channel_id: channel,
|
|
||||||
message_id: anchor,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
exact(&sidebar(&db, community, &actor).await.unread)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Control: with one slot to spare the late arrival is inside the probe.
|
/// Control: with one slot to spare the late arrival is inside the probe.
|
||||||
@@ -346,18 +275,18 @@ async fn mark_as_read_behind_fillers(fillers: u64) -> Option<u32> {
|
|||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn mark_as_read_clears_a_late_arrival_inside_the_latest_probe() {
|
async fn mark_as_read_clears_a_late_arrival_inside_the_latest_probe() {
|
||||||
let fillers = MAX_CHANNEL_SCAN as u64 - 1;
|
let fillers = MAX_CHANNEL_SCAN as u64 - 1;
|
||||||
assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0));
|
assert_eq!(mark_as_read_behind_fillers(fillers).await, (false, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn mark_as_read_clears_a_late_arrival_outside_the_latest_probe() {
|
async fn mark_as_read_clears_a_late_arrival_outside_the_latest_probe() {
|
||||||
let fillers = MAX_CHANNEL_SCAN as u64;
|
let fillers = MAX_CHANNEL_SCAN as u64;
|
||||||
assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0));
|
assert_eq!(mark_as_read_behind_fillers(fillers).await, (false, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When the newest 257 events are all uncounted, the only message is still in
|
/// When the newest 257 events are all uncounted, the only message is still in
|
||||||
/// the unread scan: the summary reports it as found, as one coherent triple.
|
/// the unread scan: the summary reports it as latest and unread.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn latest_message_behind_a_full_probe_of_reactions_is_found() {
|
async fn latest_message_behind_a_full_probe_of_reactions_is_found() {
|
||||||
@@ -371,7 +300,7 @@ async fn latest_message_behind_a_full_probe_of_reactions_is_found() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
assert_eq!(demoted, 1);
|
assert_eq!(demoted, 1);
|
||||||
let only = post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await;
|
let only = post(&db, &pool, community, channel, &actor, now - 600, now - 10).await;
|
||||||
for i in 0..MAX_CHANNEL_SCAN as u64 {
|
for i in 0..MAX_CHANNEL_SCAN as u64 {
|
||||||
let filler = EventBuilder::new(Kind::Custom(7), "+")
|
let filler = EventBuilder::new(Kind::Custom(7), "+")
|
||||||
.custom_created_at(nostr::Timestamp::from(now - 500 + i))
|
.custom_created_at(nostr::Timestamp::from(now - 500 + i))
|
||||||
@@ -384,10 +313,8 @@ async fn latest_message_behind_a_full_probe_of_reactions_is_found() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let row = sidebar(&db, community, &actor).await;
|
let row = sidebar(&db, community, &actor).await;
|
||||||
assert_eq!(row.latest_message_id, Some(only.id.to_hex()));
|
assert_eq!(row.latest_id, Some(only.id.to_hex()));
|
||||||
assert_eq!(row.latest_message_at, Some((now - 600) as i64));
|
assert_eq!((row.unread, row.mentions), (true, 1));
|
||||||
assert!(row.latest_message_complete);
|
|
||||||
assert_eq!(exact(&row.unread), Some(1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store `event` as having arrived at exactly `seconds` plus `micros`. Built
|
/// Store `event` as having arrived at exactly `seconds` plus `micros`. Built
|
||||||
@@ -419,13 +346,17 @@ async fn arrive_exact(
|
|||||||
assert_eq!(stored, at, "received_at must keep microseconds");
|
assert_eq!(stored, at, "received_at must keep microseconds");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two messages with the same author time arriving within one second; marks
|
/// Two mentioning messages with the same author time arriving within one
|
||||||
/// the one at `pick` and returns both channel states.
|
/// second; marks the one at `pick`, then the other. Returns the mention count
|
||||||
async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec<String>, Option<u32>) {
|
/// after the first mark, and the anchor (index) the frontier ends with.
|
||||||
let (db, pool, community, channel, actor, first) = fixture().await;
|
async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (u32, usize) {
|
||||||
let now = first.created_at.as_secs();
|
let (db, pool, community, channel, actor, older) = fixture().await;
|
||||||
let second = post(&db, &pool, community, channel, now, now, vec![]).await;
|
let now = older.created_at.as_secs();
|
||||||
let arrived = now as i64 - 30;
|
let arrived = now as i64 - 30;
|
||||||
|
// The fixture message arrives first, so either mark covers it.
|
||||||
|
arrive_exact(&pool, community, &older, arrived - 1, 0).await;
|
||||||
|
let first = post(&db, &pool, community, channel, &actor, now, now).await;
|
||||||
|
let second = post(&db, &pool, community, channel, &actor, now, now).await;
|
||||||
arrive_exact(&pool, community, &first, arrived, micros[0]).await;
|
arrive_exact(&pool, community, &first, arrived, micros[0]).await;
|
||||||
arrive_exact(&pool, community, &second, arrived, micros[1]).await;
|
arrive_exact(&pool, community, &second, arrived, micros[1]).await;
|
||||||
let both = [&first, &second];
|
let both = [&first, &second];
|
||||||
@@ -436,32 +367,41 @@ async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec<String>,
|
|||||||
mark_through(channel, None, &both[pick].id.to_hex()),
|
mark_through(channel, None, &both[pick].id.to_hex()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
(
|
let mentions = sidebar(&db, community, &actor).await.mentions;
|
||||||
states(&db, community, &actor, channel, &both).await,
|
apply(
|
||||||
exact(&sidebar(&db, community, &actor).await.unread),
|
&db,
|
||||||
|
community,
|
||||||
|
&actor,
|
||||||
|
mark_through(channel, None, &both[1 - pick].id.to_hex()),
|
||||||
)
|
)
|
||||||
|
.await;
|
||||||
|
let anchor = sidebar(&db, community, &actor).await.read_through_id;
|
||||||
|
let anchor = both
|
||||||
|
.iter()
|
||||||
|
.position(|e| Some(e.id.to_hex()) == anchor)
|
||||||
|
.expect("the anchor is one of the marked messages");
|
||||||
|
(mentions, anchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mid-second stamps, so truncating or rounding the frontier to whole seconds
|
/// Mid-second stamps, so truncating or rounding the frontier to whole seconds
|
||||||
/// either reads the later message or leaves the anchor unread.
|
/// either reads the later message or leaves the anchor unread. The later
|
||||||
|
/// arrival then becomes the anchor.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn arrivals_one_microsecond_apart_in_the_same_second_are_ordered() {
|
async fn arrivals_one_microsecond_apart_in_the_same_second_are_ordered() {
|
||||||
assert_eq!(
|
assert_eq!(mark_within_one_second([500_000, 500_001], 0).await, (1, 1));
|
||||||
mark_within_one_second([500_000, 500_001], 0).await,
|
|
||||||
(vec!["read".to_owned(), "unread".to_owned()], Some(1))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Order section: everything that arrived at or before the anchor is read,
|
/// The Order section: everything that arrived at or before the anchor is read,
|
||||||
/// so an identical stamp reads both, whichever is marked.
|
/// so an identical stamp reads both, whichever is marked. An equal arrival
|
||||||
|
/// keeps the first anchor.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn marking_either_of_two_identical_arrivals_reads_both() {
|
async fn marking_either_of_two_identical_arrivals_reads_both() {
|
||||||
for pick in [0, 1] {
|
for pick in [0, 1] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mark_within_one_second([500_000, 500_000], pick).await,
|
mark_within_one_second([500_000, 500_000], pick).await,
|
||||||
(vec!["read".to_owned(), "read".to_owned()], Some(0)),
|
(0, pick),
|
||||||
"marked index {pick}"
|
"marked index {pick}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
//! through; the unread horizon alone uses author time. Only fixed context
|
||||||
//! intents advance frontiers, never a query scan cap.
|
//! intents advance frontiers, never a query scan cap.
|
||||||
|
|
||||||
mod classification;
|
|
||||||
mod context;
|
|
||||||
mod model;
|
mod model;
|
||||||
mod participation;
|
mod participation;
|
||||||
mod projection;
|
mod projection;
|
||||||
|
|||||||
@@ -28,14 +28,6 @@ pub enum ReadIntent {
|
|||||||
/// Fixed anchor; retry must not substitute the latest message.
|
/// Fixed anchor; retry must not substitute the latest message.
|
||||||
message_id: String,
|
message_id: String,
|
||||||
},
|
},
|
||||||
/// Advance the channel timeline and every thread in it through one fixed
|
|
||||||
/// message's arrival time. The anchor may be a reply; ancestry is irrelevant.
|
|
||||||
MarkChannelRead {
|
|
||||||
/// Channel being marked, including all of its threads.
|
|
||||||
channel_id: Uuid,
|
|
||||||
/// Fixed anchor; retry must not substitute the latest message.
|
|
||||||
message_id: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome for one independent transaction, never acknowledged before commit.
|
/// Outcome for one independent transaction, never acknowledged before commit.
|
||||||
@@ -50,220 +42,72 @@ pub enum IntentOutcome {
|
|||||||
Invalid,
|
Invalid,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tracking boundary for the authenticated account.
|
/// The tracking boundary for the authenticated account. Not on the wire.
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ReadAccount {
|
pub(super) struct ReadAccount {
|
||||||
/// Configured tracking duration in seconds.
|
|
||||||
pub retention_seconds: u32,
|
|
||||||
/// Read-time author-time cutoff (Unix milliseconds), not a discard boundary.
|
/// Read-time author-time cutoff (Unix milliseconds), not a discard boundary.
|
||||||
pub cutoff_ms: i64,
|
pub(super) cutoff_ms: i64,
|
||||||
/// Arrival floor of every frontier: the account's first applied read
|
/// Arrival floor of every frontier: the account's first applied read
|
||||||
/// intent. `None` until then, and nothing counts as unread. Not on the wire.
|
/// intent. `None` until then, and nothing counts as unread.
|
||||||
#[serde(skip)]
|
pub(super) started_at: Option<DateTime<Utc>>,
|
||||||
pub started_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReadAccount {
|
|
||||||
/// The arrival every frontier is floored at: the start, or the end of time
|
|
||||||
/// for an account that has not started.
|
|
||||||
pub(super) fn floor(&self) -> DateTime<Utc> {
|
|
||||||
self.started_at.unwrap_or(DateTime::<Utc>::MAX_UTC)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum channel summaries in one sidebar page.
|
/// Maximum channel summaries in one sidebar page.
|
||||||
pub const MAX_CHANNELS: usize = 20;
|
pub const MAX_CHANNELS: usize = 20;
|
||||||
/// Maximum unread-thread summaries per channel row.
|
/// Maximum thread rows per channel row. A POST can return rows for
|
||||||
pub const MAX_THREAD_SUMMARIES: usize = 5;
|
/// `MAX_INTENTS` channels, and they must fit the 1 MiB response limit.
|
||||||
/// Bounded event evidence per channel; exhaustion is never inferred at this cap.
|
pub const MAX_THREAD_SUMMARIES: usize = 25;
|
||||||
|
/// Latest-probe event budget per channel, before eligibility filtering.
|
||||||
pub const MAX_CHANNEL_SCAN: usize = 256;
|
pub const MAX_CHANNEL_SCAN: usize = 256;
|
||||||
/// Unread-window work budget per channel, before eligibility/ancestry joins.
|
/// Unread-window work budget per channel, before eligibility/ancestry joins.
|
||||||
pub const MAX_UNREAD_SCAN: usize = 4096;
|
pub const MAX_UNREAD_SCAN: usize = 4096;
|
||||||
/// Conversation kinds eligible for ordinary unread state (not edits/reactions).
|
/// Conversation kinds eligible for ordinary unread state (not edits/reactions).
|
||||||
pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003];
|
pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003];
|
||||||
|
|
||||||
/// An honest aggregate: capped evidence cannot establish exact zero.
|
/// One joined channel's timeline read state. A channel and each of its
|
||||||
#[derive(Clone, Debug, Serialize)]
|
/// threads keep independent read positions.
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
|
||||||
pub enum ReadCount {
|
|
||||||
/// Exhausted the authoritative candidate set.
|
|
||||||
Exact {
|
|
||||||
/// Total within the tracking horizon.
|
|
||||||
value: u32,
|
|
||||||
},
|
|
||||||
/// Incomplete evidence establishes no positive lower bound. No numeric value.
|
|
||||||
Unknown,
|
|
||||||
/// More evidence exists or ancestry/membership could not be proved.
|
|
||||||
AtLeast {
|
|
||||||
/// Proven lower bound, not a fabricated badge cap.
|
|
||||||
value: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReadCount {
|
|
||||||
pub(super) fn from_evidence(value: u32, complete: bool) -> Self {
|
|
||||||
if complete {
|
|
||||||
Self::Exact { value }
|
|
||||||
} else if value == 0 {
|
|
||||||
Self::Unknown
|
|
||||||
} else {
|
|
||||||
Self::AtLeast { value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One joined-channel summary, not a second conversation/history API.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ChannelReadSummary {
|
pub struct ChannelReadSummary {
|
||||||
/// Joined channel UUID.
|
/// Joined channel UUID.
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
/// Existing channel name.
|
/// Whether an unread top-level message was found. Thread replies count
|
||||||
pub name: String,
|
/// only on their thread row.
|
||||||
/// Existing channel type.
|
pub unread: bool,
|
||||||
pub channel_type: String,
|
/// Unread top-level messages directed at the actor: every one in a DM,
|
||||||
/// Archived channels stay in the roster; presentation remains client-owned.
|
/// otherwise those that tag the actor with `p` or carry `broadcast=1`.
|
||||||
pub archived: bool,
|
pub mentions: u32,
|
||||||
/// Existing DM visibility preference (not an authorization decision).
|
/// The anchor of the timeline's frontier: the marked message that arrived
|
||||||
pub hidden: bool,
|
/// last. None until the timeline is marked.
|
||||||
/// Unread messages that count: every top-level message, and a reply only
|
pub read_through_id: Option<String>,
|
||||||
/// when it has a [`Reason`]. Other replies are not unread at all.
|
/// Last eligible top-level message to arrive, whatever its author or read
|
||||||
pub unread: ReadCount,
|
/// progress: marking the timeline through it reads the timeline.
|
||||||
/// The unread subset with a [`Reason`]: everything but ordinary top-level
|
pub latest_id: Option<String>,
|
||||||
/// messages. This is not Desktop notification eligibility: follows and
|
|
||||||
/// mutes do not change this count.
|
|
||||||
pub attention: ReadCount,
|
|
||||||
/// Last eligible nondeleted event to arrive, inside the unread horizon when
|
|
||||||
/// any is, whatever its author or read progress: marking through it reads
|
|
||||||
/// the row.
|
|
||||||
/// None proves absence only when latest_message_complete is true.
|
|
||||||
pub latest_message_id: Option<String>,
|
|
||||||
/// Display activity: the greatest author time (Unix seconds) among eligible
|
|
||||||
/// nondeleted events, not necessarily latest_message_id's own. None exactly
|
|
||||||
/// when it is None.
|
|
||||||
pub latest_message_at: Option<i64>,
|
|
||||||
/// Whether the latest lookup found a result or exhausted channel history.
|
|
||||||
/// False means the bounded probe found none, but an unexamined tail remains.
|
|
||||||
pub latest_message_complete: bool,
|
|
||||||
/// Threads with unread replies, newest unread reply first.
|
/// Threads with unread replies, newest unread reply first.
|
||||||
pub threads: ThreadSummaries,
|
pub threads: Vec<ThreadReadSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bounded, ordered list of unread threads within one channel row.
|
/// One thread's read state within a channel row. No conversation bytes.
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ThreadSummaries {
|
|
||||||
/// At most MAX_THREAD_SUMMARIES, by latest_reply_at DESC then root_id ASC.
|
|
||||||
pub items: Vec<ThreadReadSummary>,
|
|
||||||
/// True only when evidence was exhausted and no thread was omitted.
|
|
||||||
pub complete: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unread replies in one canonical thread; every one has a [`Reason`]. No
|
|
||||||
/// conversation bytes.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ThreadReadSummary {
|
pub struct ThreadReadSummary {
|
||||||
/// Canonical thread-root event ID.
|
/// Canonical thread-root event ID.
|
||||||
pub root_id: String,
|
pub root_id: String,
|
||||||
/// Unread replies in this thread (same definition as the row count).
|
/// Whether an unread reply that counts was found.
|
||||||
pub unread: ReadCount,
|
pub unread: bool,
|
||||||
/// Last observed unread reply to arrive: marking through it reads the thread.
|
/// Unread replies that count: directed at the actor, or in one of the
|
||||||
pub latest_reply_id: String,
|
/// actor's conversations.
|
||||||
/// Author time (Unix seconds) of latest_reply_id.
|
pub mentions: u32,
|
||||||
pub latest_reply_at: i64,
|
/// The anchor of the thread's frontier: the marked message that arrived
|
||||||
|
/// last. None until the thread is marked.
|
||||||
|
pub read_through_id: Option<String>,
|
||||||
|
/// Last counted reply to arrive: marking through it reads the thread.
|
||||||
|
pub latest_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bounded roster page, with no cross-page snapshot or removal inference.
|
/// A bounded roster page, with no cross-page snapshot or removal inference.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct SidebarPage {
|
pub struct SidebarPage {
|
||||||
/// Effective read-state lifecycle for this response.
|
|
||||||
pub account: ReadAccount,
|
|
||||||
/// Joined channels only, never every accessible public channel.
|
/// Joined channels only, never every accessible public channel.
|
||||||
pub channels: Vec<ChannelReadSummary>,
|
pub channels: Vec<ChannelReadSummary>,
|
||||||
/// Exclusive UUID roster cursor. None means this roster scan exhausted.
|
/// Exclusive UUID roster cursor. None means this roster scan exhausted.
|
||||||
pub next_cursor: Option<Uuid>,
|
pub next_cursor: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum explicit contexts in one request.
|
|
||||||
pub const MAX_CONTEXTS: usize = 20;
|
|
||||||
/// Maximum explicit message selectors across the entire context request.
|
|
||||||
pub const MAX_CONTEXT_MESSAGES: usize = 100;
|
|
||||||
|
|
||||||
/// A context and concrete messages already known through Nostr history/live reads.
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct ContextQuery {
|
|
||||||
/// Channel timeline or canonical thread, never an arbitrary filter.
|
|
||||||
pub target: ReadTarget,
|
|
||||||
/// Optional concrete message selectors; not an event history query.
|
|
||||||
#[serde(default)]
|
|
||||||
pub message_ids: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Why an unread message is directed at the actor: the first that holds.
|
|
||||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum Reason {
|
|
||||||
/// Its channel is a DM.
|
|
||||||
Direct,
|
|
||||||
/// It tags the actor with `p`.
|
|
||||||
Mention,
|
|
||||||
/// It replies to a message the actor wrote, or to one the actor also
|
|
||||||
/// replied to, in the same channel. Only live eligible messages qualify.
|
|
||||||
Conversation,
|
|
||||||
/// It carries `broadcast=1`.
|
|
||||||
Broadcast,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read progress and eligibility for one concrete message, not a public receipt.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
|
||||||
pub enum MessageReadState {
|
|
||||||
/// Missing, inaccessible, or outside the requested context. No existence oracle.
|
|
||||||
Unavailable,
|
|
||||||
/// Evidence cannot safely establish ancestry, eligibility or membership.
|
|
||||||
Unknown,
|
|
||||||
/// Not unread: own, deleted, auxiliary, outside the horizon, or a reply
|
|
||||||
/// with no [`Reason`].
|
|
||||||
NotCounted,
|
|
||||||
/// Covered by this context's frontier.
|
|
||||||
Read,
|
|
||||||
/// Counts, and is beyond this context's frontier.
|
|
||||||
Unread {
|
|
||||||
/// Null only for an ordinary top-level message. A broadcast reply whose
|
|
||||||
/// membership is undecided reports `broadcast`.
|
|
||||||
reason: Option<Reason>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An explicit message result, in request order.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ContextMessage {
|
|
||||||
/// Requested ID, not an independently disclosed event ID.
|
|
||||||
pub message_id: String,
|
|
||||||
/// Actor-private state within the requested context.
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub state: MessageReadState,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A context result. Denied and missing resources share an indistinguishable shape.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
|
||||||
pub enum ContextState {
|
|
||||||
/// Missing or inaccessible context.
|
|
||||||
Unavailable,
|
|
||||||
/// Canonical context could not be proved.
|
|
||||||
Unknown,
|
|
||||||
/// Context authority at the response snapshot.
|
|
||||||
Available {
|
|
||||||
/// Bounded explicit selectors, in request order.
|
|
||||||
messages: Vec<ContextMessage>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Actor-private bounded context response; no cross-request snapshot guarantee.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ContextPage {
|
|
||||||
/// Effective read-state lifecycle for this response.
|
|
||||||
pub account: ReadAccount,
|
|
||||||
/// One result per requested context, in request order.
|
|
||||||
pub contexts: Vec<ContextState>,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -121,32 +121,10 @@ impl World {
|
|||||||
.remove(0)
|
.remove(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wire state of one message in the channel timeline or `root`'s thread.
|
/// The row's counts and thread rows: what the sidebar says counts.
|
||||||
async fn state(
|
async fn counts(&self, channel: Uuid) -> Value {
|
||||||
&self,
|
let row = self.row(channel).await;
|
||||||
channel: Uuid,
|
json!({"unread":row.unread,"mentions":row.mentions,"threads":wire(&row.threads)})
|
||||||
root: Option<&nostr::Event>,
|
|
||||||
message: &nostr::Event,
|
|
||||||
) -> Value {
|
|
||||||
let page = self
|
|
||||||
.db
|
|
||||||
.personal_read_contexts(
|
|
||||||
self.community,
|
|
||||||
&self.actor.public_key(),
|
|
||||||
DEFAULT_RETENTION_SECONDS,
|
|
||||||
&[ContextQuery {
|
|
||||||
target: ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: root.map(|root| root.id.to_hex()),
|
|
||||||
},
|
|
||||||
message_ids: vec![message.id.to_hex()],
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let mut state = wire(&page)["contexts"][0]["messages"][0].take();
|
|
||||||
state.as_object_mut().unwrap().remove("message_id");
|
|
||||||
state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mention(&self) -> Tag {
|
fn mention(&self) -> Tag {
|
||||||
@@ -158,26 +136,20 @@ fn wire<T: serde::Serialize>(value: &T) -> Value {
|
|||||||
serde_json::to_value(value).unwrap()
|
serde_json::to_value(value).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn exact(value: u32) -> Value {
|
/// Expected `counts`: whether a top-level message is unread, how many of those
|
||||||
json!({"status":"exact","value":value})
|
/// are directed, and the listed thread rows.
|
||||||
}
|
fn counts(unread: bool, mentions: u32, threads: Vec<Value>) -> Value {
|
||||||
|
json!({"unread":unread,"mentions":mentions,"threads":threads})
|
||||||
/// Unread with a reason; `Value::Null` for an ordinary top-level message.
|
|
||||||
fn unread(reason: impl Into<Value>) -> Value {
|
|
||||||
json!({"status":"unread","reason":reason.into()})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status(status: &str) -> Value {
|
|
||||||
json!({ "status": status })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn broadcast() -> Tag {
|
fn broadcast() -> Tag {
|
||||||
Tag::parse(["broadcast", "1"]).unwrap()
|
Tag::parse(["broadcast", "1"]).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item(root: &nostr::Event, unread: u32, latest: &nostr::Event) -> Value {
|
/// A listed, never-marked thread with `mentions` counted unread replies.
|
||||||
json!({"root_id":root.id.to_hex(),"unread":exact(unread),
|
fn item(root: &nostr::Event, mentions: u32, latest: &nostr::Event) -> Value {
|
||||||
"latest_reply_id":latest.id.to_hex(),"latest_reply_at":latest.created_at.as_secs()})
|
json!({"root_id":root.id.to_hex(),"unread":true,"mentions":mentions,
|
||||||
|
"read_through_id":null,"latest_id":latest.id.to_hex()})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -197,22 +169,11 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() {
|
|||||||
let nested = w
|
let nested = w
|
||||||
.reply(c, &w.peer, &root, Some(&answer), w.now + 2, vec![])
|
.reply(c, &w.peer, &root, Some(&answer), w.now + 2, vec![])
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(w.state(c, None, &root).await, status("not_counted"));
|
// Only `answer` counts. The newer reply that does not count is not the
|
||||||
|
// thread's latest.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.state(c, Some(&root), &answer).await,
|
w.counts(c).await,
|
||||||
unread("conversation")
|
counts(false, 0, vec![item(&root, 1, &answer)])
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
w.state(c, Some(&root), &nested).await,
|
|
||||||
status("not_counted")
|
|
||||||
);
|
|
||||||
let row = w.row(c).await;
|
|
||||||
assert_eq!(wire(&row.unread), exact(1));
|
|
||||||
assert_eq!(wire(&row.attention), exact(1));
|
|
||||||
// The newer reply that does not count is not the thread's preview.
|
|
||||||
assert_eq!(
|
|
||||||
wire(&row.threads),
|
|
||||||
json!({"items":[item(&root, 1, &answer)],"complete":true})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Joining the nested conversation makes its earlier reply count. The
|
// Joining the nested conversation makes its earlier reply count. The
|
||||||
@@ -220,14 +181,8 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() {
|
|||||||
w.reply(c, &w.actor, &root, Some(&answer), w.now + 3, vec![])
|
w.reply(c, &w.actor, &root, Some(&answer), w.now + 3, vec![])
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.state(c, Some(&root), &nested).await,
|
w.counts(c).await,
|
||||||
unread("conversation")
|
counts(false, 0, vec![item(&root, 2, &nested)])
|
||||||
);
|
|
||||||
let row = w.row(c).await;
|
|
||||||
assert_eq!(wire(&row.unread), exact(2));
|
|
||||||
assert_eq!(
|
|
||||||
wire(&row.threads),
|
|
||||||
json!({"items":[item(&root, 2, &nested)],"complete":true})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// A peer's thread. The actor replied to one of two parents, long ago.
|
// A peer's thread. The actor replied to one of two parents, long ago.
|
||||||
@@ -240,45 +195,24 @@ async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() {
|
|||||||
let sibling = w
|
let sibling = w
|
||||||
.reply(c, &w.peer, &root, Some(&joined), w.now + 1, vec![])
|
.reply(c, &w.peer, &root, Some(&joined), w.now + 1, vec![])
|
||||||
.await;
|
.await;
|
||||||
let elsewhere = w
|
w.reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![])
|
||||||
.reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![])
|
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.state(c, Some(&root), &sibling).await,
|
w.counts(c).await,
|
||||||
unread("conversation")
|
counts(false, 0, vec![item(&root, 1, &sibling)])
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
w.state(c, Some(&root), &elsewhere).await,
|
|
||||||
status("not_counted")
|
|
||||||
);
|
|
||||||
let row = w.row(c).await;
|
|
||||||
assert_eq!(wire(&row.unread), exact(1));
|
|
||||||
assert_eq!(
|
|
||||||
wire(&row.threads),
|
|
||||||
json!({"items":[item(&root, 1, &sibling)],"complete":true})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// A conversation the actor never joined: one unread top-level message, and
|
// A conversation the actor never joined: one unread top-level message, and
|
||||||
// a reply that is not unread even in its own thread.
|
// a reply that is not unread even in its own thread.
|
||||||
let c = w.channel().await;
|
let c = w.channel().await;
|
||||||
let root = w.post(c, &w.peer, w.now, vec![]).await;
|
let root = w.post(c, &w.peer, w.now, vec![]).await;
|
||||||
let reply = w
|
w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
|
||||||
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
|
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(w.state(c, None, &root).await, unread(Value::Null));
|
assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
|
||||||
assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted"));
|
|
||||||
let row = w.row(c).await;
|
|
||||||
assert_eq!(wire(&row.unread), exact(1));
|
|
||||||
assert_eq!(wire(&row.attention), exact(0));
|
|
||||||
assert_eq!(wire(&row.threads), json!({"items":[],"complete":true}));
|
|
||||||
|
|
||||||
// A reply whose parent was never recorded is undecided, not absent.
|
// A reply whose parent was never recorded is undecided: left out.
|
||||||
let orphan = w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await;
|
w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await;
|
||||||
assert_eq!(w.state(c, Some(&root), &orphan).await, status("unknown"));
|
assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
|
||||||
let row = w.row(c).await;
|
|
||||||
assert_eq!(wire(&row.unread), json!({"status":"at_least","value":1}));
|
|
||||||
assert_eq!(wire(&row.attention), status("unknown"));
|
|
||||||
assert_eq!(wire(&row.threads), json!({"items":[],"complete":false}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -288,29 +222,12 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
|
|||||||
let c = w.channel().await;
|
let c = w.channel().await;
|
||||||
let root = w.post(c, &w.peer, w.now, vec![]).await;
|
let root = w.post(c, &w.peer, w.now, vec![]).await;
|
||||||
let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await;
|
let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await;
|
||||||
let own = w
|
w.reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![])
|
||||||
.reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![])
|
|
||||||
.await;
|
|
||||||
let mention = w
|
|
||||||
.reply(
|
|
||||||
c,
|
|
||||||
&w.peer,
|
|
||||||
&root,
|
|
||||||
Some(&parent),
|
|
||||||
w.now + 2,
|
|
||||||
vec![w.mention()],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let shout = w
|
|
||||||
.reply(
|
|
||||||
c,
|
|
||||||
&w.peer,
|
|
||||||
&root,
|
|
||||||
Some(&parent),
|
|
||||||
w.now + 3,
|
|
||||||
vec![broadcast()],
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
|
for (at, tags) in [(2, vec![w.mention()]), (3, vec![broadcast()])] {
|
||||||
|
w.reply(c, &w.peer, &root, Some(&parent), w.now + at, tags)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
let both = w
|
let both = w
|
||||||
.reply(
|
.reply(
|
||||||
c,
|
c,
|
||||||
@@ -321,28 +238,16 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
|
|||||||
vec![broadcast(), w.mention()],
|
vec![broadcast(), w.mention()],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(w.state(c, Some(&root), &own).await, status("not_counted"));
|
// The root is an undirected top-level message. The thread counts the
|
||||||
assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention"));
|
// actor's sibling `parent` and the three directed replies, not the
|
||||||
assert_eq!(w.state(c, Some(&root), &shout).await, unread("broadcast"));
|
// actor's own.
|
||||||
assert_eq!(w.state(c, Some(&root), &both).await, unread("mention"));
|
let expected = counts(true, 0, vec![item(&root, 4, &both)]);
|
||||||
// The root, the actor's sibling `parent`, and the three directed replies.
|
assert_eq!(w.counts(c).await, expected);
|
||||||
let row = w.row(c).await;
|
|
||||||
assert_eq!(wire(&row.unread), exact(5));
|
|
||||||
assert_eq!(wire(&row.attention), exact(4));
|
|
||||||
assert_eq!(
|
|
||||||
wire(&row.threads),
|
|
||||||
json!({"items":[item(&root, 4, &both)],"complete":true})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Conversation outranks broadcast, never mention. Counts do not move.
|
// Joining the directed replies' conversation changes no count.
|
||||||
w.reply(c, &w.actor, &root, Some(&parent), w.now + 5, vec![])
|
w.reply(c, &w.actor, &root, Some(&parent), w.now + 5, vec![])
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention"));
|
assert_eq!(w.counts(c).await, expected);
|
||||||
assert_eq!(
|
|
||||||
w.state(c, Some(&root), &shout).await,
|
|
||||||
unread("conversation")
|
|
||||||
);
|
|
||||||
assert_eq!(wire(&w.row(c).await.unread), exact(5));
|
|
||||||
|
|
||||||
// In a DM every peer message is direct, tagged or not, joined or not.
|
// In a DM every peer message is direct, tagged or not, joined or not.
|
||||||
let dm = w.channel().await;
|
let dm = w.channel().await;
|
||||||
@@ -359,33 +264,34 @@ async fn a_directed_reply_counts_outside_the_actors_conversations() {
|
|||||||
let reply = w
|
let reply = w
|
||||||
.reply(dm, &w.peer, &root, Some(&parent), w.now + 1, vec![])
|
.reply(dm, &w.peer, &root, Some(&parent), w.now + 1, vec![])
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(w.state(dm, None, &root).await, unread("direct"));
|
assert_eq!(
|
||||||
assert_eq!(w.state(dm, Some(&root), &reply).await, unread("direct"));
|
w.counts(dm).await,
|
||||||
let row = w.row(dm).await;
|
counts(true, 1, vec![item(&root, 2, &reply)])
|
||||||
assert_eq!(wire(&row.unread), exact(3));
|
);
|
||||||
assert_eq!(wire(&row.attention), exact(3));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn eligibility_then_the_read_frontier_are_reported_before_membership() {
|
async fn eligibility_frontier_and_membership_each_leave_replies_out() {
|
||||||
let w = World::new().await;
|
let w = World::new().await;
|
||||||
let c = w.channel().await;
|
let c = w.channel().await;
|
||||||
let root = w.post(c, &w.peer, w.now, vec![]).await;
|
let root = w.post(c, &w.peer, w.now, vec![]).await;
|
||||||
let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await;
|
let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await;
|
||||||
let reply = |author, at, tags| w.reply(c, author, &root, Some(&parent), w.now + at, tags);
|
let reply = |author, at, tags| w.reply(c, author, &root, Some(&parent), w.now + at, tags);
|
||||||
// Peer replies to a parent the actor neither wrote nor replied to.
|
// Peer replies to a parent the actor neither wrote nor replied to.
|
||||||
let outside = reply(&w.peer, 1, vec![]).await;
|
reply(&w.peer, 1, vec![]).await;
|
||||||
let deleted = reply(&w.peer, 2, vec![]).await;
|
let deleted = reply(&w.peer, 2, vec![w.mention()]).await;
|
||||||
w.delete(&deleted).await;
|
w.delete(&deleted).await;
|
||||||
let own = w
|
w.reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![])
|
||||||
.reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![])
|
|
||||||
.await;
|
.await;
|
||||||
let anchor = reply(&w.peer, 4, vec![w.mention()]).await;
|
let anchor = reply(&w.peer, 4, vec![w.mention()]).await;
|
||||||
let later = reply(&w.peer, 5, vec![]).await;
|
// Undirected, outside the actor's conversations: never counts.
|
||||||
|
reply(&w.peer, 5, vec![]).await;
|
||||||
|
// `parent` (a reply to the root, which the actor answered) and `anchor`
|
||||||
|
// count: not the deleted, own or undirected replies.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.state(c, Some(&root), &outside).await,
|
w.counts(c).await,
|
||||||
status("not_counted")
|
counts(true, 0, vec![item(&root, 2, &anchor)])
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -404,15 +310,14 @@ async fn eligibility_then_the_read_frontier_are_reported_before_membership() {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
IntentOutcome::Applied
|
IntentOutcome::Applied
|
||||||
);
|
);
|
||||||
for (message, expected) in [
|
assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
|
||||||
(&outside, "read"),
|
// A later directed reply is unread past the thread's anchor; the channel
|
||||||
(&deleted, "not_counted"),
|
// timeline was never marked.
|
||||||
(&own, "not_counted"),
|
let after = reply(&w.peer, 6, vec![w.mention()]).await;
|
||||||
(&anchor, "read"),
|
let mut thread = item(&root, 1, &after);
|
||||||
(&later, "not_counted"),
|
thread["read_through_id"] = json!(anchor.id.to_hex());
|
||||||
] {
|
assert_eq!(w.counts(c).await, counts(true, 0, vec![thread]));
|
||||||
assert_eq!(w.state(c, Some(&root), message).await, status(expected));
|
assert_eq!(w.row(c).await.read_through_id, None);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -425,11 +330,11 @@ async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() {
|
|||||||
|
|
||||||
// The actor wrote the parent and never replied under it.
|
// The actor wrote the parent and never replied under it.
|
||||||
let wrote = reply(&w.actor, &root, 0).await;
|
let wrote = reply(&w.actor, &root, 0).await;
|
||||||
let to_wrote = reply(&w.peer, &wrote, 10).await;
|
reply(&w.peer, &wrote, 10).await;
|
||||||
// The actor's only reply to a peer's parent.
|
// The actor's only reply to a peer's parent.
|
||||||
let once = reply(&w.peer, &root, 0).await;
|
let once = reply(&w.peer, &root, 0).await;
|
||||||
let only = reply(&w.actor, &once, 1).await;
|
let only = reply(&w.actor, &once, 1).await;
|
||||||
let to_once = reply(&w.peer, &once, 11).await;
|
reply(&w.peer, &once, 11).await;
|
||||||
// Two replies by the actor to a peer's parent.
|
// Two replies by the actor to a peer's parent.
|
||||||
let twice = reply(&w.peer, &root, 0).await;
|
let twice = reply(&w.peer, &root, 0).await;
|
||||||
let first = reply(&w.actor, &twice, 1).await;
|
let first = reply(&w.actor, &twice, 1).await;
|
||||||
@@ -440,42 +345,33 @@ async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() {
|
|||||||
let under = reply(&w.actor, &both, 1).await;
|
let under = reply(&w.actor, &both, 1).await;
|
||||||
let to_both = reply(&w.peer, &both, 13).await;
|
let to_both = reply(&w.peer, &both, 13).await;
|
||||||
|
|
||||||
// Whether each counts: `unread` in a conversation, or else `not_counted`.
|
// How many replies the thread counts, the latest of them `to_both` until
|
||||||
let counted = || async {
|
// it stops counting.
|
||||||
let mut counted = Vec::new();
|
let w = &w;
|
||||||
for message in [&to_wrote, &to_once, &to_twice, &to_both] {
|
let thread = |mentions: u32| async move {
|
||||||
let state = w.state(c, Some(&root), message).await;
|
let row = w.row(c).await;
|
||||||
let yes = state == unread("conversation");
|
assert!(!row.unread, "the root predates the horizon");
|
||||||
assert!(yes || state == status("not_counted"), "{state}");
|
assert_eq!(row.threads.len(), 1);
|
||||||
counted.push(yes);
|
assert_eq!(row.threads[0].mentions, mentions);
|
||||||
}
|
row.threads[0].latest_id.clone()
|
||||||
counted
|
|
||||||
};
|
};
|
||||||
let (yes, no) = (true, false);
|
// The four peer replies to the actor's conversations count. So do `once`
|
||||||
// `once` and `twice` are peer replies to the root, which the actor has
|
// and `twice`: peer replies to the root, which the actor has replied to
|
||||||
// replied to (`wrote`, `both`), so they count as well.
|
// (`wrote`, `both`).
|
||||||
assert_eq!(counted().await, [yes, yes, yes, yes]);
|
assert_eq!(thread(6).await, to_both.id.to_hex());
|
||||||
assert_eq!(wire(&w.row(c).await.unread), exact(6));
|
|
||||||
|
|
||||||
// Each deletion changes only its own parent's conversation.
|
// Each deletion changes only its own parent's conversation.
|
||||||
w.delete(&wrote).await;
|
w.delete(&wrote).await;
|
||||||
assert_eq!(counted().await, [no, yes, yes, yes]);
|
assert_eq!(thread(5).await, to_both.id.to_hex());
|
||||||
w.delete(&only).await;
|
w.delete(&only).await;
|
||||||
assert_eq!(counted().await, [no, no, yes, yes]);
|
assert_eq!(thread(4).await, to_both.id.to_hex());
|
||||||
w.delete(&first).await;
|
w.delete(&first).await;
|
||||||
assert_eq!(counted().await, [no, no, yes, yes]);
|
assert_eq!(thread(4).await, to_both.id.to_hex());
|
||||||
w.delete(&both).await;
|
|
||||||
assert_eq!(counted().await, [no, no, yes, yes]);
|
|
||||||
assert_eq!(wire(&w.row(c).await.unread), exact(2));
|
|
||||||
w.delete(&under).await;
|
|
||||||
assert_eq!(counted().await, [no, no, yes, no]);
|
|
||||||
// With `wrote` and `both` gone the actor has no reply to the root either.
|
// With `wrote` and `both` gone the actor has no reply to the root either.
|
||||||
let row = w.row(c).await;
|
w.delete(&both).await;
|
||||||
assert_eq!(wire(&row.unread), exact(1));
|
assert_eq!(thread(2).await, to_both.id.to_hex());
|
||||||
assert_eq!(
|
w.delete(&under).await;
|
||||||
wire(&row.threads),
|
assert_eq!(thread(1).await, to_twice.id.to_hex());
|
||||||
json!({"items":[item(&root, 1, &to_twice)],"complete":true})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -492,32 +388,21 @@ async fn membership_is_scoped_to_the_replys_own_channel() {
|
|||||||
let in_a = w
|
let in_a = w
|
||||||
.reply(a, &w.peer, &theirs, Some(&theirs), w.now, vec![])
|
.reply(a, &w.peer, &theirs, Some(&theirs), w.now, vec![])
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(w.counts(a).await, counts(false, 0, vec![]));
|
||||||
w.state(a, Some(&theirs), &in_a).await,
|
|
||||||
status("not_counted")
|
|
||||||
);
|
|
||||||
assert_eq!(wire(&w.row(a).await.unread), exact(0));
|
|
||||||
|
|
||||||
// The actor wrote a parent in A. A peer reply in B claims it as its parent.
|
// The actor wrote a parent in A. A peer reply in B claims it as its parent.
|
||||||
let mine = w.post(a, &w.actor, old, vec![]).await;
|
let mine = w.post(a, &w.actor, old, vec![]).await;
|
||||||
w.reply(b, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
|
w.reply(b, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
|
||||||
let row = w.row(b).await;
|
assert_eq!(w.counts(b).await, counts(false, 0, vec![]));
|
||||||
assert_eq!(wire(&row.unread), exact(0));
|
|
||||||
assert_eq!(wire(&row.threads), json!({"items":[],"complete":true}));
|
|
||||||
|
|
||||||
// The same parents count once the actor is a member in the reply's channel.
|
// The same parents count once the actor is a member in the reply's channel.
|
||||||
w.reply(a, &w.actor, &theirs, Some(&theirs), old, vec![])
|
w.reply(a, &w.actor, &theirs, Some(&theirs), old, vec![])
|
||||||
.await;
|
.await;
|
||||||
let to_mine = w.reply(a, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
|
let to_mine = w.reply(a, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
|
||||||
assert_eq!(
|
// Equal reply times: root ID order.
|
||||||
w.state(a, Some(&theirs), &in_a).await,
|
let mut threads = [item(&theirs, 1, &in_a), item(&mine, 1, &to_mine)];
|
||||||
unread("conversation")
|
threads.sort_by(|x, y| x["root_id"].as_str().cmp(&y["root_id"].as_str()));
|
||||||
);
|
assert_eq!(w.counts(a).await, counts(false, 0, threads.to_vec()));
|
||||||
assert_eq!(
|
|
||||||
w.state(a, Some(&mine), &to_mine).await,
|
|
||||||
unread("conversation")
|
|
||||||
);
|
|
||||||
assert_eq!(wire(&w.row(a).await.unread), exact(2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -536,13 +421,9 @@ async fn membership_is_exact_behind_a_busy_parent() {
|
|||||||
.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![])
|
.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![])
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
assert_eq!(w.state(c, Some(&root), &last).await, unread("conversation"));
|
|
||||||
let row = w.row(c).await;
|
|
||||||
assert_eq!(wire(&row.unread), exact(258));
|
|
||||||
assert_eq!(wire(&row.attention), exact(258));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
wire(&row.threads),
|
w.counts(c).await,
|
||||||
json!({"items":[item(&root, 258, &last)],"complete":true})
|
counts(false, 0, vec![item(&root, 258, &last)])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,67 +433,52 @@ async fn threads_are_capped_after_replies_that_do_not_count_are_removed() {
|
|||||||
let w = World::new().await;
|
let w = World::new().await;
|
||||||
let c = w.channel().await;
|
let c = w.channel().await;
|
||||||
let old = w.now - 40 * DAY;
|
let old = w.now - 40 * DAY;
|
||||||
// The actor's thread has the oldest unread reply. Five unjoined threads
|
// The actor's thread has the oldest unread reply. A cap's worth of
|
||||||
// are newer and would fill the list if the cap came first.
|
// unjoined threads are newer and would fill the list if the cap came
|
||||||
|
// first.
|
||||||
let mine = w.post(c, &w.actor, old, vec![]).await;
|
let mine = w.post(c, &w.actor, old, vec![]).await;
|
||||||
let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
|
let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await;
|
||||||
for i in 1..=5 {
|
for i in 1..=MAX_THREAD_SUMMARIES as u64 {
|
||||||
let root = w.post(c, &w.peer, old, vec![]).await;
|
let root = w.post(c, &w.peer, old, vec![]).await;
|
||||||
w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![])
|
w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![])
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let row = w.row(c).await;
|
|
||||||
assert_eq!(wire(&row.unread), exact(1));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
wire(&row.threads),
|
w.counts(c).await,
|
||||||
json!({"items":[item(&mine, 1, &answer)],"complete":true})
|
counts(false, 0, vec![item(&mine, 1, &answer)])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn the_parent_budget_leaves_replies_undecided_and_fabricates_no_absence() {
|
async fn the_parent_budget_leaves_undecided_replies_out() {
|
||||||
let w = World::new().await;
|
let w = World::new().await;
|
||||||
let c = w.channel().await;
|
let c = w.channel().await;
|
||||||
// The actor is in the first conversation. Whether it is inside the budget
|
// The actor is in the first conversation. Whether it is inside the budget
|
||||||
// depends on ID order, so only the bounds are asserted.
|
// depends on ID order, so only the bounds are asserted.
|
||||||
|
let mut mine = None;
|
||||||
for i in 0..1025 {
|
for i in 0..1025 {
|
||||||
let author = if i == 0 { &w.actor } else { &w.peer };
|
let author = if i == 0 { &w.actor } else { &w.peer };
|
||||||
let root = w.post(c, author, w.now, vec![]).await;
|
let root = w.post(c, author, w.now, vec![]).await;
|
||||||
w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
|
let reply = w
|
||||||
|
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
|
||||||
.await;
|
.await;
|
||||||
if i == 1023 {
|
if i == 0 {
|
||||||
// 1024 parents fit: 1023 peer roots, and the reply to the actor's.
|
mine = Some(item(&root, 1, &reply));
|
||||||
// The independent SQL deadline may still withhold the answer.
|
}
|
||||||
let row = w.row(c).await;
|
// 1024 parents fit: 1023 peer roots, and the reply to the actor's.
|
||||||
|
// The independent SQL deadline may still withhold the answer. Past
|
||||||
|
// 1024, one reply is undecided. An undecided reply is left out.
|
||||||
|
if i >= 1023 {
|
||||||
|
let counted = w.counts(c).await;
|
||||||
assert!(
|
assert!(
|
||||||
[exact(1024), json!({"status":"at_least","value":1023})]
|
[vec![], vec![mine.clone().unwrap()]]
|
||||||
.contains(&wire(&row.unread)),
|
.map(|threads| counts(true, 0, threads))
|
||||||
"{:?}",
|
.contains(&counted),
|
||||||
row.unread
|
"{i}: {counted}"
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
[exact(1), status("unknown")].contains(&wire(&row.attention)),
|
|
||||||
"{:?}",
|
|
||||||
row.attention
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 1025 parents do not: one reply is undecided, so nothing is exact.
|
|
||||||
let row = w.row(c).await;
|
|
||||||
assert!(
|
|
||||||
[1024, 1025]
|
|
||||||
.map(|value| json!({"status":"at_least","value":value}))
|
|
||||||
.contains(&wire(&row.unread)),
|
|
||||||
"{:?}",
|
|
||||||
row.unread
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
[json!({"status":"at_least","value":1}), status("unknown")].contains(&wire(&row.attention)),
|
|
||||||
"{:?}",
|
|
||||||
row.attention
|
|
||||||
);
|
|
||||||
assert!(!row.threads.complete);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -621,8 +487,7 @@ async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction(
|
|||||||
let w = World::new().await;
|
let w = World::new().await;
|
||||||
let c = w.channel().await;
|
let c = w.channel().await;
|
||||||
let root = w.post(c, &w.peer, w.now, vec![]).await;
|
let root = w.post(c, &w.peer, w.now, vec![]).await;
|
||||||
let reply = w
|
w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
|
||||||
.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![])
|
|
||||||
.await;
|
.await;
|
||||||
let mut held = w.pool.begin().await.unwrap();
|
let mut held = w.pool.begin().await.unwrap();
|
||||||
// Force the real resolver to time out, then check that its outer snapshot
|
// Force the real resolver to time out, then check that its outer snapshot
|
||||||
@@ -668,5 +533,5 @@ async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction(
|
|||||||
}
|
}
|
||||||
reader.rollback().await.unwrap();
|
reader.rollback().await.unwrap();
|
||||||
held.rollback().await.unwrap();
|
held.rollback().await.unwrap();
|
||||||
assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted"));
|
assert_eq!(w.counts(c).await, counts(true, 0, vec![]));
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
//! Bounded evidence projection. The cap limits evidence, not the definition of
|
//! Bounded evidence projection. The cap limits evidence, not the definition of
|
||||||
//! unread: an unexamined tail yields a positive lower bound or an unknown count.
|
//! unread: an unexamined tail goes uncounted, so counts may undercount.
|
||||||
|
|
||||||
use super::{model::*, participation, writes};
|
use super::{model::*, participation, writes};
|
||||||
use buzz_core::CommunityId;
|
use buzz_core::CommunityId;
|
||||||
@@ -81,22 +81,19 @@ impl Db {
|
|||||||
// The inner event LIMIT is deliberately before eligibility filtering.
|
// The inner event LIMIT is deliberately before eligibility filtering.
|
||||||
// This bounds rows/joins even with long deleted or self-authored runs.
|
// This bounds rows/joins even with long deleted or self-authored runs.
|
||||||
// Aggregate equivalent eligible evidence before transfer. Multiplicity
|
// Aggregate equivalent eligible evidence before transfer. Multiplicity
|
||||||
// preserves counts; raw scan count (not group count) proves exhaustion.
|
// preserves counts.
|
||||||
// SQL eligibility mirrors classification::eligible (PostgreSQL parity test).
|
|
||||||
// Canonical covered rows need no tags; missing ancestry stays unknown.
|
|
||||||
// Validate relevant tag parts before compacting directed/ancestry facts.
|
// Validate relevant tag parts before compacting directed/ancestry facts.
|
||||||
// PostgreSQL scalar "p" ->> 0 is "p": the type check must reject it too.
|
// PostgreSQL scalar "p" ->> 0 is "p": the type check must reject it too.
|
||||||
// Canonical timeline roots share an empty (present) root sentinel.
|
// Canonical timeline roots share an empty (present) root sentinel.
|
||||||
// Tags are bounded before transfer; oversized/corrupt evidence stays
|
// Tags are bounded before transfer; oversized/corrupt evidence is
|
||||||
// unknown, never falsely top-level/unmentioned/read.
|
// skipped, never falsely top-level/unmentioned/read.
|
||||||
// Latest comes from the unread scan, so its ID arrives no earlier than
|
// Latest comes from the unread scan, so its ID arrives no earlier than
|
||||||
// anything counted; the shallow probe answers only when the horizon
|
// anything counted; the shallow probe answers only when the horizon
|
||||||
// holds no message. Both are newest-first by author time, so the deeper
|
// holds no top-level message. Thread replies are never the timeline's
|
||||||
// one finds the same greatest author time whenever the probe finds any.
|
// latest: a timeline mark cannot use one as its anchor.
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"WITH roster AS MATERIALIZED (
|
r#"WITH roster AS MATERIALIZED (
|
||||||
SELECT c.id,c.name,c.channel_type::text AS channel_type,
|
SELECT c.id,c.channel_type::text AS channel_type
|
||||||
c.archived_at IS NOT NULL AS archived, cm.hidden_at IS NOT NULL AS hidden
|
|
||||||
FROM channel_members cm JOIN channels c
|
FROM channel_members cm JOIN channels c
|
||||||
ON c.community_id=cm.community_id AND c.id=cm.channel_id
|
ON c.community_id=cm.community_id AND c.id=cm.channel_id
|
||||||
WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL
|
WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL
|
||||||
@@ -104,23 +101,27 @@ impl Db {
|
|||||||
AND ($9::uuid[] IS NULL OR c.id=ANY($9))
|
AND ($9::uuid[] IS NULL OR c.id=ANY($9))
|
||||||
ORDER BY c.id LIMIT $4
|
ORDER BY c.id LIMIT $4
|
||||||
)
|
)
|
||||||
SELECT r.*, COALESCE(e.latest_message_id, latest.latest_message_id) AS latest_message_id,
|
SELECT r.*, COALESCE(e.latest_id, latest.latest_id) AS latest_id,
|
||||||
COALESCE(e.latest_message_at, latest.latest_message_at) AS latest_message_at,
|
encode(cf.through_message_id,'hex') AS read_through_id,
|
||||||
(COALESCE(e.latest_message_id, latest.latest_message_id) IS NOT NULL
|
COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r
|
||||||
OR latest.candidates <= $5-1) AS latest_message_complete,
|
|
||||||
e.scanned,COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r
|
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
WITH candidates AS MATERIALIZED (
|
WITH candidates AS MATERIALIZED (
|
||||||
SELECT id,created_at,received_at,kind,deleted_at FROM events
|
SELECT id,created_at,received_at,kind,deleted_at,tags FROM events
|
||||||
WHERE community_id=$1 AND channel_id=r.id
|
WHERE community_id=$1 AND channel_id=r.id
|
||||||
ORDER BY created_at DESC,id LIMIT $5
|
ORDER BY created_at DESC,id LIMIT $5
|
||||||
)
|
)
|
||||||
SELECT count(*) AS candidates,
|
SELECT (array_agg(encode(c.id,'hex') ORDER BY c.received_at DESC,c.id)
|
||||||
(array_agg(encode(id,'hex') ORDER BY received_at DESC,id)
|
FILTER (WHERE c.kind=ANY($6) AND c.deleted_at IS NULL
|
||||||
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id,
|
AND NOT COALESCE(tm.root_event_id<>c.id,false)
|
||||||
(array_agg(extract(epoch FROM created_at)::bigint ORDER BY created_at DESC,id)
|
-- Same unresolved-reply test as `threaded` below.
|
||||||
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_at
|
AND NOT (tm.root_event_id IS NULL AND jsonb_typeof(c.tags)='array'
|
||||||
FROM candidates
|
AND EXISTS (SELECT 1 FROM jsonb_array_elements(c.tags) t(tag)
|
||||||
|
WHERE jsonb_typeof(tag)='array' AND tag->>0='e'
|
||||||
|
AND tag->>3='reply'
|
||||||
|
AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'))
|
||||||
|
))[1] AS latest_id
|
||||||
|
FROM candidates c LEFT JOIN thread_metadata tm ON tm.community_id=$1
|
||||||
|
AND tm.channel_id=r.id AND tm.event_created_at=c.created_at AND tm.event_id=c.id
|
||||||
) latest ON true
|
) latest ON true
|
||||||
LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2
|
LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2
|
||||||
AND cf.channel_id=r.id AND cf.root_id=''::bytea
|
AND cf.channel_id=r.id AND cf.root_id=''::bytea
|
||||||
@@ -129,29 +130,40 @@ impl Db {
|
|||||||
SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags
|
SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags
|
||||||
FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7
|
FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7
|
||||||
ORDER BY created_at DESC,id LIMIT $8
|
ORDER BY created_at DESC,id LIMIT $8
|
||||||
), classified AS (
|
), threaded AS MATERIALIZED (
|
||||||
SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent,
|
SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent,
|
||||||
COALESCE(tm.root_event_id<>e.id,false) AS is_reply,
|
COALESCE(tm.root_event_id<>e.id,false) AS is_reply,
|
||||||
-- Every frontier is floored at the account's start.
|
-- A NIP-10 reply marker without canonical ancestry: neither
|
||||||
COALESCE(e.received_at <= GREATEST(COALESCE($10::timestamptz,'infinity'),
|
-- counted nor a timeline anchor, since a mark cannot validate
|
||||||
CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id
|
-- it. Parity-tested against the shared NIP-10 parser.
|
||||||
THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp)
|
tm.root_event_id IS NULL AND jsonb_typeof(e.tags)='array'
|
||||||
ELSE cf.through_timestamp END),false) AS covered
|
AND EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) t(tag)
|
||||||
FROM (SELECT * FROM candidates ORDER BY created_at DESC,id LIMIT $8-1) e
|
WHERE jsonb_typeof(tag)='array' AND tag->>0='e'
|
||||||
|
AND tag->>3='reply'
|
||||||
|
AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$')
|
||||||
|
AS unresolved
|
||||||
|
FROM candidates e
|
||||||
LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id
|
LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id
|
||||||
AND tm.event_created_at=e.created_at AND tm.event_id=e.id
|
AND tm.event_created_at=e.created_at AND tm.event_id=e.id
|
||||||
|
WHERE e.kind=ANY($6) AND e.deleted_at IS NULL
|
||||||
|
), classified AS (
|
||||||
|
SELECT e.*,
|
||||||
|
-- Every frontier is floored at the account's start.
|
||||||
|
COALESCE(e.received_at <= GREATEST(COALESCE($10::timestamptz,'infinity'),
|
||||||
|
CASE WHEN e.is_reply THEN tf.through_timestamp
|
||||||
|
ELSE cf.through_timestamp END),false) AS covered,
|
||||||
|
encode(tf.through_message_id,'hex') AS read_through_id
|
||||||
|
FROM threaded e
|
||||||
LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2
|
LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2
|
||||||
AND tf.channel_id=r.id AND tf.root_id=tm.root_event_id AND tm.root_event_id<>e.id
|
AND tf.channel_id=r.id AND tf.root_id=e.root AND e.is_reply
|
||||||
WHERE e.kind=ANY($6) AND e.pubkey<>$2 AND e.deleted_at IS NULL
|
WHERE e.pubkey<>$2 AND NOT e.unresolved
|
||||||
), grouped AS (
|
), grouped AS (
|
||||||
SELECT CASE WHEN root IS NULL THEN NULL
|
SELECT CASE WHEN root IS NULL THEN NULL
|
||||||
WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root,
|
WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root,
|
||||||
CASE WHEN is_reply THEN encode(parent,'hex') END AS parent,
|
CASE WHEN is_reply THEN encode(parent,'hex') END AS parent,
|
||||||
is_reply, covered,
|
is_reply, covered, read_through_id,
|
||||||
-- ->>0 also selects scalar "p"/"e": reject nonarrays first.
|
-- ->>0 also selects scalar "p"/"e": reject nonarrays first.
|
||||||
-- C collation matches Rust's ASCII case/hex rules. Reply
|
-- C collation matches Rust's ASCII case rules.
|
||||||
-- markers matter only without canonical ancestry; otherwise
|
|
||||||
-- metadata, not tag spelling, owns the context.
|
|
||||||
CASE WHEN octet_length(tags::text)<=8192
|
CASE WHEN octet_length(tags::text)<=8192
|
||||||
AND jsonb_typeof(tags)='array' THEN
|
AND jsonb_typeof(tags)='array' THEN
|
||||||
(SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array'
|
(SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array'
|
||||||
@@ -159,10 +171,7 @@ impl Db {
|
|||||||
THEN NULL ELSE jsonb_build_object(
|
THEN NULL ELSE jsonb_build_object(
|
||||||
'directed',COALESCE(bool_or(
|
'directed',COALESCE(bool_or(
|
||||||
(tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex'))
|
(tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex'))
|
||||||
OR (tag->>0='broadcast' AND tag->>1='1')),false),
|
OR (tag->>0='broadcast' AND tag->>1='1')),false)) END
|
||||||
'reply_marked',root IS NULL AND COALESCE(bool_or(tag->>0='e'
|
|
||||||
AND tag->>3='reply'
|
|
||||||
AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'),false)) END
|
|
||||||
FROM jsonb_array_elements(tags) t(tag)
|
FROM jsonb_array_elements(tags) t(tag)
|
||||||
WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts,
|
WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts,
|
||||||
count(*) AS n,
|
count(*) AS n,
|
||||||
@@ -172,29 +181,24 @@ impl Db {
|
|||||||
ORDER BY received_at DESC,id))[1] AS newest_at
|
ORDER BY received_at DESC,id))[1] AS newest_at
|
||||||
FROM classified
|
FROM classified
|
||||||
WHERE NOT covered OR root IS NULL
|
WHERE NOT covered OR root IS NULL
|
||||||
GROUP BY 1,2,3,4,5
|
GROUP BY 1,2,3,4,5,6
|
||||||
), scan AS (
|
|
||||||
SELECT count(*) AS scanned,
|
|
||||||
(array_agg(encode(id,'hex') ORDER BY received_at DESC,id)
|
|
||||||
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id,
|
|
||||||
max(extract(epoch FROM created_at)::bigint)
|
|
||||||
FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL) AS latest_message_at
|
|
||||||
FROM candidates
|
|
||||||
)
|
)
|
||||||
SELECT scan.*, (SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence FROM scan
|
SELECT (array_agg(encode(id,'hex') ORDER BY received_at DESC,id)
|
||||||
|
FILTER (WHERE NOT is_reply AND NOT unresolved))[1] AS latest_id,
|
||||||
|
(SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence
|
||||||
|
FROM threaded
|
||||||
) e ON true ORDER BY r.id"#,
|
) e ON true ORDER BY r.id"#,
|
||||||
).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after)
|
).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after)
|
||||||
.bind((limit+1) as i64).bind((MAX_CHANNEL_SCAN+1) as i64)
|
.bind((limit+1) as i64).bind(MAX_CHANNEL_SCAN as i64)
|
||||||
.bind(ELIGIBLE_KINDS.as_slice())
|
.bind(ELIGIBLE_KINDS.as_slice())
|
||||||
.bind(DateTime::from_timestamp_millis(account.cutoff_ms)
|
.bind(DateTime::from_timestamp_millis(account.cutoff_ms)
|
||||||
.ok_or_else(|| DbError::InvalidData("invalid unread cutoff".into()))?)
|
.ok_or_else(|| DbError::InvalidData("invalid unread cutoff".into()))?)
|
||||||
.bind((MAX_UNREAD_SCAN+1) as i64)
|
.bind(MAX_UNREAD_SCAN as i64)
|
||||||
.bind(only)
|
.bind(only)
|
||||||
.bind(account.started_at)
|
.bind(account.started_at)
|
||||||
.fetch_all(&mut *tx).await?;
|
.fetch_all(&mut *tx).await?;
|
||||||
let has_more = rows.len() > limit;
|
let has_more = rows.len() > limit;
|
||||||
// An unstarted account has read every arrival, scanned or not, so its
|
// An unstarted account has read every arrival, so nothing is unread.
|
||||||
// counts are exactly zero whatever the scan cap left unexamined.
|
|
||||||
let started = account.started_at.is_some();
|
let started = account.started_at.is_some();
|
||||||
let mut channels = Vec::new();
|
let mut channels = Vec::new();
|
||||||
let mut pending = Vec::new();
|
let mut pending = Vec::new();
|
||||||
@@ -204,11 +208,9 @@ impl Db {
|
|||||||
.as_array()
|
.as_array()
|
||||||
.ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?;
|
.ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?;
|
||||||
let evidence = if started { evidence.as_slice() } else { &[] };
|
let evidence = if started { evidence.as_slice() } else { &[] };
|
||||||
let complete = !started || row.try_get::<i64, _>("scanned")? <= MAX_UNREAD_SCAN as i64;
|
|
||||||
let channel_type: String = row.try_get("channel_type")?;
|
let channel_type: String = row.try_get("channel_type")?;
|
||||||
let mut unread = 0;
|
let mut unread = false;
|
||||||
let mut attention = 0;
|
let mut mentions = 0;
|
||||||
let mut unread_complete = complete;
|
|
||||||
let mut threads: HashMap<Vec<u8>, Replies> = HashMap::new();
|
let mut threads: HashMap<Vec<u8>, Replies> = HashMap::new();
|
||||||
let mut undirected = Vec::new();
|
let mut undirected = Vec::new();
|
||||||
for e in evidence {
|
for e in evidence {
|
||||||
@@ -217,16 +219,10 @@ impl Db {
|
|||||||
.filter(|n| *n <= MAX_UNREAD_SCAN as u64)
|
.filter(|n| *n <= MAX_UNREAD_SCAN as u64)
|
||||||
.ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))?
|
.ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))?
|
||||||
as u32;
|
as u32;
|
||||||
|
// Unusable tags prove nothing: the message is left out.
|
||||||
let Some(facts) = e["facts"].as_object() else {
|
let Some(facts) = e["facts"].as_object() else {
|
||||||
unread_complete = false;
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// SQL's bounded ancestry fact is parity-tested against the shared
|
|
||||||
// NIP-10 parser; no raw tag payload crosses the DB boundary.
|
|
||||||
if facts.get("reply_marked") == Some(&Value::Bool(true)) && e["root"].is_null() {
|
|
||||||
unread_complete = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if e["covered"] == true {
|
if e["covered"] == true {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -235,14 +231,13 @@ impl Db {
|
|||||||
// Roots are timeline messages; descendants belong exclusively
|
// Roots are timeline messages; descendants belong exclusively
|
||||||
// to their canonical thread. Never inherit the channel prefix.
|
// to their canonical thread. Never inherit the channel prefix.
|
||||||
if e["is_reply"] != true {
|
if e["is_reply"] != true {
|
||||||
unread += n;
|
unread = true;
|
||||||
if directed {
|
if directed {
|
||||||
attention += n;
|
mentions += n;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(root) = e["root"].as_str().and_then(writes::event_id) else {
|
let Some(root) = e["root"].as_str().and_then(writes::event_id) else {
|
||||||
unread_complete = false;
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let replies = Replies {
|
let replies = Replies {
|
||||||
@@ -255,6 +250,7 @@ impl Db {
|
|||||||
.to_owned(),
|
.to_owned(),
|
||||||
),
|
),
|
||||||
newest_at: e["newest_at"].as_i64().ok_or_else(invalid_newest)?,
|
newest_at: e["newest_at"].as_i64().ok_or_else(invalid_newest)?,
|
||||||
|
read_through_id: e["read_through_id"].as_str().map(str::to_owned),
|
||||||
};
|
};
|
||||||
// A directed reply counts whatever its conversation. Any other
|
// A directed reply counts whatever its conversation. Any other
|
||||||
// reply counts only in one of the actor's conversations.
|
// reply counts only in one of the actor's conversations.
|
||||||
@@ -262,64 +258,38 @@ impl Db {
|
|||||||
count(&mut threads, root, replies);
|
count(&mut threads, root, replies);
|
||||||
} else if let Some(parent) = e["parent"].as_str().and_then(writes::event_id) {
|
} else if let Some(parent) = e["parent"].as_str().and_then(writes::event_id) {
|
||||||
undirected.push((root, parent, replies));
|
undirected.push((root, parent, replies));
|
||||||
} else {
|
|
||||||
unread_complete = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pending.push((threads, undirected, unread, attention, unread_complete));
|
pending.push((threads, undirected));
|
||||||
channels.push(ChannelReadSummary {
|
channels.push(ChannelReadSummary {
|
||||||
channel_id: row.try_get("id")?,
|
channel_id: row.try_get("id")?,
|
||||||
name: row.try_get("name")?,
|
unread,
|
||||||
channel_type,
|
mentions,
|
||||||
archived: row.try_get("archived")?,
|
read_through_id: row.try_get("read_through_id")?,
|
||||||
hidden: row.try_get("hidden")?,
|
latest_id: row.try_get("latest_id")?,
|
||||||
// Counts and threads wait for conversation membership below.
|
// Threads wait for conversation membership below.
|
||||||
unread: ReadCount::Unknown,
|
threads: Vec::new(),
|
||||||
attention: ReadCount::Unknown,
|
|
||||||
latest_message_id: row.try_get("latest_message_id")?,
|
|
||||||
latest_message_at: row.try_get("latest_message_at")?,
|
|
||||||
latest_message_complete: row.try_get("latest_message_complete")?,
|
|
||||||
threads: ThreadSummaries {
|
|
||||||
items: Vec::new(),
|
|
||||||
complete: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let targets: Vec<_> = channels
|
let targets: Vec<_> = channels
|
||||||
.iter()
|
.iter()
|
||||||
.zip(&pending)
|
.zip(&pending)
|
||||||
.flat_map(|(channel, (_, undirected, ..))| {
|
.flat_map(|(channel, (_, undirected))| {
|
||||||
undirected
|
undirected
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, parent, _)| (channel.channel_id, parent.clone()))
|
.map(|(_, parent, _)| (channel.channel_id, parent.clone()))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?;
|
let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?;
|
||||||
for (channel, (mut threads, undirected, mut unread, mut attention, mut complete)) in
|
for (channel, (mut threads, undirected)) in channels.iter_mut().zip(pending) {
|
||||||
channels.iter_mut().zip(pending)
|
// Relevance is decided before the thread cap. An undecided
|
||||||
{
|
// membership leaves the reply out.
|
||||||
// Relevance is decided before counts, previews and the thread cap.
|
|
||||||
for (root, parent, replies) in undirected {
|
for (root, parent, replies) in undirected {
|
||||||
match members.get(&(channel.channel_id, parent)) {
|
if members.get(&(channel.channel_id, parent)) == Some(&true) {
|
||||||
Some(true) => count(&mut threads, root, replies),
|
count(&mut threads, root, replies);
|
||||||
Some(false) => {}
|
|
||||||
None => complete = false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut items = Vec::with_capacity(threads.len());
|
channel.threads = summarize(threads);
|
||||||
for (root, thread) in threads {
|
|
||||||
unread += thread.n;
|
|
||||||
attention += thread.n;
|
|
||||||
items.push(ThreadReadSummary {
|
|
||||||
root_id: hex::encode(root),
|
|
||||||
unread: ReadCount::from_evidence(thread.n, complete),
|
|
||||||
latest_reply_id: thread.newest.1,
|
|
||||||
latest_reply_at: thread.newest_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
channel.unread = ReadCount::from_evidence(unread, complete);
|
|
||||||
channel.attention = ReadCount::from_evidence(attention, complete);
|
|
||||||
channel.threads = summarize(items, complete);
|
|
||||||
}
|
}
|
||||||
let next_cursor = if has_more {
|
let next_cursor = if has_more {
|
||||||
channels.last().map(|c| c.channel_id)
|
channels.last().map(|c| c.channel_id)
|
||||||
@@ -328,7 +298,6 @@ impl Db {
|
|||||||
};
|
};
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(SidebarPage {
|
Ok(SidebarPage {
|
||||||
account,
|
|
||||||
channels,
|
channels,
|
||||||
next_cursor,
|
next_cursor,
|
||||||
})
|
})
|
||||||
@@ -343,6 +312,8 @@ struct Replies {
|
|||||||
newest: (i64, String),
|
newest: (i64, String),
|
||||||
/// Its author seconds.
|
/// Its author seconds.
|
||||||
newest_at: i64,
|
newest_at: i64,
|
||||||
|
/// The thread's frontier anchor, shared by every group of the thread.
|
||||||
|
read_through_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add replies that count to their thread.
|
/// Add replies that count to their thread.
|
||||||
@@ -369,20 +340,25 @@ fn invalid_newest() -> DbError {
|
|||||||
DbError::InvalidData("invalid thread evidence".into())
|
DbError::InvalidData("invalid thread evidence".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Order newest unread reply first (root ID breaks ties) and cap the list. The
|
/// Order newest unread reply first (root ID breaks ties) and cap the list.
|
||||||
/// list is complete only when evidence was exhausted and nothing was omitted.
|
fn summarize(threads: HashMap<Vec<u8>, Replies>) -> Vec<ThreadReadSummary> {
|
||||||
pub(super) fn summarize(
|
let mut threads: Vec<_> = threads.into_iter().collect();
|
||||||
mut items: Vec<ThreadReadSummary>,
|
threads.sort_unstable_by(|(a_root, a), (b_root, b)| {
|
||||||
evidence_complete: bool,
|
b.newest_at
|
||||||
) -> ThreadSummaries {
|
.cmp(&a.newest_at)
|
||||||
items.sort_unstable_by(|a, b| {
|
.then_with(|| a_root.cmp(b_root))
|
||||||
b.latest_reply_at
|
|
||||||
.cmp(&a.latest_reply_at)
|
|
||||||
.then_with(|| a.root_id.cmp(&b.root_id))
|
|
||||||
});
|
});
|
||||||
let complete = evidence_complete && items.len() <= MAX_THREAD_SUMMARIES;
|
threads
|
||||||
items.truncate(MAX_THREAD_SUMMARIES);
|
.into_iter()
|
||||||
ThreadSummaries { items, complete }
|
.take(MAX_THREAD_SUMMARIES)
|
||||||
|
.map(|(root, thread)| ThreadReadSummary {
|
||||||
|
root_id: hex::encode(root),
|
||||||
|
unread: true,
|
||||||
|
mentions: thread.n,
|
||||||
|
read_through_id: thread.read_through_id,
|
||||||
|
latest_id: thread.newest.1,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-time horizon and the account's start, in the caller's snapshot.
|
/// Read-time horizon and the account's start, in the caller's snapshot.
|
||||||
@@ -404,7 +380,6 @@ pub(super) async fn read_account(
|
|||||||
.await?;
|
.await?;
|
||||||
let cutoff: DateTime<Utc> = row.try_get("cutoff")?;
|
let cutoff: DateTime<Utc> = row.try_get("cutoff")?;
|
||||||
Ok(ReadAccount {
|
Ok(ReadAccount {
|
||||||
retention_seconds,
|
|
||||||
cutoff_ms: cutoff.timestamp_millis(),
|
cutoff_ms: cutoff.timestamp_millis(),
|
||||||
started_at: row.try_get("started_at")?,
|
started_at: row.try_get("started_at")?,
|
||||||
})
|
})
|
||||||
@@ -414,42 +389,32 @@ pub(super) async fn read_account(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn item(root: u8, at: i64) -> ThreadReadSummary {
|
fn replies(at: i64) -> Replies {
|
||||||
ThreadReadSummary {
|
Replies {
|
||||||
root_id: hex::encode([root; 32]),
|
n: 1,
|
||||||
unread: ReadCount::Exact { value: 1 },
|
newest: (at, String::new()),
|
||||||
latest_reply_id: hex::encode([root; 32]),
|
newest_at: at,
|
||||||
latest_reply_at: at,
|
read_through_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn thread_summaries_order_newest_first_break_ties_by_root_and_cap_at_five() {
|
fn thread_rows_order_newest_first_break_ties_by_root_and_cap_at_twenty_five() {
|
||||||
// Literal contract boundaries deliberately do not derive from the constant.
|
// Literal contract boundaries deliberately do not derive from the constant.
|
||||||
for count in [0_u8, 1, 4, 5, 6, 7] {
|
for count in [0_u8, 1, 24, 25, 26, 27] {
|
||||||
// Descending roots with pairwise-equal times exercise the tie-break.
|
// Pairwise-equal times exercise the tie-break.
|
||||||
let items: Vec<_> = (0..count)
|
let threads = (0..count)
|
||||||
.rev()
|
.map(|i| (vec![i; 32], replies(i64::from(i / 2))))
|
||||||
.map(|i| item(i, i64::from(i / 2)))
|
|
||||||
.collect();
|
.collect();
|
||||||
let summaries = summarize(items, true);
|
let roots: Vec<_> = summarize(threads).into_iter().map(|t| t.root_id).collect();
|
||||||
let roots: Vec<_> = summaries.items.iter().map(|t| t.root_id.clone()).collect();
|
|
||||||
let mut expected: Vec<_> = (0..count).map(|i| (i64::from(i / 2), i)).collect();
|
let mut expected: Vec<_> = (0..count).map(|i| (i64::from(i / 2), i)).collect();
|
||||||
expected.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
expected.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||||
let expected: Vec<_> = expected
|
let expected: Vec<_> = expected
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.take(5)
|
.take(25)
|
||||||
.map(|(_, i)| hex::encode([i; 32]))
|
.map(|(_, i)| hex::encode([i; 32]))
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(roots, expected, "count {count}");
|
assert_eq!(roots, expected, "count {count}");
|
||||||
assert_eq!(summaries.complete, count <= 5, "count {count}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn thread_summaries_are_incomplete_when_evidence_is() {
|
|
||||||
let summaries = summarize(vec![item(1, 1)], false);
|
|
||||||
assert_eq!(summaries.items.len(), 1);
|
|
||||||
assert!(!summaries.complete);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,37 @@
|
|||||||
use super::{classification, postgres_tests::fixture, *};
|
use super::{postgres_tests::fixture, *};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unread_horizon_includes_its_own_cutoff() {
|
|
||||||
for (created_ms, counted) in [(999, false), (1000, true), (1001, true)] {
|
|
||||||
assert_eq!(
|
|
||||||
classification::eligible(9, false, false, created_ms, 1000),
|
|
||||||
counted
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn sidebar_sql_eligibility_matches_selector_classifier() {
|
async fn sidebar_sql_eligibility_follows_kind_author_deletion_and_horizon() {
|
||||||
let (db, pool, community, channel, actor, event) = fixture().await;
|
let (db, pool, community, _, actor, event) = fixture().await;
|
||||||
let query = [ContextQuery {
|
|
||||||
target: ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: None,
|
|
||||||
},
|
|
||||||
message_ids: vec![event.id.to_hex()],
|
|
||||||
}];
|
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let horizon = i64::from(DEFAULT_RETENTION_SECONDS) * 1000;
|
let horizon = i64::from(DEFAULT_RETENTION_SECONDS) * 1000;
|
||||||
for kind in [9, 40002, 45001, 45003, 1, 7, 39002, 40008] {
|
// Addressed to the actor, so a counted message is also exactly one mention.
|
||||||
|
let tags = json!([["p", actor.public_key().to_hex()]]);
|
||||||
|
for (kind, eligible_kind) in [
|
||||||
|
(9, true),
|
||||||
|
(40002, true),
|
||||||
|
(45001, true),
|
||||||
|
(45003, true),
|
||||||
|
(1, false),
|
||||||
|
(7, false),
|
||||||
|
(39002, false),
|
||||||
|
(40008, false),
|
||||||
|
] {
|
||||||
for own in [false, true] {
|
for own in [false, true] {
|
||||||
for deleted in [false, true] {
|
for deleted in [false, true] {
|
||||||
// Now, then one minute inside and one minute outside the horizon.
|
// Now, then one minute inside and one minute outside the horizon.
|
||||||
for age in [0, horizon - 60_000, horizon + 60_000] {
|
for (age, inside) in [
|
||||||
|
(0, true),
|
||||||
|
(horizon - 60_000, true),
|
||||||
|
(horizon + 60_000, false),
|
||||||
|
] {
|
||||||
let created = now - age;
|
let created = now - age;
|
||||||
sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,created_at=to_timestamp($5::double precision/1000) WHERE community_id=$1")
|
sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,created_at=to_timestamp($5::double precision/1000),tags=$6 WHERE community_id=$1")
|
||||||
.bind(community.as_uuid()).bind(kind)
|
.bind(community.as_uuid()).bind(kind)
|
||||||
.bind(if own { actor.public_key().to_bytes() } else { event.pubkey.to_bytes() }.as_slice())
|
.bind(if own { actor.public_key().to_bytes() } else { event.pubkey.to_bytes() }.as_slice())
|
||||||
.bind(deleted).bind(created as f64).execute(&pool).await.unwrap();
|
.bind(deleted).bind(created as f64).bind(&tags).execute(&pool).await.unwrap();
|
||||||
let page = db
|
let page = db
|
||||||
.personal_read_sidebar(
|
.personal_read_sidebar(
|
||||||
community,
|
community,
|
||||||
@@ -44,31 +42,11 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let expected = u32::from(classification::eligible(
|
let expected = eligible_kind && !own && !deleted && inside;
|
||||||
kind,
|
let row = &page.channels[0];
|
||||||
own,
|
|
||||||
deleted,
|
|
||||||
created,
|
|
||||||
page.account.cutoff_ms,
|
|
||||||
));
|
|
||||||
assert!(
|
|
||||||
matches!(page.channels[0].unread, ReadCount::Exact { value } if value == expected),
|
|
||||||
"kind={kind} own={own} deleted={deleted} age={age}"
|
|
||||||
);
|
|
||||||
// The per-message selector must agree with the aggregate.
|
|
||||||
let contexts = db
|
|
||||||
.personal_read_contexts(
|
|
||||||
community,
|
|
||||||
&actor.public_key(),
|
|
||||||
DEFAULT_RETENTION_SECONDS,
|
|
||||||
&query,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(&contexts).unwrap()["contexts"][0]["messages"][0]
|
(row.unread, row.mentions),
|
||||||
["status"],
|
(expected, u32::from(expected)),
|
||||||
["not_counted", "unread"][expected as usize],
|
|
||||||
"kind={kind} own={own} deleted={deleted} age={age}"
|
"kind={kind} own={own} deleted={deleted} age={age}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,27 +60,25 @@ async fn sidebar_sql_eligibility_matches_selector_classifier() {
|
|||||||
async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() {
|
async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() {
|
||||||
let (db, pool, community, _, actor, _) = fixture().await;
|
let (db, pool, community, _, actor, _) = fixture().await;
|
||||||
let actor_hex = actor.public_key().to_hex().to_uppercase();
|
let actor_hex = actor.public_key().to_hex().to_uppercase();
|
||||||
for (tags, unread, attention) in [
|
// Unusable tags prove nothing: that message is left out, neither unread
|
||||||
(json!([]), Some(1), Some(0)),
|
// nor a mention.
|
||||||
(json!(["p"]), None, None),
|
for (tags, unread, mentions) in [
|
||||||
(json!(["e"]), None, None),
|
(json!([]), true, 0),
|
||||||
(json!(["broadcast"]), None, None),
|
(json!(["p"]), false, 0),
|
||||||
(
|
(json!(["e"]), false, 0),
|
||||||
json!([["p", actor_hex, "relay", "petname"]]),
|
(json!(["broadcast"]), false, 0),
|
||||||
Some(1),
|
(json!([["p", actor_hex, "relay", "petname"]]), true, 1),
|
||||||
Some(1),
|
(json!([["p", "00".repeat(32)]]), true, 0),
|
||||||
),
|
(json!([["broadcast", "1", "extra"]]), true, 1),
|
||||||
(json!([["p", "00".repeat(32)]]), Some(1), Some(0)),
|
(json!([["broadcast", "0"]]), true, 0),
|
||||||
(json!([["broadcast", "1", "extra"]]), Some(1), Some(1)),
|
(json!([["p", "00".repeat(32), 42]]), false, 0),
|
||||||
(json!([["broadcast", "0"]]), Some(1), Some(0)),
|
(json!([["broadcast", "0", 42]]), false, 0),
|
||||||
(json!([["p", "00".repeat(32), 42]]), None, None),
|
(json!([["e", "00".repeat(32), "", "root", 42]]), false, 0),
|
||||||
(json!([["broadcast", "0", 42]]), None, None),
|
(json!([["p", actor_hex], ["p", "other", 42]]), false, 0),
|
||||||
(json!([["e", "00".repeat(32), "", "root", 42]]), None, None),
|
(json!([["e", "00".repeat(32), "", "reply"]]), false, 0),
|
||||||
(json!([["p", actor_hex], ["p", "other", 42]]), None, None),
|
(json!([["x", 42]]), true, 0),
|
||||||
(json!([["e", "00".repeat(32), "", "reply"]]), None, None),
|
(json!({"p":actor_hex}), false, 0),
|
||||||
(json!([["x", 42]]), Some(1), Some(0)),
|
(json!([["p", "x".repeat(8193)]]), false, 0),
|
||||||
(json!({"p":actor_hex}), None, None),
|
|
||||||
(json!([["p", "x".repeat(8193)]]), None, None),
|
|
||||||
] {
|
] {
|
||||||
sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1")
|
sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1")
|
||||||
.bind(community.as_uuid())
|
.bind(community.as_uuid())
|
||||||
@@ -120,19 +96,12 @@ async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for (actual, expected) in [
|
let row = &page.channels[0];
|
||||||
(&page.channels[0].unread, unread),
|
assert_eq!(
|
||||||
(&page.channels[0].attention, attention),
|
(row.unread, row.mentions),
|
||||||
] {
|
(unread, mentions),
|
||||||
assert!(
|
"tags={tags}"
|
||||||
match (actual, expected) {
|
);
|
||||||
(ReadCount::Exact { value }, Some(n)) => *value == n,
|
|
||||||
(ReadCount::Unknown, None) => true,
|
|
||||||
_ => false,
|
|
||||||
},
|
|
||||||
"tags={tags} actual={actual:?} expected={expected:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,12 +156,12 @@ async fn sidebar_ancestry_fact_matches_shared_nip10_parser() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
// A reply marker without recorded ancestry leaves the message out.
|
||||||
if reply {
|
assert_eq!(page.channels[0].unread, !reply, "tags={tags:?}");
|
||||||
matches!(page.channels[0].unread, ReadCount::Unknown)
|
// ...and it is never the timeline's anchor.
|
||||||
} else {
|
assert_eq!(
|
||||||
matches!(page.channels[0].unread, ReadCount::Exact { value: 1 })
|
page.channels[0].latest_id.is_some(),
|
||||||
},
|
!reply,
|
||||||
"tags={tags:?}"
|
"tags={tags:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,7 +169,7 @@ async fn sidebar_ancestry_fact_matches_shared_nip10_parser() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn sidebar_directed_fact_matches_selector_classifier() {
|
async fn sidebar_directed_fact_follows_dm_mention_and_broadcast_rules() {
|
||||||
let (db, pool, community, channel, actor, _) = fixture().await;
|
let (db, pool, community, channel, actor, _) = fixture().await;
|
||||||
let actor_hex = actor.public_key().to_hex();
|
let actor_hex = actor.public_key().to_hex();
|
||||||
let fullwidth: String = actor_hex
|
let fullwidth: String = actor_hex
|
||||||
@@ -208,18 +177,21 @@ async fn sidebar_directed_fact_matches_selector_classifier() {
|
|||||||
.map(|c| if c.is_ascii_alphabetic() { 'A' } else { c })
|
.map(|c| if c.is_ascii_alphabetic() { 'A' } else { c })
|
||||||
.collect();
|
.collect();
|
||||||
assert_ne!(fullwidth, actor_hex);
|
assert_ne!(fullwidth, actor_hex);
|
||||||
let cases: Vec<Vec<Vec<String>>> = serde_json::from_value(json!([
|
// Whether each tag set is directed in a stream; in a DM every one is.
|
||||||
[],
|
let cases = [
|
||||||
[["p", actor_hex]],
|
(json!([]), false),
|
||||||
[["p", actor_hex.to_uppercase()]],
|
(json!([["p", actor_hex]]), true),
|
||||||
[["p", fullwidth]],
|
(json!([["p", actor_hex.to_uppercase()]]), true),
|
||||||
[["p"]],
|
(json!([["p", fullwidth]]), false),
|
||||||
[["broadcast", "1"]],
|
(json!([["p"]]), false),
|
||||||
[["broadcast", "true"]],
|
(json!([["broadcast", "1"]]), true),
|
||||||
[["p", "00".repeat(32)]],
|
(json!([["broadcast", "true"]]), false),
|
||||||
[["broadcast", "0"], ["p", actor_hex.to_uppercase()]]
|
(json!([["p", "00".repeat(32)]]), false),
|
||||||
]))
|
(
|
||||||
.unwrap();
|
json!([["broadcast", "0"], ["p", actor_hex.to_uppercase()]]),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
for channel_type in ["stream", "dm"] {
|
for channel_type in ["stream", "dm"] {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE channels SET channel_type=$3::channel_type WHERE community_id=$1 AND id=$2",
|
"UPDATE channels SET channel_type=$3::channel_type WHERE community_id=$1 AND id=$2",
|
||||||
@@ -230,12 +202,11 @@ async fn sidebar_directed_fact_matches_selector_classifier() {
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for tags in &cases {
|
for (tags, in_stream) in &cases {
|
||||||
let expected =
|
let expected = u32::from(channel_type == "dm" || *in_stream);
|
||||||
u32::from(classification::reason(channel_type, &actor_hex, tags).is_some());
|
|
||||||
sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1")
|
sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1")
|
||||||
.bind(community.as_uuid())
|
.bind(community.as_uuid())
|
||||||
.bind(json!(tags))
|
.bind(tags)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -249,13 +220,10 @@ async fn sidebar_directed_fact_matches_selector_classifier() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(page.channels[0].unread);
|
||||||
page.channels[0].unread,
|
assert_eq!(
|
||||||
ReadCount::Exact { value: 1 }
|
page.channels[0].mentions, expected,
|
||||||
));
|
"channel_type={channel_type} tags={tags}"
|
||||||
assert!(
|
|
||||||
matches!(page.channels[0].attention, ReadCount::Exact { value } if value == expected),
|
|
||||||
"channel_type={channel_type} tags={tags:?} expected={expected}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Whole-channel reads, thread summaries and targeted refresh.
|
//! Thread summaries, anchor validation and targeted refresh.
|
||||||
use super::{postgres_tests::fixture, *};
|
use super::{postgres_tests::fixture, *};
|
||||||
use crate::{
|
use crate::{
|
||||||
channel::{ChannelType, ChannelVisibility},
|
channel::{ChannelType, ChannelVisibility},
|
||||||
@@ -102,105 +102,22 @@ async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channel_read(channel: Uuid, message: &nostr::Event) -> ReadIntent {
|
fn channel_mark(channel: Uuid, message_id: String) -> ReadIntent {
|
||||||
ReadIntent::MarkChannelRead {
|
ReadIntent::MarkThrough {
|
||||||
channel_id: channel,
|
target: ReadTarget {
|
||||||
message_id: message.id.to_hex(),
|
channel_id: channel,
|
||||||
}
|
root_id: None,
|
||||||
}
|
},
|
||||||
|
message_id,
|
||||||
fn exact(count: &ReadCount) -> Option<u32> {
|
|
||||||
match count {
|
|
||||||
ReadCount::Exact { value } => Some(*value),
|
|
||||||
_ => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() {
|
async fn mark_through_rejects_unresolved_missing_auxiliary_and_malformed_anchors() {
|
||||||
let (db, pool, community, channel, actor, root) = fixture().await;
|
let (db, _pool, community, channel, actor, root) = fixture().await;
|
||||||
join(&db, &pool, community, channel, &actor, &root).await;
|
|
||||||
let base = root.created_at.as_secs();
|
let base = root.created_at.as_secs();
|
||||||
let first = reply(&db, &pool, community, channel, &root, base + 10, vec![]).await;
|
// Unresolved ancestry cannot be classified as top-level or reply.
|
||||||
post(&db, community, channel, base + 20, vec![]).await;
|
|
||||||
let second = reply(&db, &pool, community, channel, &root, base + 30, vec![]).await;
|
|
||||||
|
|
||||||
// A reply anchor is valid for the whole channel, unlike channel mark_through.
|
|
||||||
let channel_only = ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: None,
|
|
||||||
};
|
|
||||||
let through_reply = ReadIntent::MarkThrough {
|
|
||||||
target: channel_only,
|
|
||||||
message_id: first.id.to_hex(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
apply(&db, community, &actor, through_reply).await,
|
|
||||||
IntentOutcome::Blocked
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
apply(&db, community, &actor, channel_read(channel, &first)).await,
|
|
||||||
IntentOutcome::Applied
|
|
||||||
);
|
|
||||||
let row = sidebar(&db, community, &actor).await;
|
|
||||||
assert_eq!(
|
|
||||||
exact(&row.unread),
|
|
||||||
Some(2),
|
|
||||||
"top at +20 and reply at +30 remain"
|
|
||||||
);
|
|
||||||
assert_eq!(row.threads.items.len(), 1);
|
|
||||||
assert_eq!(row.threads.items[0].latest_reply_id, second.id.to_hex());
|
|
||||||
assert!(row.threads.complete);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
apply(&db, community, &actor, channel_read(channel, &second)).await,
|
|
||||||
IntentOutcome::Applied
|
|
||||||
);
|
|
||||||
let row = sidebar(&db, community, &actor).await;
|
|
||||||
assert_eq!(
|
|
||||||
exact(&row.unread),
|
|
||||||
Some(0),
|
|
||||||
"every thread is covered by the cut"
|
|
||||||
);
|
|
||||||
assert!(row.threads.items.is_empty() && row.threads.complete);
|
|
||||||
|
|
||||||
// A reply that arrives after the cut is unread, however far backdated.
|
|
||||||
let late = reply(&db, &pool, community, channel, &root, base + 5, vec![]).await;
|
|
||||||
let newer = reply(&db, &pool, community, channel, &root, base + 40, vec![]).await;
|
|
||||||
let row = sidebar(&db, community, &actor).await;
|
|
||||||
assert_eq!(exact(&row.unread), Some(2));
|
|
||||||
assert_eq!(row.threads.items[0].latest_reply_id, newer.id.to_hex());
|
|
||||||
|
|
||||||
// Contexts apply the same effective thread frontier.
|
|
||||||
let page = db
|
|
||||||
.personal_read_contexts(
|
|
||||||
community,
|
|
||||||
&actor.public_key(),
|
|
||||||
DEFAULT_RETENTION_SECONDS,
|
|
||||||
&[ContextQuery {
|
|
||||||
target: ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: Some(root.id.to_hex()),
|
|
||||||
},
|
|
||||||
message_ids: vec![second.id.to_hex(), late.id.to_hex(), newer.id.to_hex()],
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let wire = serde_json::to_value(&page).unwrap();
|
|
||||||
assert!(wire["contexts"][0].get("through_timestamp").is_none());
|
|
||||||
assert_eq!(wire["contexts"][0]["messages"][0]["status"], "read");
|
|
||||||
assert_eq!(wire["contexts"][0]["messages"][1]["status"], "unread");
|
|
||||||
assert_eq!(wire["contexts"][0]["messages"][2]["status"], "unread");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "requires Postgres"]
|
|
||||||
async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independent() {
|
|
||||||
let (db, pool, community, channel, actor, root) = fixture().await;
|
|
||||||
let base = root.created_at.as_secs();
|
|
||||||
// Unresolved ancestry blocks ordinary marks, but not an arrival-only cut.
|
|
||||||
let orphan_parent = "ab".repeat(32);
|
let orphan_parent = "ab".repeat(32);
|
||||||
let orphan = post(
|
let orphan = post(
|
||||||
&db,
|
&db,
|
||||||
@@ -214,151 +131,76 @@ async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independe
|
|||||||
.apply_personal_read_intent(
|
.apply_personal_read_intent(
|
||||||
community,
|
community,
|
||||||
&actor.public_key(),
|
&actor.public_key(),
|
||||||
&ReadIntent::MarkThrough {
|
&channel_mark(channel, orphan.id.to_hex()),
|
||||||
target: ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: None
|
|
||||||
},
|
|
||||||
message_id: orphan.id.to_hex(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.is_err());
|
.is_err());
|
||||||
assert_eq!(
|
|
||||||
apply(&db, community, &actor, channel_read(channel, &orphan)).await,
|
|
||||||
IntentOutcome::Applied
|
|
||||||
);
|
|
||||||
|
|
||||||
// Missing, auxiliary and malformed anchors.
|
|
||||||
let missing = ReadIntent::MarkChannelRead {
|
|
||||||
channel_id: channel,
|
|
||||||
message_id: "cd".repeat(32),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
apply(&db, community, &actor, missing).await,
|
|
||||||
IntentOutcome::Blocked
|
|
||||||
);
|
|
||||||
let malformed = ReadIntent::MarkChannelRead {
|
|
||||||
channel_id: channel,
|
|
||||||
message_id: "zz".into(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
apply(&db, community, &actor, malformed).await,
|
|
||||||
IntentOutcome::Invalid
|
|
||||||
);
|
|
||||||
let reaction = EventBuilder::new(Kind::Custom(7), "+")
|
let reaction = EventBuilder::new(Kind::Custom(7), "+")
|
||||||
.sign_with_keys(&Keys::generate())
|
.sign_with_keys(&Keys::generate())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
db.insert_event(community, &reaction, Some(channel))
|
db.insert_event(community, &reaction, Some(channel))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
for (anchor, outcome) in [
|
||||||
apply(&db, community, &actor, channel_read(channel, &reaction)).await,
|
("cd".repeat(32), IntentOutcome::Blocked),
|
||||||
IntentOutcome::Blocked
|
(reaction.id.to_hex(), IntentOutcome::Blocked),
|
||||||
);
|
("zz".into(), IntentOutcome::Invalid),
|
||||||
|
] {
|
||||||
// Ordinary channel marks never set the whole-channel cut.
|
assert_eq!(
|
||||||
let other = Keys::generate();
|
apply(
|
||||||
assert_eq!(
|
&db,
|
||||||
apply(
|
community,
|
||||||
&db,
|
&actor,
|
||||||
community,
|
channel_mark(channel, anchor.clone())
|
||||||
&other,
|
)
|
||||||
ReadIntent::MarkThrough {
|
.await,
|
||||||
target: ReadTarget {
|
outcome,
|
||||||
channel_id: channel,
|
"{anchor}"
|
||||||
root_id: None
|
);
|
||||||
},
|
}
|
||||||
message_id: root.id.to_hex(),
|
let row = sidebar(&db, community, &actor).await;
|
||||||
},
|
assert_eq!(row.read_through_id, None, "nothing was marked");
|
||||||
)
|
|
||||||
.await,
|
|
||||||
IntentOutcome::Applied
|
|
||||||
);
|
|
||||||
let cuts: Vec<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
|
|
||||||
"SELECT threads_through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2",
|
|
||||||
)
|
|
||||||
.bind(community.as_uuid())
|
|
||||||
.bind(other.public_key().to_bytes().as_slice())
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(cuts, vec![None]);
|
|
||||||
|
|
||||||
// The schema admits the cut on channel rows only.
|
|
||||||
let thread_cut = sqlx::query(
|
|
||||||
"UPDATE personal_read_frontiers SET threads_through_timestamp=now()
|
|
||||||
WHERE community_id=$1 AND actor=$2",
|
|
||||||
)
|
|
||||||
.bind(community.as_uuid())
|
|
||||||
.bind(actor.public_key().to_bytes().as_slice())
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
assert!(thread_cut.is_ok(), "channel row accepts the cut");
|
|
||||||
let root_id = root.id.to_hex();
|
|
||||||
let thread = ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: Some(root_id),
|
|
||||||
};
|
|
||||||
let mark = ReadIntent::MarkThrough {
|
|
||||||
target: thread,
|
|
||||||
message_id: root.id.to_hex(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
apply(&db, community, &actor, mark).await,
|
|
||||||
IntentOutcome::Applied
|
|
||||||
);
|
|
||||||
assert!(sqlx::query(
|
|
||||||
"UPDATE personal_read_frontiers SET threads_through_timestamp=now()
|
|
||||||
WHERE community_id=$1 AND actor=$2 AND root_id<>''::bytea",
|
|
||||||
)
|
|
||||||
.bind(community.as_uuid())
|
|
||||||
.bind(actor.public_key().to_bytes().as_slice())
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn absent_whole_channel_cut_never_covers_epoch_zero_replies() {
|
async fn channel_frontier_never_covers_an_epoch_zero_reply() {
|
||||||
let (db, pool, community, channel, actor, root) = fixture().await;
|
let (db, pool, community, channel, actor, root) = fixture().await;
|
||||||
join(&db, &pool, community, channel, &actor, &root).await;
|
join(&db, &pool, community, channel, &actor, &root).await;
|
||||||
reply(&db, &pool, community, channel, &root, 0, vec![]).await;
|
let ancient = reply(&db, &pool, community, channel, &root, 0, vec![]).await;
|
||||||
// A channel row exists, with no whole-channel cut: NULL must stay NULL.
|
// A channel frontier exists, with no thread frontier: absent must not
|
||||||
|
// read as epoch zero or as the channel's.
|
||||||
apply(
|
apply(
|
||||||
&db,
|
&db,
|
||||||
community,
|
community,
|
||||||
&actor,
|
&actor,
|
||||||
ReadIntent::MarkThrough {
|
channel_mark(channel, root.id.to_hex()),
|
||||||
target: ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: None,
|
|
||||||
},
|
|
||||||
message_id: root.id.to_hex(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// Widen the horizon to reach the epoch, so that only a NULL cut read as
|
// Widen the horizon to reach the epoch, so that only a misread absent
|
||||||
// zero could hide this reply.
|
// frontier could hide this reply.
|
||||||
let row = db
|
let row = db
|
||||||
.personal_read_sidebar(community, &actor.public_key(), u32::MAX, 20, None)
|
.personal_read_sidebar(community, &actor.public_key(), u32::MAX, 20, None)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.channels
|
.channels
|
||||||
.remove(0);
|
.remove(0);
|
||||||
assert_eq!(exact(&row.unread), Some(1), "epoch-zero reply stays unread");
|
assert!(!row.unread);
|
||||||
assert_eq!(row.threads.items[0].latest_reply_at, 0);
|
assert_eq!(row.threads.len(), 1, "epoch-zero reply stays unread");
|
||||||
|
assert_eq!(row.threads[0].mentions, 1);
|
||||||
|
assert_eq!(row.threads[0].latest_id, ancient.id.to_hex());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
|
async fn thread_summaries_order_cap_and_anchor() {
|
||||||
let (db, pool, community, channel, actor, fixture_root) = fixture().await;
|
let (db, pool, community, channel, actor, fixture_root) = fixture().await;
|
||||||
// After the fixture root, so marking the newest root covers every root.
|
// After the fixture root, so marking the newest root covers every root.
|
||||||
let base = fixture_root.created_at.as_secs() + 1;
|
let base = fixture_root.created_at.as_secs() + 1;
|
||||||
|
// One more thread than the cap.
|
||||||
|
let n = MAX_THREAD_SUMMARIES as u64 + 1;
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
for i in 0..6 {
|
for i in 0..n {
|
||||||
let root = post(&db, community, channel, base + i, vec![]).await;
|
let root = post(&db, community, channel, base + i, vec![]).await;
|
||||||
join(&db, &pool, community, channel, &actor, &root).await;
|
join(&db, &pool, community, channel, &actor, &root).await;
|
||||||
roots.push(root);
|
roots.push(root);
|
||||||
@@ -368,20 +210,16 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
|
|||||||
&db,
|
&db,
|
||||||
community,
|
community,
|
||||||
&actor,
|
&actor,
|
||||||
ReadIntent::MarkThrough {
|
channel_mark(channel, newest_root.id.to_hex()),
|
||||||
target: ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: None,
|
|
||||||
},
|
|
||||||
message_id: newest_root.id.to_hex(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// Threads 0 and 1 tie on newest reply time; thread 2 has one directed
|
// Threads 0 and 1 tie on newest reply time and the rest follow, newest
|
||||||
// reply and two that arrive last, together.
|
// first. Thread 2 has one directed reply and two that arrive last,
|
||||||
|
// together.
|
||||||
|
let offset = |i: u64| if i < 2 { n * 10 } else { (n - i) * 10 };
|
||||||
let mut anchors = Vec::new();
|
let mut anchors = Vec::new();
|
||||||
for (i, root) in roots.iter().enumerate() {
|
for (i, root) in (0..).zip(&roots) {
|
||||||
let at = base + 100 + [50, 50, 40, 30, 20, 10][i];
|
let at = base + 100 + offset(i);
|
||||||
anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await);
|
anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await);
|
||||||
}
|
}
|
||||||
reply(
|
reply(
|
||||||
@@ -400,7 +238,7 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
|
|||||||
community,
|
community,
|
||||||
channel,
|
channel,
|
||||||
&roots[2],
|
&roots[2],
|
||||||
base + 140,
|
base + 100 + offset(2),
|
||||||
vec![],
|
vec![],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -414,59 +252,42 @@ async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let row = sidebar(&db, community, &actor).await;
|
let row = sidebar(&db, community, &actor).await;
|
||||||
assert_eq!(exact(&row.unread), Some(8));
|
// Every root is read; the eight unread replies count only on threads.
|
||||||
assert!(!row.threads.complete, "six unread threads exceed the cap");
|
assert_eq!((row.unread, row.mentions), (false, 0));
|
||||||
|
// The timeline's latest is its last top-level arrival, never a reply.
|
||||||
|
assert_eq!(row.latest_id, Some(newest_root.id.to_hex()));
|
||||||
let mut tied = [roots[0].id.to_hex(), roots[1].id.to_hex()];
|
let mut tied = [roots[0].id.to_hex(), roots[1].id.to_hex()];
|
||||||
tied.sort();
|
tied.sort();
|
||||||
let order: Vec<_> = row
|
let order = |row: &ChannelReadSummary| -> Vec<_> {
|
||||||
.threads
|
row.threads.iter().map(|t| t.root_id.clone()).collect()
|
||||||
.items
|
};
|
||||||
.iter()
|
let ids = |roots: &[nostr::Event]| roots.iter().map(|r| r.id.to_hex()).collect::<Vec<_>>();
|
||||||
.map(|t| t.root_id.clone())
|
let last = roots.len() - 1;
|
||||||
.collect();
|
// The oldest unread thread is past the cap.
|
||||||
|
assert_eq!(order(&row), [tied.to_vec(), ids(&roots[2..last])].concat());
|
||||||
|
let third = &row.threads[2];
|
||||||
|
assert_eq!(third.mentions, 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
order,
|
third.latest_id,
|
||||||
vec![
|
|
||||||
tied[0].clone(),
|
|
||||||
tied[1].clone(),
|
|
||||||
roots[2].id.to_hex(),
|
|
||||||
roots[3].id.to_hex(),
|
|
||||||
roots[4].id.to_hex(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
let third = &row.threads.items[2];
|
|
||||||
assert_eq!(exact(&third.unread), Some(3));
|
|
||||||
assert_eq!(third.latest_reply_at, (base + 140) as i64);
|
|
||||||
assert_eq!(
|
|
||||||
third.latest_reply_id,
|
|
||||||
std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()),
|
std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()),
|
||||||
"equal arrivals break toward the smaller ID"
|
"equal arrivals break toward the smaller ID"
|
||||||
);
|
);
|
||||||
// The row's anchor is its last arrival; its activity is the greatest
|
|
||||||
// author time, another message's.
|
|
||||||
assert_eq!(row.latest_message_id.as_ref(), Some(&third.latest_reply_id));
|
|
||||||
assert_eq!(row.latest_message_at, Some((base + 150) as i64));
|
|
||||||
|
|
||||||
// Reading one listed thread through its anchor completes the list.
|
// Reading one listed thread through its anchor lists the oldest.
|
||||||
let first = row.threads.items[0].clone_target(channel);
|
let first = row.threads[0].clone_target(channel);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
apply(&db, community, &actor, first).await,
|
apply(&db, community, &actor, first).await,
|
||||||
IntentOutcome::Applied
|
IntentOutcome::Applied
|
||||||
);
|
);
|
||||||
let row = sidebar(&db, community, &actor).await;
|
let row = sidebar(&db, community, &actor).await;
|
||||||
assert!(row.threads.complete);
|
|
||||||
assert_eq!(row.threads.items.len(), 5);
|
|
||||||
let sum: u32 = row
|
|
||||||
.threads
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.map(|t| exact(&t.unread).unwrap())
|
|
||||||
.sum();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some(sum),
|
order(&row),
|
||||||
exact(&row.unread),
|
[vec![tied[1].clone()], ids(&roots[2..])].concat()
|
||||||
"complete summaries account for every reply"
|
|
||||||
);
|
);
|
||||||
|
let mentions: Vec<_> = row.threads.iter().map(|t| t.mentions).collect();
|
||||||
|
let mut expected = vec![1; MAX_THREAD_SUMMARIES];
|
||||||
|
expected[1] = 3;
|
||||||
|
assert_eq!(mentions, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ThreadReadSummary {
|
impl ThreadReadSummary {
|
||||||
@@ -476,7 +297,7 @@ impl ThreadReadSummary {
|
|||||||
channel_id: channel,
|
channel_id: channel,
|
||||||
root_id: Some(self.root_id.clone()),
|
root_id: Some(self.root_id.clone()),
|
||||||
},
|
},
|
||||||
message_id: self.latest_reply_id.clone(),
|
message_id: self.latest_id.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,44 +321,20 @@ async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() {
|
|||||||
let elsewhere = reply(&db, &pool, community, channel, &root, base + 20, mention()).await;
|
let elsewhere = reply(&db, &pool, community, channel, &root, base + 20, mention()).await;
|
||||||
|
|
||||||
let row = sidebar(&db, community, &actor).await;
|
let row = sidebar(&db, community, &actor).await;
|
||||||
assert_eq!(exact(&row.unread), Some(3), "the root and two replies");
|
// The undirected fixture root is unread; the directed diff is not counted.
|
||||||
assert_eq!(row.threads.items.len(), 2);
|
assert_eq!((row.unread, row.mentions), (true, 0));
|
||||||
let listed = &row.threads.items[1];
|
assert_eq!(row.threads.len(), 2);
|
||||||
|
let listed = &row.threads[1];
|
||||||
assert_eq!(listed.root_id, diff.id.to_hex());
|
assert_eq!(listed.root_id, diff.id.to_hex());
|
||||||
assert_eq!(listed.latest_reply_id, on_diff.id.to_hex());
|
assert_eq!(listed.latest_id, on_diff.id.to_hex());
|
||||||
|
assert_eq!(listed.mentions, 1);
|
||||||
// The listed thread is a usable context; the diff stays uncounted.
|
|
||||||
let thread = ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: Some(listed.root_id.clone()),
|
|
||||||
};
|
|
||||||
let timeline = ReadTarget {
|
|
||||||
channel_id: channel,
|
|
||||||
root_id: None,
|
|
||||||
};
|
|
||||||
let queries = [(&thread, &on_diff), (&timeline, &diff)].map(|(target, message)| ContextQuery {
|
|
||||||
target: target.clone(),
|
|
||||||
message_ids: vec![message.id.to_hex()],
|
|
||||||
});
|
|
||||||
let page = db
|
|
||||||
.personal_read_contexts(
|
|
||||||
community,
|
|
||||||
&actor.public_key(),
|
|
||||||
DEFAULT_RETENTION_SECONDS,
|
|
||||||
&queries,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let wire = serde_json::to_value(&page).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
wire["contexts"][0]["messages"][0],
|
|
||||||
serde_json::json!({"message_id":on_diff.id.to_hex(),"status":"unread","reason":"mention"})
|
|
||||||
);
|
|
||||||
assert_eq!(wire["contexts"][1]["messages"][0]["status"], "not_counted");
|
|
||||||
|
|
||||||
// The diff is still no anchor; the listed reply reads its thread alone.
|
// The diff is still no anchor; the listed reply reads its thread alone.
|
||||||
let through_diff = ReadIntent::MarkThrough {
|
let through_diff = ReadIntent::MarkThrough {
|
||||||
target: thread,
|
target: ReadTarget {
|
||||||
|
channel_id: channel,
|
||||||
|
root_id: Some(listed.root_id.clone()),
|
||||||
|
},
|
||||||
message_id: diff.id.to_hex(),
|
message_id: diff.id.to_hex(),
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -549,49 +346,9 @@ async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() {
|
|||||||
IntentOutcome::Applied
|
IntentOutcome::Applied
|
||||||
);
|
);
|
||||||
let row = sidebar(&db, community, &actor).await;
|
let row = sidebar(&db, community, &actor).await;
|
||||||
assert_eq!(exact(&row.unread), Some(2));
|
assert_eq!((row.unread, row.mentions), (true, 0));
|
||||||
assert_eq!(row.threads.items.len(), 1);
|
assert_eq!(row.threads.len(), 1);
|
||||||
assert_eq!(row.threads.items[0].latest_reply_id, elsewhere.id.to_hex());
|
assert_eq!(row.threads[0].latest_id, elsewhere.id.to_hex());
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "requires Postgres"]
|
|
||||||
async fn thread_summaries_are_incomplete_when_tag_evidence_could_hide_a_root() {
|
|
||||||
let (db, pool, community, channel, actor, root) = fixture().await;
|
|
||||||
join(&db, &pool, community, channel, &actor, &root).await;
|
|
||||||
let listed = reply(
|
|
||||||
&db,
|
|
||||||
&pool,
|
|
||||||
community,
|
|
||||||
channel,
|
|
||||||
&root,
|
|
||||||
root.created_at.as_secs() + 1,
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let hidden = post(
|
|
||||||
&db,
|
|
||||||
community,
|
|
||||||
channel,
|
|
||||||
root.created_at.as_secs() + 2,
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
sqlx::query("UPDATE events SET tags=$3 WHERE community_id=$1 AND id=$2")
|
|
||||||
.bind(community.as_uuid())
|
|
||||||
.bind(hidden.id.as_bytes().as_slice())
|
|
||||||
.bind(serde_json::json!([["e", "x".repeat(9000)]]))
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let row = sidebar(&db, community, &actor).await;
|
|
||||||
assert_eq!(row.threads.items.len(), 1);
|
|
||||||
assert_eq!(row.threads.items[0].latest_reply_id, listed.id.to_hex());
|
|
||||||
assert!(!row.threads.complete);
|
|
||||||
assert!(!matches!(
|
|
||||||
row.threads.items[0].unread,
|
|
||||||
ReadCount::Exact { .. }
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -128,25 +128,6 @@ async fn message(
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An eligible-kind message's arrival time, deleted or not, without tags.
|
|
||||||
async fn anchor_received_at(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
community: CommunityId,
|
|
||||||
channel: Uuid,
|
|
||||||
id: &[u8],
|
|
||||||
) -> Result<Option<DateTime<Utc>>> {
|
|
||||||
Ok(sqlx::query_scalar(
|
|
||||||
"SELECT received_at FROM events
|
|
||||||
WHERE community_id=$1 AND channel_id=$2 AND id=$3 AND kind=ANY($4) LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(community.as_uuid())
|
|
||||||
.bind(channel)
|
|
||||||
.bind(id)
|
|
||||||
.bind(ELIGIBLE_KINDS.as_slice())
|
|
||||||
.fetch_optional(conn)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn valid_target(
|
pub(super) async fn valid_target(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
community: CommunityId,
|
community: CommunityId,
|
||||||
@@ -177,20 +158,27 @@ pub(super) async fn valid_target(
|
|||||||
Ok(Some(root))
|
Ok(Some(root))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance a monotone frontier. The anchor ID follows the greatest arrival;
|
||||||
|
/// an equal arrival keeps the existing anchor.
|
||||||
async fn frontier(
|
async fn frontier(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
community: CommunityId,
|
community: CommunityId,
|
||||||
actor: &[u8],
|
actor: &[u8],
|
||||||
target: &ReadTarget,
|
target: &ReadTarget,
|
||||||
root: &[u8],
|
root: &[u8],
|
||||||
through: DateTime<Utc>,
|
msg: &Message,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO personal_read_frontiers
|
"INSERT INTO personal_read_frontiers
|
||||||
(community_id, actor, channel_id, root_id, through_timestamp) VALUES ($1,$2,$3,$4,$5)
|
(community_id, actor, channel_id, root_id, through_timestamp, through_message_id)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6)
|
||||||
ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE
|
ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE
|
||||||
SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp)",
|
SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp),
|
||||||
).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root).bind(through)
|
through_message_id=CASE
|
||||||
|
WHEN EXCLUDED.through_timestamp > personal_read_frontiers.through_timestamp
|
||||||
|
THEN EXCLUDED.through_message_id ELSE personal_read_frontiers.through_message_id END",
|
||||||
|
).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root)
|
||||||
|
.bind(msg.received_at).bind(&msg.id)
|
||||||
.execute(&mut *conn).await?;
|
.execute(&mut *conn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -226,31 +214,7 @@ pub(super) async fn apply(
|
|||||||
{
|
{
|
||||||
return Ok(IntentOutcome::Blocked);
|
return Ok(IntentOutcome::Blocked);
|
||||||
}
|
}
|
||||||
frontier(conn, community, actor, target, &root, msg.received_at).await?;
|
frontier(conn, community, actor, target, &root, &msg).await?;
|
||||||
}
|
|
||||||
ReadIntent::MarkChannelRead {
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
} => {
|
|
||||||
let Some(id) = event_id(message_id) else {
|
|
||||||
return Ok(IntentOutcome::Invalid);
|
|
||||||
};
|
|
||||||
if !access(conn, community, actor, *channel_id).await? {
|
|
||||||
return Ok(IntentOutcome::Blocked);
|
|
||||||
}
|
|
||||||
// Only the anchor's arrival matters: ancestry cannot change which
|
|
||||||
// messages a whole-channel cut covers.
|
|
||||||
let Some(through) = anchor_received_at(conn, community, *channel_id, &id).await? else {
|
|
||||||
return Ok(IntentOutcome::Blocked);
|
|
||||||
};
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO personal_read_frontiers (community_id, actor, channel_id, root_id,
|
|
||||||
through_timestamp, threads_through_timestamp) VALUES ($1,$2,$3,''::bytea,$4,$4)
|
|
||||||
ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE
|
|
||||||
SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, $4),
|
|
||||||
threads_through_timestamp=GREATEST(personal_read_frontiers.threads_through_timestamp, $4)",
|
|
||||||
).bind(community.as_uuid()).bind(actor).bind(channel_id).bind(through)
|
|
||||||
.execute(&mut *conn).await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(IntentOutcome::Applied)
|
Ok(IntentOutcome::Applied)
|
||||||
|
|||||||
@@ -6068,35 +6068,18 @@ pub(crate) mod postgres_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// All accessory methods must preserve the shared admission wire contract.
|
// All accessory methods must preserve the shared admission wire contract.
|
||||||
// Keep each route independent so the unfixed adapter fails all three tests.
|
// Keep each route independent so the unfixed adapter fails both tests.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn nip_fi_buzz_v1_sidebar_wire_contract() {
|
async fn nip_fi_buzz_v1_sidebar_wire_contract() {
|
||||||
buzz_v1_wire_contract("GET", "/buzz/v1/me/sidebar?limit=1", b"").await;
|
buzz_v1_wire_contract("GET", "/buzz/v1/me/sidebar?limit=1", b"").await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "requires Postgres"]
|
|
||||||
async fn nip_fi_buzz_v1_contexts_wire_contract() {
|
|
||||||
let targets = serde_json::json!([{"target":{"channel_id":uuid::Uuid::new_v4()}}]);
|
|
||||||
let encoded: String = targets
|
|
||||||
.to_string()
|
|
||||||
.bytes()
|
|
||||||
.map(|b| format!("%{b:02X}"))
|
|
||||||
.collect();
|
|
||||||
buzz_v1_wire_contract(
|
|
||||||
"GET",
|
|
||||||
&format!("/buzz/v1/me/read-state?targets={encoded}"),
|
|
||||||
b"",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn nip_fi_buzz_v1_write_wire_contract() {
|
async fn nip_fi_buzz_v1_write_wire_contract() {
|
||||||
let body = serde_json::to_vec(&serde_json::json!({"intents":[{
|
let body = serde_json::to_vec(&serde_json::json!({"intents":[{
|
||||||
"type":"mark_channel_read", "channel_id":uuid::Uuid::new_v4(),
|
"type":"mark_through", "target":{"channel_id":uuid::Uuid::new_v4()},
|
||||||
"message_id":"ab".repeat(32)
|
"message_id":"ab".repeat(32)
|
||||||
}]}))
|
}]}))
|
||||||
.expect("serialize intent");
|
.expect("serialize intent");
|
||||||
@@ -6145,25 +6128,13 @@ pub(crate) mod postgres_tests {
|
|||||||
assert_eq!(headers[header::CACHE_CONTROL], "private, no-store");
|
assert_eq!(headers[header::CACHE_CONTROL], "private, no-store");
|
||||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("control JSON");
|
let value: serde_json::Value = serde_json::from_slice(&body).expect("control JSON");
|
||||||
if method == "POST" {
|
if method == "POST" {
|
||||||
|
// A blocked intent applies to no channel, so no row is refreshed.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
body.as_ref(),
|
body.as_ref(),
|
||||||
br#"{"outcomes":[{"status":"blocked"}],"projection_status":"not_requested"}"#
|
br#"{"channels":[],"outcomes":[{"status":"blocked"}]}"#
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
assert!(value["account"]["cutoff_ms"].is_i64());
|
assert_eq!(value, serde_json::json!({"channels":[],"next_cursor":null}));
|
||||||
assert_eq!(
|
|
||||||
value["account"]["retention_seconds"],
|
|
||||||
state.config.buzz_v1_retention_seconds
|
|
||||||
);
|
|
||||||
if path.contains("sidebar") {
|
|
||||||
assert_eq!(value["channels"], serde_json::json!([]));
|
|
||||||
assert_eq!(value["next_cursor"], serde_json::Value::Null);
|
|
||||||
} else {
|
|
||||||
assert_eq!(
|
|
||||||
value["contexts"],
|
|
||||||
serde_json::json!([{"status":"unavailable"}])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for case in [
|
for case in [
|
||||||
|
|||||||
@@ -161,9 +161,12 @@ pub(super) async fn recheck(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Largest serialized API response; a larger one is a 503.
|
||||||
|
pub(super) const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
pub(super) fn response(value: impl serde::Serialize) -> Result<Response, Error> {
|
pub(super) fn response(value: impl serde::Serialize) -> Result<Response, Error> {
|
||||||
let bytes = serde_json::to_vec(&value).map_err(|_| Error::unavailable())?;
|
let bytes = serde_json::to_vec(&value).map_err(|_| Error::unavailable())?;
|
||||||
if bytes.len() > 1024 * 1024 {
|
if bytes.len() > MAX_RESPONSE_BYTES {
|
||||||
return Err(Error::unavailable());
|
return Err(Error::unavailable());
|
||||||
}
|
}
|
||||||
Ok((
|
Ok((
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ use axum::{
|
|||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use buzz_db::personal_read::{ReadIntent, MAX_CHANNELS, MAX_INTENTS};
|
use buzz_db::personal_read::{
|
||||||
|
ChannelReadSummary, ReadIntent, SidebarPage, MAX_CHANNELS, MAX_INTENTS,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{collections::BTreeSet, sync::Arc, time::Duration};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid.
|
/// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid.
|
||||||
@@ -72,26 +74,63 @@ pub(super) async fn sidebar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map_err(|_| Error::unavailable())?;
|
.map_err(|_| Error::unavailable())?;
|
||||||
auth::recheck(&state, &headers, &principal).await?;
|
auth::response(release(&state, &headers, &principal, page).await?)
|
||||||
let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect();
|
|
||||||
let memberships = state
|
|
||||||
.db
|
|
||||||
.membership_pairs(
|
|
||||||
principal.tenant.community(),
|
|
||||||
&channels,
|
|
||||||
&[principal.actor.to_bytes().to_vec()],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Error::unavailable())?;
|
|
||||||
if memberships.len() != channels.len() {
|
|
||||||
return Err(Error::unavailable());
|
|
||||||
}
|
|
||||||
auth::response(page)
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| Error::unavailable())?
|
.map_err(|_| Error::unavailable())?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Release projected rows only after admission and the actor's membership of
|
||||||
|
/// every row are rechecked outside the projection snapshot.
|
||||||
|
async fn release(
|
||||||
|
state: &AppState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
principal: &auth::Principal,
|
||||||
|
page: SidebarPage,
|
||||||
|
) -> Result<SidebarPage, Error> {
|
||||||
|
auth::recheck(state, headers, principal).await?;
|
||||||
|
let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect();
|
||||||
|
let memberships = state
|
||||||
|
.db
|
||||||
|
.membership_pairs(
|
||||||
|
principal.tenant.community(),
|
||||||
|
&channels,
|
||||||
|
&[principal.actor.to_bytes().to_vec()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::unavailable())?;
|
||||||
|
if memberships.len() != channels.len() {
|
||||||
|
return Err(Error::unavailable());
|
||||||
|
}
|
||||||
|
Ok(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updated rows for the channels a batch applied to. A channel the actor has
|
||||||
|
/// not joined has no row.
|
||||||
|
async fn refreshed(
|
||||||
|
state: &AppState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
principal: &auth::Principal,
|
||||||
|
channels: &BTreeSet<Uuid>,
|
||||||
|
) -> Result<Vec<ChannelReadSummary>, Error> {
|
||||||
|
let channels: Vec<_> = channels.iter().copied().collect();
|
||||||
|
let mut rows = Vec::with_capacity(channels.len());
|
||||||
|
for chunk in channels.chunks(MAX_CHANNELS) {
|
||||||
|
let page = state
|
||||||
|
.db
|
||||||
|
.personal_read_sidebar_channels(
|
||||||
|
principal.tenant.community(),
|
||||||
|
&principal.actor,
|
||||||
|
state.config.buzz_v1_retention_seconds,
|
||||||
|
chunk,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::unavailable())?;
|
||||||
|
rows.extend(release(state, headers, principal, page).await?.channels);
|
||||||
|
}
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct Batch {
|
struct Batch {
|
||||||
@@ -114,6 +153,7 @@ pub(super) async fn write(
|
|||||||
}
|
}
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
|
||||||
let mut outcomes = Vec::with_capacity(batch.intents.len());
|
let mut outcomes = Vec::with_capacity(batch.intents.len());
|
||||||
|
let mut applied = BTreeSet::new();
|
||||||
for item in batch.intents {
|
for item in batch.intents {
|
||||||
let Ok(intent) = serde_json::from_value::<ReadIntent>(item) else {
|
let Ok(intent) = serde_json::from_value::<ReadIntent>(item) else {
|
||||||
outcomes.push(json!({"status":"invalid"}));
|
outcomes.push(json!({"status":"invalid"}));
|
||||||
@@ -121,83 +161,27 @@ pub(super) async fn write(
|
|||||||
};
|
};
|
||||||
// A deadline/DB failure after commit is ambiguous, not a false failure.
|
// A deadline/DB failure after commit is ambiguous, not a false failure.
|
||||||
// Earlier acknowledged commits survive all later projection/item failures.
|
// Earlier acknowledged commits survive all later projection/item failures.
|
||||||
outcomes.push(
|
let outcome = tokio::time::timeout_at(
|
||||||
tokio::time::timeout_at(
|
deadline,
|
||||||
deadline,
|
write_intent(&state, &headers, &principal, &intent),
|
||||||
write_intent(&state, &headers, &principal, &intent),
|
)
|
||||||
)
|
.await
|
||||||
.await
|
.unwrap_or_else(|_| json!({"status":"unknown","retryable":true}));
|
||||||
.unwrap_or_else(|_| json!({"status":"unknown","retryable":true})),
|
if outcome["status"] == "applied" {
|
||||||
);
|
let ReadIntent::MarkThrough { target, .. } = &intent;
|
||||||
|
applied.insert(target.channel_id);
|
||||||
|
}
|
||||||
|
outcomes.push(outcome);
|
||||||
}
|
}
|
||||||
auth::response(json!({"outcomes":outcomes,"projection_status":"not_requested"}))
|
let mut body = json!({ "outcomes": outcomes });
|
||||||
}
|
// Committed outcomes stand even when their rows cannot be read in the
|
||||||
|
// remaining budget: omit them.
|
||||||
#[derive(Deserialize)]
|
if let Ok(Ok(channels)) =
|
||||||
#[serde(deny_unknown_fields)]
|
tokio::time::timeout_at(deadline, refreshed(&state, &headers, &principal, &applied)).await
|
||||||
pub(super) struct ContextQuery {
|
{
|
||||||
// JSON array carried as one URL-encoded, signed query parameter.
|
body["channels"] = json!(channels);
|
||||||
targets: String,
|
}
|
||||||
}
|
auth::response(body)
|
||||||
|
|
||||||
pub(super) async fn contexts(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
OriginalUri(uri): OriginalUri,
|
|
||||||
query: Result<Query<ContextQuery>, axum::extract::rejection::QueryRejection>,
|
|
||||||
) -> Result<Response, Error> {
|
|
||||||
use buzz_db::personal_read::{
|
|
||||||
ContextQuery as Target, ContextState, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES,
|
|
||||||
};
|
|
||||||
tokio::time::timeout(Duration::from_secs(8), async {
|
|
||||||
if uri.to_string().len() > 16 * 1024 {
|
|
||||||
return Err(Error::invalid());
|
|
||||||
}
|
|
||||||
let principal = auth::authorize(&state, &headers, &uri, "GET", None).await?;
|
|
||||||
let Query(query) = query.map_err(|_| Error::invalid())?;
|
|
||||||
let targets: Vec<Target> =
|
|
||||||
serde_json::from_str(&query.targets).map_err(|_| Error::invalid())?;
|
|
||||||
let valid_id = |id: &str| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit());
|
|
||||||
if targets.is_empty()
|
|
||||||
|| targets.len() > MAX_CONTEXTS
|
|
||||||
|| targets.iter().map(|t| t.message_ids.len()).sum::<usize>() > MAX_CONTEXT_MESSAGES
|
|
||||||
|| targets.iter().any(|t| {
|
|
||||||
t.message_ids.iter().any(|id| !valid_id(id))
|
|
||||||
|| t.target.root_id.as_deref().is_some_and(|id| !valid_id(id))
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err(Error::invalid());
|
|
||||||
}
|
|
||||||
let mut page = state
|
|
||||||
.db
|
|
||||||
.personal_read_contexts(
|
|
||||||
principal.tenant.community(),
|
|
||||||
&principal.actor,
|
|
||||||
state.config.buzz_v1_retention_seconds,
|
|
||||||
&targets,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Error::unavailable())?;
|
|
||||||
auth::recheck(&state, &headers, &principal).await?;
|
|
||||||
let channels: Vec<_> = targets.iter().map(|t| t.target.channel_id).collect();
|
|
||||||
let allowed = state
|
|
||||||
.db
|
|
||||||
.personal_read_accessible_contexts(
|
|
||||||
principal.tenant.community(),
|
|
||||||
&principal.actor,
|
|
||||||
&channels,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Error::unavailable())?;
|
|
||||||
for (target, result) in targets.iter().zip(&mut page.contexts) {
|
|
||||||
if !allowed.contains(&target.target.channel_id) {
|
|
||||||
*result = ContextState::Unavailable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auth::response(page)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| Error::unavailable())?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve earlier committed outcomes while distinguishing a definite denial
|
// Preserve earlier committed outcomes while distinguishing a definite denial
|
||||||
@@ -224,3 +208,39 @@ pub(super) async fn write_intent(
|
|||||||
Err(_) => json!({"status":"unknown","retryable":true}),
|
Err(_) => json!({"status":"unknown","retryable":true}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use buzz_db::personal_read::{ThreadReadSummary, MAX_THREAD_SUMMARIES};
|
||||||
|
|
||||||
|
/// A POST that applied to `MAX_INTENTS` channels, each with a full thread
|
||||||
|
/// list, must still fit: an oversized body is a 503 that hides committed
|
||||||
|
/// outcomes.
|
||||||
|
#[test]
|
||||||
|
fn largest_write_response_fits_the_response_limit() {
|
||||||
|
let id = || Some("f".repeat(64));
|
||||||
|
let channels: Vec<_> = (0..MAX_INTENTS)
|
||||||
|
.map(|_| ChannelReadSummary {
|
||||||
|
channel_id: Uuid::max(),
|
||||||
|
unread: true,
|
||||||
|
mentions: u32::MAX,
|
||||||
|
read_through_id: id(),
|
||||||
|
latest_id: id(),
|
||||||
|
threads: (0..MAX_THREAD_SUMMARIES)
|
||||||
|
.map(|_| ThreadReadSummary {
|
||||||
|
root_id: "f".repeat(64),
|
||||||
|
unread: true,
|
||||||
|
mentions: u32::MAX,
|
||||||
|
read_through_id: id(),
|
||||||
|
latest_id: "f".repeat(64),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let outcomes = vec![json!({"status":"unknown","retryable":true}); MAX_INTENTS];
|
||||||
|
let body = json!({ "outcomes": outcomes, "channels": channels });
|
||||||
|
let bytes = serde_json::to_vec(&body).unwrap().len();
|
||||||
|
assert!(bytes <= auth::MAX_RESPONSE_BYTES, "{bytes} bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ mod auth;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use axum::{routing::get, Router};
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Public accessory prefix, shared by routing and capability discovery.
|
/// Public accessory prefix, shared by routing and capability discovery.
|
||||||
@@ -14,10 +17,7 @@ pub const BASE_PATH: &str = "/buzz/v1";
|
|||||||
pub fn router(state: Arc<AppState>) -> Router {
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/me/sidebar", get(handlers::sidebar))
|
.route("/me/sidebar", get(handlers::sidebar))
|
||||||
.route(
|
.route("/me/read-state", post(handlers::write))
|
||||||
"/me/read-state",
|
|
||||||
get(handlers::contexts).post(handlers::write),
|
|
||||||
)
|
|
||||||
.fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") })
|
.fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") })
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,9 +191,20 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() {
|
|||||||
.insert_event(community, &event, Some(channel))
|
.insert_event(community, &event, Some(channel))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.add_member(
|
||||||
|
community,
|
||||||
|
channel,
|
||||||
|
&other.public_key().to_bytes(),
|
||||||
|
buzz_db::channel::MemberRole::Member,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let write_path = "/buzz/v1/me/read-state";
|
let write_path = "/buzz/v1/me/read-state";
|
||||||
let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_channel_read",
|
let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_through",
|
||||||
"channel_id":channel,"message_id":event.id.to_hex()}]}))
|
"target":{"channel_id":channel},"message_id":event.id.to_hex()}]}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let missing_hash = proof(&actor, &host, write_path, "POST", None);
|
let missing_hash = proof(&actor, &host, write_path, "POST", None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -228,32 +239,30 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() {
|
|||||||
assert_eq!(applied.0, StatusCode::OK, "{}", applied.1);
|
assert_eq!(applied.0, StatusCode::OK, "{}", applied.1);
|
||||||
assert_eq!(applied.1["outcomes"][0]["status"], "applied");
|
assert_eq!(applied.1["outcomes"][0]["status"], "applied");
|
||||||
// The frontier belongs to the signer alone.
|
// The frontier belongs to the signer alone.
|
||||||
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]);
|
let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}");
|
||||||
let targets: String = targets
|
|
||||||
.to_string()
|
|
||||||
.bytes()
|
|
||||||
.map(|b| format!("%{b:02X}"))
|
|
||||||
.collect();
|
|
||||||
let path = format!("/buzz/v1/me/read-state?targets={targets}");
|
|
||||||
start_before_everything(&state, community, &other).await;
|
start_before_everything(&state, community, &other).await;
|
||||||
for (key, status) in [(&actor, "read"), (&other, "unread")] {
|
for (key, unread, read_through) in [
|
||||||
|
(&actor, false, json!(event.id.to_hex())),
|
||||||
|
(&other, true, Value::Null),
|
||||||
|
] {
|
||||||
let auth = proof(key, &host, &path, "GET", None);
|
let auth = proof(key, &host, &path, "GET", None);
|
||||||
let page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
let page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
||||||
assert_eq!(page.0, StatusCode::OK, "{}", page.1);
|
assert_eq!(page.0, StatusCode::OK, "{}", page.1);
|
||||||
assert_eq!(page.1["contexts"][0]["messages"][0]["status"], status);
|
assert_eq!(page.1["channels"][0]["unread"], unread, "{}", page.1);
|
||||||
|
assert_eq!(page.1["channels"][0]["read_through_id"], read_through);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
async fn accessory_context_get_signed_query_and_independent_batch_outcomes() {
|
async fn accessory_batch_outcomes_are_independent_and_return_applied_rows() {
|
||||||
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
|
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut state = (*fixture).clone();
|
let mut state = (*fixture).clone();
|
||||||
Arc::make_mut(&mut state.config).buzz_v1_enabled = true;
|
Arc::make_mut(&mut state.config).buzz_v1_enabled = true;
|
||||||
let state = Arc::new(state);
|
let state = Arc::new(state);
|
||||||
let host = format!("bff-context-{}.local", uuid::Uuid::new_v4());
|
let host = format!("bff-batch-{}.local", uuid::Uuid::new_v4());
|
||||||
let community = state
|
let community = state
|
||||||
.db
|
.db
|
||||||
.ensure_configured_community(&host)
|
.ensure_configured_community(&host)
|
||||||
@@ -261,76 +270,95 @@ async fn accessory_context_get_signed_query_and_independent_batch_outcomes() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.id;
|
.id;
|
||||||
let actor = Keys::generate();
|
let actor = Keys::generate();
|
||||||
let channel = state
|
let create = |name: &'static str, owner: &Keys| {
|
||||||
.db
|
let (state, owner) = (state.clone(), owner.public_key().to_bytes());
|
||||||
.create_channel(
|
async move {
|
||||||
community,
|
state
|
||||||
"context",
|
.db
|
||||||
buzz_db::channel::ChannelType::Stream,
|
.create_channel(
|
||||||
buzz_db::channel::ChannelVisibility::Open,
|
community,
|
||||||
None,
|
name,
|
||||||
&actor.public_key().to_bytes(),
|
buzz_db::channel::ChannelType::Stream,
|
||||||
None,
|
buzz_db::channel::ChannelVisibility::Open,
|
||||||
)
|
None,
|
||||||
.await
|
&owner,
|
||||||
.unwrap()
|
None,
|
||||||
.id;
|
)
|
||||||
let event = EventBuilder::new(Kind::Custom(9), "message selector")
|
.await
|
||||||
.sign_with_keys(&Keys::generate())
|
.unwrap()
|
||||||
.unwrap();
|
.id
|
||||||
state
|
}
|
||||||
.db
|
|
||||||
.insert_event(community, &event, Some(channel))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]);
|
|
||||||
let encode = |value: &str| {
|
|
||||||
value
|
|
||||||
.bytes()
|
|
||||||
.map(|b| format!("%{b:02X}"))
|
|
||||||
.collect::<String>()
|
|
||||||
};
|
};
|
||||||
let path = format!(
|
let channel = create("joined", &actor).await;
|
||||||
"/buzz/v1/me/read-state?targets={}",
|
// Open, so marking it applies, but never joined.
|
||||||
encode(&targets.to_string())
|
let unjoined = create("open, not joined", &Keys::generate()).await;
|
||||||
);
|
let post = |channel| {
|
||||||
|
let state = state.clone();
|
||||||
|
async move {
|
||||||
|
let event = EventBuilder::new(Kind::Custom(9), "read state")
|
||||||
|
.sign_with_keys(&Keys::generate())
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.insert_event(community, &event, Some(channel))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
event.id.to_hex()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (event, elsewhere) = (post(channel).await, post(unjoined).await);
|
||||||
start_before_everything(&state, community, &actor).await;
|
start_before_everything(&state, community, &actor).await;
|
||||||
|
let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}");
|
||||||
let auth = proof(&actor, &host, &path, "GET", None);
|
let auth = proof(&actor, &host, &path, "GET", None);
|
||||||
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
||||||
assert_eq!(result.0, StatusCode::OK, "{}", result.1);
|
assert_eq!(result.0, StatusCode::OK, "{}", result.1);
|
||||||
assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "unread");
|
let unread = json!({"channel_id":channel,"unread":true,"mentions":0,
|
||||||
let auth = proof(&actor, &host, "/buzz/v1/me/read-state", "GET", None);
|
"read_through_id":null,"latest_id":event,"threads":[]});
|
||||||
assert_eq!(
|
assert_eq!(result.1["channels"], json!([unread]));
|
||||||
request(state.clone(), &host, &path, "GET", Some(&auth), b"")
|
|
||||||
.await
|
|
||||||
.0,
|
|
||||||
StatusCode::UNAUTHORIZED
|
|
||||||
);
|
|
||||||
let bad_path = "/buzz/v1/me/read-state?targets=invalid";
|
|
||||||
let auth = proof(&actor, &host, bad_path, "GET", None);
|
|
||||||
assert_eq!(
|
|
||||||
request(state.clone(), &host, bad_path, "GET", Some(&auth), b"")
|
|
||||||
.await
|
|
||||||
.0,
|
|
||||||
StatusCode::BAD_REQUEST
|
|
||||||
);
|
|
||||||
let write_path = "/buzz/v1/me/read-state";
|
let write_path = "/buzz/v1/me/read-state";
|
||||||
let body = serde_json::to_vec(&json!({"intents":[
|
let body = serde_json::to_vec(&json!({"intents":[
|
||||||
{"type":"unknown"},
|
{"type":"unknown"},
|
||||||
{"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event.id.to_hex()},
|
{"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event},
|
||||||
{"type":"mark_through","target":{"channel_id":channel},"message_id":event.id.to_hex()},
|
{"type":"mark_through","target":{"channel_id":channel},"message_id":event},
|
||||||
{"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"}
|
{"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"},
|
||||||
]})).unwrap();
|
{"type":"mark_through","target":{"channel_id":channel},"message_id":event},
|
||||||
|
{"type":"mark_through","target":{"channel_id":unjoined},"message_id":elsewhere}
|
||||||
|
]}))
|
||||||
|
.unwrap();
|
||||||
let auth = proof(&actor, &host, write_path, "POST", Some(&body));
|
let auth = proof(&actor, &host, write_path, "POST", Some(&body));
|
||||||
let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await;
|
let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await;
|
||||||
assert_eq!(result.0, StatusCode::OK, "{}", result.1);
|
assert_eq!(result.0, StatusCode::OK, "{}", result.1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.1["outcomes"],
|
result.1["outcomes"],
|
||||||
json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},{"status":"invalid"}])
|
json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},
|
||||||
|
{"status":"invalid"},{"status":"applied"},{"status":"applied"}])
|
||||||
);
|
);
|
||||||
let auth = proof(&actor, &host, &path, "GET", None);
|
// One updated row per applied channel; the blocked and the unjoined
|
||||||
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
// channels have none.
|
||||||
assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "read");
|
let read = json!({"channel_id":channel,"unread":false,"mentions":0,
|
||||||
|
"read_through_id":event,"latest_id":event,"threads":[]});
|
||||||
|
assert_eq!(result.1["channels"], json!([read]));
|
||||||
|
|
||||||
|
// The removed context read is not served, however well signed.
|
||||||
|
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event]}]);
|
||||||
|
let targets: String = targets
|
||||||
|
.to_string()
|
||||||
|
.bytes()
|
||||||
|
.map(|b| format!("%{b:02X}"))
|
||||||
|
.collect();
|
||||||
|
let path = format!("/buzz/v1/me/read-state?targets={targets}");
|
||||||
|
let response = crate::router::build_router(state.clone())
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(&path)
|
||||||
|
.header("host", &host)
|
||||||
|
.header("authorization", proof(&actor, &host, &path, "GET", None))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -379,8 +407,11 @@ async fn accessory_write_revocation_is_terminal_before_persistence() {
|
|||||||
.insert_event(community, &event, Some(channel))
|
.insert_event(community, &event, Some(channel))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let intent = buzz_db::personal_read::ReadIntent::MarkChannelRead {
|
let intent = buzz_db::personal_read::ReadIntent::MarkThrough {
|
||||||
channel_id: channel,
|
target: buzz_db::personal_read::ReadTarget {
|
||||||
|
channel_id: channel,
|
||||||
|
root_id: None,
|
||||||
|
},
|
||||||
message_id: event.id.to_hex(),
|
message_id: event.id.to_hex(),
|
||||||
};
|
};
|
||||||
let path = "/buzz/v1/me/read-state";
|
let path = "/buzz/v1/me/read-state";
|
||||||
@@ -456,19 +487,13 @@ async fn accessory_discovery_is_host_bound_and_opt_in() {
|
|||||||
"enabled={enabled} known_host={known_host} path={path}"
|
"enabled={enabled} known_host={known_host} path={path}"
|
||||||
);
|
);
|
||||||
if enabled && known_host {
|
if enabled && known_host {
|
||||||
let d = &doc["buzz_v1"];
|
// Exactly these fields: context and thread-cap limits are gone.
|
||||||
assert_eq!(d["version"], 1);
|
|
||||||
assert_eq!(d["base_path"], "/buzz/v1");
|
|
||||||
assert_eq!(d["retention_seconds"], 1234);
|
|
||||||
assert_eq!(d["max_channels"], buzz_db::personal_read::MAX_CHANNELS);
|
|
||||||
assert_eq!(d["max_intents"], buzz_db::personal_read::MAX_INTENTS);
|
|
||||||
assert_eq!(d["max_contexts"], buzz_db::personal_read::MAX_CONTEXTS);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
d["max_context_messages"],
|
doc["buzz_v1"],
|
||||||
buzz_db::personal_read::MAX_CONTEXT_MESSAGES
|
json!({"version":1,"base_path":"/buzz/v1","retention_seconds":1234,
|
||||||
|
"max_channels":20,"max_intents":100,
|
||||||
|
"eligible_kinds":[9, 40002, 45001, 45003]})
|
||||||
);
|
);
|
||||||
assert_eq!(d["max_thread_summaries"], 5);
|
|
||||||
assert_eq!(d["eligible_kinds"], json!([9, 40002, 45001, 45003]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,8 +638,12 @@ async fn signed_sidebar_deletion(deletion_kind: u16) {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
// Addressed to the reader, so its mention count pins the exact number.
|
||||||
let message = EventBuilder::new(Kind::Custom(9), "unread message to delete")
|
let message = EventBuilder::new(Kind::Custom(9), "unread message to delete")
|
||||||
.tags([Tag::parse(["h", &channel.to_string()]).unwrap()])
|
.tags([
|
||||||
|
Tag::parse(["h", &channel.to_string()]).unwrap(),
|
||||||
|
Tag::public_key(reader.public_key()),
|
||||||
|
])
|
||||||
.sign_with_keys(&author)
|
.sign_with_keys(&author)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut tags = vec![Tag::parse(["e", &message.id.to_hex()]).unwrap()];
|
let mut tags = vec![Tag::parse(["e", &message.id.to_hex()]).unwrap()];
|
||||||
@@ -662,8 +691,8 @@ async fn signed_sidebar_deletion(deletion_kind: u16) {
|
|||||||
assert_eq!(channels.len(), 1, "{sidebar}");
|
assert_eq!(channels.len(), 1, "{sidebar}");
|
||||||
assert_eq!(channels[0]["channel_id"], channel.to_string());
|
assert_eq!(channels[0]["channel_id"], channel.to_string());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
channels[0]["unread"],
|
(&channels[0]["unread"], &channels[0]["mentions"]),
|
||||||
json!({"status":"exact", "value":count}),
|
(&json!(count == 1), &json!(count)),
|
||||||
"kind {deletion_kind}, after kind {}: {sidebar}",
|
"kind {deletion_kind}, after kind {}: {sidebar}",
|
||||||
event.kind.as_u16()
|
event.kind.as_u16()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,12 +94,6 @@ pub struct BuzzV1Descriptor {
|
|||||||
pub max_channels: usize,
|
pub max_channels: usize,
|
||||||
/// Maximum independent write intents per request.
|
/// Maximum independent write intents per request.
|
||||||
pub max_intents: usize,
|
pub max_intents: usize,
|
||||||
/// Maximum explicit contexts per read request.
|
|
||||||
pub max_contexts: usize,
|
|
||||||
/// Maximum message selectors across one context request.
|
|
||||||
pub max_context_messages: usize,
|
|
||||||
/// Maximum unread-thread summaries per sidebar channel row.
|
|
||||||
pub max_thread_summaries: usize,
|
|
||||||
/// Message kinds that count as unread and as latest activity. Clients
|
/// Message kinds that count as unread and as latest activity. Clients
|
||||||
/// classify live arrivals with this set instead of keeping a copy.
|
/// classify live arrivals with this set instead of keeping a copy.
|
||||||
pub eligible_kinds: [i32; 4],
|
pub eligible_kinds: [i32; 4],
|
||||||
@@ -389,19 +383,13 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st
|
|||||||
);
|
);
|
||||||
if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await {
|
if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await {
|
||||||
if state.config.buzz_v1_enabled {
|
if state.config.buzz_v1_enabled {
|
||||||
use buzz_db::personal_read::{
|
use buzz_db::personal_read::{ELIGIBLE_KINDS, MAX_CHANNELS, MAX_INTENTS};
|
||||||
ELIGIBLE_KINDS, MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS,
|
|
||||||
MAX_THREAD_SUMMARIES,
|
|
||||||
};
|
|
||||||
info.buzz_v1 = Some(BuzzV1Descriptor {
|
info.buzz_v1 = Some(BuzzV1Descriptor {
|
||||||
version: 1,
|
version: 1,
|
||||||
base_path: crate::api::buzz_v1::BASE_PATH.to_owned(),
|
base_path: crate::api::buzz_v1::BASE_PATH.to_owned(),
|
||||||
retention_seconds: state.config.buzz_v1_retention_seconds,
|
retention_seconds: state.config.buzz_v1_retention_seconds,
|
||||||
max_channels: MAX_CHANNELS,
|
max_channels: MAX_CHANNELS,
|
||||||
max_intents: MAX_INTENTS,
|
max_intents: MAX_INTENTS,
|
||||||
max_contexts: MAX_CONTEXTS,
|
|
||||||
max_context_messages: MAX_CONTEXT_MESSAGES,
|
|
||||||
max_thread_summaries: MAX_THREAD_SUMMARIES,
|
|
||||||
eligible_kinds: ELIGIBLE_KINDS,
|
eligible_kinds: ELIGIBLE_KINDS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`.
|
|
||||||
//!
|
|
||||||
//! The CSP is only enforced on assets Tauri itself serves, so neither
|
|
||||||
//! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under
|
|
||||||
//! `vite preview`) can catch a policy that breaks the app. These tests pin the
|
|
||||||
//! non-obvious sources the frontend actually needs, so a future tightening
|
|
||||||
//! fails here instead of in a signed build.
|
|
||||||
//!
|
|
||||||
//! Kept as an integration test so the policy can be checked without the app
|
|
||||||
//! crate having to declare a test-only module.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
const TAURI_CONF: &str = include_str!("../tauri.conf.json");
|
|
||||||
|
|
||||||
fn csp_directives() -> HashMap<String, Vec<String>> {
|
|
||||||
let conf: serde_json::Value =
|
|
||||||
serde_json::from_str(TAURI_CONF).expect("tauri.conf.json is valid JSON");
|
|
||||||
let csp = conf["app"]["security"]["csp"]
|
|
||||||
.as_str()
|
|
||||||
.expect("app.security.csp is set as a policy string");
|
|
||||||
|
|
||||||
csp.split(';')
|
|
||||||
.filter_map(|directive| {
|
|
||||||
let mut parts = directive.split_whitespace();
|
|
||||||
let name = parts.next()?;
|
|
||||||
Some((name.to_owned(), parts.map(str::to_owned).collect()))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sources(directive: &str) -> Vec<String> {
|
|
||||||
csp_directives()
|
|
||||||
.remove(directive)
|
|
||||||
.unwrap_or_else(|| panic!("csp is missing the {directive} directive"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn script_src_allows_wasm_instantiation() {
|
|
||||||
// Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for
|
|
||||||
// every code block; MediaPipe selfie segmentation does the same. Without
|
|
||||||
// this token both silently degrade — highlighting drops to plain text and
|
|
||||||
// animated avatars keep their background.
|
|
||||||
assert!(sources("script-src").contains(&"'wasm-unsafe-eval'".to_owned()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `MEDIAPIPE_WASM_BASE` literal the frontend hands to `FilesetResolver`.
|
|
||||||
fn mediapipe_wasm_base() -> String {
|
|
||||||
const CAPTURE: &str = include_str!("../../src/features/profile/lib/animatedAvatarCapture.ts");
|
|
||||||
|
|
||||||
let after = CAPTURE
|
|
||||||
.split_once("const MEDIAPIPE_WASM_BASE =")
|
|
||||||
.expect("animatedAvatarCapture.ts declares MEDIAPIPE_WASM_BASE")
|
|
||||||
.1;
|
|
||||||
let url = after
|
|
||||||
.split_once('"')
|
|
||||||
.expect("MEDIAPIPE_WASM_BASE is a double-quoted string literal")
|
|
||||||
.1;
|
|
||||||
url.split_once('"')
|
|
||||||
.expect("MEDIAPIPE_WASM_BASE literal is terminated")
|
|
||||||
.0
|
|
||||||
.to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The npm scope the MediaPipe loader must come from. A CSP source ending in
|
|
||||||
/// `/` is a path *prefix* — paths can't be wildcarded — so this admits any
|
|
||||||
/// `@mediapipe` package while excluding the rest of what jsDelivr serves.
|
|
||||||
const MEDIAPIPE_SCOPE: &str = "https://cdn.jsdelivr.net/npm/@mediapipe/";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn script_src_scopes_the_mediapipe_loader() {
|
|
||||||
// `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js`
|
|
||||||
// via a `<script>` tag (which of the two depends on a runtime SIMD probe),
|
|
||||||
// so the loader URL the frontend builds has to fall inside the allowlisted
|
|
||||||
// prefix — checked here rather than discovered in a signed build.
|
|
||||||
let allowed = sources("script-src");
|
|
||||||
assert!(
|
|
||||||
allowed.contains(&MEDIAPIPE_SCOPE.to_owned()),
|
|
||||||
"script-src must allow {MEDIAPIPE_SCOPE}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let base = mediapipe_wasm_base();
|
|
||||||
assert!(
|
|
||||||
base.starts_with(MEDIAPIPE_SCOPE),
|
|
||||||
"MEDIAPIPE_WASM_BASE ({base}) must sit under the allowlisted {MEDIAPIPE_SCOPE}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many path segments a source narrows to past its origin.
|
|
||||||
fn path_depth(source: &str) -> usize {
|
|
||||||
let Some((_scheme, authority)) = source.split_once("://") else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
match authority.split_once('/') {
|
|
||||||
Some((_host, path)) => path.split('/').filter(|part| !part.is_empty()).count(),
|
|
||||||
None => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a `script-src` source is narrow enough to be worth allowing. CSP
|
|
||||||
/// keywords (`'self'`, `'wasm-unsafe-eval'`) pass. A remote source must name a
|
|
||||||
/// non-wildcard host *and* a path reaching at least a publisher scope — a bare
|
|
||||||
/// origin, a scheme, or a registry root would put arbitrary third-party code
|
|
||||||
/// one injected `<script>` away.
|
|
||||||
fn is_pinned_script_source(source: &str) -> bool {
|
|
||||||
if source.starts_with('\'') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
!source.contains('*') && path_depth(source) >= 2
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn script_src_trusts_no_bare_origins() {
|
|
||||||
for source in sources("script-src") {
|
|
||||||
assert!(
|
|
||||||
is_pinned_script_source(&source),
|
|
||||||
"script-src must stay scoped, found broad source `{source}`"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pinned_script_source_rejects_broad_sources() {
|
|
||||||
// Guards the guard: the check above is only worth having if it fails on the
|
|
||||||
// shapes that reopen the allowlist.
|
|
||||||
for allowed in [
|
|
||||||
"'self'",
|
|
||||||
"'wasm-unsafe-eval'",
|
|
||||||
MEDIAPIPE_SCOPE,
|
|
||||||
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm/vision_wasm_internal.js",
|
|
||||||
] {
|
|
||||||
assert!(is_pinned_script_source(allowed), "{allowed} should pass");
|
|
||||||
}
|
|
||||||
for rejected in [
|
|
||||||
"https://cdn.jsdelivr.net",
|
|
||||||
"https://cdn.jsdelivr.net/",
|
|
||||||
"https://cdn.jsdelivr.net/npm/",
|
|
||||||
"https://cdn.jsdelivr.net/gh/",
|
|
||||||
"https://*.jsdelivr.net/npm/@mediapipe/",
|
|
||||||
"https:",
|
|
||||||
"*",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!is_pinned_script_source(rejected),
|
|
||||||
"{rejected} should be rejected"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn media_directives_allow_the_buzz_media_scheme() {
|
|
||||||
// `rewriteRelayUrl` emits `buzz-media://localhost/...` until the loopback
|
|
||||||
// proxy port resolves, so cold-start media renders through the custom
|
|
||||||
// scheme (mapped to `http://buzz-media.localhost` on Windows).
|
|
||||||
for directive in ["img-src", "media-src", "connect-src"] {
|
|
||||||
let allowed = sources(directive);
|
|
||||||
assert!(
|
|
||||||
allowed.contains(&"buzz-media:".to_owned()),
|
|
||||||
"{directive} must allow buzz-media:"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
allowed.contains(&"http://buzz-media.localhost".to_owned()),
|
|
||||||
"{directive} must allow http://buzz-media.localhost"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn connect_src_allows_ipc_and_cleartext_relays() {
|
|
||||||
// `ipc:` / `http://ipc.localhost` carry every Tauri command. Cleartext
|
|
||||||
// `http:`/`ws:` stay allowed because a relay URL is user-supplied and the
|
|
||||||
// app accepts plain `ws://` on any host (`communityStorage::normalizeRelayUrl`,
|
|
||||||
// the community edit form): `relayProbe` opens a browser WebSocket to it,
|
|
||||||
// so narrowing this to loopback would report reachable relays as dead —
|
|
||||||
// while the real connection, which runs through tauri-plugin-websocket in
|
|
||||||
// Rust, is not governed by CSP at all. Blanket `https:` is already allowed,
|
|
||||||
// so restricting the cleartext schemes would close no exfiltration path.
|
|
||||||
let allowed = sources("connect-src");
|
|
||||||
for source in [
|
|
||||||
"ipc:",
|
|
||||||
"http://ipc.localhost",
|
|
||||||
"https:",
|
|
||||||
"http:",
|
|
||||||
"wss:",
|
|
||||||
"ws:",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
allowed.contains(&source.to_owned()),
|
|
||||||
"connect-src must allow {source}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn script_src_stays_free_of_unsafe_inline_and_eval() {
|
|
||||||
let allowed = sources("script-src");
|
|
||||||
// The inline boot script in index.html is covered by Tauri's build-time
|
|
||||||
// SHA-256 hashing (scripts only — Tauri nonces inline <style> elements via
|
|
||||||
// a different path), so neither escape hatch is ever needed here. The boot
|
|
||||||
// background style was moved to public/boot.css to avoid the nonce path for
|
|
||||||
// style-src; see boot.css for the full rationale.
|
|
||||||
assert!(!allowed.contains(&"'unsafe-inline'".to_owned()));
|
|
||||||
assert!(!allowed.contains(&"'unsafe-eval'".to_owned()));
|
|
||||||
}
|
|
||||||
181
desktop/src/app/contentSecurityPolicy.test.mjs
Normal file
181
desktop/src/app/contentSecurityPolicy.test.mjs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`.
|
||||||
|
//
|
||||||
|
// The CSP is only enforced on assets Tauri itself serves, so neither
|
||||||
|
// `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under
|
||||||
|
// `vite preview`) can catch a policy that breaks the app. These tests pin the
|
||||||
|
// non-obvious sources the frontend actually needs, so a future tightening
|
||||||
|
// fails here instead of in a signed build.
|
||||||
|
//
|
||||||
|
// Lives on the frontend side because it checks the policy against frontend
|
||||||
|
// code (`MEDIAPIPE_WASM_BASE`), and the `desktop/**` CI filter covers both
|
||||||
|
// that code and `src-tauri/tauri.conf.json`.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { MEDIAPIPE_WASM_BASE } from "../features/profile/lib/animatedAvatarCapture.ts";
|
||||||
|
|
||||||
|
const TAURI_CONF = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
new URL("../../src-tauri/tauri.conf.json", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function cspDirectives() {
|
||||||
|
const csp = TAURI_CONF.app?.security?.csp;
|
||||||
|
assert.equal(
|
||||||
|
typeof csp,
|
||||||
|
"string",
|
||||||
|
"app.security.csp is set as a policy string",
|
||||||
|
);
|
||||||
|
const directives = new Map();
|
||||||
|
for (const directive of csp.split(";")) {
|
||||||
|
const [name, ...sources] = directive.split(/\s+/).filter(Boolean);
|
||||||
|
if (name) directives.set(name, sources);
|
||||||
|
}
|
||||||
|
return directives;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sources(directive) {
|
||||||
|
const allowed = cspDirectives().get(directive);
|
||||||
|
assert.ok(allowed, `csp is missing the ${directive} directive`);
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The npm scope the MediaPipe loader must come from. A CSP source ending in
|
||||||
|
* `/` is a path *prefix* — paths can't be wildcarded — so this admits any
|
||||||
|
* `@mediapipe` package while excluding the rest of what jsDelivr serves. */
|
||||||
|
const MEDIAPIPE_SCOPE = "https://cdn.jsdelivr.net/npm/@mediapipe/";
|
||||||
|
|
||||||
|
/** How many path segments a source narrows to past its origin. */
|
||||||
|
function pathDepth(source) {
|
||||||
|
const authority = source.split("://")[1];
|
||||||
|
if (authority === undefined) return 0;
|
||||||
|
const slash = authority.indexOf("/");
|
||||||
|
if (slash === -1) return 0;
|
||||||
|
return authority
|
||||||
|
.slice(slash + 1)
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a `script-src` source is narrow enough to be worth allowing. CSP
|
||||||
|
* keywords (`'self'`, `'wasm-unsafe-eval'`) pass. A remote source must name a
|
||||||
|
* non-wildcard host *and* a path reaching at least a publisher scope — a bare
|
||||||
|
* origin, a scheme, or a registry root would put arbitrary third-party code
|
||||||
|
* one injected `<script>` away. */
|
||||||
|
function isPinnedScriptSource(source) {
|
||||||
|
if (source.startsWith("'")) return true;
|
||||||
|
return !source.includes("*") && pathDepth(source) >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("script-src allows wasm instantiation", () => {
|
||||||
|
// Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for
|
||||||
|
// every code block; MediaPipe selfie segmentation does the same. Without
|
||||||
|
// this token both silently degrade — highlighting drops to plain text and
|
||||||
|
// animated avatars keep their background.
|
||||||
|
assert.ok(sources("script-src").includes("'wasm-unsafe-eval'"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("script-src scopes the MediaPipe loader", () => {
|
||||||
|
// `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js`
|
||||||
|
// via a `<script>` tag (which of the two depends on a runtime SIMD probe),
|
||||||
|
// so the loader URL the frontend builds has to fall inside the allowlisted
|
||||||
|
// prefix — checked here rather than discovered in a signed build.
|
||||||
|
assert.ok(
|
||||||
|
sources("script-src").includes(MEDIAPIPE_SCOPE),
|
||||||
|
`script-src must allow ${MEDIAPIPE_SCOPE}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
MEDIAPIPE_WASM_BASE.startsWith(MEDIAPIPE_SCOPE),
|
||||||
|
`MEDIAPIPE_WASM_BASE (${MEDIAPIPE_WASM_BASE}) must sit under the allowlisted ${MEDIAPIPE_SCOPE}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("script-src trusts no bare origins", () => {
|
||||||
|
for (const source of sources("script-src")) {
|
||||||
|
assert.ok(
|
||||||
|
isPinnedScriptSource(source),
|
||||||
|
`script-src must stay scoped, found broad source \`${source}\``,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pinned script source check rejects broad sources", () => {
|
||||||
|
// Guards the guard: the check above is only worth having if it fails on the
|
||||||
|
// shapes that reopen the allowlist.
|
||||||
|
for (const allowed of [
|
||||||
|
"'self'",
|
||||||
|
"'wasm-unsafe-eval'",
|
||||||
|
MEDIAPIPE_SCOPE,
|
||||||
|
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm/vision_wasm_internal.js",
|
||||||
|
]) {
|
||||||
|
assert.ok(isPinnedScriptSource(allowed), `${allowed} should pass`);
|
||||||
|
}
|
||||||
|
for (const rejected of [
|
||||||
|
"https://cdn.jsdelivr.net",
|
||||||
|
"https://cdn.jsdelivr.net/",
|
||||||
|
"https://cdn.jsdelivr.net/npm/",
|
||||||
|
"https://cdn.jsdelivr.net/gh/",
|
||||||
|
"https://*.jsdelivr.net/npm/@mediapipe/",
|
||||||
|
"https:",
|
||||||
|
"*",
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
!isPinnedScriptSource(rejected),
|
||||||
|
`${rejected} should be rejected`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("media directives allow the buzz-media scheme", () => {
|
||||||
|
// `rewriteRelayUrl` emits `buzz-media://localhost/...` until the loopback
|
||||||
|
// proxy port resolves, so cold-start media renders through the custom
|
||||||
|
// scheme (mapped to `http://buzz-media.localhost` on Windows).
|
||||||
|
for (const directive of ["img-src", "media-src", "connect-src"]) {
|
||||||
|
const allowed = sources(directive);
|
||||||
|
assert.ok(
|
||||||
|
allowed.includes("buzz-media:"),
|
||||||
|
`${directive} must allow buzz-media:`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
allowed.includes("http://buzz-media.localhost"),
|
||||||
|
`${directive} must allow http://buzz-media.localhost`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connect-src allows IPC and cleartext relays", () => {
|
||||||
|
// `ipc:` / `http://ipc.localhost` carry every Tauri command. Cleartext
|
||||||
|
// `http:`/`ws:` stay allowed because a relay URL is user-supplied and the
|
||||||
|
// app accepts plain `ws://` on any host (`communityStorage::normalizeRelayUrl`,
|
||||||
|
// the community edit form): `relayProbe` opens a browser WebSocket to it,
|
||||||
|
// so narrowing this to loopback would report reachable relays as dead —
|
||||||
|
// while the real connection, which runs through tauri-plugin-websocket in
|
||||||
|
// Rust, is not governed by CSP at all. Blanket `https:` is already allowed,
|
||||||
|
// so restricting the cleartext schemes would close no exfiltration path.
|
||||||
|
const allowed = sources("connect-src");
|
||||||
|
for (const source of [
|
||||||
|
"ipc:",
|
||||||
|
"http://ipc.localhost",
|
||||||
|
"https:",
|
||||||
|
"http:",
|
||||||
|
"wss:",
|
||||||
|
"ws:",
|
||||||
|
]) {
|
||||||
|
assert.ok(allowed.includes(source), `connect-src must allow ${source}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("script-src stays free of unsafe-inline and unsafe-eval", () => {
|
||||||
|
const allowed = sources("script-src");
|
||||||
|
// The inline boot script in index.html is covered by Tauri's build-time
|
||||||
|
// SHA-256 hashing (scripts only — Tauri nonces inline <style> elements via
|
||||||
|
// a different path), so neither escape hatch is ever needed here. The boot
|
||||||
|
// background style was moved to public/boot.css to avoid the nonce path for
|
||||||
|
// style-src; see boot.css for the full rationale.
|
||||||
|
assert.ok(!allowed.includes("'unsafe-inline'"));
|
||||||
|
assert.ok(!allowed.includes("'unsafe-eval'"));
|
||||||
|
});
|
||||||
@@ -23,7 +23,55 @@ class ThrowingNotification {
|
|||||||
|
|
||||||
globalThis.window = { Notification: ThrowingNotification };
|
globalThis.window = { Notification: ThrowingNotification };
|
||||||
|
|
||||||
const { sendDesktopNotification } = await import("./desktop.ts");
|
const { sendDesktopNotification, getDesktopNotificationPermissionState } =
|
||||||
|
await import("./desktop.ts");
|
||||||
|
|
||||||
|
// Stands in for the plugin's injected shim: `permission` is a cached value and
|
||||||
|
// only `requestPermission()` reaches the backend and rewrites it.
|
||||||
|
function shimNotification(initialPermission, grantedPermission = "granted") {
|
||||||
|
class ShimNotification {
|
||||||
|
static permission = initialPermission;
|
||||||
|
static requestCount = 0;
|
||||||
|
|
||||||
|
static async requestPermission() {
|
||||||
|
ShimNotification.requestCount += 1;
|
||||||
|
ShimNotification.permission = grantedPermission;
|
||||||
|
return grantedPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShimNotification;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withEnvironment({ platform, isTauri, notification }, callback) {
|
||||||
|
const originalNavigator = Object.getOwnPropertyDescriptor(
|
||||||
|
globalThis,
|
||||||
|
"navigator",
|
||||||
|
);
|
||||||
|
const originalNotification = window.Notification;
|
||||||
|
const originalIsTauri = globalThis.isTauri;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: { platform, userAgent: "" },
|
||||||
|
});
|
||||||
|
window.Notification = notification;
|
||||||
|
globalThis.isTauri = isTauri;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await callback();
|
||||||
|
} finally {
|
||||||
|
if (originalNavigator) {
|
||||||
|
Object.defineProperty(globalThis, "navigator", originalNavigator);
|
||||||
|
} else {
|
||||||
|
delete globalThis.navigator;
|
||||||
|
}
|
||||||
|
window.Notification = originalNotification;
|
||||||
|
globalThis.isTauri = originalIsTauri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => {
|
test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => {
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
@@ -50,3 +98,72 @@ test("constructor failure is a delivery miss and does not prevent a later notifi
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Windows Tauri repairs the shim's false denied state on read", async () => {
|
||||||
|
const Notification = shimNotification("denied");
|
||||||
|
|
||||||
|
const permission = await withEnvironment(
|
||||||
|
{ platform: "Win32", isTauri: true, notification: Notification },
|
||||||
|
() => getDesktopNotificationPermissionState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(permission, "granted");
|
||||||
|
assert.equal(Notification.requestCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the repaired state is cached, so later reads do not request again", async () => {
|
||||||
|
const Notification = shimNotification("denied");
|
||||||
|
|
||||||
|
await withEnvironment(
|
||||||
|
{ platform: "Win32", isTauri: true, notification: Notification },
|
||||||
|
async () => {
|
||||||
|
await getDesktopNotificationPermissionState();
|
||||||
|
const second = await getDesktopNotificationPermissionState();
|
||||||
|
assert.equal(second, "granted");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(Notification.requestCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a denial that survives the request is reported as denied", async () => {
|
||||||
|
const Notification = shimNotification("denied", "denied");
|
||||||
|
|
||||||
|
const permission = await withEnvironment(
|
||||||
|
{ platform: "Win32", isTauri: true, notification: Notification },
|
||||||
|
() => getDesktopNotificationPermissionState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(permission, "denied");
|
||||||
|
assert.equal(Notification.requestCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("denied stays terminal outside the Windows Tauri app", async () => {
|
||||||
|
for (const environment of [
|
||||||
|
{ label: "Windows web", platform: "Win32", isTauri: false },
|
||||||
|
{ label: "Linux Tauri", platform: "Linux x86_64", isTauri: true },
|
||||||
|
{ label: "Linux web", platform: "Linux x86_64", isTauri: false },
|
||||||
|
]) {
|
||||||
|
const Notification = shimNotification("denied");
|
||||||
|
|
||||||
|
const permission = await withEnvironment(
|
||||||
|
{ ...environment, notification: Notification },
|
||||||
|
() => getDesktopNotificationPermissionState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(permission, "denied", environment.label);
|
||||||
|
assert.equal(Notification.requestCount, 0, environment.label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("granted is returned untouched and never triggers a request", async () => {
|
||||||
|
const Notification = shimNotification("granted");
|
||||||
|
|
||||||
|
const permission = await withEnvironment(
|
||||||
|
{ platform: "Win32", isTauri: true, notification: Notification },
|
||||||
|
() => getDesktopNotificationPermissionState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(permission, "granted");
|
||||||
|
assert.equal(Notification.requestCount, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
onAction,
|
onAction,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
} from "@tauri-apps/plugin-notification";
|
} from "@tauri-apps/plugin-notification";
|
||||||
import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform";
|
import {
|
||||||
|
isLinuxPlatform,
|
||||||
|
isMacPlatform,
|
||||||
|
isWindowsPlatform,
|
||||||
|
} from "@/shared/lib/platform";
|
||||||
|
|
||||||
// Backend event emitted when a native Linux notification is clicked or a
|
// Backend event emitted when a native Linux notification is clicked or a
|
||||||
// queued macOS activation becomes available. See src-tauri notification code.
|
// queued macOS activation becomes available. See src-tauri notification code.
|
||||||
@@ -147,6 +151,25 @@ export async function getDesktopNotificationPermissionState(): Promise<DesktopNo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (window.Notification.permission !== "default") {
|
if (window.Notification.permission !== "default") {
|
||||||
|
// On Windows the notification plugin's injected shim stamps `denied` at
|
||||||
|
// startup without ever consulting the backend — `permission_state()`
|
||||||
|
// returns `Granted` unconditionally on desktop — so a `denied` reading
|
||||||
|
// inside the Windows Tauri app is never a real denial. Repair it by
|
||||||
|
// requesting once: on Windows `request_permission()` raises no prompt,
|
||||||
|
// returns `Granted`, and rewrites the shim's cached value, so subsequent
|
||||||
|
// reads short-circuit here as `granted`.
|
||||||
|
if (
|
||||||
|
window.Notification.permission === "denied" &&
|
||||||
|
isTauri() &&
|
||||||
|
isWindowsPlatform()
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await requestDesktopNotificationAccess();
|
||||||
|
} catch {
|
||||||
|
return window.Notification.permission;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return window.Notification.permission;
|
return window.Notification.permission;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ const PERSON_OUTLINE_OFFSETS = [
|
|||||||
// always matches the JS API. `script-src` in tauri.conf.json allowlists the
|
// always matches the JS API. `script-src` in tauri.conf.json allowlists the
|
||||||
// jsDelivr path prefix for the @mediapipe npm scope rather than the whole
|
// jsDelivr path prefix for the @mediapipe npm scope rather than the whole
|
||||||
// origin, which also serves arbitrary npm and GitHub scripts; this URL must
|
// origin, which also serves arbitrary npm and GitHub scripts; this URL must
|
||||||
// stay under that prefix — `src-tauri/tests/csp.rs` checks that it does.
|
// stay under that prefix — `src/app/contentSecurityPolicy.test.mjs` checks
|
||||||
const MEDIAPIPE_WASM_BASE =
|
// that it does.
|
||||||
|
export const MEDIAPIPE_WASM_BASE =
|
||||||
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
|
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
|
||||||
const SELFIE_SEGMENTER_MODEL_URL =
|
const SELFIE_SEGMENTER_MODEL_URL =
|
||||||
"https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";
|
"https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";
|
||||||
|
|||||||
38
desktop/src/shared/lib/platform.test.mjs
Normal file
38
desktop/src/shared/lib/platform.test.mjs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { isWindowsPlatform } from "./platform.ts";
|
||||||
|
|
||||||
|
function withNavigator(navigator, callback) {
|
||||||
|
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: navigator,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
callback();
|
||||||
|
} finally {
|
||||||
|
if (original) {
|
||||||
|
Object.defineProperty(globalThis, "navigator", original);
|
||||||
|
} else {
|
||||||
|
delete globalThis.navigator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Windows detection accepts the platform strings WebView2 reports", () => {
|
||||||
|
for (const platform of ["Win32", "Win64", "Windows"]) {
|
||||||
|
withNavigator({ platform, userAgent: "" }, () => {
|
||||||
|
assert.equal(isWindowsPlatform(), true, platform);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Windows detection rejects the other desktop platforms", () => {
|
||||||
|
for (const platform of ["MacIntel", "Darwin", "Linux x86_64"]) {
|
||||||
|
withNavigator({ platform, userAgent: "" }, () => {
|
||||||
|
assert.equal(isWindowsPlatform(), false, platform);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true on Windows desktops. */
|
||||||
|
export function isWindowsPlatform(): boolean {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^win/i.test(navigator.platform);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform's normal application-shortcut modifier:
|
* The platform's normal application-shortcut modifier:
|
||||||
* - macOS: Command (Meta)
|
* - macOS: Command (Meta)
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
Design guidance for follow-up work, not implemented wire capabilities.
|
Design guidance for follow-up work, not implemented wire capabilities.
|
||||||
The current [API contract](buzz-v1-read-state.md) remains authoritative.
|
The current [API contract](buzz-v1-read-state.md) remains authoritative.
|
||||||
|
|
||||||
Thread summaries are implemented (see `threads` in the API contract). Richer
|
Thread rows are implemented (see `threads` in the API contract). Richer
|
||||||
previews that carry signed-event content should be opt-in and preserve the base sidebar's work/byte budgets;
|
previews that carry signed-event content should be opt-in and preserve the base sidebar's work/byte budgets;
|
||||||
fetching detail separately remains valid. Reuse canonical classification and
|
fetching detail separately remains valid. Reuse canonical classification and
|
||||||
read frontiers, not a second definition of unread. Historical-mention eligibility
|
read frontiers, not a second definition of unread. Historical-mention eligibility
|
||||||
or mute-aware attention requires an explicit policy contract, not an unnoticed
|
or mute-aware mentions requires an explicit policy contract, not an unnoticed
|
||||||
change to today's `attention` field.
|
change to today's `mentions` field.
|
||||||
|
|
||||||
Channel-content revisions, personal-state revisions and synchronized preferences
|
Channel-content revisions, personal-state revisions and synchronized preferences
|
||||||
are separate from read frontiers. Future revisions must describe the same
|
are separate from read frontiers. Future revisions must describe the same
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ application/nostr+json`, or `GET /info`) includes `buzz_v1` only when enabled:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000,
|
{"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000,
|
||||||
"max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100,
|
"max_channels":20,"max_intents":100,"eligible_kinds":[9,40002,45001,45003]}}
|
||||||
"max_thread_summaries":5,"eligible_kinds":[9,40002,45001,45003]}}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the requesting origin plus this relative prefix. Discovery is a configured
|
Use the requesting origin plus this relative prefix. Discovery is a configured
|
||||||
@@ -50,14 +49,9 @@ Unknown request fields are rejected. Never turn a transport failure into read.
|
|||||||
## Sidebar
|
## Sidebar
|
||||||
|
|
||||||
`GET /buzz/v1/me/sidebar?limit=20&cursor=<exclusive-channel-uuid>` returns
|
`GET /buzz/v1/me/sidebar?limit=20&cursor=<exclusive-channel-uuid>` returns
|
||||||
`account`, `channels`, and `next_cursor`. Omit the cursor on the first request.
|
`channels` and `next_cursor`. Omit the cursor on the first request. Only
|
||||||
Each channel includes identity/name/type, archived and hidden flags, `unread`,
|
joined, nondeleted channels are listed; names, types, archived and hidden state
|
||||||
`attention`, `latest_message_id` (the last eligible message to arrive: the
|
come from Nostr channel metadata, not this API. Each page has a
|
||||||
row's read anchor, see [Order](#order)), `latest_message_at` (the greatest
|
|
||||||
author time among eligible messages, in seconds: display activity, not
|
|
||||||
necessarily that message's own time; null exactly when the ID is null),
|
|
||||||
`latest_message_complete`, and `threads`. Only joined, nondeleted channels are
|
|
||||||
listed. Hidden/archived presentation remains client-owned. Each page has a
|
|
||||||
writer-consistent snapshot; separate pages do not share a snapshot, and an
|
writer-consistent snapshot; separate pages do not share a snapshot, and an
|
||||||
unfinished traversal cannot prove channel removal.
|
unfinished traversal cannot prove channel removal.
|
||||||
|
|
||||||
@@ -67,65 +61,63 @@ combined with `limit` or `cursor`. A requested ID absent from the result was not
|
|||||||
a joined, nondeleted, accessible sidebar row at that snapshot: remove its row.
|
a joined, nondeleted, accessible sidebar row at that snapshot: remove its row.
|
||||||
Absence says nothing else about access to an open channel.
|
Absence says nothing else about access to an open channel.
|
||||||
|
|
||||||
`threads` lists unread threads in the row, newest unread reply first:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"items":[{"root_id":"<64-hex>","unread":{"status":"exact","value":2},
|
{"channels":[{"channel_id":"<uuid>","unread":true,"mentions":1,
|
||||||
"latest_reply_id":"<64-hex>","latest_reply_at":1700000000}],"complete":true}
|
"read_through_id":"<64-hex>","latest_id":"<64-hex>",
|
||||||
|
"threads":[{"root_id":"<64-hex>","unread":true,"mentions":2,
|
||||||
|
"read_through_id":null,"latest_id":"<64-hex>"}]}],
|
||||||
|
"next_cursor":null}
|
||||||
```
|
```
|
||||||
|
|
||||||
Items are canonical roots with unread replies that count (see below), ordered
|
A channel's timeline and each of its threads keep independent read positions;
|
||||||
by `latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is
|
marking one never moves another.
|
||||||
the last such reply to arrive (equal arrivals prefer the smaller ID), so a
|
|
||||||
thread `mark_through` at it reads every reply listed; `latest_reply_at` is its
|
|
||||||
author time. Replies that do not count are filtered out before the count, the
|
|
||||||
latest reply and the cap are chosen. `unread` uses the row's
|
|
||||||
definition; every counted reply is also attention, so items carry no separate
|
|
||||||
attention count. `complete=true` means the unread window was exhausted, no
|
|
||||||
evidence had unresolved ancestry, unusable tags or undecided membership, and no
|
|
||||||
thread was omitted; then item unread counts sum to the row's unread replies.
|
|
||||||
Otherwise the list is a cut of observed evidence and counts may be lower bounds
|
|
||||||
or unknown. No message bytes are included.
|
|
||||||
|
|
||||||
Counts have exactly three representations:
|
| Field | Channel row | Thread row |
|
||||||
|
|---|---|---|
|
||||||
|
| `unread` | an unread top-level message counts | an unread reply counts |
|
||||||
|
| `mentions` | unread top-level messages directed at you | unread replies that count |
|
||||||
|
| `read_through_id` | the anchor of the timeline's frontier, or null | the anchor of the thread's frontier, or null |
|
||||||
|
| `latest_id` | the last eligible top-level message to arrive, or null | the last counted unread reply to arrive |
|
||||||
|
|
||||||
```json
|
A thread reply never makes the channel row unread and never becomes its
|
||||||
[{"status":"exact","value":0},{"status":"at_least","value":7},{"status":"unknown"}]
|
`latest_id`, including a reply carrying `broadcast=1`: it shows on its thread
|
||||||
```
|
row. `latest_id` is the anchor that reads its scope: "mark channel read" sends a
|
||||||
|
channel `mark_through` at the channel's `latest_id`, plus one thread
|
||||||
|
`mark_through` at each listed thread's `latest_id` to clear those too. The
|
||||||
|
channel `latest_id` counts any author and ignores read progress. Null means the
|
||||||
|
bounded scans found no top-level message, not that the channel is empty.
|
||||||
|
`read_through_id` may name a message that has since been deleted.
|
||||||
|
|
||||||
Only exact zero proves absence. Unknown has no numeric value. A message is
|
`threads` lists threads with unread replies that count, newest unread reply
|
||||||
eligible when it is non-own, nondeleted, of the advertised `eligible_kinds` and
|
first by author time (then `root_id`), at most 25. Threads past the 25th are
|
||||||
inside the horizon. The same kinds alone define latest activity, so an edit,
|
omitted with their mentions, and nothing signals the omission: after marking
|
||||||
reaction or diff (40008) neither makes a channel unread nor moves it. Classify
|
the listed threads read, a refresh can list the next ones. A thread row's
|
||||||
live arrivals with the advertised set, not a client copy.
|
`unread` is always true in this version. No message bytes are included.
|
||||||
|
|
||||||
An eligible message beyond the matching context frontier counts as unread for
|
A message is eligible when it is non-own, nondeleted, of the advertised
|
||||||
the first reason that holds:
|
`eligible_kinds` and inside the horizon. The same kinds alone define latest
|
||||||
|
activity, so an edit, reaction or diff (40008) neither makes a channel unread
|
||||||
|
nor moves it. Classify live arrivals with the advertised set, not a client copy.
|
||||||
|
|
||||||
| `reason` | Holds when |
|
A top-level message is directed at you when its channel is a DM, it tags you
|
||||||
|---|---|
|
with `p`, or it carries `broadcast=1`. A reply counts only when it is directed
|
||||||
| `direct` | its channel is a DM |
|
in the same way, or it is in one of your conversations: you wrote its direct
|
||||||
| `mention` | it tags the actor with `p` |
|
parent or have a reply to that same parent, in that channel. Conversation
|
||||||
| `conversation` | it is a reply, and the actor wrote its direct parent or has a reply to that same parent, in that channel |
|
membership uses only your live eligible messages (a deleted parent proves
|
||||||
| `broadcast` | it carries `broadcast=1` |
|
nothing; a surviving reply to it still does), looks at the direct parent only
|
||||||
| null | it is top-level |
|
(owning the root or replying elsewhere in the thread proves nothing), and is
|
||||||
|
independent of read progress and retention. This is not Desktop notification
|
||||||
|
policy: follows and mutes do not affect these counts.
|
||||||
|
|
||||||
A reply with no reason does not count: it is not unread and appears in no
|
Counts are what the bounded scans could establish, and may undercount. A message
|
||||||
thread list. `unread` counts the messages that count; `attention` is the subset
|
whose tags are unusable, whose ancestry is unresolved, or whose conversation
|
||||||
with a reason. Conversation membership uses only the actor's live eligible
|
membership is undecided is left out, as are messages beyond the scan bounds
|
||||||
messages (a deleted parent proves nothing; a surviving reply to it still does),
|
(see [Bounds](#bounds-and-deployment)).
|
||||||
looks at the direct parent only (owning the root or replying elsewhere in the
|
|
||||||
thread proves nothing), and is independent of read progress and retention. A
|
|
||||||
reply whose membership is undecided is left out, and the row's counts become
|
|
||||||
lower bounds or unknown. The sidebar counts a broadcast reply without asking
|
|
||||||
which of the two reasons applies. This is not Desktop notification policy:
|
|
||||||
follows and mutes do not affect these counts.
|
|
||||||
|
|
||||||
The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and is
|
The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and is
|
||||||
measured in author time (`created_at`): a message counts while its author time
|
measured in author time (`created_at`). It filters unread and mentions, not
|
||||||
is at or after `account.cutoff_ms`. It filters unread/attention, not latest
|
latest activity, event storage or frontier state. Frontiers use a different
|
||||||
activity, event storage or frontier state. Frontiers use a different clock,
|
clock, relay arrival (see [Order](#order)). Three consequences:
|
||||||
relay arrival (see [Order](#order)). Three consequences:
|
|
||||||
|
|
||||||
- A message accepted late with an author time beyond the horizon (an import, a
|
- A message accepted late with an author time beyond the horizon (an import, a
|
||||||
backfill, a long-offline sender) is excluded under the current horizon,
|
backfill, a long-offline sender) is excluded under the current horizon,
|
||||||
@@ -137,33 +129,6 @@ relay arrival (see [Order](#order)). Three consequences:
|
|||||||
frontier, and author time at or after the cutoff.
|
frontier, and author time at or after the cutoff.
|
||||||
|
|
||||||
A later configuration expansion can change counts without having lost progress.
|
A later configuration expansion can change counts without having lost progress.
|
||||||
Latest activity is independent of actor and frontiers. A null latest ID proves
|
|
||||||
an empty eligible history only when `latest_message_complete=true`.
|
|
||||||
|
|
||||||
## Explicit contexts
|
|
||||||
|
|
||||||
`GET /buzz/v1/me/read-state?targets=<URL-encoded-JSON-array>` accepts up to 20
|
|
||||||
contexts and 100 total concrete message selectors. It is not event history or a
|
|
||||||
global export of frontiers. Example decoded `targets`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[{"target":{"channel_id":"<uuid>","root_id":"<64-hex-root>"},
|
|
||||||
"message_ids":["<64-hex-event>"]}]
|
|
||||||
```
|
|
||||||
|
|
||||||
Omitting `root_id` selects the channel timeline. The result contains `account`
|
|
||||||
and one `contexts` entry per request entry, in order. Context status is
|
|
||||||
`available` (with `messages`), `unknown`, or `unavailable`. A thread context's
|
|
||||||
frontier includes any whole-channel cut. Message status is `read`, `not_counted`,
|
|
||||||
`unread` (with `reason`), `unknown`, or `unavailable`. Wrong-context, missing
|
|
||||||
and forbidden selectors share unavailable. Status is decided in this order:
|
|
||||||
ancestry and context; eligibility (`not_counted` for own, deleted, other kinds
|
|
||||||
and outside the horizon); the frontier (`read`, with no membership lookup); then
|
|
||||||
the reason. A reply past the frontier with no reason is `not_counted` when it is
|
|
||||||
proven outside the actor's conversations and `unknown` when membership is
|
|
||||||
undecided. A broadcast reply whose membership is undecided is `unread` with
|
|
||||||
reason `broadcast` and may report `conversation` on a later request.
|
|
||||||
Conversation bytes must still come from the existing Nostr path.
|
|
||||||
|
|
||||||
## Fixed-operand writes
|
## Fixed-operand writes
|
||||||
|
|
||||||
@@ -172,33 +137,30 @@ Conversation bytes must still come from the existing Nostr path.
|
|||||||
```json
|
```json
|
||||||
{"intents":[
|
{"intents":[
|
||||||
{"type":"mark_through","target":{"channel_id":"<uuid>"},"message_id":"<64-hex-event>"},
|
{"type":"mark_through","target":{"channel_id":"<uuid>"},"message_id":"<64-hex-event>"},
|
||||||
{"type":"mark_through","target":{"channel_id":"<uuid>","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"},
|
{"type":"mark_through","target":{"channel_id":"<uuid>","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"}
|
||||||
{"type":"mark_channel_read","channel_id":"<uuid>","message_id":"<64-hex-event>"}
|
|
||||||
]}
|
]}
|
||||||
```
|
```
|
||||||
|
|
||||||
Each intent commits atomically and returns its own `applied`, `blocked` or
|
The response is `{"outcomes":[...],"channels":[...]}`. Each intent commits
|
||||||
`invalid` outcome. An ambiguous timeout/storage failure returns
|
atomically and returns its own `applied`, `blocked` or `invalid` outcome, in
|
||||||
|
request order. An ambiguous timeout/storage failure returns
|
||||||
`{"status":"unknown","retryable":true}`. Earlier committed outcomes survive
|
`{"status":"unknown","retryable":true}`. Earlier committed outcomes survive
|
||||||
later failures. `projection_status` is `not_requested`; a successful write does
|
later failures. `channels` holds the updated sidebar row of each distinct
|
||||||
not assert a client has refreshed. Retry the same operands, never substitute
|
channel with an applied intent, in the sidebar's row shape and ordered by ID;
|
||||||
latest. Keep pending intent durably on the client until its outcome is resolved.
|
a channel you have not joined has no row. Replace those rows rather than
|
||||||
|
re-deriving them. `channels` is omitted when the rows could not be read within
|
||||||
|
the request's deadline after the writes committed: refresh them with `?channel_ids=`. Retry the same
|
||||||
|
operands, never substitute latest. Keep pending intent durably on the client
|
||||||
|
until its outcome is resolved.
|
||||||
|
|
||||||
A mark-through validates a fixed message and advances its context's monotone
|
A mark-through validates a fixed message and advances its context's monotone
|
||||||
frontier to that message's relay arrival (see [Order](#order)). Channel and
|
frontier to that message's relay arrival (see [Order](#order)). A channel
|
||||||
thread frontiers never inherit in either direction. Opening a view is not
|
anchor must be a top-level message; a thread anchor must be the thread's root
|
||||||
itself a reading action; client dwell/focus policy determines when to send an
|
or one of its replies. The frontier records the anchor as `read_through_id`
|
||||||
actual observed anchor. Old or deleted valid anchors may advance a frontier.
|
when it advances; an anchor that arrived at or before the current frontier
|
||||||
|
changes nothing. Opening a view is not itself a reading action; client
|
||||||
`mark_channel_read` is the one whole-channel cut: it advances the channel
|
dwell/focus policy determines when to send an actual observed anchor. Old or
|
||||||
timeline and every thread in that channel, including unlisted ones, through the
|
deleted valid anchors may advance a frontier.
|
||||||
anchor's arrival. The anchor must be an accessible eligible-kind message in
|
|
||||||
the channel, top-level or reply, deleted or not; ancestry is not checked. A
|
|
||||||
reply is read at or below the greater of its thread frontier and this cut. An
|
|
||||||
anchor that no longer exists is `blocked`. `latest_message_id` is the anchor
|
|
||||||
that reads the whole row. A null ID with `latest_message_complete=false` does
|
|
||||||
not prove empty history; it only leaves the client without an anchor. Thread
|
|
||||||
marks and channel `mark_through` never set the cut.
|
|
||||||
|
|
||||||
### Order
|
### Order
|
||||||
|
|
||||||
@@ -209,11 +171,11 @@ before the anchor is read, whatever its author time. A message that arrives
|
|||||||
later is unread even when backdated, and a future-dated anchor reads nothing
|
later is unread even when backdated, and a future-dated anchor reads nothing
|
||||||
that arrives after it.
|
that arrives after it.
|
||||||
|
|
||||||
The order is the relay's and is not exposed: no response carries a frontier,
|
The order is the relay's and is not exposed: a response carries the anchor
|
||||||
and no field lets a client compute what a mark will cover. Send the anchors the
|
message (`read_through_id`), never its arrival time, and no field lets a client
|
||||||
user actually saw, let the relay take the greatest, and ask a context which
|
compute what a mark will cover. Send the anchors the user actually saw and let
|
||||||
messages are read. Do not compare author times, IDs or local receipt order to
|
the relay take the greatest. Do not compare author times, IDs or local receipt
|
||||||
drop one pending anchor in favor of another.
|
order to drop one pending anchor in favor of another.
|
||||||
|
|
||||||
Arrival is the accepting relay process's clock, read just before the insert, at
|
Arrival is the accepting relay process's clock, read just before the insert, at
|
||||||
microsecond resolution. Three limits follow, none of which strands a badge:
|
microsecond resolution. Three limits follow, none of which strands a badge:
|
||||||
@@ -224,10 +186,11 @@ microsecond resolution. Three limits follow, none of which strands a badge:
|
|||||||
skew.
|
skew.
|
||||||
- Messages with the identical stamp are read together.
|
- Messages with the identical stamp are read together.
|
||||||
|
|
||||||
`latest_message_id` is the last message to arrive among those the unread count
|
A channel's `latest_id` is the last top-level message to arrive among those the
|
||||||
examined: the 4,096 most recent events by author time inside the horizon. So
|
unread count examined: the 4,096 most recent events by author time inside the
|
||||||
marking through it reads everything counted. When the horizon holds no message,
|
horizon. So marking through it reads every top-level message counted. When
|
||||||
it is the last to arrive among the channel's 256 most recent events.
|
that scan holds no top-level message, it is the last top-level message to
|
||||||
|
arrive among the channel's 256 most recent events.
|
||||||
|
|
||||||
There is no import of earlier client read state. The relay records the
|
There is no import of earlier client read state. The relay records the
|
||||||
account's `started_at` at its first applied read intent and never moves it.
|
account's `started_at` at its first applied read intent and never moves it.
|
||||||
@@ -238,40 +201,45 @@ device-local.
|
|||||||
|
|
||||||
## Bounds and deployment
|
## Bounds and deployment
|
||||||
|
|
||||||
- 20 sidebar rows, 100 intents, 20 contexts / 100 selectors per request.
|
- 20 sidebar rows and 100 intents per request; 25 thread rows per channel, so
|
||||||
- 64 KiB write body; 16 KiB context URL; 1 MiB serialized API response.
|
a POST's rows for 100 channels fit the response limit.
|
||||||
- 4096 raw events per channel inside the horizon plus one exhaustion sentinel,
|
- 64 KiB write body; 1 MiB serialized API response.
|
||||||
before eligibility.
|
- 4096 raw events per channel inside the horizon, before eligibility.
|
||||||
- Latest activity probes 256 events plus a sentinel; long ineligible tails may
|
- The latest fallback probes 256 events; a long ineligible or reply-only tail
|
||||||
leave latest incomplete even when unread is exact.
|
can leave `latest_id` null.
|
||||||
- Tag documents over 8192 bytes or malformed relevant tags yield uncertainty.
|
- Tag documents over 8192 bytes or malformed relevant tags leave the message
|
||||||
Compact boolean facts cross the database boundary, never raw tag payloads.
|
out. Compact boolean facts cross the database boundary, never raw tag
|
||||||
|
payloads.
|
||||||
- Conversation membership: at most 1024 unique parents per request, with a
|
- Conversation membership: at most 1024 unique parents per request, with a
|
||||||
500 ms savepoint budget. The lookup is exact, so its work grows with the
|
500 ms savepoint budget. The lookup is exact, so its work grows with the
|
||||||
replies under each parent. Past either bound a reply is undecided, never
|
replies under each parent. Past either bound a reply is undecided and left
|
||||||
absent: counts become lower bounds or unknown and a context reports `unknown`.
|
out.
|
||||||
- DB statement/lock deadlines and HTTP read deadlines bound work; writes use a
|
- DB statement/lock deadlines and HTTP read deadlines bound work; a write's intents
|
||||||
shared eight-second intent-processing deadline after admission. Limits are
|
and its row refresh share one eight-second deadline after admission. Limits are
|
||||||
containment, not a production capacity claim.
|
containment, not a production capacity claim.
|
||||||
|
|
||||||
Apply migrations 0056 and 0057 (or the equivalent desired schema). 0056
|
Apply migrations 0056 through 0058 (or the equivalent desired schema). 0056
|
||||||
creates two empty private tables and no index on `events`, and 0057 adds the
|
creates two empty private tables and no index on `events`, 0057 adds the
|
||||||
nullable `personal_read_accounts.started_at` column. Both sidebar scans are
|
nullable `personal_read_accounts.started_at` column, and 0058 adds the nullable
|
||||||
|
`personal_read_frontiers.through_message_id` column and drops
|
||||||
|
`threads_through_timestamp`. Both sidebar scans are
|
||||||
served by the existing `idx_events_community_channel_created`. No new
|
served by the existing `idx_events_community_channel_created`. No new
|
||||||
per-message ingest write path or stored unread counters are introduced.
|
per-message ingest write path or stored unread counters are introduced.
|
||||||
|
|
||||||
Use existing HTTP route/status/latency metrics for `/buzz/v1/me/sidebar` and
|
Use existing HTTP route/status/latency metrics for `/buzz/v1/me/sidebar` and
|
||||||
`/buzz/v1/me/read-state`, plus database pool/statement metrics. Inspect exact /
|
`/buzz/v1/me/read-state`, plus database pool/statement metrics. No payload,
|
||||||
lower-bound / unknown proportions in controlled acceptance captures; no payload,
|
|
||||||
actor, channel or frontier values should become metric labels. The measured
|
actor, channel or frontier values should become metric labels. The measured
|
||||||
local seed is not a DAU/concurrency or p95/p99 production acceptance result.
|
local seed is not a DAU/concurrency or p95/p99 production acceptance result.
|
||||||
|
|
||||||
## Compatibility and extension rules
|
## Compatibility and extension rules
|
||||||
|
|
||||||
Within `/buzz/v1`, clients must ignore unknown response object fields. Existing
|
Within `/buzz/v1`, clients must ignore unknown response object fields. The
|
||||||
required fields, status variants and their meanings remain stable; additive
|
shape is stable: existing fields, their types, status variants and what a
|
||||||
fields do not authorize silently changing `attention` or frontier semantics.
|
`mark_through` does remain. What the counts include is not yet: which messages
|
||||||
Breaking changes require an explicitly negotiated contract or a new API version.
|
are unread or directed, which threads are listed, and the bounds may change
|
||||||
|
while v1 is pre-release. A client therefore replaces its rows with each
|
||||||
|
response rather than reconciling them with its own counts. Breaking shape
|
||||||
|
changes require an explicitly negotiated contract or a new API version.
|
||||||
Requests remain strict: send new parameters or intent types only after the relay
|
Requests remain strict: send new parameters or intent types only after the relay
|
||||||
advertises the corresponding capability. Missing optional data means unsupported
|
advertises the corresponding capability. Missing optional data means unsupported
|
||||||
or not requested, never an empty list, zero count or unchanged revision.
|
or not requested, never an empty list, zero count or unchanged revision.
|
||||||
|
|||||||
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;
|
||||||
@@ -130,7 +130,9 @@ Future<void> _deactivateCommunityPushLease(
|
|||||||
}
|
}
|
||||||
final decoded = nostr.Nip19.decode(payload: nsec);
|
final decoded = nostr.Nip19.decode(payload: nsec);
|
||||||
final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public;
|
final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public;
|
||||||
final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl);
|
final descriptor = await fetchBuzzPushLeaseDescriptor(
|
||||||
|
canonicalBuzzPushRelayHttpUrl(community.relayUrl),
|
||||||
|
);
|
||||||
final matchingGrant = (await readBuzzPushEndpointGrants())
|
final matchingGrant = (await readBuzzPushEndpointGrants())
|
||||||
.where(
|
.where(
|
||||||
(grant) =>
|
(grant) =>
|
||||||
|
|||||||
56
mobile/test/shared/community/push_opt_out_url_test.dart
Normal file
56
mobile/test/shared/community/push_opt_out_url_test.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:buzz/shared/community/community.dart';
|
||||||
|
import 'package:buzz/shared/community/community_provider.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:nostr/nostr.dart' as nostr;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final scheme in ['ws', 'wss', 'http', 'https']) {
|
||||||
|
test(
|
||||||
|
'opt-out fetches relay metadata over HTTP for $scheme community',
|
||||||
|
() async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final community = Community.create(
|
||||||
|
name: 'Test',
|
||||||
|
relayUrl: '$scheme://relay.example:8443',
|
||||||
|
nsec: nostr.Keys.generate().nsec,
|
||||||
|
);
|
||||||
|
final requests = <http.Request>[];
|
||||||
|
final client = MockClient((request) async {
|
||||||
|
requests.add(request);
|
||||||
|
// Stop at the HTTP boundary, before native grants or publication.
|
||||||
|
return http.Response('test unavailable', 503);
|
||||||
|
});
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
await http.runWithClient(() async {
|
||||||
|
await expectLater(
|
||||||
|
container.read(communityPushLeaseDeactivatorProvider)(
|
||||||
|
community,
|
||||||
|
generation: 2,
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<StateError>().having(
|
||||||
|
(error) => error.message,
|
||||||
|
'message',
|
||||||
|
contains('NIP-11 request failed with HTTP 503'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, () => client);
|
||||||
|
|
||||||
|
final httpScheme = scheme == 'ws' || scheme == 'http'
|
||||||
|
? 'http'
|
||||||
|
: 'https';
|
||||||
|
expect(
|
||||||
|
requests.single.url,
|
||||||
|
Uri.parse('$httpScheme://relay.example:8443/'),
|
||||||
|
);
|
||||||
|
expect(requests.single.headers['Accept'], 'application/nostr+json');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,8 @@ CREATE TABLE personal_read_frontiers (
|
|||||||
channel_id UUID NOT NULL,
|
channel_id UUID NOT NULL,
|
||||||
root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)),
|
root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)),
|
||||||
through_timestamp TIMESTAMPTZ NOT NULL,
|
through_timestamp TIMESTAMPTZ NOT NULL,
|
||||||
-- Whole-channel cut covering every thread; channel rows only.
|
-- The message whose arrival is through_timestamp; NULL before 0058.
|
||||||
threads_through_timestamp TIMESTAMPTZ
|
through_message_id BYTEA CHECK (octet_length(through_message_id) = 32),
|
||||||
CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea),
|
|
||||||
PRIMARY KEY (community_id, actor, channel_id, root_id),
|
PRIMARY KEY (community_id, actor, channel_id, root_id),
|
||||||
FOREIGN KEY (community_id, actor)
|
FOREIGN KEY (community_id, actor)
|
||||||
REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,
|
REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,
|
||||||
|
|||||||
Reference in New Issue
Block a user