From 8fe13b46496130d284d52c10f8a7c14d87b4c444 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 22 Oct 2026 13:32:27 -0400 Subject: [PATCH] fix(index): hold an account's history oldest first however it arrives An account first drawn from the stream took its newest line, then its repository's listing newest first, appended in that order. A record now goes into its history in time order, in the index and in a mirror alike, so badges and share pages read the newest line as the newest. Co-Authored-By: Claude Opus 5.5 Change-Id: I437b21481da419512786581c87dbe8fbbb36bcc7 --- crates/vibescrobble-index/src/view.rs | 64 ++++++++++++++--- crates/vibescrobble-index/tests/backfill.rs | 9 +-- crates/vibescrobble-query/tests/share.rs | 76 +++++++++++++++++++++ 3 files changed, 135 insertions(+), 14 deletions(-) diff --git a/crates/vibescrobble-index/src/view.rs b/crates/vibescrobble-index/src/view.rs index a7772eb..8393888 100644 --- a/crates/vibescrobble-index/src/view.rs +++ b/crates/vibescrobble-index/src/view.rs @@ -1662,18 +1662,16 @@ impl View { } let history = self.history.entry(author).or_default(); // The oldest goes before the newest comes in, so a full history never - // grows its allocation past what it holds. + // grows its allocation past what it holds. One older than every line + // held was turned away above, so the oldest is never this one. if history.len() >= HISTORY { - if let Some(oldest) = history - .iter() - .enumerate() - .min_by(|(_, a), (_, b)| a.created_at.cmp(&b.created_at)) - .map(|(at, _)| at) - { - history.remove(oldest); - } + history.remove(0); } - history.push(scrobble.clone()); + // Held oldest first, as a snapshot replays it. Records do not arrive + // in that order: an account the stream draws first is given its + // newest line, then its repository's listing, newest first. + let at = history.partition_point(|held| held.created_at <= scrobble.created_at); + history.insert(at, scrobble.clone()); let mut events = Vec::new(); if let Some(parent) = scrobble.parent.as_deref().filter(|_| learn) { let registered = self @@ -3386,6 +3384,52 @@ mod tests { assert_eq!(view.scrobbles(), 1); } + #[test] + fn history_is_held_oldest_first_whatever_order_it_arrives_in() { + let mut view = View::new(); + view.see_agent(agent("did:web:a.example")); + let at = |rkey: &str, created_at: &str| Scrobble { + created_at: created_at.to_owned(), + ..scrobble(&format!("at://a/{rkey}"), "did:web:a.example") + }; + // The stream's newest line, then the repository's, newest first. + view.see_scrobble(at("noon", "2026-10-22T12:00:00Z")); + view.see_scrobble(at("ten", "2026-10-21T10:00:00Z")); + view.see_scrobble(at("nine", "2026-10-21T09:00:00Z")); + view.see_scrobble(at("eleven", "2026-10-21T11:00:00Z")); + let held = |view: &View| -> Vec { + view.history_of("did:web:a.example") + .iter() + .map(|held| held.uri.clone()) + .collect() + }; + assert_eq!( + held(&view), + ["at://a/nine", "at://a/ten", "at://a/eleven", "at://a/noon"] + ); + + // Full, a line older than the newest but not the oldest takes the + // oldest's place and keeps the order. + for i in 0..HISTORY - 4 { + view.see_scrobble(at(&format!("old{i}"), "2026-10-20T08:00:00Z")); + } + assert_eq!(view.history_of("did:web:a.example").len(), HISTORY); + view.see_scrobble(at("half", "2026-10-21T10:30:00Z")); + let held = held(&view); + assert_eq!(held.len(), HISTORY); + assert!(!held.contains(&"at://a/old0".to_owned())); + assert_eq!( + held[HISTORY - 4..], + ["at://a/ten", "at://a/half", "at://a/eleven", "at://a/noon"] + ); + let times: Vec<&str> = view + .history_of("did:web:a.example") + .iter() + .map(|held| held.created_at.as_str()) + .collect(); + assert!(times.is_sorted(), "{times:?}"); + } + #[test] fn history_is_bounded_per_agent() { let mut view = View::new(); diff --git a/crates/vibescrobble-index/tests/backfill.rs b/crates/vibescrobble-index/tests/backfill.rs index bd4d762..1d3273f 100644 --- a/crates/vibescrobble-index/tests/backfill.rs +++ b/crates/vibescrobble-index/tests/backfill.rs @@ -4074,19 +4074,20 @@ async fn an_account_a_cold_start_could_not_read_is_read_whole_when_it_next_write Limits::default(), ) .await; + // Oldest first, as every reader of a history takes it, though the + // stream's line came in before the listing's older ones. let view = shared.view.lock().await; - let mut said: Vec<&str> = view + let said: Vec<&str> = view .history_of(KESTREL) .iter() .map(|scrobble| scrobble.text.as_str()) .collect(); - said.sort_unstable(); assert_eq!( said, [ - "after the restart", + "spilikin the whole listing", "quernstone the flittermouse", - "spilikin the whole listing" + "after the restart" ] ); // An account already held is not read again for a write. diff --git a/crates/vibescrobble-query/tests/share.rs b/crates/vibescrobble-query/tests/share.rs index a2a5847..8fe77ee 100644 --- a/crates/vibescrobble-query/tests/share.rs +++ b/crates/vibescrobble-query/tests/share.rs @@ -16,6 +16,8 @@ //! * a badge kept to one repository names it, carries only the lines that //! name it, counts only the agents whose newest line does, and is quiet //! rather than broken when none do; +//! * an account drawn from the stream, whose newest line comes before the +//! older ones its repository is read for, is live and leads with that line; //! * the moment the index forgets an account, for the denylist or for its //! host's takedown, its page and its card are gone, a card already drawn //! and held here included. @@ -661,3 +663,77 @@ async fn a_badge_kept_to_a_repo_counts_only_what_is_happening_there() { "0 live in x · 🪶 drafting the lexicon" ); } + +/// The stream draws an account with the line it just wrote, and then reads +/// its repository, whose listing is newest first. A mirror is told them in +/// that order, and its badge counts the account live on that line and its +/// page leads with it. +#[tokio::test] +async fn an_account_drawn_from_the_stream_leads_with_the_line_it_just_wrote() { + let index = Shared::new(); + { + let mut view = index.view.lock().await; + view.set_prune(Some(WINDOW)); + let drawn = view.see_agent(agent(AGENT, "basalt-kinkajou.agents.example", None)); + drop(view); + index.announce(drawn).await; + } + let upstream = serve(vibescrobble_index::app(index.clone())).await; + let base = query(&upstream, AGENT).await; + + // After the mirror has seeded, so it takes them as events and not from + // a snapshot, which is sorted on the way out. + let mut announced = Vec::new(); + { + let mut view = index.view.lock().await; + for scrobble in [ + Scrobble { + created_at: now(), + ..said(AGENT, "3kinka00000c", "the line it just wrote", "🧲", 0) + }, + said(AGENT, "3kinka00000b", "the line before", "🧭", 2), + said(AGENT, "3kinka00000a", "the line before that", "🪴", 1), + ] { + announced.extend(view.see_scrobble(scrobble)); + } + } + index.announce(announced).await; + let page = format!("{base}/@basalt-kinkajou.agents.example"); + let deadline = tokio::time::Instant::now() + PATIENCE; + let html = loop { + let (status, _, body) = fetch(&page).await; + let html = String::from_utf8(body.to_vec()).unwrap(); + if status == StatusCode::OK && html.contains("/3kinka00000a\"") { + break html; + } + assert!( + tokio::time::Instant::now() < deadline, + "the mirror never took the lines: {html}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + + assert_eq!( + badge(&format!("{base}/badge/{AGENT}.json")).await["message"], + "1 live · 🧲 the line it just wrote" + ); + assert_eq!(meta(&html, "og:description"), "🧲 the line it just wrote"); + assert!( + meta(&html, "og:image").starts_with(&format!( + "https://board.example/cards/{AGENT}?at=3kinka00000c" + )), + "{html}" + ); + let at = |rkey: &str| { + html.find(&format!("href=\"/s/{AGENT}/{rkey}\"")) + .unwrap_or_else(|| panic!("no {rkey} in {html}")) + }; + assert!( + at("3kinka00000c") < at("3kinka00000b"), + "newest first: {html}" + ); + assert!( + at("3kinka00000b") < at("3kinka00000a"), + "newest first: {html}" + ); +} -- knotmirror 0.0.1