From 1ef2d608cd13d0fb6ce6e6545b00da241c26ed26 Mon Sep 17 00:00:00 2001
From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com>
Date: Mon, 26 Oct 2026 14:15:59 +0100
Subject: [PATCH 1/3] feat(library): sync reading history and saved posts
---
.github/workflows/ci.yml | 2 +-
AGENTS.md | 2 +-
CHANGELOG.md | 5 +
README.md | 7 +-
crates/allthing-core/src/api.rs | 126 +-
crates/allthing-core/src/following.rs | 27 +
crates/allthing-core/src/lib.rs | 1 +
crates/allthing-core/src/library.rs | 589 ++++++
crates/allthing-core/src/seen.rs | 10 +
crates/allthing-data/src/hypersnap.rs | 134 +-
crates/allthing-data/src/lib.rs | 53 +
crates/allthing-data/src/pages.rs | 266 ++-
crates/allthing-web/Cargo.toml | 2 +
.../allthing-web/src/components/bookmark.rs | 76 +
crates/allthing-web/src/components/cast.rs | 49 +-
crates/allthing-web/src/components/chips.rs | 8 +-
crates/allthing-web/src/components/layout.rs | 11 +-
crates/allthing-web/src/components/mod.rs | 1 +
crates/allthing-web/src/components/signin.rs | 1 +
crates/allthing-web/src/lib.rs | 7 +
crates/allthing-web/src/library.rs | 1657 +++++++++++++++++
crates/allthing-web/src/library_disk.rs | 617 ++++++
crates/allthing-web/src/notification_sync.rs | 73 +-
crates/allthing-web/src/pages/home.rs | 20 +-
crates/allthing-web/src/pages/library.rs | 585 ++++++
crates/allthing-web/src/pages/mod.rs | 1 +
crates/allthing-web/src/seen.rs | 246 ++-
crates/allthing-web/src/split.rs | 6 +-
crates/allthing-web/src/util.rs | 5 +
crates/allthing-worker/src/lib.rs | 4 +-
docs/ARCHITECTURE.md | 18 +-
docs/BUDGET.md | 6 +-
e2e/early-paint.fixture.spec.ts | 70 +-
e2e/feed-tabs.fixture.spec.ts | 12 +-
e2e/fixtures/library-sync-wire.ts | 52 +
e2e/following-feed.fixture.spec.ts | 27 +-
e2e/following-navigation.fixture.spec.ts | 7 +-
e2e/helpers/app.ts | 3 +-
e2e/library-sync.fixture.spec.ts | 616 ++++++
e2e/library.fixture.spec.ts | 218 +++
e2e/like-recast.fixture.spec.ts | 2 +-
e2e/presence.fixture.spec.ts | 2 +
e2e/stdb-attest.fixture.spec.ts | 2 +-
e2e/tap-targets.fixture.spec.ts | 5 +-
spacetime/HANDOFF.md | 13 +-
spacetime/spacetimedb/src/lib.rs | 1 +
spacetime/spacetimedb/src/library.rs | 549 ++++++
style/allthing.css | 115 ++
tests/stdb-bridge.test.mjs | 114 ++
web/_headers | 2 +-
web/index.html | 4 +-
web/paint.js | 6 +-
web/stdb/bridge.ts | 66 +
.../module_bindings/clear_seen_reducer.ts | 17 +
.../module_bindings/forget_seen_reducer.ts | 18 +
web/stdb/module_bindings/index.ts | 54 +
.../mark_seen_batch_reducer.ts | 23 +
.../my_library_membership_table.ts | 25 +
.../module_bindings/my_library_page_table.ts | 25 +
.../module_bindings/my_library_state_table.ts | 22 +
.../module_bindings/set_bookmark_reducer.ts | 18 +
web/stdb/module_bindings/types.ts | 48 +
web/stdb/module_bindings/types/reducers.ts | 8 +
xtask/src/main.rs | 2 +-
64 files changed, 6647 insertions(+), 114 deletions(-)
create mode 100644 crates/allthing-core/src/library.rs
create mode 100644 crates/allthing-web/src/components/bookmark.rs
create mode 100644 crates/allthing-web/src/library.rs
create mode 100644 crates/allthing-web/src/library_disk.rs
create mode 100644 crates/allthing-web/src/pages/library.rs
create mode 100644 e2e/fixtures/library-sync-wire.ts
create mode 100644 e2e/library-sync.fixture.spec.ts
create mode 100644 e2e/library.fixture.spec.ts
create mode 100644 spacetime/spacetimedb/src/library.rs
create mode 100644 tests/stdb-bridge.test.mjs
create mode 100644 web/stdb/module_bindings/clear_seen_reducer.ts
create mode 100644 web/stdb/module_bindings/forget_seen_reducer.ts
create mode 100644 web/stdb/module_bindings/mark_seen_batch_reducer.ts
create mode 100644 web/stdb/module_bindings/my_library_membership_table.ts
create mode 100644 web/stdb/module_bindings/my_library_page_table.ts
create mode 100644 web/stdb/module_bindings/my_library_state_table.ts
create mode 100644 web/stdb/module_bindings/set_bookmark_reducer.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6957dc4f..5a95334f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -159,7 +159,7 @@ jobs:
- name: Test the static copy
run: |
bash scripts/build-pages.sh
- E2E_PAGES=1 npx playwright test e2e/static-body-limit.fixture.spec.ts e2e/static-metadata.fixture.spec.ts e2e/static-navigation.fixture.spec.ts e2e/static-follows.fixture.spec.ts e2e/preloaded-replies.fixture.spec.ts e2e/poll.fixture.spec.ts --output=test-results/static
+ E2E_PAGES=1 npx playwright test e2e/static-body-limit.fixture.spec.ts e2e/static-metadata.fixture.spec.ts e2e/static-navigation.fixture.spec.ts e2e/static-follows.fixture.spec.ts e2e/preloaded-replies.fixture.spec.ts e2e/poll.fixture.spec.ts e2e/library.fixture.spec.ts --output=test-results/static
env:
PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report/static
- name: Upload browser failure reports
diff --git a/AGENTS.md b/AGENTS.md
index 054e9f14..ef19748b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,7 @@ Allthing is a Farcaster client for reading, written in Rust: a Leptos WebAssembl
- Payloads go through `cache::serve`.
- Never await another request's I/O; see the coalescing notes in `cache.rs`.
- Update `docs/BUDGET.md` when costs change.
-- **Links.** The app's routes are `/`, `/c/`, `/p/`, `/u/`, `/u//followers`, `/u//following`, `/channels`, `/search?q=`, `/dau`, `/scheduled`, `/messages` (`?c=`, `?g=`, `?new=1`) and `/notifications` (`?tab=mentions` or `replies`). Build them with the helpers in `crates/allthing-web/src/util.rs` (`href`, `channel_href`, …), never as literal strings, and go to one from code with `util::use_go`, not the router's `use_navigate`, which puts the base before it again: the GitHub Pages build lives under `/Allthing`.
+- **Links.** The app's routes are `/`, `/c/`, `/p/`, `/u/`, `/u//followers`, `/u//following`, `/channels`, `/search?q=`, `/dau`, `/scheduled`, `/messages` (`?c=`, `?g=`, `?new=1`), `/notifications` (`?tab=mentions` or `replies`), and `/library` (`?tab=bookmarks|likes|recasts|seen`, optional `q` filters loaded posts). Build them with the helpers in `crates/allthing-web/src/util.rs` (`href`, `channel_href`, …), never as literal strings, and go to one from code with `util::use_go`, not the router's `use_navigate`, which puts the base before it again: the GitHub Pages build lives under `/Allthing`.
- **Licensing.** Licensing is AGPL-3.0-or-later. Start new files with an SPDX header. New dependencies must pass `cargo deny check licenses`.
- **Secrets.** Never commit secrets, tokens, `.dev.vars` or `.env` files, or session URLs. The Worker's secrets (`SESSION_SECRET`, `SOLANA_RPC_URL`) are set in Cloudflare; see `docs/DEPLOY.md#secrets`.
- **Checks.**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bbb6e2d..30ea982a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,10 +7,15 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch
### Added
+- A private Library for bookmarks and seen history synced across signed-in devices through SpacetimeDB, plus paginated Farcaster likes and recasts. Seen history no longer expires after 30 days or 2,000 posts; fresh Home candidates wait for synced history, while already drawn cards keep their place. Library includes loaded-post filtering, activity times, retry, Back restoration, Mark unread and Clear history controls.
- Sync notification read progress across signed-in sessions with a private, attested SpacetimeDB watermark. The existing local cache works offline; a delayed background connection uses separate credentials without creating chat presence or DM keys.
- Videos up to 250 MB upload in 8 MB chunks. Retry continues from the last acknowledged chunk; Remove cancels the pending upload. Preparation and finishing have visible states, and the cast stays disabled until its attachments are ready.
- Compatible MP4 and MOV videos are prepared for HLS from bounded metadata and file slices, preserving their frames and sound without copying the entire video into WebAssembly. Existing image limits and storage safeguards remain.
+### Changed
+
+- Following opens with New selected; For you still opens with Hot. Explicit sort choices remain selected when changing the time range.
+
### Fixed
- After Escape closes a hover card, layout changes cannot reopen it under a stationary pointer. Moving away and back still opens it normally.
diff --git a/README.md b/README.md
index e7312fdf..c3432555 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,8 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin
### Reading, no account needed
-- **Feeds.** A front page sorted **hot**, **new** or **top**, with a time filter in every sort. Signed in, **For you** lifts casts from recently active accounts you follow, while **Following** shows their casts only. New stays chronological. Channel feeds, a channel directory and one search box cover casts, people and channels; the client bar filters casts by the app they came from.
-- **Seen posts.** Front-page feeds leave out posts you already read on a later visit, with Show seen posts and Reset seen posts to bring them back. History stays in this browser for 30 days, separate for each reader; cards stay put while you read.
+- **Feeds.** A front page sorted **hot**, **new** or **top**, with a time filter in every sort. Signed in, **For you** defaults to **hot** and lifts casts from recently active accounts you follow, while **Following** defaults to **new** and shows their casts only. New stays chronological. Channel feeds, a channel directory and one search box cover casts, people and channels; the client bar filters casts by the app they came from.
+- **Seen posts.** Home leaves out posts you already read on future visits. Signed-in history stays private and syncs across devices through SpacetimeDB without a 30-day expiry or 2,000-post limit. Cards stay put while you read; anonymous history stays in this browser for 30 days.
- **Who is here.** An online count beside Home, including on phones. Signed-in readers can choose whether their name appears.
- **Posts and threads.** A post with its parent, quoted casts (inline up to three levels) and threaded replies that collapse and expand.
- **Honest engagement.** Likes, recasts and replies, the only counts Farcaster has. Hovering or tapping a count lists who liked, recasted or replied.
@@ -50,6 +50,7 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin
- **Sign in by one QR code.** Approve Allthing once in the Farcaster app (a QR code on a computer, a button on a phone). That one approval signs you in and gives your browser a key to write with; the key never leaves it.
- **Write from Allthing.** Cast, reply, quote, like, recast, follow, delete your own casts and [schedule casts](docs/ARCHITECTURE.md#scheduled-casts) (listed at /scheduled), shown "via Allthing". Each is a Farcaster message signed in your browser and sent to Hypersnap; Allthing asks for keys as its own account, @allthingapp.
- **A composer like the Farcaster app's.** Images and videos (PNG, JPEG, GIF, WebP; MP4, MOV, WebM) by the attach button, paste or drop, with photo location data stripped in the browser; @mention autocomplete; a Pepe emoji picker, animated Pepe stickers and GIFs (from [KLIPY](https://klipy.com)); drafts that last; a sheet on the keyboard on a phone.
+- **Library.** Bookmarks, Likes, Recasts and Seen, with paginated history, activity times, a filter for loaded posts and Back restoration. Save posts privately without a Farcaster signing key; mark posts unread or clear reading history across devices while keeping bookmarks.
- **Notifications.** A bell with what's new, and /notifications with likes, recasts, replies, mentions, quotes and follows, grouped as the Farcaster app groups them.
- **Messages.** One inbox on SpacetimeDB at /messages: Allthing's public `#general` room and end-to-end encrypted conversations ([docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#messages-on-spacetimedb)).
- **Signed out**, a write opens the cast, the profile or a ready draft in the Farcaster app instead.
@@ -57,7 +58,7 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin
### Fast and frugal
- **One API call per page**, cached at the edge, so the whole site runs on the Cloudflare Workers Free plan.
-- **Early paint.** A small JavaScript renderer draws the front page's first cards from its existing response while the Rust app downloads. It stays in JavaScript because it beat the Rust prototype in the [paired cold-load comparison](docs/PERFORMANCE.md#measured-exception-the-javascript-first-card-renderer).
+- **Early paint.** For anonymous readers, a small JavaScript renderer draws the front page's first cards from its existing response while the Rust app downloads. Signed-in Home waits for private history membership to avoid flashing previously read posts. It stays in JavaScript because it beat the Rust prototype in the [paired cold-load comparison](docs/PERFORMANCE.md#measured-exception-the-javascript-first-card-renderer).
- **Split WebAssembly.** Pages, the composer, writing, sign-in and the DAU dashboard are code of their own, fetched when used, so every visit downloads less.
- **Instant navigation.** Posts open from the feed's own data, cards are prefetched within a capped budget, and Back returns to the same place. CI holds smoothness to budgets ([docs/PERFORMANCE.md](docs/PERFORMANCE.md)).
- **Accessible.** Keyboard throughout, alt text, focus kept in dialogs, reduced motion respected, and it fits a phone.
diff --git a/crates/allthing-core/src/api.rs b/crates/allthing-core/src/api.rs
index 808f39c9..c408ec90 100644
--- a/crates/allthing-core/src/api.rs
+++ b/crates/allthing-core/src/api.rs
@@ -15,7 +15,7 @@ use crate::latest::Cursor;
use crate::model::{Cast, Channel, ThreadNode, User, UserSummary};
use crate::params::{channel_key_from_param, hash_from_param, percent_decode, percent_encode, username_from_param};
use crate::profile::UserCard;
-use crate::provenance::{Backend, Origin};
+use crate::provenance::{Backend, Origin, base64_decode};
use crate::ranking::{Sort, TimeRange, TrendingWindow};
use crate::website::Website;
@@ -242,6 +242,100 @@ impl ReactionKind {
}
}
+/// Library reads are small batches, independent of the account's history size.
+pub const LIBRARY_PAGE_SIZE: usize = 25;
+pub const LIBRARY_PATH: &str = "/api/v1/library";
+pub const LIBRARY_CASTS_PATH: &str = "/api/v1/library/casts";
+
+/// An active like/recast and the cast it targets. `at` is the reaction's
+/// Unix time, not the cast's publication time.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LibraryEntry {
+ pub hash: String,
+ pub author_fid: u64,
+ pub at: i64,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LibraryPayload {
+ pub fid: u64,
+ pub kind: ReactionKind,
+ pub entries: Vec,
+ /// Available casts in reaction order, with mini apps marked for settings.
+ pub pool: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub next: Option,
+ /// Omitted by a successful provider lookup; this does not assert deletion.
+ #[serde(default)]
+ pub unavailable: Vec,
+ /// Hydrated, but omitted by the app's content rules, not by the provider.
+ #[serde(default)]
+ pub filtered: Vec,
+ #[serde(default)]
+ pub notes: Annotations,
+ pub meta: Meta,
+}
+
+/// Public cast hydration for a private list. This endpoint knows only the
+/// requested public cast hashes, not whether they were bookmarked or seen.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LibraryCastsPayload {
+ pub pool: Vec,
+ #[serde(default)]
+ pub unavailable: Vec,
+ #[serde(default)]
+ pub filtered: Vec,
+ #[serde(default)]
+ pub notes: Annotations,
+ pub meta: Meta,
+}
+
+/// An opaque hub key: validate its bounded encoding, never reinterpret it as
+/// a multi-shard cursor. An account's outgoing reactions live in one shard.
+pub fn library_cursor(value: &str) -> Option {
+ if value.is_empty()
+ || value.len() > 1024
+ || !value.bytes().all(|b| b.is_ascii_alphanumeric() || b"+/=".contains(&b))
+ {
+ return None;
+ }
+ let bytes = base64_decode(value)?;
+ (!bytes.is_empty() && bytes.len() <= 512).then(|| value.to_owned())
+}
+
+/// Canonical, unique hashes in the caller's order. Never silently truncate a
+/// request: the limit applies to its raw slots too, keeping URLs bounded.
+pub fn parse_library_hashes(value: Option<&str>) -> Option> {
+ let value = value?;
+ if value.is_empty() || value.len() > LIBRARY_PAGE_SIZE * 45 {
+ return None;
+ }
+ let mut hashes = Vec::new();
+ let mut seen = HashSet::new();
+ for (index, value) in value.split(',').enumerate() {
+ if index >= LIBRARY_PAGE_SIZE {
+ return None;
+ }
+ let hash = hash_from_param(value)?;
+ if seen.insert(hash.clone()) {
+ hashes.push(hash);
+ }
+ }
+ (!hashes.is_empty()).then_some(hashes)
+}
+
+pub fn library_path(fid: u64, kind: ReactionKind, cursor: Option<&str>) -> String {
+ let mut path = format!("{LIBRARY_PATH}?fid={fid}&kind={}", kind.as_str());
+ if let Some(cursor) = cursor {
+ path.push_str(&format!("&cursor={}", percent_encode(cursor)));
+ }
+ path
+}
+
+pub fn library_casts_path(hashes: &[String]) -> String {
+ format!("{LIBRARY_CASTS_PATH}?hashes={}", hashes.join(","))
+}
+
/// Who liked or recasted a cast, newest first.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReactionsPayload {
@@ -978,6 +1072,8 @@ pub const WALLET_RETRY_CACHE: CachePolicy = CachePolicy { fresh: 300, stale: 600
pub const CHANNELS_CACHE: CachePolicy = CachePolicy { fresh: 3600, stale: 86_400, keep: 172_800, browser: 600 };
pub const SEARCH_CACHE: CachePolicy = CachePolicy { fresh: 300, stale: 1800, keep: 86_400, browser: 120 };
pub const REACTIONS_CACHE: CachePolicy = CachePolicy { fresh: 300, stale: 3600, keep: 86_400, browser: 300 };
+/// Short freshness for lists changed by writes in other Farcaster clients.
+pub const LIBRARY_CACHE: CachePolicy = CachePolicy { fresh: 60, stale: 300, keep: 3600, browser: 0 };
/// A batch of like, recast and replies previews: as fresh as a like or
/// recast preview, since that is what most of them become in the browser.
/// Its replies previews may be a few minutes older than `/replies`' own,
@@ -1011,6 +1107,34 @@ pub const MISS_CACHE: CachePolicy = CachePolicy { fresh: 300, stale: 300, keep:
mod tests {
use super::*;
+ #[test]
+ fn library_batches_preserve_order_without_silent_truncation() {
+ let (a, b) = (format!("0x{:040x}", 1), format!("0x{:040x}", 2));
+ assert_eq!(
+ parse_library_hashes(Some(&format!("{b},{},{b}", a.to_uppercase()))),
+ Some(vec![b.clone(), a.clone()])
+ );
+ let batch = vec![a; LIBRARY_PAGE_SIZE];
+ assert!(parse_library_hashes(Some(&batch.join(","))).is_some());
+ assert!(parse_library_hashes(Some(&[batch, vec![b]].concat().join(","))).is_none());
+ for value in [None, Some(""), Some("0x12"), Some("https://example.org"), Some(",")] {
+ assert!(parse_library_hashes(value).is_none());
+ }
+ }
+
+ #[test]
+ fn library_cursors_are_bounded_opaque_bytes_and_escaped_once() {
+ let token = "+/8=";
+ assert_eq!(library_cursor(token).as_deref(), Some(token));
+ assert_eq!(
+ library_path(7, ReactionKind::Likes, Some(token)),
+ "/api/v1/library?fid=7&kind=likes&cursor=%2B%2F8%3D"
+ );
+ for token in ["", "%%%", "a b", "\n", &"A".repeat(1025)] {
+ assert!(library_cursor(token).is_none());
+ }
+ }
+
#[test]
fn pages_read_what_they_ask_for() {
let hash = format!("0x{}", "ab".repeat(20));
diff --git a/crates/allthing-core/src/following.rs b/crates/allthing-core/src/following.rs
index 001ec7ca..c6970aa3 100644
--- a/crates/allthing-core/src/following.rs
+++ b/crates/allthing-core/src/following.rs
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::api::{API_PREFIX, Annotations, CachePolicy, Meta};
use crate::model::Cast;
+use crate::ranking::Sort;
/// Where the app reads it.
pub const FOLLOWING_PATH: &str = "/api/v1/following";
@@ -36,6 +37,19 @@ pub enum Feed {
}
impl Feed {
+ /// Following starts chronologically; For you starts with its hot ranking.
+ pub fn default_sort(self) -> Sort {
+ match self {
+ Feed::ForYou => Sort::Hot,
+ Feed::Following => Sort::New,
+ }
+ }
+
+ /// An explicit supported sort always wins over the tab's default.
+ pub fn sort(self, param: Option<&str>) -> Sort {
+ Sort::parse(param, self.default_sort())
+ }
+
/// The tab a reader sees for the page's `feed` query: Following only
/// signed in and asked for; signed out, For you is the only one.
pub fn of(signed_in: bool, param: Option<&str>) -> Feed {
@@ -162,6 +176,19 @@ mod tests {
assert_eq!(Feed::ForYou.param(), None);
}
+ #[test]
+ fn following_starts_new_while_explicit_sorts_and_signed_out_defaults_are_preserved() {
+ assert_eq!(Feed::Following.sort(None), Sort::New);
+ assert_eq!(Feed::Following.sort(Some("unsupported")), Sort::New);
+ assert_eq!(Feed::ForYou.sort(None), Sort::Hot);
+ assert_eq!(Feed::of(false, Some(FOLLOWING)).sort(None), Sort::Hot);
+ for feed in [Feed::ForYou, Feed::Following] {
+ for sort in Sort::ALL {
+ assert_eq!(feed.sort(Some(sort.as_str())), sort);
+ }
+ }
+ }
+
#[test]
fn followed_authors_are_the_pools() {
let mut b = cast("0xb", None);
diff --git a/crates/allthing-core/src/lib.rs b/crates/allthing-core/src/lib.rs
index e4024af4..2f08f106 100644
--- a/crates/allthing-core/src/lib.rs
+++ b/crates/allthing-core/src/lib.rs
@@ -26,6 +26,7 @@ pub mod guard;
pub mod hls;
pub mod index;
pub mod latest;
+pub mod library;
pub mod listing;
pub mod media;
pub mod message;
diff --git a/crates/allthing-core/src/library.rs b/crates/allthing-core/src/library.rs
new file mode 100644
index 00000000..07019f9a
--- /dev/null
+++ b/crates/allthing-core/src/library.rs
@@ -0,0 +1,589 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! Private Library state and durable offline intent. The server owns revisions;
+//! a disconnected reader cannot turn an old bookmark/read back on after a reset.
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Account {
+ pub fid: u64,
+ pub generation: u64,
+ pub revision: u64,
+ pub seen_count: u64,
+ pub bookmark_count: u64,
+ pub cleared_at: i64,
+ pub last_clear_id: String,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Item {
+ pub fid: u64,
+ pub generation: u64,
+ pub hash: String,
+ pub seen: bool,
+ pub seen_at: i64,
+ pub seen_revision: u64,
+ pub bookmarked: bool,
+ pub bookmark_at: i64,
+ pub bookmark_revision: u64,
+ #[serde(default)]
+ pub cursor: String,
+}
+
+pub fn canonical_hash(value: &str) -> Option {
+ (value.len() == 42 && value.starts_with("0x") && value[2..].bytes().all(|b| b.is_ascii_hexdigit()))
+ .then(|| value.to_ascii_lowercase())
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ReadIntent {
+ Seen { at: i64 },
+ Unread,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PendingRead {
+ pub generation: u64,
+ pub expected_revision: u64,
+ pub intent: ReadIntent,
+ /// Distinguishes a newer local action from the write being acknowledged.
+ pub sequence: u64,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PendingBookmark {
+ pub desired: bool,
+ pub expected_revision: u64,
+ pub sequence: u64,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CachedItem {
+ pub server: Item,
+ /// False until a caller-scoped membership/page response established this row.
+ pub known: bool,
+ pub read: Option,
+ pub bookmark: Option,
+ #[serde(default)]
+ pub read_settled: u64,
+ #[serde(default)]
+ pub bookmark_settled: u64,
+}
+
+impl CachedItem {
+ fn accepts(&self, row: &Item, account: &Account) -> bool {
+ row.fid == account.fid
+ && canonical_hash(&row.hash).as_ref() == Some(&row.hash)
+ && ((!self.known && self.server.hash.is_empty())
+ || (self.server.fid == row.fid && self.server.hash == row.hash))
+ }
+
+ pub fn seen(&self, generation: u64) -> bool {
+ match self.read.as_ref().filter(|read| read.generation == generation) {
+ Some(PendingRead { intent: ReadIntent::Seen { .. }, .. }) => true,
+ Some(PendingRead { intent: ReadIntent::Unread, .. }) => false,
+ None => self.known && self.server.generation == generation && self.server.seen,
+ }
+ }
+
+ pub fn bookmarked(&self) -> Option {
+ self.bookmark.as_ref().map(|intent| intent.desired).or_else(|| self.known.then_some(self.server.bookmarked))
+ }
+
+ /// Merge only newer authoritative state, independently for read and bookmark
+ /// revisions. View caches can temporarily contain overlapping argument sets.
+ pub fn observe(&mut self, row: Item, account: &Account) {
+ if !self.accepts(&row, account) {
+ return;
+ }
+ if !self.known {
+ self.server = row.clone();
+ self.known = true;
+ }
+ if row.bookmark_revision >= self.server.bookmark_revision {
+ self.server.bookmarked = row.bookmarked;
+ self.server.bookmark_at = row.bookmark_at;
+ self.server.bookmark_revision = row.bookmark_revision;
+ }
+ if row.generation == account.generation
+ && row.generation >= self.server.generation
+ && (self.server.generation != row.generation || row.seen_revision >= self.server.seen_revision)
+ {
+ self.server.generation = row.generation;
+ self.server.seen = row.seen;
+ self.server.seen_at = row.seen_at;
+ self.server.seen_revision = row.seen_revision;
+ }
+ self.reconcile(account);
+ }
+
+ /// Apply a fresh row only after this exact sent operation succeeded. A
+ /// newer local action may have replaced it while the reducer was in flight.
+ /// Rebase that action only across our own single revision, never across an
+ /// unrelated server change or a clear. The controller must defer ordinary
+ /// observations for an in-flight item until its outcome is known; otherwise
+ /// `observe` correctly treats an unexplained revision as a remote conflict.
+ pub fn accepted_read(&mut self, sent: &PendingRead, row: Item, account: &Account) {
+ if !self.accepts(&row, account) {
+ return;
+ }
+ let own_revision = row.seen_revision == sent.expected_revision
+ || sent.expected_revision.checked_add(1) == Some(row.seen_revision);
+ let matches = match sent.intent {
+ ReadIntent::Seen { at } => row.seen && (row.seen_revision == sent.expected_revision || row.seen_at <= at),
+ ReadIntent::Unread => !row.seen,
+ };
+ if own_revision
+ && matches
+ && sent.generation == account.generation
+ && row.generation == account.generation
+ && let Some(pending) = self.read.as_mut()
+ && pending.sequence > sent.sequence
+ && pending.generation == sent.generation
+ && pending.expected_revision == sent.expected_revision
+ {
+ pending.expected_revision = row.seen_revision;
+ }
+ self.acknowledged_read(sent.sequence);
+ self.observe(row, account);
+ }
+
+ /// Bookmark counterpart of `accepted_read`. A successful same-value no-op
+ /// is safe too, provided no intervening revision has changed the row.
+ pub fn accepted_bookmark(&mut self, sent: &PendingBookmark, row: Item, account: &Account) {
+ if !self.accepts(&row, account) {
+ return;
+ }
+ let own_revision = row.bookmark_revision == sent.expected_revision
+ || sent.expected_revision.checked_add(1) == Some(row.bookmark_revision);
+ if own_revision
+ && row.bookmarked == sent.desired
+ && let Some(pending) = self.bookmark.as_mut()
+ && pending.sequence > sent.sequence
+ && pending.expected_revision == sent.expected_revision
+ {
+ pending.expected_revision = row.bookmark_revision;
+ }
+ self.acknowledged_bookmark(sent.sequence);
+ self.observe(row, account);
+ }
+
+ pub fn reconcile(&mut self, account: &Account) {
+ let generation = if self.known { account.generation.max(self.server.generation) } else { account.generation };
+ if self.read.as_ref().is_some_and(|read| {
+ read.generation != generation
+ || (self.known
+ && self.server.generation == generation
+ && self.server.seen_revision > read.expected_revision)
+ || (account.generation == generation
+ && matches!(read.intent, ReadIntent::Seen { at } if at <= account.cleared_at))
+ }) && let Some(read) = self.read.take()
+ {
+ self.read_settled = self.read_settled.max(read.sequence);
+ }
+ // A later server operation wins over disconnected intent. An identical
+ // value is already complete; a different one is a conflict, never replayed.
+ if self.bookmark.as_ref().is_some_and(|intent| {
+ self.known
+ && (self.server.bookmark_revision > intent.expected_revision
+ || self.server.bookmarked == intent.desired)
+ }) && let Some(intent) = self.bookmark.take()
+ {
+ self.bookmark_settled = self.bookmark_settled.max(intent.sequence);
+ }
+ }
+
+ pub fn acknowledged_read(&mut self, sequence: u64) {
+ self.read_settled = self.read_settled.max(sequence);
+ if self.read.as_ref().is_some_and(|read| read.sequence <= sequence) {
+ self.read = None;
+ }
+ }
+
+ pub fn acknowledged_bookmark(&mut self, sequence: u64) {
+ self.bookmark_settled = self.bookmark_settled.max(sequence);
+ if self.bookmark.as_ref().is_some_and(|intent| intent.sequence <= sequence) {
+ self.bookmark = None;
+ }
+ }
+
+ pub fn pending(&self) -> bool {
+ self.read.is_some() || self.bookmark.is_some()
+ }
+
+ /// Merge inside an IndexedDB read/write transaction. This also protects
+ /// against another tab committing an older snapshot after an acknowledgement.
+ pub fn merge_disk(&mut self, incoming: &Self) {
+ self.merge_disk_preserving_inflight(incoming, None, None);
+ }
+
+ /// Preserve a queued successor while an older operation is still in flight.
+ /// The sender must finish with `accepted_read`/`accepted_bookmark` and a fresh
+ /// row, or observe the authoritative failure state. This protection never
+ /// overrides explicit settlement or a history-generation reset.
+ pub fn merge_disk_preserving_inflight(
+ &mut self,
+ incoming: &Self,
+ read_sequence: Option,
+ bookmark_sequence: Option,
+ ) {
+ if canonical_hash(&incoming.server.hash).as_ref() != Some(&incoming.server.hash) {
+ return;
+ }
+ if self.server.hash.is_empty() {
+ self.server = incoming.server.clone();
+ }
+ if self.server.fid != incoming.server.fid || self.server.hash != incoming.server.hash {
+ return;
+ }
+ if incoming.known {
+ if !self.known || incoming.server.bookmark_revision >= self.server.bookmark_revision {
+ self.server.bookmarked = incoming.server.bookmarked;
+ self.server.bookmark_at = incoming.server.bookmark_at;
+ self.server.bookmark_revision = incoming.server.bookmark_revision;
+ }
+ if !self.known
+ || incoming.server.generation > self.server.generation
+ || (incoming.server.generation == self.server.generation
+ && incoming.server.seen_revision >= self.server.seen_revision)
+ {
+ self.server.generation = incoming.server.generation;
+ self.server.seen = incoming.server.seen;
+ self.server.seen_at = incoming.server.seen_at;
+ self.server.seen_revision = incoming.server.seen_revision;
+ }
+ self.known = true;
+ }
+ self.read_settled = self.read_settled.max(incoming.read_settled);
+ self.bookmark_settled = self.bookmark_settled.max(incoming.bookmark_settled);
+ self.read = self
+ .read
+ .iter()
+ .chain(incoming.read.iter())
+ .filter(|intent| intent.sequence > self.read_settled)
+ .max_by_key(|intent| (intent.sequence, intent.expected_revision))
+ .cloned();
+ self.bookmark = self
+ .bookmark
+ .iter()
+ .chain(incoming.bookmark.iter())
+ .filter(|intent| intent.sequence > self.bookmark_settled)
+ .max_by_key(|intent| (intent.sequence, intent.expected_revision))
+ .cloned();
+ if self.known {
+ if let Some(read) = self.read.as_ref()
+ && (read.generation < self.server.generation
+ || (read.generation == self.server.generation
+ && read.expected_revision < self.server.seen_revision
+ && !read_sequence.is_some_and(|sent| read.sequence >= sent)))
+ {
+ self.acknowledged_read(read.sequence);
+ }
+ if let Some(bookmark) = self.bookmark.as_ref()
+ && bookmark.expected_revision < self.server.bookmark_revision
+ && !bookmark_sequence.is_some_and(|sent| bookmark.sequence >= sent)
+ {
+ self.acknowledged_bookmark(bookmark.sequence);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn item() -> Item {
+ Item { fid: 3, hash: format!("0x{:040x}", 9), ..Item::default() }
+ }
+
+ fn account() -> Account {
+ Account { fid: 3, ..Account::default() }
+ }
+
+ #[test]
+ fn old_membership_cannot_undo_newer_bookmark_or_read_state() {
+ let mut cache = CachedItem::default();
+ let row = Item {
+ seen: true,
+ seen_at: 200,
+ seen_revision: 5,
+ bookmarked: true,
+ bookmark_at: 201,
+ bookmark_revision: 9,
+ ..item()
+ };
+ cache.observe(row.clone(), &account());
+ cache.observe(item(), &account());
+ assert_eq!(cache.server, row);
+ assert_eq!(cache.bookmarked(), Some(true));
+ assert!(cache.seen(0));
+ }
+
+ #[test]
+ fn a_remote_clear_fences_all_older_offline_observations() {
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ read: Some(PendingRead {
+ generation: 0,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 150 },
+ sequence: 1,
+ }),
+ ..CachedItem::default()
+ };
+ let account = Account { generation: 1, cleared_at: 200, ..account() };
+ cache.reconcile(&account);
+ assert!(cache.read.is_none());
+ assert!(!cache.seen(1));
+ }
+
+ #[test]
+ fn per_item_tombstones_reject_stale_offline_reads_and_bookmarks() {
+ let mut cache = CachedItem {
+ read: Some(PendingRead {
+ generation: 0,
+ expected_revision: 2,
+ intent: ReadIntent::Seen { at: 150 },
+ sequence: 1,
+ }),
+ bookmark: Some(PendingBookmark { desired: true, expected_revision: 4, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ cache.observe(Item { seen_revision: 3, bookmark_revision: 5, ..item() }, &account());
+ assert!(!cache.pending());
+ assert!(!cache.seen(0));
+ assert_eq!(cache.bookmarked(), Some(false));
+ }
+
+ #[test]
+ fn a_lost_acknowledgement_never_removes_a_newer_local_action() {
+ let mut cache = CachedItem {
+ read: Some(PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Unread, sequence: 2 }),
+ bookmark: Some(PendingBookmark { desired: false, expected_revision: 0, sequence: 3 }),
+ ..CachedItem::default()
+ };
+ cache.acknowledged_read(1);
+ cache.acknowledged_bookmark(2);
+ assert!(cache.pending());
+ cache.acknowledged_read(2);
+ cache.acknowledged_bookmark(3);
+ assert!(!cache.pending());
+ }
+
+ #[test]
+ fn initial_unknown_state_does_not_claim_a_post_is_unbookmarked() {
+ let mut cache = CachedItem::default();
+ assert_eq!(cache.bookmarked(), None);
+ cache.observe(item(), &account());
+ assert_eq!(cache.bookmarked(), Some(false));
+ assert!(!cache.pending());
+ }
+
+ #[test]
+ fn other_accounts_and_noncanonical_rows_never_enter_the_cache() {
+ let mut cache = CachedItem::default();
+ cache.observe(Item { fid: 4, ..item() }, &account());
+ cache.observe(Item { hash: "bad".into(), ..item() }, &account());
+ assert!(!cache.known);
+ assert_eq!(canonical_hash(&format!("0x{:040X}", 10)), Some(format!("0x{:040x}", 10)));
+ assert_eq!(canonical_hash("x"), None);
+ }
+
+ #[test]
+ fn late_disk_snapshots_cannot_resurrect_settled_or_replace_newer_intent() {
+ let mut old = CachedItem {
+ server: item(),
+ bookmark: Some(PendingBookmark { desired: true, expected_revision: 0, sequence: 1 }),
+ ..CachedItem::default()
+ };
+ let mut newer = old.clone();
+ newer.bookmark = Some(PendingBookmark { desired: false, expected_revision: 0, sequence: 2 });
+ old.merge_disk(&newer);
+ assert_eq!(old.bookmark.as_ref().unwrap().sequence, 2);
+ let stale = old.clone();
+ old.acknowledged_bookmark(2);
+ old.merge_disk(&stale);
+ assert!(old.bookmark.is_none());
+ assert_eq!(old.bookmark_settled, 2);
+ }
+
+ #[test]
+ fn persisted_remote_tombstones_also_fence_another_tabs_outbox() {
+ let mut pending = CachedItem {
+ server: item(),
+ read: Some(PendingRead {
+ generation: 0,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 10 },
+ sequence: 1,
+ }),
+ ..CachedItem::default()
+ };
+ let remote = CachedItem { known: true, server: Item { seen_revision: 2, ..item() }, ..CachedItem::default() };
+ pending.merge_disk(&remote);
+ assert!(pending.read.is_none());
+ assert_eq!(pending.read_settled, 1);
+ }
+
+ #[test]
+ fn accepted_add_preserves_a_newer_remove_through_observe_and_disk_merge() {
+ let sent = PendingBookmark { desired: true, expected_revision: 0, sequence: 1 };
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ bookmark: Some(PendingBookmark { desired: false, expected_revision: 0, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ let stale_disk = cache.clone();
+ cache.accepted_bookmark(
+ &sent,
+ Item { bookmarked: true, bookmark_at: 150, bookmark_revision: 1, ..item() },
+ &account(),
+ );
+ assert_eq!(cache.bookmarked(), Some(false));
+ assert_eq!(cache.bookmark.as_ref().unwrap().expected_revision, 1);
+ cache.merge_disk(&stale_disk);
+ assert_eq!(cache.bookmark.as_ref().unwrap().expected_revision, 1);
+ assert_eq!(cache.bookmark.as_ref().unwrap().sequence, 2);
+ cache.observe(Item { bookmark_revision: 2, ..item() }, &account());
+ assert!(cache.bookmark.is_none());
+ assert_eq!(cache.bookmark_settled, 2);
+ }
+
+ #[test]
+ fn accepted_seen_preserves_a_newer_unread_through_observe_and_disk_merge() {
+ let sent =
+ PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Seen { at: 150 }, sequence: 1 };
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ read: Some(PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Unread, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ let stale_disk = cache.clone();
+ cache.accepted_read(&sent, Item { seen: true, seen_at: 150, seen_revision: 1, ..item() }, &account());
+ assert!(!cache.seen(0));
+ assert_eq!(cache.read.as_ref().unwrap().expected_revision, 1);
+ cache.merge_disk(&stale_disk);
+ assert_eq!(cache.read.as_ref().unwrap().expected_revision, 1);
+ cache.observe(Item { seen_revision: 2, ..item() }, &account());
+ assert!(cache.read.is_none());
+ assert_eq!(cache.read_settled, 2);
+ }
+
+ #[test]
+ fn accepted_same_value_noop_does_not_drop_a_newer_action() {
+ let mut cache = CachedItem {
+ known: true,
+ server: Item { bookmarked: true, bookmark_revision: 4, ..item() },
+ bookmark: Some(PendingBookmark { desired: false, expected_revision: 4, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ let row = cache.server.clone();
+ cache.accepted_bookmark(&PendingBookmark { desired: true, expected_revision: 4, sequence: 1 }, row, &account());
+ assert_eq!(cache.bookmark.as_ref().unwrap().sequence, 2);
+ assert_eq!(cache.bookmark_settled, 1);
+ }
+
+ #[test]
+ fn successful_old_calls_never_rebase_across_foreign_changes_or_clears() {
+ let sent = PendingBookmark { desired: true, expected_revision: 0, sequence: 1 };
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ bookmark: Some(PendingBookmark { desired: true, expected_revision: 0, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ cache.accepted_bookmark(&sent, Item { bookmark_revision: 2, ..item() }, &account());
+ assert!(cache.bookmark.is_none());
+ assert_eq!(cache.bookmarked(), Some(false));
+ cache.read = Some(PendingRead {
+ generation: 0,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 150 },
+ sequence: 4,
+ });
+ cache.accepted_read(
+ &PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Seen { at: 100 }, sequence: 3 },
+ Item { generation: 1, ..item() },
+ &Account { generation: 1, cleared_at: 200, ..account() },
+ );
+ assert!(cache.read.is_none());
+ assert!(!cache.seen(1));
+ }
+
+ #[test]
+ fn existing_rows_cannot_mix_accounts_even_when_the_cast_hash_matches() {
+ for known in [false, true] {
+ let mut cache = CachedItem { known, server: item(), ..CachedItem::default() };
+ cache.observe(
+ Item { fid: 4, bookmarked: true, bookmark_revision: 9, ..item() },
+ &Account { fid: 4, ..account() },
+ );
+ assert_eq!(cache.server, item());
+ assert_eq!(cache.known, known);
+ }
+ }
+
+ #[test]
+ fn cached_equal_value_is_not_an_ack_for_a_queued_inverse() {
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ bookmark: Some(PendingBookmark { desired: false, expected_revision: 0, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ cache.merge_disk(&CachedItem { known: true, server: item(), ..CachedItem::default() });
+ assert_eq!(cache.bookmark.as_ref().unwrap().sequence, 2);
+ }
+
+ #[test]
+ fn inflight_disk_merge_waits_for_provenance_but_never_defeats_a_clear() {
+ let sent =
+ PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Seen { at: 150 }, sequence: 1 };
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ read: Some(PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Unread, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ let fresh = Item { seen: true, seen_at: 150, seen_revision: 1, ..item() };
+ cache.merge_disk_preserving_inflight(
+ &CachedItem { known: true, server: fresh.clone(), ..CachedItem::default() },
+ Some(1),
+ None,
+ );
+ assert_eq!(cache.read.as_ref().unwrap().expected_revision, 0);
+ cache.accepted_read(&sent, fresh, &account());
+ assert_eq!(cache.read.as_ref().unwrap().expected_revision, 1);
+ cache.merge_disk_preserving_inflight(
+ &CachedItem { known: true, server: Item { generation: 1, ..item() }, ..CachedItem::default() },
+ Some(1),
+ None,
+ );
+ assert!(cache.read.is_none());
+ }
+
+ #[test]
+ fn stale_account_and_membership_cannot_roll_back_a_known_reset() {
+ let mut cache = CachedItem {
+ known: true,
+ server: Item { generation: 1, ..item() },
+ read: Some(PendingRead {
+ generation: 1,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 250 },
+ sequence: 2,
+ }),
+ ..CachedItem::default()
+ };
+ cache.observe(Item { seen: true, seen_at: 150, seen_revision: 10, ..item() }, &account());
+ assert_eq!(cache.server.generation, 1);
+ assert!(!cache.server.seen);
+ assert_eq!(cache.read.as_ref().unwrap().generation, 1);
+ cache.reconcile(&Account { generation: 1, cleared_at: 200, ..account() });
+ assert!(cache.seen(1));
+ }
+}
diff --git a/crates/allthing-core/src/seen.rs b/crates/allthing-core/src/seen.rs
index 1423a554..bcf4e91d 100644
--- a/crates/allthing-core/src/seen.rs
+++ b/crates/allthing-core/src/seen.rs
@@ -79,6 +79,16 @@ impl History {
self.scopes.iter().filter(|s| s.name == scope).flat_map(|s| s.posts.iter().map(|p| p.hash.clone())).collect()
}
+ /// The existing, bounded browser cache, for a one-time private server
+ /// migration. The server, rather than this cache, owns durable history.
+ pub fn entries(&self, scope: &str) -> Vec<(String, i64)> {
+ self.scopes
+ .iter()
+ .filter(|s| s.name == scope)
+ .flat_map(|s| s.posts.iter().map(|p| (p.hash.clone(), p.at)))
+ .collect()
+ }
+
pub fn record(&mut self, name: &str, value: &str, now: i64) {
self.record_at(name, value, now, now);
}
diff --git a/crates/allthing-data/src/hypersnap.rs b/crates/allthing-data/src/hypersnap.rs
index 5c75184c..7fed9f7a 100644
--- a/crates/allthing-data/src/hypersnap.rs
+++ b/crates/allthing-data/src/hypersnap.rs
@@ -6,7 +6,7 @@
use std::collections::HashMap;
-use allthing_core::api::FollowsDir;
+use allthing_core::api::{FollowsDir, LIBRARY_PAGE_SIZE, LibraryEntry, ReactionKind, library_cursor};
use allthing_core::channels::{
canonical_parent_url, channel_id_for_url, channel_url_for_id, legacy_url, parent_url_variants,
short_parent_url_label,
@@ -125,6 +125,138 @@ pub async fn casts_bulk(inv: &Invocation, hashes: &[String]) -> Result Result, UpstreamError> {
+ #[derive(Deserialize)]
+ struct Found {
+ casts: Vec,
+ }
+ if hashes.is_empty() {
+ return Ok(Vec::new());
+ }
+ if hashes.len() > LIBRARY_PAGE_SIZE {
+ return Err(UpstreamError::Parse("too many Library casts".into()));
+ }
+ let list = hashes.join(",");
+ let found: Found = get_json(inv, Upstream::Hypersnap, &v2("casts", &[("casts", &list)]), false)
+ .await?
+ .ok_or(UpstreamError::Status(404))?;
+ if found.casts.len() > LIBRARY_PAGE_SIZE
+ || found.casts.iter().any(|cast| {
+ allthing_core::params::hash_from_param(&cast.hash).is_none()
+ || cast.author.as_ref().is_none_or(|author| author.fid == 0)
+ })
+ {
+ return Err(UpstreamError::Parse("invalid Library cast lookup".into()));
+ }
+ Ok(found.casts)
+}
+
+#[derive(Deserialize)]
+struct LibraryReactionPage {
+ messages: Vec,
+ #[serde(rename = "nextPageToken")]
+ next: String,
+}
+#[derive(Deserialize)]
+struct LibraryReactionMessage {
+ data: LibraryReactionData,
+}
+#[derive(Deserialize)]
+struct LibraryReactionData {
+ fid: u64,
+ timestamp: u64,
+ #[serde(rename = "type")]
+ kind: serde_json::Value,
+ #[serde(rename = "reactionBody")]
+ reaction: LibraryReactionBody,
+}
+#[derive(Deserialize)]
+struct LibraryReactionBody {
+ #[serde(rename = "type")]
+ kind: serde_json::Value,
+ #[serde(rename = "targetCastId")]
+ target: Option,
+ #[serde(rename = "targetUrl")]
+ target_url: Option,
+}
+#[derive(Deserialize)]
+struct LibraryTarget {
+ fid: u64,
+ hash: String,
+}
+
+/// A page of the account's retained active reactions. Follow the opaque
+/// `next` key to the end; unlike a target's reactors this is a single shard.
+pub async fn library_reactions(
+ inv: &Invocation,
+ fid: u64,
+ kind: ReactionKind,
+ cursor: Option<&str>,
+) -> Result<(Vec, Option), UpstreamError> {
+ let fid_text = fid.to_string();
+ let size = LIBRARY_PAGE_SIZE.to_string();
+ let (number, name, query_kind) = match kind {
+ ReactionKind::Likes => (1, "REACTION_TYPE_LIKE", "Like"),
+ ReactionKind::Recasts => (2, "REACTION_TYPE_RECAST", "Recast"),
+ };
+ let mut query = vec![
+ ("fid", fid_text.as_str()),
+ ("reaction_type", query_kind),
+ ("pageSize", size.as_str()),
+ ("reverse", "true"),
+ ];
+ if let Some(cursor) = cursor {
+ query.push(("pageToken", cursor));
+ }
+ let page: LibraryReactionPage = get_json(inv, Upstream::Hypersnap, &v1("reactionsByFid", &query), false)
+ .await?
+ .ok_or(UpstreamError::Status(404))?;
+ let invalid = || UpstreamError::Parse("invalid Library reaction page".into());
+ if page.messages.len() > LIBRARY_PAGE_SIZE {
+ return Err(invalid());
+ }
+ let mut seen = std::collections::HashSet::new();
+ let mut entries = Vec::new();
+ for message in page.messages {
+ let data = message.data;
+ let matches =
+ |value: &serde_json::Value, number, name| value.as_u64() == Some(number) || value.as_str() == Some(name);
+ if data.fid != fid
+ || !matches(&data.kind, 3, "MESSAGE_TYPE_REACTION_ADD")
+ || !matches(&data.reaction.kind, number, name)
+ {
+ return Err(invalid());
+ }
+ // A reaction to a URL is valid network history, but is not a cast.
+ let Some(target) = data.reaction.target else {
+ if data.reaction.target_url.as_deref().is_some_and(|url| !url.trim().is_empty()) {
+ continue;
+ }
+ return Err(invalid());
+ };
+ let hash = allthing_core::params::hash_from_param(&target.hash).ok_or_else(invalid)?;
+ let at = i64::try_from(data.timestamp)
+ .ok()
+ .and_then(|at| at.checked_add(allthing_core::message::EPOCH))
+ .ok_or_else(invalid)?;
+ if target.fid == 0 {
+ return Err(invalid());
+ }
+ if seen.insert(hash.clone()) {
+ entries.push(LibraryEntry { hash, author_fid: target.fid, at });
+ }
+ }
+ let next = (!page.next.is_empty()).then_some(page.next);
+ if let Some(next) = &next
+ && (library_cursor(next).is_none() || Some(next.as_str()) == cursor)
+ {
+ return Err(invalid());
+ }
+ Ok((entries, next))
+}
+
/// Who liked (`likes`) or recasted (`recasts`) a cast, at most 100
/// (Hypersnap does not page further), as Hypersnap lists them: each shard's
/// reactions oldest first, one shard after another ([`normalize::reactors`]
diff --git a/crates/allthing-data/src/lib.rs b/crates/allthing-data/src/lib.rs
index f01c5f34..ef118355 100644
--- a/crates/allthing-data/src/lib.rs
+++ b/crates/allthing-data/src/lib.rs
@@ -217,6 +217,10 @@ pub enum Endpoint {
Wallets(u64),
Channels(Option),
Search(String),
+ /// One page of an account's retained active likes or recasts.
+ Library(u64, ReactionKind, Option),
+ /// Public cast hydration for an ordered private Library page.
+ LibraryCasts(Vec),
Reactions(String, ReactionKind, u32),
/// An account's followers or following, by name (`fid:` for later
/// pages), from the start or from a cursor.
@@ -260,6 +264,25 @@ impl Endpoint {
}
"/latest" => Ok(Endpoint::Latest),
"/activity" => Ok(Endpoint::Activity),
+ "/library" => {
+ let fid = query("fid")
+ .and_then(|v| v.parse::().ok())
+ .filter(|fid| *fid > 0)
+ .ok_or(bad("Expected ?fid="))?;
+ let kind =
+ query("kind").and_then(|v| ReactionKind::parse(&v)).ok_or(bad("Expected ?kind=likes|recasts"))?;
+ let cursor = match query("cursor") {
+ None => None,
+ Some(cursor) => Some(
+ allthing_core::api::library_cursor(&cursor)
+ .ok_or(bad("Expected ?cursor="))?,
+ ),
+ };
+ Ok(Endpoint::Library(fid, kind, cursor))
+ }
+ "/library/casts" => allthing_core::api::parse_library_hashes(query("hashes").as_deref())
+ .map(Endpoint::LibraryCasts)
+ .ok_or(bad("Expected ?hashes=")),
"/reactions" => {
let hash = query("hash").and_then(|v| hash_from_param(&v)).ok_or(bad("Expected ?hash="))?;
let kind =
@@ -365,6 +388,14 @@ impl Endpoint {
}
Endpoint::Latest => "latest".into(),
Endpoint::Activity => "activity".into(),
+ Endpoint::Library(fid, kind, cursor) => allthing_core::api::library_path(*fid, *kind, cursor.as_deref())
+ .trim_start_matches(API_PREFIX)
+ .trim_start_matches('/')
+ .to_owned(),
+ Endpoint::LibraryCasts(hashes) => allthing_core::api::library_casts_path(hashes)
+ .trim_start_matches(API_PREFIX)
+ .trim_start_matches('/')
+ .to_owned(),
Endpoint::Reactions(hash, kind, limit) => {
format!("reactions?hash={hash}&type={}&limit={limit}", kind.as_str())
}
@@ -396,6 +427,8 @@ impl Endpoint {
Endpoint::Home(offset, window) => pages::home(inv, offset, window, previous).await,
Endpoint::Latest => pages::latest(inv, previous).await,
Endpoint::Activity => pages::activity(inv).await,
+ Endpoint::Library(fid, kind, cursor) => pages::library(inv, fid, kind, cursor, previous).await,
+ Endpoint::LibraryCasts(hashes) => pages::library_casts(inv, hashes, previous).await,
Endpoint::Reactions(hash, kind, limit) => pages::reactions(inv, hash, kind, limit).await,
Endpoint::Engagement(request) => pages::engagement(inv, request).await,
Endpoint::Replies(hash) => pages::replies(inv, hash).await,
@@ -416,6 +449,26 @@ impl Endpoint {
mod tests {
use super::*;
+ #[test]
+ fn library_routes_scope_and_escape_pages_and_keep_hydration_order() {
+ let page = Endpoint::from_path("/api/v1/library?fid=3&kind=recasts&cursor=%2B%2F8%3D").unwrap();
+ assert_eq!(page, Endpoint::Library(3, ReactionKind::Recasts, Some("+/8=".into())));
+ assert_eq!(page.cache_name(), "library?fid=3&kind=recasts&cursor=%2B%2F8%3D");
+ let (a, b) = (format!("0x{:040x}", 1), format!("0x{:040x}", 2));
+ let batch = Endpoint::from_path(&format!("/api/v1/library/casts?hashes={b},{},{b}", a.to_uppercase())).unwrap();
+ assert_eq!(batch, Endpoint::LibraryCasts(vec![b.clone(), a.clone()]));
+ assert_eq!(batch.cache_name(), format!("library/casts?hashes={b},{a}"));
+ for path in [
+ "/api/v1/library",
+ "/api/v1/library?fid=0&kind=likes",
+ "/api/v1/library?fid=3&kind=other",
+ "/api/v1/library?fid=3&kind=likes&cursor=bad!",
+ "/api/v1/library/casts?hashes=0x12",
+ ] {
+ assert_eq!(Endpoint::from_path(path).map_err(|error| error.status), Err(400));
+ }
+ }
+
#[test]
fn counts_share_one_entry_whatever_the_order() {
let (a, b) = (format!("0x{:040x}", 1), format!("0x{:040x}", 2));
diff --git a/crates/allthing-data/src/pages.rs b/crates/allthing-data/src/pages.rs
index 3418eb9d..b2764e59 100644
--- a/crates/allthing-data/src/pages.rs
+++ b/crates/allthing-data/src/pages.rs
@@ -7,8 +7,8 @@ use std::rc::Rc;
use allthing_core::activity::AppsWindow;
use allthing_core::api::{
self, Annotations, CastPayload, ChannelPayload, ChannelsPayload, EngagementPayload, EngagementRequest, FollowsDir,
- FollowsPayload, HomePayload, IndexCoverage, LatestPayload, Meta, QuotedCast, ReactionKind, ReactionsPayload,
- RepliesPayload, SearchPayload, UserPayload,
+ FollowsPayload, HomePayload, IndexCoverage, LatestPayload, LibraryCastsPayload, LibraryPayload, Meta, QuotedCast,
+ ReactionKind, ReactionsPayload, RepliesPayload, SearchPayload, UserPayload,
};
use allthing_core::channels::{channel_id_for_url, channel_url_for_id, is_channel_id};
use allthing_core::embeds::link_key;
@@ -533,6 +533,268 @@ pub async fn following(
Built::json(&payload, following::FOLLOWING_CACHE)
}
+/// Hydrate a bounded Library page in the caller's order. The provider's bulk
+/// order is not the list's order. Errors retain the caller's cursor for Retry;
+/// successful omissions and content filtering have separate classifications.
+async fn hydrate_library(
+ inv: &Invocation,
+ hashes: &[String],
+ previous: Option>>,
+) -> Result {
+ let raw = hypersnap::library_casts(inv, hashes).await.map_err(Failure::upstream)?;
+ let mut by_hash: HashMap =
+ raw.iter().map(normalize::cast).map(|cast| (cast.hash.clone(), cast)).collect();
+ let unavailable = hashes.iter().filter(|hash| !by_hash.contains_key(*hash)).cloned().collect();
+ let ordered: Vec = hashes.iter().filter_map(|hash| by_hash.remove(hash)).collect();
+ let filtered = ordered.iter().filter(|cast| cast.spam).map(|cast| cast.hash.clone()).collect();
+ let mut notes = carried(previous.as_deref().map(Vec::as_slice));
+ let known_links = std::mem::take(&mut notes.links);
+ let pool = drop_hidden(inv, ordered, &mut notes, &known_links, true).await;
+ let quoted = annotate_listing(inv, &mut notes, &pool, ORIGIN_AUTHORS, &known_links).await;
+ notes.keep_users(read_cards(&raw, quoted), listed_accounts(&pool, ¬es.quotes));
+ keep_shown(&mut notes, &pool);
+ Ok(LibraryCastsPayload { pool, unavailable, filtered, notes, meta: meta(inv) })
+}
+
+/// The account's retained active likes or recasts, newest reaction first,
+/// including older pages until the hub has no continuation token. The
+/// browser's recent-reaction cache does not limit this history.
+pub async fn library(
+ inv: Rc,
+ fid: u64,
+ kind: ReactionKind,
+ cursor: Option,
+ previous: Option>>,
+) -> Result {
+ let (entries, next) =
+ hypersnap::library_reactions(&inv, fid, kind, cursor.as_deref()).await.map_err(Failure::upstream)?;
+ let hashes: Vec = entries.iter().map(|entry| entry.hash.clone()).collect();
+ let LibraryCastsPayload { pool, unavailable, filtered, notes, meta } =
+ hydrate_library(&inv, &hashes, previous).await?;
+ Built::json(
+ &LibraryPayload { fid, kind, entries, pool, next, unavailable, filtered, notes, meta },
+ api::LIBRARY_CACHE,
+ )
+}
+
+/// Public cast data for bookmarks/seen metadata supplied by the browser, at
+/// most 25 hashes. This does not read or expose any private Library records.
+pub async fn library_casts(
+ inv: Rc,
+ hashes: Vec,
+ previous: Option>>,
+) -> Result {
+ Built::json(&hydrate_library(&inv, &hashes, previous).await?, api::LIBRARY_CACHE)
+}
+
+#[cfg(test)]
+mod library_tests {
+ use super::*;
+ use crate::{FetchError, Host, HttpRequest, HttpResponse};
+ use allthing_core::provenance::{base64_decode, base64_encode};
+ use futures::future::{FutureExt, LocalBoxFuture};
+ use serde_json::json;
+ use std::cell::{Cell, RefCell};
+
+ struct Hub {
+ total: usize,
+ missing: HashSet,
+ reaction_status: u16,
+ reaction_body: Option,
+ bulk_status: u16,
+ bulk_body: Option,
+ timeout: bool,
+ clock: Cell,
+ requests: RefCell>,
+ }
+ impl Hub {
+ fn new(total: usize) -> Self {
+ Self {
+ total,
+ missing: HashSet::new(),
+ reaction_status: 200,
+ reaction_body: None,
+ bulk_status: 200,
+ bulk_body: None,
+ timeout: false,
+ clock: Cell::new(1_800_000_000_000.0),
+ requests: RefCell::new(Vec::new()),
+ }
+ }
+ }
+ fn hash(n: usize) -> String {
+ format!("0x{n:040x}")
+ }
+ fn query(url: &str, name: &str) -> Option {
+ url.split_once('?')?.1.split('&').find_map(|part| {
+ let (key, value) = part.split_once('=')?;
+ (key == name).then(|| allthing_core::params::percent_decode(value)).flatten()
+ })
+ }
+ impl Host for Hub {
+ fn now(&self) -> f64 {
+ // Independent invocations can refill the upstream test bucket;
+ // no timing or performance behavior is being measured here.
+ let now = self.clock.get();
+ self.clock.set(now + 10_000.0);
+ now
+ }
+ fn fetch(&self, request: HttpRequest) -> LocalBoxFuture<'static, Result> {
+ let url = request.url;
+ assert!(url.starts_with(hypersnap::BASE), "Library never switches read providers: {url}");
+ self.requests.borrow_mut().push(url.clone());
+ let answer = |status, body: String| {
+ Ok(HttpResponse { status, retry_after: None, body: (status == 200).then(|| body.into_bytes()) })
+ };
+ let response = if url.contains("/v1/reactionsByFid?") {
+ if self.timeout {
+ return async { Err(FetchError::Timeout) }.boxed_local();
+ }
+ assert_eq!(query(&url, "fid").as_deref(), Some("3"));
+ assert_eq!(query(&url, "pageSize").as_deref(), Some("25"));
+ assert_eq!(query(&url, "reverse").as_deref(), Some("true"));
+ let offset = query(&url, "pageToken")
+ .map(|token| String::from_utf8(base64_decode(&token).unwrap()).unwrap().parse::().unwrap())
+ .unwrap_or(0);
+ let end = (offset + api::LIBRARY_PAGE_SIZE).min(self.total);
+ let kind = if query(&url, "reaction_type").as_deref() == Some("Recast") {
+ "REACTION_TYPE_RECAST"
+ } else {
+ "REACTION_TYPE_LIKE"
+ };
+ let messages: Vec<_> = (offset..end)
+ .map(|n| {
+ json!({"data": {
+ "fid":3,"type":"MESSAGE_TYPE_REACTION_ADD","timestamp":1_000_000 - n,
+ "reactionBody":{"type":kind,"targetCastId":{"fid":n+1000,"hash":hash(n)}}
+ }})
+ })
+ .collect();
+ let next = if end < self.total { base64_encode(end.to_string().as_bytes()) } else { String::new() };
+ answer(
+ self.reaction_status,
+ self.reaction_body
+ .clone()
+ .unwrap_or_else(|| json!({"messages":messages,"nextPageToken":next}).to_string()),
+ )
+ } else if url.contains("/v2/farcaster/casts?") {
+ let hashes = query(&url, "casts").unwrap();
+ let casts: Vec<_> = hashes.split(',').rev().filter_map(|h| {
+ let n = usize::from_str_radix(h.trim_start_matches("0x"),16).unwrap();
+ if self.missing.contains(&n) { return None; }
+ let text = if n == 2 { "Just unlocked my DEGEN rewards" } else { "A saved cast" };
+ Some(json!({"hash":h,"author":{"fid":n+1000,"username":format!("author{n}"),"follower_count":1},"text":text,
+ "timestamp":"2026-01-01T00:00:00Z","embeds": if n == 3 { vec![json!({"url":"https://farcaster.xyz/miniapps/abc/game"})] } else { vec![] }}))
+ }).collect();
+ answer(self.bulk_status, self.bulk_body.clone().unwrap_or_else(|| json!({"casts":casts}).to_string()))
+ } else {
+ answer(404, String::new())
+ };
+ async move { response }.boxed_local()
+ }
+ fn spawn(&self, _: LocalBoxFuture<'static, ()>) {}
+ }
+ fn read(hub: Rc, kind: ReactionKind, cursor: Option) -> Result {
+ let built = library(Rc::new(Invocation::new(hub, 43)), 3, kind, cursor, None).now_or_never().unwrap()?;
+ Ok(serde_json::from_slice(&built.body).unwrap())
+ }
+
+ #[test]
+ fn library_pages_continue_past_recent_cache_limits_to_the_actual_end() {
+ for kind in [ReactionKind::Likes, ReactionKind::Recasts] {
+ let hub = Rc::new(Hub::new(131));
+ let (mut cursor, mut entries, mut pages) = (None, Vec::new(), 0);
+ loop {
+ let page = read(hub.clone(), kind, cursor).unwrap();
+ assert_eq!((page.fid, page.kind), (3, kind));
+ entries.extend(page.entries);
+ pages += 1;
+ cursor = page.next;
+ if cursor.is_none() {
+ break;
+ }
+ assert!(pages < 10, "cursor must finish");
+ }
+ assert_eq!(entries.len(), 131);
+ assert_eq!(
+ entries.iter().map(|entry| entry.hash.clone()).collect::>(),
+ (0..131).map(hash).collect::>()
+ );
+ assert!(entries.windows(2).all(|rows| rows[0].at > rows[1].at));
+ assert_eq!(pages, 6);
+ assert_eq!(hub.requests.borrow().iter().filter(|url| url.contains("/v1/reactionsByFid?")).count(), 6);
+ assert_eq!(hub.requests.borrow().iter().filter(|url| url.contains("/v2/farcaster/casts?")).count(), 6);
+ }
+ }
+
+ #[test]
+ fn library_hydration_keeps_list_order_and_separates_unavailable_from_filtered() {
+ let mut hub = Hub::new(0);
+ hub.missing.insert(1);
+ let hub = Rc::new(hub);
+ let ordered = [3, 1, 2, 4].map(hash).to_vec();
+ let built = library_casts(Rc::new(Invocation::new(hub, 43)), ordered, None).now_or_never().unwrap().unwrap();
+ let page: LibraryCastsPayload = serde_json::from_slice(&built.body).unwrap();
+ assert_eq!(page.pool.iter().map(|cast| cast.hash.clone()).collect::>(), [hash(3), hash(4)]);
+ assert_eq!(page.unavailable, [hash(1)]);
+ assert_eq!(page.filtered, [hash(2)]);
+ assert!(page.pool[0].mini_app, "settings, not server hydration, decide whether mini apps show");
+ assert!(page.notes.users.contains_key(&1003));
+ assert!(!page.notes.users.contains_key(&1002));
+ }
+
+ #[test]
+ fn library_source_failures_and_bad_shapes_never_become_empty_success() {
+ for status in [400, 404, 500] {
+ let mut hub = Hub::new(1);
+ hub.reaction_status = status;
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ let mut hub = Hub::new(1);
+ hub.bulk_status = status;
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ }
+ let mut hub = Hub::new(1);
+ hub.timeout = true;
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ for body in ["{}", r#"{"messages":[]}"#, r#"{"messages":[],"nextPageToken":"invalid!"}"#] {
+ let mut hub = Hub::new(1);
+ hub.reaction_body = Some(body.into());
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ }
+ let mut hub = Hub::new(1);
+ hub.bulk_body = Some("{}".into());
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ let mut hub = Hub::new(1);
+ hub.reaction_body = Some(json!({"messages":[{"data":{"fid":3,"timestamp":1,"type":"MESSAGE_TYPE_REACTION_ADD","reactionBody":{"type":"REACTION_TYPE_LIKE"}}}],"nextPageToken":""}).to_string());
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ let mut hub = Hub::new(1);
+ hub.reaction_body = Some(json!({"messages":[{"data":{"fid":3,"timestamp":1,"type":"MESSAGE_TYPE_REACTION_ADD","reactionBody":{"type":"REACTION_TYPE_LIKE","targetUrl":"https://example.org"}}}],"nextPageToken":""}).to_string());
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).unwrap().entries.is_empty());
+ }
+
+ #[test]
+ fn library_empty_history_is_explicit_and_does_not_fetch_casts() {
+ let hub = Rc::new(Hub::new(0));
+ let page = read(hub.clone(), ReactionKind::Likes, None).unwrap();
+ assert!(page.entries.is_empty() && page.pool.is_empty() && page.unavailable.is_empty() && page.next.is_none());
+ assert_eq!(hub.requests.borrow().len(), 1);
+ }
+
+ #[test]
+ fn library_annotations_stay_within_the_shared_request_budget() {
+ let hub = Rc::new(Hub::new(25));
+ let page = read(hub.clone(), ReactionKind::Recasts, None).unwrap();
+ assert_eq!(page.entries.len(), 25);
+ let urls = hub.requests.borrow();
+ assert!(urls.len() <= 43, "the cache reserves its two calls from the45 budget: {}", urls.len());
+ assert_eq!(urls.iter().filter(|url| url.contains("/v2/farcaster/casts?")).count(), 1);
+ assert!(
+ urls.iter().filter(|url| url.contains("/v1/castById?")).count() <= ORIGIN_AUTHORS,
+ "only the existing bounded provenance annotator may read individual casts"
+ );
+ }
+}
+
/// How many voted each way in Allthing's poll, so a reader who voted sees
/// them with no request of their own: one D1 read of the two counts its
/// votes keep, given an index and a subrequest for it. `None` before anyone
diff --git a/crates/allthing-web/Cargo.toml b/crates/allthing-web/Cargo.toml
index b6013a04..f898e48f 100644
--- a/crates/allthing-web/Cargo.toml
+++ b/crates/allthing-web/Cargo.toml
@@ -110,6 +110,8 @@ features = [
"CryptoKeyPair",
"SubtleCrypto",
"IdbDatabase",
+ "IdbKeyRange",
+ "DomException",
"IdbFactory",
"IdbObjectStore",
"IdbObjectStoreParameters",
diff --git a/crates/allthing-web/src/components/bookmark.rs b/crates/allthing-web/src/components/bookmark.rs
new file mode 100644
index 00000000..18188ae3
--- /dev/null
+++ b/crates/allthing-web/src/components/bookmark.rs
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! A private saved-post toggle. It needs a session, never a Farcaster signer.
+
+use leptos::prelude::*;
+
+#[cfg(not(feature = "static-host"))]
+#[component]
+pub fn BookmarkButton(hash: String) -> impl IntoView {
+ use crate::components::signin::use_session;
+ use crate::library::{SyncStatus, use_library};
+
+ let library = use_library();
+ let session = use_session();
+ // Split pages can construct a view under a short-lived async owner before
+ // mounting it. Follow the mounted control's effect lifetime, as CastCard's
+ // viewport observer does, so Back retains a live membership subscription.
+ let node = NodeRef::::new();
+ let watched = hash.clone();
+ Effect::new(move |_| {
+ if node.get().is_some() {
+ library.watch(&watched);
+ }
+ });
+ let key = hash.clone();
+ let saved = Memo::new(move |_| library.bookmarked(&key));
+ let signed_in = Memo::new(move |_| session.user.with(Option::is_some));
+ let waiting = Memo::new(move |_| signed_in.get() && saved.get().is_none());
+ let message = RwSignal::new(None::);
+ Effect::new(move |_| {
+ session.user.with(|user| user.as_ref().map(|user| user.fid));
+ message.set(None);
+ });
+ let label = move || if saved.get() == Some(true) { "Remove bookmark" } else { "Save post" };
+ view! {
+
+
+
+
+
+
+
+ {move || message.get()}
+
+
+ }
+}
+
+/// The public-only static copy has no private account state or save action.
+#[cfg(feature = "static-host")]
+#[component]
+pub fn BookmarkButton(hash: String) -> impl IntoView {
+ let _ = hash;
+}
diff --git a/crates/allthing-web/src/components/cast.rs b/crates/allthing-web/src/components/cast.rs
index 1865e2f4..e5c6865c 100644
--- a/crates/allthing-web/src/components/cast.rs
+++ b/crates/allthing-web/src/components/cast.rs
@@ -45,6 +45,9 @@ pub fn CastCard(
#[prop(optional)] index: usize,
#[prop(optional)] show_parent: bool,
#[prop(optional)] track_seen: bool,
+ /// A Library activity line/action, inside the existing card element.
+ #[prop(optional_no_strip)]
+ row_footer: Option>,
) -> impl IntoView {
let href = cast_href(&cast.hash);
// Quoted casts the payload carries show as cards; others as links.
@@ -64,13 +67,18 @@ pub fn CastCard(
let reactions = crate::components::reactions::card_reactions(&cast, ¬es);
#[cfg(feature = "static-host")]
let reactions = ();
+ let extra = row_footer.map(|footer| footer.run(cast.clone()));
let gone = crate::components::deleted(&cast.hash);
// Drawn as Back or Forward puts the reader's place back, a card is where
// it was: it doesn't rise into place again.
let entrance = if crate::nav::restoring() { "; animation: none" } else { "" };
let node = NodeRef::::new();
let seen = crate::seen::use_seen();
+ let read_mark = track_seen.then(|| seen.read_mark(cast.hash.clone()));
Effect::new(move |_| {
+ if let Some(read_mark) = read_mark {
+ read_mark.track();
+ }
if let Some(element) = node.get() {
if track_seen {
crate::seen::watch(&element, seen);
@@ -133,6 +141,7 @@ pub fn CastCard(
{reactions}
+ {extra}
}
}
@@ -166,14 +175,29 @@ pub fn CastList(
/// and keeping its place as they reorder: the front page's poll.
#[prop(default = None)]
inset: Option AnyView>,
+ /// Optional per-cast Library metadata, without wrapping card elements.
+ #[prop(optional_no_strip)]
+ row_footer: Option>,
) -> impl IntoView {
// Mini app casts, which payloads keep marked, are left out unless the
// reader chose to see them in the Settings panel (`crate::miniapps`).
let all = casts;
- let seen_feed = crate::seen::Feed::new(hide_seen);
+ let candidates = Signal::derive(move || {
+ let show = crate::miniapps::shown();
+ all.with(|casts| {
+ casts
+ .iter()
+ .filter(|cast| !cast.hidden_for(show))
+ .filter_map(|cast| allthing_core::library::canonical_hash(&cast.hash))
+ .collect()
+ })
+ });
+ let seen_feed = crate::seen::Feed::new(hide_seen, candidates);
let hidden_seen = Memo::new(move |_| {
let show_apps = crate::miniapps::shown();
- all.with(|casts| casts.iter().filter(|cast| !cast.hidden_for(show_apps) && seen_feed.hides(&cast.hash)).count())
+ all.with(|casts| {
+ casts.iter().filter(|cast| !cast.hidden_for(show_apps) && seen_feed.hidden(&cast.hash)).count()
+ })
});
let casts: Signal> = Memo::new(move |_| {
let show = crate::miniapps::shown();
@@ -277,6 +301,9 @@ pub fn CastList(
}
});
let grow = move || {
+ if seen_feed.pending() {
+ return;
+ }
// Still drawing what is shown: its end isn't the list's end yet.
if drawn.with_value(|(count, _)| *count) < shown.get_untracked().min(casts.with_untracked(Vec::len)) {
return;
@@ -298,7 +325,7 @@ pub fn CastList(
// A short or filtered page can leave the sentinel inside the observer's
// margin, so there is no new intersection event to continue the feed.
Effect::new(move |_| {
- let _ = (casts.with(Vec::len), shown.get(), drew_more.get(), page_cursor.get());
+ let _ = (casts.with(Vec::len), shown.get(), drew_more.get(), page_cursor.get(), seen_feed.pending());
request_animation_frame(move || {
let Some(element) = sentinel.try_get_untracked().flatten() else { return };
if !element.is_connected() {
@@ -320,8 +347,13 @@ pub fn CastList(
// fewer, as while the first card waits for its image's size, it was at
// the list's top, and was pushed down as they came.
let items = move || {
- let mut items: Vec<(String, Option<(usize, Cast)>)> =
- visible().into_iter().map(|(index, cast)| (cast.hash.clone(), Some((index, cast)))).collect();
+ let mut items: Vec<(String, Option<(usize, Cast)>)> = visible()
+ .into_iter()
+ .map(|(index, cast)| {
+ seen_feed.preserve(&cast.hash);
+ (cast.hash.clone(), Some((index, cast)))
+ })
+ .collect();
let all = casts.with(Vec::len);
if inset.is_some() && !items.is_empty() && items.len() >= INSET_AFTER.min(all) {
let at = INSET_AFTER.min(items.len());
@@ -332,13 +364,16 @@ pub fn CastList(
};
view! {
- "Nothing here yet."
}>
+ }.into_any() }
+ else { view! { "Nothing here yet."
}.into_any() }
+ }>
view! { }.into_any(),
+ Some((index, cast)) => view! { }.into_any(),
None => inset.map(|inset| inset()).into_any(),
}
/>
diff --git a/crates/allthing-web/src/components/chips.rs b/crates/allthing-web/src/components/chips.rs
index 95b5e6e8..5e4aac0c 100644
--- a/crates/allthing-web/src/components/chips.rs
+++ b/crates/allthing-web/src/components/chips.rs
@@ -181,7 +181,13 @@ pub fn Engagement(
};
// The ranking note is for screen readers: a native tooltip on the row
// would sit on top of the counts' own popovers.
- view! { {likes}{recasts}{replies}{title}
}
+ view! {
+
+ {likes}{recasts}{replies}
+
+ {title}
+
+ }
}
/// The reply count of the cast `hash`, `read` as the page has it, with the
diff --git a/crates/allthing-web/src/components/layout.rs b/crates/allthing-web/src/components/layout.rs
index 875dbc78..f50d621a 100644
--- a/crates/allthing-web/src/components/layout.rs
+++ b/crates/allthing-web/src/components/layout.rs
@@ -31,6 +31,7 @@ pub fn SiteHeader() -> impl IntoView {
@@ -164,14 +165,20 @@ fn offered(range: TimeRange) -> bool {
/// Reddit's time filter: how far back the front page reaches, kept in the
/// URL as `t` next to the sort.
#[component]
-pub fn TimeFilter(path: String, sort: Sort, current: TimeRange) -> impl IntoView {
+pub fn TimeFilter(
+ path: String,
+ sort: Sort,
+ current: TimeRange,
+ #[prop(optional)] default_sort: Option,
+) -> impl IntoView {
+ let default_sort = default_sort.unwrap_or(Sort::Hot);
let navigate = use_go();
let change = move |ev: leptos::ev::Event| {
let range = TimeRange::parse(Some(&event_target_value(&ev)));
let href = with_query(
&path,
&[
- ("sort", (sort != Sort::Hot).then(|| sort.as_str().to_owned())),
+ ("sort", (sort != default_sort).then(|| sort.as_str().to_owned())),
("t", (range != TimeRange::default()).then(|| range.as_str().to_owned())),
],
);
diff --git a/crates/allthing-web/src/components/mod.rs b/crates/allthing-web/src/components/mod.rs
index 010f2699..1fe29972 100644
--- a/crates/allthing-web/src/components/mod.rs
+++ b/crates/allthing-web/src/components/mod.rs
@@ -3,6 +3,7 @@
#[cfg(not(feature = "static-host"))]
pub mod attach;
+pub mod bookmark;
pub mod cast;
pub mod chips;
pub mod clients;
diff --git a/crates/allthing-web/src/components/signin.rs b/crates/allthing-web/src/components/signin.rs
index 6762111b..0b7a005d 100644
--- a/crates/allthing-web/src/components/signin.rs
+++ b/crates/allthing-web/src/components/signin.rs
@@ -650,6 +650,7 @@ pub fn Account() -> impl IntoView {
"@"{username.clone()}
"Your profile"
+ "Library"
diff --git a/crates/allthing-web/src/lib.rs b/crates/allthing-web/src/lib.rs
index 817f2227..d8849913 100644
--- a/crates/allthing-web/src/lib.rs
+++ b/crates/allthing-web/src/lib.rs
@@ -13,6 +13,10 @@ mod kept;
#[cfg(not(feature = "static-host"))]
mod keys;
mod leanings;
+#[cfg(not(feature = "static-host"))]
+mod library;
+#[cfg(not(feature = "static-host"))]
+mod library_disk;
mod miniapps;
mod nav;
#[cfg(not(feature = "static-host"))]
@@ -79,6 +83,8 @@ fn App() -> impl IntoView {
#[cfg(not(feature = "static-host"))]
provide_context(components::signin::Session::new());
#[cfg(not(feature = "static-host"))]
+ provide_context(library::Library::new());
+ #[cfg(not(feature = "static-host"))]
provide_context(components::notifications::Notifications::new());
#[cfg(not(feature = "static-host"))]
provide_context(components::compose::Composer::new());
@@ -112,6 +118,7 @@ fn App() -> impl IntoView {
+
diff --git a/crates/allthing-web/src/library.rs b/crates/allthing-web/src/library.rs
new file mode 100644
index 00000000..4dbc6699
--- /dev/null
+++ b/crates/allthing-web/src/library.rs
@@ -0,0 +1,1657 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! Private Library state, caller-scoped subscriptions and a committed offline outbox.
+//! Shares the verified notification connection; never touches chat or public presence.
+
+use std::cell::{Cell, RefCell};
+use std::collections::{BTreeMap, BTreeSet, VecDeque};
+use std::rc::Rc;
+
+use allthing_core::library::{Account, CachedItem, Item, PendingBookmark, PendingRead, ReadIntent, canonical_hash};
+use allthing_core::stdb::Called;
+use js_sys::Array;
+use leptos::prelude::*;
+use serde::{Deserialize, Serialize};
+use wasm_bindgen::JsValue;
+
+use crate::library_disk;
+use crate::stdb_bridge::{method, parse};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum SyncStatus {
+ SignedOut,
+ Connecting,
+ Ready,
+ Offline,
+ Failed(String),
+}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PrivateKind {
+ Seen,
+ Bookmarks,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PrivateCursor {
+ pub generation: u64,
+ pub after: String,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PrivateEntry {
+ pub hash: String,
+ pub at: i64,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PrivatePage {
+ pub entries: Vec,
+ pub next: Option,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub(crate) struct PendingClear {
+ operation: String,
+ expected_generation: u64,
+ sequence: u64,
+}
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub(crate) struct Metadata {
+ pub(crate) fid: u64,
+ server: Account,
+ known: bool,
+ pending_clear: Option,
+ sequence: u64,
+ clear_settled: u64,
+ legacy_imported: bool,
+}
+impl Metadata {
+ pub(crate) fn merge_disk(&mut self, incoming: &Self) {
+ if self.fid != incoming.fid {
+ return;
+ }
+ if incoming.known
+ && (!self.known
+ || (incoming.server.generation, incoming.server.revision)
+ >= (self.server.generation, self.server.revision))
+ {
+ self.server = incoming.server.clone();
+ self.known = true;
+ }
+ self.sequence = self.sequence.max(incoming.sequence);
+ self.clear_settled = self.clear_settled.max(incoming.clear_settled);
+ self.legacy_imported |= incoming.legacy_imported;
+ self.pending_clear = self
+ .pending_clear
+ .iter()
+ .chain(incoming.pending_clear.iter())
+ .filter(|p| p.sequence > self.clear_settled)
+ .max_by_key(|p| p.sequence)
+ .cloned();
+ if self.pending_clear.as_ref().is_some_and(|p| self.known && self.server.generation > p.expected_generation)
+ && let Some(p) = self.pending_clear.take()
+ {
+ self.clear_settled = self.clear_settled.max(p.sequence);
+ }
+ }
+ fn effective(&self) -> Account {
+ let mut account = self.server.clone();
+ account.fid = self.fid;
+ if let Some(clear) = &self.pending_clear {
+ account.generation = clear.expected_generation.saturating_add(1);
+ }
+ account
+ }
+}
+
+#[derive(Clone)]
+struct State {
+ fid: Option,
+ capability: Option,
+ status: SyncStatus,
+ revision: u64,
+ metadata: Metadata,
+ items: BTreeMap,
+ checked: BTreeSet,
+ loaded: BTreeSet,
+}
+impl Default for State {
+ fn default() -> Self {
+ Self {
+ fid: None,
+ capability: None,
+ status: SyncStatus::SignedOut,
+ revision: 0,
+ metadata: Metadata::default(),
+ items: BTreeMap::new(),
+ checked: BTreeSet::new(),
+ loaded: BTreeSet::new(),
+ }
+ }
+}
+#[derive(Clone, Copy)]
+pub struct Library(RwSignal, crate::components::signin::Session);
+thread_local! {
+ static HANDLE: Cell> = const { Cell::new(None) };
+ static CURRENT: RefCell > = const { RefCell::new(None) };
+ static WATCHES: RefCell>> = const { RefCell::new(BTreeMap::new()) };
+ static IDS: Cell = const { Cell::new(0) };
+}
+pub fn use_library() -> Library {
+ use_context::().expect("Library context")
+}
+fn handle() -> Option {
+ HANDLE.with(Cell::get)
+}
+fn next_id() -> u64 {
+ IDS.with(|id| {
+ let n = id.get().wrapping_add(1);
+ id.set(n);
+ n
+ })
+}
+fn canonical(hash: &str) -> Result {
+ canonical_hash(hash).ok_or_else(|| "Invalid post hash".into())
+}
+fn now() -> i64 {
+ (js_sys::Date::now() / 1000.0) as i64
+}
+fn all_watches() -> BTreeSet {
+ WATCHES.with(|all| all.borrow().values().flat_map(|v| v.iter().cloned()).collect())
+}
+
+impl Library {
+ pub fn new() -> Self {
+ let session = crate::components::signin::use_session();
+ let fid = session.user.with_untracked(|u| u.as_ref().map(|u| u.fid).filter(|fid| *fid > 0));
+ let state = State {
+ fid,
+ status: if fid.is_some() { SyncStatus::Connecting } else { SyncStatus::SignedOut },
+ metadata: Metadata { fid: fid.unwrap_or(0), ..Metadata::default() },
+ ..State::default()
+ };
+ let library = Self(RwSignal::new(state), session);
+ HANDLE.with(|h| h.set(Some(library)));
+ library
+ }
+ fn fid(self) -> Option {
+ self.1.user.with(|u| u.as_ref().map(|u| u.fid).filter(|fid| *fid > 0))
+ }
+ fn same(self) -> bool {
+ let fid = self.fid();
+ self.0.with(|s| s.fid == fid)
+ }
+ pub fn status(self) -> SyncStatus {
+ if !self.same() {
+ return if self.fid().is_some() { SyncStatus::Connecting } else { SyncStatus::SignedOut };
+ }
+ self.0.with(|s| s.status.clone())
+ }
+ pub fn revision(self) -> u64 {
+ self.0.with(|s| s.revision)
+ }
+ pub fn generation(self) -> u64 {
+ self.0.with(|s| s.metadata.effective().generation)
+ }
+ pub fn remote_required(self) -> bool {
+ let fid = self.fid();
+ fid.is_some() && self.0.with(|s| s.fid != fid || s.capability != Some(false))
+ }
+ pub fn membership_ready(self, hashes: &[String]) -> bool {
+ if !self.same() {
+ return false;
+ }
+ self.0.with(|s| {
+ s.capability == Some(false)
+ || (s.status == SyncStatus::Ready
+ && s.metadata.legacy_imported
+ && hashes.iter().all(|h| s.checked.contains(h) && s.loaded.contains(h)))
+ })
+ }
+ pub fn seen(self, hash: &str) -> Option {
+ if !self.same() {
+ return None;
+ }
+ self.0.with(|s| {
+ s.items.get(hash).filter(|c| c.known || c.read.is_some()).map(|c| c.seen(s.metadata.effective().generation))
+ })
+ }
+ pub fn bookmarked(self, hash: &str) -> Option {
+ if !self.same() {
+ return None;
+ }
+ self.0.with(|s| s.items.get(hash).and_then(CachedItem::bookmarked))
+ }
+ pub fn watch(self, hash: &str) {
+ let key = format!("card-{}", next_id());
+ let cleanup = key.clone();
+ // The constructing card owns this registration. Register retirement
+ // before any signal writes; subscription plumbing must not become a
+ // dependency of the parent render/memo or of its cleanup callback.
+ on_cleanup(move || untrack(|| self.unwatch_batch(&cleanup)));
+ untrack(|| self.watch_batch(&key, &[hash.into()]));
+ }
+ pub fn watch_batch(self, owner: &str, hashes: &[String]) {
+ let values = hashes.iter().filter_map(|h| canonical_hash(h)).collect::>();
+ WATCHES.with(|w| {
+ w.borrow_mut().insert(owner.into(), values);
+ });
+ if let Some(b) = current() {
+ b.load_watched();
+ b.watches();
+ }
+ crate::notification_sync::wake_urgent();
+ }
+ pub fn unwatch_batch(self, owner: &str) {
+ WATCHES.with(|w| {
+ w.borrow_mut().remove(owner);
+ });
+ if let Some(b) = current() {
+ b.watches();
+ }
+ }
+ pub fn retry(self) {
+ crate::notification_sync::wake_urgent();
+ if let Some(b) = current() {
+ b.inner.disk_failed.set(false);
+ b.bootstrap();
+ b.persist();
+ b.load_watched();
+ b.watches();
+ b.drain();
+ }
+ }
+ pub fn toggle_bookmark(self, hash: &str) -> Result<(), String> {
+ let hash = canonical(hash)?;
+ let b = require()?;
+ self.bookmarked(&hash).ok_or("Wait for this post's saved state to load")?;
+ b.action(hash, Action::Toggle)
+ }
+ pub fn record_seen(self, hash: &str, at: i64) -> Result<(), String> {
+ let hash = canonical(hash)?;
+ let b = require()?;
+ if self.seen(&hash) == Some(true) {
+ return Ok(());
+ }
+ b.action(hash, Action::Seen(at.min(now()).max(1)))
+ }
+ pub fn forget_seen(self, hash: &str) -> Result<(), String> {
+ let hash = canonical(hash)?;
+ require()?.action(hash, Action::Unread)
+ }
+ pub fn clear_seen(self) -> Result<(), String> {
+ let b = require()?;
+ if !self.0.with_untracked(|s| s.metadata.known) {
+ return Err("Wait for Library state to load before clearing history".into());
+ }
+ if self.0.with_untracked(|s| s.metadata.pending_clear.is_some()) {
+ return Ok(());
+ }
+ b.clear_action()?;
+ Ok(())
+ }
+ pub async fn page(self, kind: PrivateKind, cursor: Option) -> Result {
+ let b = require()?;
+ b.page(kind, cursor).await
+ }
+}
+fn empty(fid: u64, hash: &str, generation: u64) -> CachedItem {
+ CachedItem { server: Item { fid, hash: hash.into(), generation, ..Item::default() }, ..CachedItem::default() }
+}
+fn current() -> Option {
+ CURRENT.with(|c| c.borrow().clone())
+}
+fn require() -> Result {
+ let b = current().filter(|b| b.active()).ok_or("Sign in to use your Library")?;
+ if b.library.0.with_untracked(|s| s.capability == Some(false)) {
+ return Err("Private Library sync is not configured on this server".into());
+ }
+ Ok(b)
+}
+
+#[derive(Clone)]
+struct Broker {
+ fid: u64,
+ library: Library,
+ inner: Rc,
+}
+struct Inner {
+ stopped: Cell,
+ attempt: Cell,
+ bridge: RefCell>,
+ base: Cell,
+ disk: Cell,
+ disk_failed: Cell,
+ bootstrapping: Cell,
+ clear_busy: Cell,
+ loading: RefCell>,
+ groups: RefCell>,
+ writing: Cell,
+ drain_again: Cell,
+ persisting: Cell,
+ writes: RefCell>,
+ in_flight: RefCell>,
+ buffered: RefCell>,
+ busy: RefCell>,
+ touches: RefCell>,
+ clock: Cell,
+}
+#[derive(Clone)]
+enum Write {
+ Item(CachedItem),
+ Metadata(Metadata),
+}
+#[derive(Clone)]
+enum Action {
+ Toggle,
+ Seen(i64),
+ Unread,
+}
+impl Action {
+ fn bookmark(&self) -> bool {
+ matches!(self, Self::Toggle)
+ }
+ fn pending(&self, item: &CachedItem) -> bool {
+ if self.bookmark() { item.bookmark.is_some() } else { item.read.is_some() }
+ }
+}
+#[derive(Clone)]
+enum Flight {
+ Read(PendingRead),
+ Bookmark(PendingBookmark),
+}
+struct Lease {
+ bridge: JsValue,
+ tag: String,
+ hashes: BTreeSet,
+}
+impl Drop for Lease {
+ fn drop(&mut self) {
+ if let Ok(f) = method(&self.bridge, "unwatch") {
+ let _ = f.call1(&self.bridge, &self.tag.clone().into());
+ }
+ }
+}
+const MAX_MEMORY: usize = 2000;
+fn evictions(
+ items: &BTreeMap,
+ protected: &BTreeSet,
+ touches: &BTreeMap,
+) -> Vec {
+ let mut eligible: Vec<_> = items
+ .iter()
+ .filter(|(hash, item)| !protected.contains(*hash) && !item.pending())
+ .map(|(hash, _)| (touches.get(hash).copied().unwrap_or(0), hash.clone()))
+ .collect();
+ eligible.sort();
+ eligible.into_iter().take(items.len().saturating_sub(MAX_MEMORY)).map(|(_, hash)| hash).collect()
+}
+impl Broker {
+ fn active(&self) -> bool {
+ !self.inner.stopped.get()
+ && self.library.1.user.with_untracked(|u| u.as_ref().map(|u| u.fid)) == Some(self.fid)
+ && current().is_some_and(|b| Rc::ptr_eq(&b.inner, &self.inner))
+ }
+ fn touch(&self, hash: &str) {
+ let next = self.inner.clock.get().wrapping_add(1);
+ self.inner.clock.set(next);
+ self.inner.touches.borrow_mut().insert(hash.into(), next);
+ }
+ fn prune(&self) {
+ if !self.active() {
+ return;
+ }
+ let mut protected = all_watches();
+ protected.extend(self.inner.busy.borrow().iter().map(|(hash, _)| hash.clone()));
+ protected.extend(self.inner.in_flight.borrow().keys().cloned());
+ protected.extend(self.inner.loading.borrow().iter().cloned());
+ self.library.0.update(|s| {
+ for hash in evictions(&s.items, &protected, &self.inner.touches.borrow()) {
+ s.items.remove(&hash);
+ s.loaded.remove(&hash);
+ s.checked.remove(&hash);
+ }
+ s.loaded.retain(|hash| protected.contains(hash) || s.items.contains_key(hash));
+ self.inner.touches.borrow_mut().retain(|hash, _| s.items.contains_key(hash));
+ });
+ }
+ fn check(&self, attempt: u64) -> Result<(), String> {
+ if self.active() && self.inner.attempt.get() == attempt && self.inner.base.get() {
+ Ok(())
+ } else {
+ Err("Library session changed or disconnected".into())
+ }
+ }
+ fn failed(&self, error: String) {
+ if self.active() {
+ self.inner.groups.borrow_mut().clear();
+ self.library.0.update(|s| {
+ s.status = SyncStatus::Failed(error);
+ s.checked.clear();
+ });
+ }
+ }
+ fn sequence(&self) -> u64 {
+ let random = crate::keys::random::<4>().map(u32::from_le_bytes).unwrap_or(0) as u64 % 1_000_000;
+ let base = (js_sys::Date::now().max(0.0) as u64).saturating_mul(1_000_000).saturating_add(random);
+ self.library.0.update(|s| s.metadata.sequence = base.max(s.metadata.sequence.saturating_add(1)));
+ self.library.0.with_untracked(|s| s.metadata.sequence)
+ }
+ fn stop(&self) {
+ self.inner.stopped.set(true);
+ self.inner.base.set(false);
+ self.inner.groups.borrow_mut().clear();
+ self.inner.bridge.borrow_mut().take();
+ }
+ fn persist_item(&self, hash: &str) {
+ if let Some(item) = self.library.0.with_untracked(|s| s.items.get(hash).cloned()) {
+ self.inner.writes.borrow_mut().push_back(Write::Item(item));
+ self.persist();
+ }
+ }
+ fn persist_meta(&self) {
+ let value = self.library.0.with_untracked(|s| s.metadata.clone());
+ self.inner.writes.borrow_mut().push_back(Write::Metadata(value));
+ self.persist();
+ }
+ fn persist(&self) {
+ if !self.active() || self.inner.persisting.replace(true) {
+ return;
+ }
+ let b = self.clone();
+ leptos::task::spawn_local(async move {
+ loop {
+ let Some(write) = b.inner.writes.borrow_mut().pop_front() else { break };
+ let answer = match &write {
+ Write::Item(item) => library_disk::keep_item(b.fid, item).await,
+ Write::Metadata(meta) => match serde_json::to_string(meta) {
+ Ok(json) => library_disk::keep_account(b.fid, &json).await,
+ Err(e) => Err(e.to_string()),
+ },
+ };
+ // Finish every already accepted disk write under its captured
+ // FID even after sign-out; never send it over an old socket.
+ if let Err(e) = answer {
+ b.inner.writes.borrow_mut().push_front(write);
+ b.inner.disk_failed.set(true);
+ b.failed(format!("Your Library change could not be saved on this device: {e}"));
+ break;
+ }
+ if matches!(&write, Write::Metadata(_)) && b.active() {
+ match library_disk::account(b.fid).await {
+ Ok(Some(json)) => match serde_json::from_str(&json) {
+ Ok(meta) => b.merge_metadata(meta),
+ Err(_) => {
+ b.inner.disk_failed.set(true);
+ b.failed("Unreadable Library metadata".into());
+ break;
+ }
+ },
+ Ok(None) => {
+ b.inner.disk_failed.set(true);
+ b.failed("Library metadata was not committed".into());
+ break;
+ }
+ Err(error) => {
+ b.inner.disk_failed.set(true);
+ b.failed(error);
+ break;
+ }
+ }
+ }
+ }
+ b.inner.persisting.set(false);
+ if b.active() {
+ b.drain();
+ }
+ });
+ }
+ fn load_watched(&self) {
+ let hashes: Vec<_> = all_watches()
+ .into_iter()
+ .filter(|h| {
+ !self.library.0.with_untracked(|s| s.loaded.contains(h))
+ && self.inner.loading.borrow_mut().insert(h.clone())
+ })
+ .collect();
+ if hashes.is_empty() {
+ return;
+ }
+ let b = self.clone();
+ leptos::task::spawn_local(async move {
+ let rows = library_disk::items(b.fid, &hashes).await;
+ if !b.active() {
+ return;
+ }
+ for hash in &hashes {
+ b.inner.loading.borrow_mut().remove(hash);
+ }
+ match rows {
+ Ok(rows) => {
+ b.merge_cached(rows);
+ b.library.0.update(|s| s.loaded.extend(hashes));
+ }
+ Err(e) => {
+ b.inner.disk_failed.set(true);
+ b.failed(e);
+ }
+ }
+ });
+ }
+ fn merge_cached(&self, rows: Vec) {
+ self.library.0.update(|s| {
+ for row in rows {
+ let hash = row.server.hash.clone();
+ self.touch(&hash);
+ let flight = self.inner.in_flight.borrow().get(&hash).cloned();
+ let (read, bookmark) = match flight {
+ Some(Flight::Read(sent)) => (Some(sent.sequence), None),
+ Some(Flight::Bookmark(sent)) => (None, Some(sent.sequence)),
+ None => (None, None),
+ };
+ let c = s
+ .items
+ .entry(hash)
+ .or_insert_with(|| empty(self.fid, &row.server.hash, s.metadata.effective().generation));
+ let old = c.clone();
+ c.merge_disk_preserving_inflight(&row, read, bookmark);
+ if old != *c {
+ s.revision += 1;
+ }
+ }
+ });
+ self.prune();
+ }
+ fn bridge(&self) -> Result {
+ self.inner.bridge.borrow().clone().ok_or_else(|| "Library is disconnected".into())
+ }
+ fn raw(&self, name: &str, args: &[JsValue]) -> Result {
+ self.check(self.inner.attempt.get())?;
+ let bridge = self.bridge()?;
+ method(&bridge, name)?.apply(&bridge, &args.iter().collect::()).map_err(crate::stdb_bridge::js_error)
+ }
+ async fn call(&self, name: &str, args: &[JsValue]) -> Result<(), String> {
+ let attempt = self.inner.attempt.get();
+ let result: Called = parse(crate::notification_sync::bounded(self.raw(name, args)?).await?)?;
+ self.check(attempt)?;
+ result.result()
+ }
+ async fn snapshot(&self, accessor: &str, query: String) -> Result, String> {
+ let attempt = self.inner.attempt.get();
+ let bridge = self.bridge()?;
+ let tag = format!("library-once-{}", next_id());
+ let _lease = Lease { bridge, tag: tag.clone(), hashes: BTreeSet::new() };
+ let json = serde_json::to_string(&[query]).map_err(|e| e.to_string())?;
+ let value = self.raw("snapshot", &[tag.into(), accessor.into(), json.into()])?;
+ let rows = parse(crate::notification_sync::bounded(value).await?)?;
+ self.check(attempt)?;
+ Ok(rows)
+ }
+ async fn membership(&self, hashes: &[String]) -> Result, String> {
+ if hashes.is_empty() || hashes.len() > 100 {
+ return Err("Invalid Library membership batch".into());
+ }
+ let rows = self
+ .snapshot("myLibraryMembership", format!("SELECT * FROM my_library_membership('{}')", hashes.join(",")))
+ .await?;
+ self.validate_membership(hashes, rows)
+ }
+ fn validate_membership(&self, hashes: &[String], rows: Vec- ) -> Result
, String> {
+ let generation = self.library.0.with_untracked(|s| s.metadata.server.generation);
+ let wanted: BTreeSet<_> = hashes.iter().cloned().collect();
+ let rows = fold_rows(rows, self.fid, generation)
+ .into_iter()
+ .filter(|(hash, _)| wanted.contains(hash))
+ .collect::>();
+ if rows.len() != wanted.len() {
+ return Err("Library membership is incomplete; try again".into());
+ }
+ Ok(hashes.iter().filter_map(|h| rows.get(h).cloned()).collect())
+ }
+ fn observe(&self, row: Item) {
+ if self.inner.in_flight.borrow().contains_key(&row.hash) {
+ let account = self.library.0.with_untracked(|s| s.metadata.effective());
+ let mut buffered = self.inner.buffered.borrow_mut();
+ if let Some(old) = buffered.get_mut(&row.hash) {
+ merge_row(old, row, &account);
+ } else {
+ buffered.insert(row.hash.clone(), row);
+ }
+ return;
+ }
+ let hash = row.hash.clone();
+ self.touch(&hash);
+ let mut changed = false;
+ self.library.0.update(|s| {
+ let account = s.metadata.effective();
+ let c = s.items.entry(hash.clone()).or_insert_with(|| empty(self.fid, &hash, account.generation));
+ let old = c.clone();
+ c.observe(row, &account);
+ changed = old != *c;
+ if changed {
+ s.revision += 1;
+ }
+ });
+ if changed {
+ self.persist_item(&hash);
+ }
+ self.prune();
+ }
+ fn watches(&self) {
+ self.prune();
+ if !self.active()
+ || !self.inner.base.get()
+ || !self.inner.disk.get()
+ || !self.library.0.with_untracked(|s| s.metadata.known && s.metadata.pending_clear.is_none())
+ {
+ return;
+ }
+ let hashes: Vec<_> = all_watches().into_iter().collect();
+ let groups: BTreeMap<_, _> =
+ hashes.chunks(100).map(|hs| (hs.join(","), hs.iter().cloned().collect::>())).collect();
+ self.inner.groups.borrow_mut().retain(|signature, lease| {
+ if groups.contains_key(signature) {
+ true
+ } else {
+ self.library.0.update(|s| {
+ for hash in &lease.hashes {
+ s.checked.remove(hash);
+ }
+ });
+ false
+ }
+ });
+ for (signature, hashes) in groups {
+ if self.inner.groups.borrow().contains_key(&signature) {
+ continue;
+ }
+ let Ok(bridge) = self.bridge() else { return };
+ let tag = format!("library-live-{}", next_id());
+ let query = serde_json::to_string(&[format!("SELECT * FROM my_library_membership('{signature}')")])
+ .unwrap_or_default();
+ let Ok(value) = self.raw("watch", &[tag.clone().into(), query.into()]) else { continue };
+ self.inner
+ .groups
+ .borrow_mut()
+ .insert(signature.clone(), Lease { bridge, tag: tag.clone(), hashes: hashes.clone() });
+ let b = self.clone();
+ let attempt = self.inner.attempt.get();
+ leptos::task::spawn_local(async move {
+ let result = crate::notification_sync::bounded(value).await;
+ if b.check(attempt).is_err()
+ || !b.inner.groups.borrow().get(&signature).is_some_and(|lease| lease.tag == tag)
+ {
+ return;
+ }
+ let answer = result.and_then(|_| {
+ let bridge = b.bridge()?;
+ let value = method(&bridge, "rows")?
+ .call1(&bridge, &"myLibraryMembership".into())
+ .map_err(crate::stdb_bridge::js_error)?;
+ b.validate_membership(&hashes.iter().cloned().collect::>(), parse(value)?)
+ });
+ match answer {
+ Ok(rows) => {
+ for row in rows {
+ b.observe(row);
+ }
+ b.library.0.update(|s| {
+ s.checked.extend(hashes);
+ });
+ b.drain();
+ }
+ Err(e) => {
+ b.inner.groups.borrow_mut().remove(&signature);
+ b.failed(e);
+ }
+ }
+ });
+ }
+ }
+ async fn page(&self, kind: PrivateKind, cursor: Option) -> Result {
+ if self.library.status() != SyncStatus::Ready {
+ return Err("Connect your Library to load this page; saved offline changes are kept".into());
+ }
+ let generation = self.library.0.with_untracked(|s| s.metadata.server.generation);
+ if self.library.0.with_untracked(|s| s.metadata.pending_clear.is_some()) {
+ return Err("Your history clear is still syncing".into());
+ }
+ let after = match cursor {
+ Some(c) if c.generation != generation => return Err("History changed; refresh this list".into()),
+ Some(c) => c.after,
+ None => String::new(),
+ };
+ if !valid_cursor(&after) {
+ return Err("Invalid Library cursor".into());
+ }
+ let rows = self
+ .snapshot(
+ "myLibraryPage",
+ format!(
+ "SELECT * FROM my_library_page({},{generation},'{after}',25)",
+ if kind == PrivateKind::Seen { 0 } else { 1 }
+ ),
+ )
+ .await?;
+ if self.library.generation() != generation {
+ return Err("History changed; refresh this list".into());
+ }
+ let account = self.library.0.with_untracked(|s| s.metadata.effective());
+ let mut folded = fold_rows(rows, self.fid, generation);
+ self.library.0.with_untracked(|s| {
+ for (hash, row) in &mut folded {
+ if let Some(cache) = s.items.get(hash).filter(|c| c.known) {
+ merge_row(row, cache.server.clone(), &account);
+ }
+ }
+ });
+ let mut rows: Vec<_> = folded
+ .into_values()
+ .filter(|r| match kind {
+ PrivateKind::Seen => r.seen,
+ PrivateKind::Bookmarks => r.bookmarked,
+ })
+ .filter(|r| after.is_empty() || before(r, &after, kind))
+ .collect();
+ rows.sort_by(|a, b| stamp(b, kind).cmp(&stamp(a, kind)).then_with(|| b.hash.cmp(&a.hash)));
+ rows.dedup_by(|a, b| a.hash == b.hash);
+ let more = rows.len() > 25;
+ rows.truncate(25);
+ let entries: Vec<_> = rows.iter().map(|r| PrivateEntry { hash: r.hash.clone(), at: stamp(r, kind) }).collect();
+ for row in rows {
+ self.observe(row);
+ }
+ let next = if more {
+ entries.last().map(|e| PrivateCursor { generation, after: format!("{}:{}", e.at, e.hash) })
+ } else {
+ None
+ };
+ Ok(PrivatePage { entries, next })
+ }
+ fn merge_metadata(&self, meta: Metadata) {
+ if meta.fid != self.fid {
+ return;
+ }
+ if meta.known {
+ observe_account(self, meta.server.clone());
+ }
+ self.library.0.update(|s| {
+ let old = s.metadata.clone();
+ s.metadata.merge_disk(&meta);
+ let account = s.metadata.effective();
+ let mut changed = old != s.metadata;
+ for (hash, c) in &mut s.items {
+ if !self.inner.in_flight.borrow().contains_key(hash) {
+ let before = c.clone();
+ c.reconcile(&account);
+ changed |= *c != before;
+ }
+ }
+ if changed {
+ s.revision += 1;
+ }
+ });
+ }
+ fn clear_action(&self) -> Result<(), String> {
+ if self.inner.clear_busy.replace(true) {
+ return Err("A history clear is already being saved".into());
+ }
+ let b = self.clone();
+ leptos::task::spawn_local(async move {
+ let result = async {
+ let kept = library_disk::account(b.fid).await?;
+ if !b.active() {
+ return Err("Library session changed".into());
+ }
+ if let Some(json) = kept {
+ let meta = serde_json::from_str(&json).map_err(|_| "Unreadable Library metadata")?;
+ b.merge_metadata(meta);
+ }
+ if b.library.0.with_untracked(|s| s.metadata.pending_clear.is_some()) {
+ return Err("A history clear from this account is still syncing".into());
+ }
+ let sequence = b.sequence();
+ let random = crate::keys::random::<16>().ok_or("Couldn't create a clear operation")?;
+ let operation = random.iter().map(|b| format!("{b:02x}")).collect();
+ let mut changed = Vec::new();
+ b.library.0.update(|s| {
+ s.metadata.pending_clear =
+ Some(PendingClear { operation, expected_generation: s.metadata.server.generation, sequence });
+ let account = s.metadata.effective();
+ for (hash, c) in &mut s.items {
+ let old = c.clone();
+ c.reconcile(&account);
+ if old != *c {
+ changed.push(hash.clone());
+ }
+ }
+ s.checked.clear();
+ s.revision += 1;
+ });
+ b.inner.groups.borrow_mut().clear();
+ b.persist_meta();
+ for hash in changed {
+ b.persist_item(&hash);
+ }
+ Ok::<_, String>(())
+ }
+ .await;
+ b.inner.clear_busy.set(false);
+ if b.active()
+ && let Err(error) = result
+ {
+ b.failed(error);
+ }
+ });
+ Ok(())
+ }
+ fn bootstrap(&self) {
+ if self.inner.disk.get() || self.inner.bootstrapping.replace(true) {
+ return;
+ }
+ let fid = self.fid;
+ let b = self.clone();
+ leptos::task::spawn_local(async move {
+ let result = async {
+ let metadata = library_disk::account(fid).await?;
+ let pending = library_disk::pending(fid, 100).await?;
+ Ok::<_, String>((metadata, pending))
+ }
+ .await;
+ b.inner.bootstrapping.set(false);
+ if !b.active() {
+ return;
+ }
+ match result {
+ Ok((metadata, pending)) => {
+ if let Some(json) = metadata {
+ match serde_json::from_str::(&json) {
+ Ok(meta) => b.merge_metadata(meta),
+ Err(_) => {
+ b.inner.disk_failed.set(true);
+ b.failed("Unreadable Library account metadata".into());
+ return;
+ }
+ }
+ }
+ b.merge_cached(pending);
+ b.inner.disk.set(true);
+ b.ready();
+ b.watches();
+ b.drain();
+ }
+ Err(e) => {
+ b.inner.disk_failed.set(true);
+ b.failed(e);
+ }
+ }
+ });
+ }
+ fn action(&self, hash: String, action: Action) -> Result<(), String> {
+ let pending = self.library.0.with_untracked(|s| s.items.get(&hash).is_some_and(|item| action.pending(item)));
+ let inflight = self.inner.in_flight.borrow().get(&hash).is_some_and(|flight| {
+ matches!((action.bookmark(), flight), (true, Flight::Bookmark(_)) | (false, Flight::Read(_)))
+ });
+ let busy = (hash.clone(), action.bookmark());
+ if pending || inflight || !self.inner.busy.borrow_mut().insert(busy.clone()) {
+ if matches!(action, Action::Seen(_)) {
+ return Ok(());
+ }
+ return Err("Syncing the previous change to this post; try again when it finishes".into());
+ }
+ let b = self.clone();
+ leptos::task::spawn_local(async move {
+ let answer = async {
+ let old = library_disk::items(b.fid, std::slice::from_ref(&hash)).await?;
+ if !b.active() {
+ return Err("Library session changed".into());
+ }
+ b.merge_cached(old);
+ if b.library.0.with_untracked(|s| s.items.get(&hash).is_some_and(|item| action.pending(item))) {
+ if matches!(action, Action::Seen(_)) {
+ return Ok(());
+ }
+ return Err("Another tab is syncing this post's previous change; try again when it finishes".into());
+ }
+ let sequence = b.sequence();
+ let mut result: Result<(), String> = Ok(());
+ b.library.0.update(|s| {
+ let generation = s.metadata.effective().generation;
+ let c = s.items.entry(hash.clone()).or_insert_with(|| empty(b.fid, &hash, generation));
+ match action {
+ Action::Toggle => match c.bookmarked() {
+ Some(value) => {
+ c.bookmark = Some(PendingBookmark {
+ desired: !value,
+ expected_revision: c.server.bookmark_revision,
+ sequence,
+ })
+ }
+ None => result = Err("Wait for this post's saved state to load".into()),
+ },
+ Action::Seen(at) => {
+ if !c.seen(generation) {
+ c.read = Some(PendingRead {
+ generation,
+ expected_revision: c.server.seen_revision,
+ intent: ReadIntent::Seen { at },
+ sequence,
+ });
+ }
+ }
+ Action::Unread => {
+ c.read = Some(PendingRead {
+ generation,
+ expected_revision: c.server.seen_revision,
+ intent: ReadIntent::Unread,
+ sequence,
+ })
+ }
+ }
+ s.revision += 1;
+ });
+ result?;
+ b.persist_item(&hash);
+ Ok::<_, String>(())
+ }
+ .await;
+ b.inner.busy.borrow_mut().remove(&busy);
+ if b.active()
+ && let Err(error) = answer
+ {
+ b.failed(error);
+ }
+ });
+ Ok(())
+ }
+ fn ready(&self) {
+ if self.inner.base.get()
+ && self.inner.disk.get()
+ && !self.inner.disk_failed.get()
+ && self.library.0.with_untracked(|s| s.metadata.legacy_imported)
+ {
+ let restore = self.library.0.with_untracked(|s| s.status != SyncStatus::Ready);
+ self.library.0.update(|s| {
+ s.status = SyncStatus::Ready;
+ });
+ // A failed operation retires all managed subscriptions. A later
+ // successful drain must restore them before presenting Ready.
+ // Rebuild only after releasing the State/RefCell borrows above.
+ if restore {
+ self.watches();
+ }
+ }
+ }
+ fn yield_for_clear(&self) -> bool {
+ let waiting = self.library.0.with_untracked(|s| s.metadata.pending_clear.is_some());
+ if waiting {
+ self.inner.drain_again.set(true);
+ }
+ waiting
+ }
+ fn drain(&self) {
+ if !self.active()
+ || !self.inner.base.get()
+ || !self.inner.disk.get()
+ || self.inner.disk_failed.get()
+ || self.inner.persisting.get()
+ || !self.inner.writes.borrow().is_empty()
+ {
+ return;
+ }
+ if self.inner.writing.replace(true) {
+ self.inner.drain_again.set(true);
+ return;
+ }
+ let b = self.clone();
+ let attempt = self.inner.attempt.get();
+ leptos::task::spawn_local(async move {
+ let result = b.drain_all(attempt).await;
+ b.inner.writing.set(false);
+ if b.active() && b.inner.attempt.get() == attempt {
+ match result {
+ Err(error) => b.failed(error),
+ Ok(()) => {
+ if b.inner.drain_again.replace(false) {
+ b.drain();
+ }
+ }
+ }
+ } else if b.active() {
+ b.drain();
+ }
+ });
+ }
+ async fn drain_all(&self, attempt: u64) -> Result<(), String> {
+ self.check(attempt)?;
+ // A clear is a durable, idempotent generation change and precedes reads
+ // created after it. The module's operation ID makes a lost ACK safe.
+ let clear = self.library.0.with_untracked(|s| s.metadata.pending_clear.clone());
+ if let Some(clear) = clear {
+ let sent = self
+ .call("clearSeen", &[(clear.expected_generation as f64).into(), clear.operation.clone().into()])
+ .await;
+ self.check(attempt)?;
+ self.refresh_account().await?;
+ self.check(attempt)?;
+ if self.library.0.with_untracked(|s| s.metadata.pending_clear.is_some()) {
+ sent?;
+ }
+ if self.library.0.with_untracked(|s| s.metadata.pending_clear.is_some()) {
+ return Err("History clear has not been acknowledged; retry to reconnect".into());
+ }
+ }
+ self.migrate_legacy(attempt).await?;
+ self.ready();
+ loop {
+ self.check(attempt)?;
+ if self.yield_for_clear() {
+ break;
+ }
+ if self.inner.persisting.get() || !self.inner.writes.borrow().is_empty() {
+ break;
+ }
+ let pending = library_disk::pending(self.fid, 100).await?;
+ self.check(attempt)?;
+ if pending.is_empty() {
+ break;
+ }
+ // Point membership is authoritative before replay. A higher remote
+ // tombstone cancels offline intent; never rebase an old conflict.
+ let hashes: Vec<_> = pending.iter().map(|p| p.server.hash.clone()).collect();
+ self.merge_cached(pending);
+ let rows = self.membership(&hashes).await?;
+ for row in rows {
+ let hash = row.hash.clone();
+ self.library.0.update(|s| {
+ let account = s.metadata.effective();
+ let c = s.items.entry(hash.clone()).or_insert_with(|| empty(self.fid, &hash, account.generation));
+ c.observe(row, &account);
+ clamp_replay(c, now(), account.cleared_at);
+ s.revision += 1;
+ });
+ }
+ let mut candidates = Vec::new();
+ for hash in &hashes {
+ let item = self
+ .library
+ .0
+ .with_untracked(|s| s.items.get(hash).cloned())
+ .ok_or("Missing Library outbox item")?;
+ library_disk::keep_item(self.fid, &item).await?;
+ self.check(attempt)?;
+ // Read back the atomically merged row: another tab can have
+ // committed newer intent while this snapshot was being saved.
+ let durable = library_disk::items(self.fid, std::slice::from_ref(hash)).await?;
+ self.check(attempt)?;
+ self.merge_cached(durable);
+ if let Some(item) =
+ self.library.0.with_untracked(|s| s.items.get(hash).cloned()).filter(CachedItem::pending)
+ {
+ candidates.push(item);
+ }
+ }
+ if candidates.is_empty() {
+ continue;
+ }
+ // A clear may have been requested while the point reads/commits
+ // above were awaiting. Restart with that durable generation change
+ // before dispatching any intent created after it.
+ if self.yield_for_clear() {
+ break;
+ }
+ // One reducer flight at a time. Automatic Seen observations share
+ // a bounded batch; user removals/bookmarks are serialized separately.
+ let seen: Vec<_> = candidates
+ .iter()
+ .filter_map(|c| {
+ c.read
+ .as_ref()
+ .filter(|r| matches!(r.intent, ReadIntent::Seen { .. }))
+ .map(|r| (c.server.hash.clone(), r.clone()))
+ })
+ .collect();
+ if !seen.is_empty() {
+ let generation = seen[0].1.generation;
+ let seen: Vec<_> = seen.into_iter().filter(|(_, r)| r.generation == generation).collect();
+ for (hash, read) in &seen {
+ self.inner.in_flight.borrow_mut().insert(hash.clone(), Flight::Read(read.clone()));
+ }
+ let entries:Vec<_>=seen.iter().map(|(hash,r)|serde_json::json!({"hash":hash,"at":match r.intent {ReadIntent::Seen{at}=>at.min(now()),ReadIntent::Unread=>0},"expected_revision":r.expected_revision})).collect();
+ let result = self
+ .call(
+ "markSeenBatch",
+ &[
+ (generation as f64).into(),
+ serde_json::to_string(&entries).map_err(|e| e.to_string())?.into(),
+ ],
+ )
+ .await;
+ self.finish_flights(&seen.iter().map(|(h, _)| h.clone()).collect::>(), result.is_ok(), attempt)
+ .await?;
+ result?;
+ } else if let Some(item) = candidates.first() {
+ let hash = item.server.hash.clone();
+ let result = if let Some(read) = &item.read {
+ self.inner.in_flight.borrow_mut().insert(hash.clone(), Flight::Read(read.clone()));
+ self.call(
+ "forgetSeen",
+ &[(read.generation as f64).into(), hash.clone().into(), (read.expected_revision as f64).into()],
+ )
+ .await
+ } else if let Some(bookmark) = &item.bookmark {
+ self.inner.in_flight.borrow_mut().insert(hash.clone(), Flight::Bookmark(bookmark.clone()));
+ self.call(
+ "setBookmark",
+ &[hash.clone().into(), bookmark.desired.into(), (bookmark.expected_revision as f64).into()],
+ )
+ .await
+ } else {
+ continue;
+ };
+ self.finish_flights(std::slice::from_ref(&hash), result.is_ok(), attempt).await?;
+ result?;
+ }
+ }
+ Ok(())
+ }
+ async fn finish_flights(&self, hashes: &[String], accepted: bool, attempt: u64) -> Result<(), String> {
+ self.check(attempt)?;
+ let fresh = self.membership(hashes).await;
+ self.check(attempt)?;
+ let rows = match fresh {
+ Ok(rows) => rows,
+ Err(error) => {
+ for hash in hashes {
+ self.inner.in_flight.borrow_mut().remove(hash);
+ let buffered = self.inner.buffered.borrow_mut().remove(hash);
+ if let Some(row) = buffered {
+ self.observe(row);
+ }
+ }
+ return Err(error);
+ }
+ };
+ for row in rows {
+ let hash = row.hash.clone();
+ let flight = self.inner.in_flight.borrow_mut().remove(&hash);
+ let buffered = self.inner.buffered.borrow_mut().remove(&hash);
+ self.library.0.update(|s| {
+ let account = s.metadata.effective();
+ let c = s.items.entry(hash.clone()).or_insert_with(|| empty(self.fid, &hash, account.generation));
+ apply_flight(c, flight, accepted, row, buffered, &account);
+ s.revision += 1;
+ });
+ if let Some(item) = self.library.0.with_untracked(|s| s.items.get(&hash).cloned()) {
+ library_disk::keep_item(self.fid, &item).await?;
+ }
+ }
+ Ok(())
+ }
+ async fn refresh_account(&self) -> Result<(), String> {
+ let attempt = self.inner.attempt.get();
+ let bridge = self.bridge()?;
+ let tag = format!("library-meta-{}", next_id());
+ let _lease = Lease { bridge, tag: tag.clone(), hashes: BTreeSet::new() };
+ let value = self
+ .raw("snapshot", &[tag.into(), "myLibraryState".into(), "[\"SELECT * FROM my_library_state\"]".into()])?;
+ let rows: Vec = parse(crate::notification_sync::bounded(value).await?)?;
+ self.check(attempt)?;
+ let row = rows.into_iter().find(|r| r.fid == self.fid).ok_or("Private Library metadata is missing")?;
+ observe_account(self, row);
+ Ok(())
+ }
+ async fn migrate_legacy(&self, attempt: u64) -> Result<(), String> {
+ if self.library.0.with_untracked(|s| s.metadata.legacy_imported) {
+ return Ok(());
+ }
+ let entries = crate::seen::legacy_entries(self.fid);
+ for batch in entries.chunks(100) {
+ self.check(attempt)?;
+ let hashes: Vec<_> = batch.iter().filter_map(|(hash, _)| canonical_hash(hash)).collect();
+ if hashes.is_empty() {
+ continue;
+ }
+ let rows = self.membership(&hashes).await?;
+ for row in rows {
+ let hash = row.hash.clone();
+ let at = batch.iter().find(|(h, _)| h == &hash).map_or(0, |(_, at)| *at);
+ let sequence = self.sequence();
+ self.library.0.update(|s| {
+ let account = s.metadata.effective();
+ let c = s.items.entry(hash.clone()).or_insert_with(|| empty(self.fid, &hash, account.generation));
+ c.observe(row, &account);
+ if !c.pending()
+ && !c.server.seen
+ && c.server.seen_revision == 0
+ && at > account.cleared_at
+ && at <= now()
+ {
+ c.read = Some(PendingRead {
+ generation: account.generation,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at },
+ sequence,
+ });
+ }
+ s.revision += 1;
+ });
+ if let Some(item) = self.library.0.with_untracked(|s| s.items.get(&hash).cloned()) {
+ library_disk::keep_item(self.fid, &item).await?;
+ }
+ }
+ }
+ let mut meta = self.library.0.with_untracked(|s| s.metadata.clone());
+ meta.legacy_imported = true;
+ library_disk::keep_account(self.fid, &serde_json::to_string(&meta).map_err(|e| e.to_string())?).await?;
+ self.check(attempt)?;
+ self.library.0.update(|s| s.metadata.legacy_imported = true);
+ Ok(())
+ }
+}
+fn merge_row(current: &mut Item, incoming: Item, account: &Account) {
+ let mut cache = CachedItem { server: current.clone(), known: true, ..CachedItem::default() };
+ cache.observe(incoming, account);
+ *current = cache.server;
+}
+fn fold_rows(rows: Vec- , fid: u64, generation: u64) -> BTreeMap
{
+ let account = Account { fid, generation, ..Account::default() };
+ let mut folded = BTreeMap::::new();
+ for mut row in rows
+ .into_iter()
+ .filter(|r| r.fid == fid && r.generation == generation && canonical_hash(&r.hash).as_ref() == Some(&r.hash))
+ {
+ row.cursor.clear();
+ if let Some(old) = folded.get_mut(&row.hash) {
+ merge_row(old, row, &account);
+ } else {
+ folded.insert(row.hash.clone(), row);
+ }
+ }
+ folded
+}
+fn apply_flight(
+ cache: &mut CachedItem,
+ flight: Option,
+ accepted: bool,
+ row: Item,
+ buffered: Option- ,
+ account: &Account,
+) {
+ match (accepted, flight) {
+ (true, Some(Flight::Read(sent))) => cache.accepted_read(&sent, row, account),
+ (true, Some(Flight::Bookmark(sent))) => cache.accepted_bookmark(&sent, row, account),
+ _ => cache.observe(row, account),
+ }
+ // A newer remote operation can arrive after the one-shot response was
+ // captured but before its Promise resumes Rust. It remains authoritative.
+ if let Some(buffered) = buffered {
+ cache.observe(buffered, account);
+ }
+}
+fn clamp_replay(item: &mut CachedItem, now: i64, cleared_at: i64) {
+ if let Some(read) = item.read.as_mut()
+ && let ReadIntent::Seen { at } = &mut read.intent
+ {
+ *at = (*at).min(now);
+ if *at <= cleared_at {
+ let seq = read.sequence;
+ item.acknowledged_read(seq);
+ }
+ }
+}
+fn stamp(row: &Item, kind: PrivateKind) -> i64 {
+ if kind == PrivateKind::Seen { row.seen_at } else { row.bookmark_at }
+}
+fn valid_cursor(value: &str) -> bool {
+ value.is_empty()
+ || value
+ .split_once(':')
+ .is_some_and(|(at, h)| at.parse::
().is_ok_and(|v| v > 0) && canonical_hash(h).as_deref() == Some(h))
+}
+fn before(row: &Item, after: &str, kind: PrivateKind) -> bool {
+ after
+ .split_once(':')
+ .and_then(|(at, h)| at.parse::().ok().map(|at| (stamp(row, kind), row.hash.as_str()) < (at, h)))
+ .unwrap_or(false)
+}
+
+pub(crate) fn account(fid: Option) {
+ let Some(library) = handle() else { return };
+ if current().map(|b| b.fid) == fid && library.0.with_untracked(|s| s.fid) == fid {
+ return;
+ }
+ if let Some(b) = CURRENT.with(|c| c.borrow_mut().take()) {
+ b.stop();
+ }
+ crate::pages::library::clear_cached();
+ library.0.set(State {
+ fid,
+ status: if fid.is_some() { SyncStatus::Connecting } else { SyncStatus::SignedOut },
+ metadata: Metadata { fid: fid.unwrap_or(0), ..Metadata::default() },
+ ..State::default()
+ });
+ let Some(fid) = fid else { return };
+ let b = Broker {
+ fid,
+ library,
+ inner: Rc::new(Inner {
+ stopped: Cell::new(false),
+ attempt: Cell::new(0),
+ bridge: RefCell::new(None),
+ base: Cell::new(false),
+ disk: Cell::new(false),
+ disk_failed: Cell::new(false),
+ bootstrapping: Cell::new(false),
+ clear_busy: Cell::new(false),
+ loading: RefCell::new(BTreeSet::new()),
+ groups: RefCell::new(BTreeMap::new()),
+ writing: Cell::new(false),
+ drain_again: Cell::new(false),
+ persisting: Cell::new(false),
+ writes: RefCell::new(VecDeque::new()),
+ in_flight: RefCell::new(BTreeMap::new()),
+ buffered: RefCell::new(BTreeMap::new()),
+ busy: RefCell::new(BTreeSet::new()),
+ touches: RefCell::new(BTreeMap::new()),
+ clock: Cell::new(0),
+ }),
+ };
+ CURRENT.with(|c| *c.borrow_mut() = Some(b.clone()));
+ b.load_watched();
+ b.bootstrap();
+}
+
+pub(crate) fn capability(fid: u64, enabled: Option) {
+ if let Some(b) = current().filter(|b| b.fid == fid) {
+ b.library.0.update(|s| {
+ s.capability = enabled;
+ if enabled == Some(false) {
+ s.status = SyncStatus::Failed("Private Library sync is not configured on this server".into());
+ }
+ });
+ }
+}
+pub(crate) fn bind(fid: u64, attempt: u64, bridge: JsValue) {
+ if let Some(b) = current().filter(|b| b.fid == fid) {
+ b.inner.groups.borrow_mut().clear();
+ b.inner.in_flight.borrow_mut().clear();
+ b.inner.buffered.borrow_mut().clear();
+ b.inner.attempt.set(attempt);
+ b.inner.base.set(false);
+ *b.inner.bridge.borrow_mut() = Some(bridge);
+ b.library.0.update(|s| {
+ s.checked.clear();
+ s.status = SyncStatus::Connecting;
+ });
+ }
+}
+pub(crate) fn base_applied(fid: u64) -> Result<(), String> {
+ let b = current().filter(|b| b.fid == fid).ok_or("Library session changed")?;
+ let bridge = b.bridge()?;
+ let rows: Vec = parse(
+ method(&bridge, "rows")?.call1(&bridge, &"myLibraryState".into()).map_err(crate::stdb_bridge::js_error)?,
+ )?;
+ let row = rows.into_iter().find(|r| r.fid == fid).ok_or("Private Library metadata is missing")?;
+ b.inner.base.set(true);
+ observe_account(&b, row);
+ b.ready();
+ b.watches();
+ b.drain();
+ Ok(())
+}
+fn observe_account(b: &Broker, row: Account) {
+ if row.fid != b.fid {
+ return;
+ }
+ let mut changed = false;
+ let mut reset = false;
+ let mut settled = Vec::new();
+ b.library.0.update(|s| {
+ if s.metadata.known
+ && (row.generation, row.revision) < (s.metadata.server.generation, s.metadata.server.revision)
+ {
+ return;
+ }
+ let old = s.metadata.clone();
+ reset = row.generation != s.metadata.server.generation;
+ let foreign = s
+ .metadata
+ .pending_clear
+ .as_ref()
+ .filter(|p| row.generation > p.expected_generation && row.last_clear_id != p.operation)
+ .map(|p| p.expected_generation + 1);
+ s.metadata.server = row;
+ s.metadata.known = true;
+ if let Some(p) = s.metadata.pending_clear.clone()
+ && s.metadata.server.generation > p.expected_generation
+ {
+ s.metadata.clear_settled = s.metadata.clear_settled.max(p.sequence);
+ s.metadata.pending_clear = None;
+ }
+ let account = s.metadata.effective();
+ for (hash, c) in &mut s.items {
+ let previous = c.clone();
+ if foreign.is_some_and(|g| c.read.as_ref().is_some_and(|p| p.generation == g))
+ && let Some(read) = c.read.take()
+ {
+ c.acknowledged_read(read.sequence);
+ }
+ if !b.inner.in_flight.borrow().contains_key(hash) {
+ c.reconcile(&account);
+ }
+ if previous != *c {
+ settled.push(hash.clone());
+ changed = true;
+ }
+ }
+ changed |= old != s.metadata;
+ if changed {
+ s.revision += 1;
+ }
+ if reset {
+ s.checked.clear();
+ }
+ });
+ if reset {
+ b.inner.groups.borrow_mut().clear();
+ }
+ if changed {
+ b.persist_meta();
+ }
+ for hash in settled {
+ b.persist_item(&hash);
+ }
+ b.watches();
+}
+pub(crate) fn event(fid: u64, json: &str) {
+ #[derive(Deserialize)]
+ struct Event {
+ t: String,
+ table: String,
+ row: Option,
+ }
+ let Some(b) = current().filter(|b| b.fid == fid) else { return };
+ let Ok(e) = serde_json::from_str::(json) else { return };
+ if !matches!(e.t.as_str(), "insert" | "update") {
+ return;
+ }
+ let Some(row) = e.row else { return };
+ match e.table.as_str() {
+ "my_library_state" => {
+ if let Ok(row) = serde_json::from_value(row) {
+ observe_account(&b, row);
+ }
+ }
+ "my_library_membership" => {
+ if let Ok(row) = serde_json::from_value::- (row)
+ && row.fid == fid
+ {
+ b.observe(row);
+ }
+ }
+ _ => {}
+ }
+}
+pub(crate) fn connection_failed(fid: u64, error: String) {
+ if let Some(b) = current().filter(|b| b.fid == fid) {
+ b.inner.base.set(false);
+ b.inner.groups.borrow_mut().clear();
+ b.inner.bridge.borrow_mut().take();
+ b.library.0.update(|s| {
+ s.checked.clear();
+ s.status = if web_sys::window().is_some_and(|w| !w.navigator().on_line()) {
+ SyncStatus::Offline
+ } else {
+ SyncStatus::Failed(error)
+ };
+ s.revision += 1;
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ fn meta(generation: u64, revision: u64) -> Metadata {
+ Metadata {
+ fid: 3,
+ server: Account { fid: 3, generation, revision, ..Account::default() },
+ known: true,
+ ..Metadata::default()
+ }
+ }
+ #[test]
+ fn disk_metadata_keeps_the_latest_clear_and_never_resurrects_a_settled_operation() {
+ let mut current = meta(2, 4);
+ current.pending_clear = Some(PendingClear { operation: "a".repeat(32), expected_generation: 2, sequence: 20 });
+ let stale = current.clone();
+ let mut remote = meta(3, 5);
+ remote.clear_settled = 20;
+ remote.legacy_imported = true;
+ current.merge_disk(&remote);
+ current.merge_disk(&stale);
+ assert_eq!(current.server.generation, 3);
+ assert_eq!(current.server.revision, 5);
+ assert!(current.pending_clear.is_none());
+ assert!(current.legacy_imported);
+ assert_eq!(current.clear_settled, 20);
+ }
+ #[test]
+ fn concurrent_clear_metadata_uses_the_newest_local_sequence_and_preserves_server_revision() {
+ let mut first = meta(1, 9);
+ first.sequence = 10;
+ first.pending_clear = Some(PendingClear { operation: "a".repeat(32), expected_generation: 1, sequence: 10 });
+ let mut second = meta(1, 7);
+ second.sequence = 11;
+ second.pending_clear = Some(PendingClear { operation: "b".repeat(32), expected_generation: 1, sequence: 11 });
+ first.merge_disk(&second);
+ assert_eq!(first.server.revision, 9);
+ assert_eq!(first.pending_clear.as_ref().unwrap().operation, "b".repeat(32));
+ assert_eq!(first.effective().generation, 2);
+ let foreign = Metadata { fid: 4, ..meta(99, 99) };
+ let snapshot = first.clone();
+ first.merge_disk(&foreign);
+ assert_eq!(first, snapshot);
+ }
+ #[test]
+ fn private_page_cursor_is_a_strict_descending_time_hash_boundary() {
+ let hash = format!("0x{:040x}", 9);
+ let cursor = format!("100:{hash}");
+ assert!(valid_cursor(""));
+ assert!(valid_cursor(&cursor));
+ for invalid in ["0:x", "-1:x", "100:0xnope", "100:x' OR 1=1", "100:0x000000000000000000000000000000000000000A"]
+ {
+ assert!(!valid_cursor(invalid));
+ }
+ let row = Item { hash: hash.clone(), seen_at: 100, bookmark_at: 99, ..Item::default() };
+ assert!(!before(&row, &cursor, PrivateKind::Seen));
+ assert!(before(&row, &cursor, PrivateKind::Bookmarks));
+ assert!(before(&Item { hash: format!("0x{:040x}", 8), ..row }, &cursor, PrivateKind::Seen));
+ }
+ #[test]
+ fn memory_eviction_keeps_active_and_pending_items_and_preserves_recently_touched_rows() {
+ let mut items = BTreeMap::new();
+ let mut touches = BTreeMap::new();
+ for i in 0..2003 {
+ let hash = format!("0x{i:040x}");
+ items.insert(hash.clone(), empty(3, &hash, 0));
+ touches.insert(hash, i);
+ }
+ let active = format!("0x{:040x}", 0);
+ let pending = format!("0x{:040x}", 1);
+ items.get_mut(&pending).unwrap().read = Some(PendingRead {
+ generation: 0,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 100 },
+ sequence: 1,
+ });
+ let evicted = evictions(&items, &BTreeSet::from([active.clone()]), &touches);
+ assert_eq!(evicted.len(), 3);
+ assert!(!evicted.contains(&active));
+ assert!(!evicted.contains(&pending));
+ assert_eq!(evicted, vec![format!("0x{:040x}", 2), format!("0x{:040x}", 3), format!("0x{:040x}", 4)]);
+ assert_eq!(items.get(&pending).unwrap().read.as_ref().unwrap().intent, ReadIntent::Seen { at: 100 });
+ }
+ #[test]
+ fn corrected_clock_replay_clamps_future_observations_but_never_crosses_a_clear() {
+ let hash = format!("0x{:040x}", 1);
+ let mut item = empty(3, &hash, 2);
+ item.read = Some(PendingRead {
+ generation: 2,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 500 },
+ sequence: 17,
+ });
+ clamp_replay(&mut item, 200, 100);
+ assert_eq!(item.read.as_ref().unwrap().intent, ReadIntent::Seen { at: 200 });
+ clamp_replay(&mut item, 100, 100);
+ assert!(item.read.is_none());
+ assert_eq!(item.read_settled, 17);
+ }
+ #[test]
+ fn automatic_seen_and_explicit_bookmark_have_independent_pending_domains() {
+ let mut item = empty(3, &format!("0x{:040x}", 1), 0);
+ item.read = Some(PendingRead {
+ generation: 0,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 100 },
+ sequence: 1,
+ });
+ assert!(Action::Unread.pending(&item));
+ assert!(!Action::Toggle.pending(&item));
+ item.bookmark = Some(PendingBookmark { desired: true, expected_revision: 0, sequence: 2 });
+ assert!(Action::Toggle.pending(&item));
+ item.read = None;
+ assert!(!Action::Seen(101).pending(&item));
+ }
+ #[test]
+ fn sdk_union_fold_is_order_independent_for_read_and_bookmark_revisions() {
+ let hash = format!("0x{:040x}", 1);
+ let base = Item { fid: 3, hash: hash.clone(), ..Item::default() };
+ let read = Item { seen: true, seen_at: 100, seen_revision: 4, bookmark_revision: 1, ..base.clone() };
+ let bookmark =
+ Item { bookmarked: true, bookmark_at: 101, bookmark_revision: 5, seen_revision: 2, ..base.clone() };
+ let forward = fold_rows(vec![read.clone(), bookmark.clone()], 3, 0);
+ let reverse = fold_rows(vec![bookmark.clone(), read.clone(), base.clone()], 3, 0);
+ assert_eq!(forward, reverse);
+ let row = &forward[&hash];
+ assert!(row.seen && row.bookmarked);
+ assert_eq!(row.seen_revision, 4);
+ assert_eq!(row.bookmark_revision, 5);
+ assert!(fold_rows(vec![Item { fid: 4, ..base.clone() }, Item { generation: 1, ..base }], 3, 0).is_empty());
+ }
+ #[test]
+ fn buffered_remote_remove_after_own_ack_cannot_be_lost_or_restore_old_pending() {
+ let hash = format!("0x{:040x}", 1);
+ let base = Item { fid: 3, hash, ..Item::default() };
+ let account = Account { fid: 3, ..Account::default() };
+ let sent = PendingBookmark { desired: true, expected_revision: 0, sequence: 1 };
+ let mut cache =
+ CachedItem { server: base.clone(), known: true, bookmark: Some(sent.clone()), ..CachedItem::default() };
+ let own = Item { bookmarked: true, bookmark_revision: 1, bookmark_at: 100, ..base.clone() };
+ let remote = Item { bookmark_revision: 2, ..base.clone() };
+ apply_flight(&mut cache, Some(Flight::Bookmark(sent)), true, own, Some(remote), &account);
+ assert_eq!(cache.bookmarked(), Some(false));
+ assert_eq!(cache.server.bookmark_revision, 2);
+ assert!(cache.bookmark.is_none());
+ assert_eq!(cache.bookmark_settled, 1);
+ }
+}
diff --git a/crates/allthing-web/src/library_disk.rs b/crates/allthing-web/src/library_disk.rs
new file mode 100644
index 00000000..0e4aceef
--- /dev/null
+++ b/crates/allthing-web/src/library_disk.rs
@@ -0,0 +1,617 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! Durable private Library cache and outbox. Point reads never enumerate the
+//! history; an outbox page scans only one FID's pending-key range. A write is
+//! acknowledged only by transaction completion, after both copies commit.
+
+use std::cell::RefCell;
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+use std::task::{Context, Poll, Waker};
+
+use allthing_core::library::{CachedItem, canonical_hash};
+use js_sys::Array;
+use wasm_bindgen::{JsCast, JsValue, closure::Closure};
+use web_sys::{Event, IdbDatabase, IdbKeyRange, IdbOpenDbRequest, IdbRequest, IdbTransaction, IdbTransactionMode};
+
+const DATABASE: &str = "allthing-library-v1";
+const STORE: &str = "records";
+const MAX_RECORD: usize = 65_536;
+const MAX_PENDING: u32 = 100;
+
+fn error(value: JsValue) -> String {
+ crate::stdb_bridge::js_error(value)
+}
+
+struct Completion
{
+ settled: bool,
+ result: Option>,
+ waker: Option,
+}
+
+impl Default for Completion {
+ fn default() -> Self {
+ Self { settled: false, result: None, waker: None }
+ }
+}
+
+impl Completion {
+ fn finish(state: &RefCell, result: Result) {
+ let waker = {
+ let mut state = state.borrow_mut();
+ if state.settled {
+ return;
+ }
+ state.settled = true;
+ state.result = Some(result);
+ state.waker.take()
+ };
+ if let Some(waker) = waker {
+ waker.wake();
+ }
+ }
+
+ fn poll(state: &RefCell, cx: &Context<'_>) -> Poll> {
+ let mut state = state.borrow_mut();
+ if let Some(result) = state.result.take() {
+ Poll::Ready(result)
+ } else {
+ state.waker = Some(cx.waker().clone());
+ Poll::Pending
+ }
+ }
+}
+
+/// A connection belongs to this operation, including a version-change closer.
+/// Closing it does not cancel committed data or another operation's connection.
+struct Database {
+ db: IdbDatabase,
+ changed: Closure,
+}
+
+impl Database {
+ fn new(db: IdbDatabase) -> Result {
+ let close = db.clone();
+ let changed = Closure::new(move || close.close());
+ if let Err(failed) = db.add_event_listener_with_callback("versionchange", changed.as_ref().unchecked_ref()) {
+ db.close();
+ return Err(error(failed));
+ }
+ Ok(Self { db, changed })
+ }
+}
+
+impl Drop for Database {
+ fn drop(&mut self) {
+ let _ = self.db.remove_event_listener_with_callback("versionchange", self.changed.as_ref().unchecked_ref());
+ self.db.close();
+ }
+}
+
+/// IndexedDB cannot cancel a pending open request. Normal listeners are owned;
+/// cancellation replaces them with GC-owned one-shot upgrade-abort/success-close
+/// listeners with no captured request/self cycle, so a late open leaks no DB.
+struct Opening {
+ request: IdbOpenDbRequest,
+ state: Rc>>,
+ finished: Rc>,
+ success: Closure,
+ failed: Closure,
+ blocked: Closure,
+ upgrade: Closure,
+ timeout: Closure,
+ timer: Option,
+}
+
+impl Opening {
+ fn new() -> Result {
+ let factory = web_sys::window()
+ .ok_or("No browser window")?
+ .indexed_db()
+ .map_err(error)?
+ .ok_or("Library storage is unavailable")?;
+ let request = factory.open_with_u32(DATABASE, 1).map_err(error)?;
+ let state = Rc::new(RefCell::new(Completion::default()));
+ let finished = Rc::new(std::cell::Cell::new(false));
+ let success_state = Rc::downgrade(&state);
+ let success_request = request.clone();
+ let success_finished = finished.clone();
+ let failed_state = Rc::downgrade(&state);
+ let failed_finished = finished.clone();
+ let failed_request = request.clone();
+ let blocked_state = Rc::downgrade(&state);
+ let upgrade_state = Rc::downgrade(&state);
+ let upgrade_request = request.clone();
+ let timeout_state = Rc::downgrade(&state);
+ let mut opening = Self {
+ request,
+ state,
+ finished,
+ success: Closure::new(move || {
+ success_finished.set(true);
+ let result = success_request
+ .result()
+ .map_err(error)
+ .and_then(|value| value.dyn_into::().map_err(error));
+ if let Some(state) = success_state.upgrade() {
+ Completion::finish(&state, result);
+ } else if let Ok(db) = result {
+ db.close();
+ }
+ }),
+ failed: Closure::new(move || {
+ failed_finished.set(true);
+ if let Some(state) = failed_state.upgrade() {
+ let message = failed_request
+ .error()
+ .ok()
+ .flatten()
+ .map(|error| error.message())
+ .unwrap_or_else(|| "Couldn't open Library storage".into());
+ Completion::finish(&state, Err(message));
+ }
+ }),
+ blocked: Closure::new(move || {
+ if let Some(state) = blocked_state.upgrade() {
+ Completion::finish(&state, Err("Library storage is blocked by another tab".into()));
+ }
+ }),
+ upgrade: Closure::new(move || {
+ let result = upgrade_request
+ .result()
+ .map_err(error)
+ .and_then(|value| value.dyn_into::().map_err(error))
+ .and_then(|db| db.create_object_store(STORE).map(|_| ()).map_err(error));
+ if let Err(error) = result {
+ if let Some(tx) = upgrade_request.transaction() {
+ let _ = tx.abort();
+ }
+ if let Some(state) = upgrade_state.upgrade() {
+ Completion::finish(&state, Err(error));
+ }
+ }
+ }),
+ timeout: Closure::new(move || {
+ if let Some(state) = timeout_state.upgrade() {
+ Completion::finish(&state, Err("Library storage took too long to open".into()));
+ }
+ }),
+ timer: None,
+ };
+ for (name, callback) in opening.listeners() {
+ opening.request.add_event_listener_with_callback(name, callback).map_err(error)?;
+ }
+ opening.timer = Some(crate::stdb_bridge::set_timeout(opening.timeout.as_ref(), 30_000)?);
+ Ok(opening)
+ }
+
+ fn listeners(&self) -> [(&'static str, &js_sys::Function); 4] {
+ [
+ ("success", self.success.as_ref().unchecked_ref()),
+ ("error", self.failed.as_ref().unchecked_ref()),
+ ("blocked", self.blocked.as_ref().unchecked_ref()),
+ ("upgradeneeded", self.upgrade.as_ref().unchecked_ref()),
+ ]
+ }
+}
+
+impl Future for Opening {
+ type Output = Result;
+ fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll {
+ Completion::poll(&self.state, cx)
+ }
+}
+
+impl Drop for Opening {
+ fn drop(&mut self) {
+ if let Some(timer) = self.timer.take() {
+ crate::stdb_bridge::clear_timeout(&timer);
+ }
+ for (name, callback) in self.listeners() {
+ let _ = self.request.remove_event_listener_with_callback(name, callback);
+ }
+ if let Some(Ok(db)) = self.state.borrow_mut().result.take() {
+ db.close();
+ }
+ if self.finished.get() {
+ return;
+ }
+ if let Some(tx) = self.request.transaction() {
+ let _ = tx.abort();
+ }
+ let once = web_sys::AddEventListenerOptions::new();
+ once.set_once(true);
+ let close = Closure::::new(|event: Event| {
+ if let Some(request) = event.target().and_then(|target| target.dyn_into::().ok())
+ && let Ok(db) = request.result().and_then(|value| value.dyn_into::())
+ {
+ db.close();
+ }
+ })
+ .into_js_value();
+ let abort = Closure::::new(|event: Event| {
+ if let Some(request) = event.target().and_then(|target| target.dyn_into::().ok())
+ && let Some(tx) = request.transaction()
+ {
+ let _ = tx.abort();
+ }
+ })
+ .into_js_value();
+ let _ = self.request.add_event_listener_with_callback_and_add_event_listener_options(
+ "success",
+ close.unchecked_ref(),
+ &once,
+ );
+ let _ = self.request.add_event_listener_with_callback_and_add_event_listener_options(
+ "upgradeneeded",
+ abort.unchecked_ref(),
+ &once,
+ );
+ }
+}
+
+struct Transaction {
+ database: Database,
+ tx: IdbTransaction,
+ state: Rc>>>,
+ requests: Rc>>,
+ callbacks: Vec<(IdbRequest, Closure)>,
+ complete: Closure,
+ failed: Closure,
+}
+
+impl Transaction {
+ fn new(database: Database, mode: IdbTransactionMode) -> Result {
+ let tx = database.db.transaction_with_str_and_mode(STORE, mode).map_err(error)?;
+ let state = Rc::new(RefCell::new(Completion::default()));
+ let requests = Rc::new(RefCell::new(Vec::::new()));
+ let completed_state = Rc::downgrade(&state);
+ let completed_requests = requests.clone();
+ let failed_state = Rc::downgrade(&state);
+ let failed_tx = tx.clone();
+ let transaction = Self {
+ database,
+ tx,
+ state,
+ requests,
+ callbacks: Vec::new(),
+ complete: Closure::new(move || {
+ if let Some(state) = completed_state.upgrade() {
+ Completion::finish(
+ &state,
+ completed_requests.borrow().iter().map(|request| request.result().map_err(error)).collect(),
+ );
+ }
+ }),
+ failed: Closure::new(move || {
+ if let Some(state) = failed_state.upgrade() {
+ let message = failed_tx
+ .error()
+ .map(|error| error.message())
+ .unwrap_or_else(|| "Library storage transaction aborted".into());
+ Completion::finish(&state, Err(message));
+ }
+ }),
+ };
+ for (name, callback) in transaction.listeners() {
+ transaction.tx.add_event_listener_with_callback(name, callback).map_err(error)?;
+ }
+ Ok(transaction)
+ }
+ fn listeners(&self) -> [(&'static str, &js_sys::Function); 3] {
+ [
+ ("complete", self.complete.as_ref().unchecked_ref()),
+ ("error", self.failed.as_ref().unchecked_ref()),
+ ("abort", self.failed.as_ref().unchecked_ref()),
+ ]
+ }
+ fn store(&self) -> Result {
+ self.tx.object_store(STORE).map_err(error)
+ }
+ fn read(&self, request: IdbRequest) {
+ self.requests.borrow_mut().push(request);
+ }
+ fn on_success(&mut self, request: IdbRequest, callback: Closure) -> Result<(), String> {
+ request.add_event_listener_with_callback("success", callback.as_ref().unchecked_ref()).map_err(error)?;
+ self.callbacks.push((request, callback));
+ Ok(())
+ }
+}
+
+impl Future for Transaction {
+ type Output = Result, String>;
+ fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll {
+ Completion::poll(&self.state, cx)
+ }
+}
+
+impl Drop for Transaction {
+ fn drop(&mut self) {
+ for (name, callback) in self.listeners() {
+ let _ = self.tx.remove_event_listener_with_callback(name, callback);
+ }
+ for (request, callback) in &self.callbacks {
+ let _ = request.remove_event_listener_with_callback("success", callback.as_ref().unchecked_ref());
+ }
+ if !self.state.borrow().settled {
+ let _ = self.tx.abort();
+ }
+ // Keep the connection owned through commit/error/cancellation.
+ self.database.db.close();
+ }
+}
+
+async fn transaction(mode: IdbTransactionMode) -> Result {
+ Transaction::new(Database::new(Opening::new()?.await?)?, mode)
+}
+
+fn prefix(kind: &str, fid: u64) -> Result {
+ if fid == 0 {
+ return Err("Library storage requires a signed-in account".into());
+ }
+ Ok(format!("{kind}:{fid:020}:"))
+}
+
+fn item_key(kind: &str, fid: u64, hash: &str) -> Result {
+ if canonical_hash(hash).as_deref() != Some(hash) {
+ return Err("Invalid Library cast hash".into());
+ }
+ Ok(format!("{}{hash}", prefix(kind, fid)?))
+}
+
+fn encoded_item(fid: u64, item: &CachedItem) -> Result {
+ item_key("cache", fid, &item.server.hash)?;
+ if item.server.fid != fid {
+ return Err("Library cache belongs to another account".into());
+ }
+ let json = serde_json::to_string(item).map_err(|_| "Couldn't encode Library storage".to_owned())?;
+ if json.len() > MAX_RECORD {
+ return Err("Library record is too large".into());
+ }
+ Ok(json)
+}
+
+fn decoded_item(fid: u64, value: JsValue) -> Result {
+ let json = value.as_string().ok_or("Unreadable Library cache record")?;
+ if json.len() > MAX_RECORD {
+ return Err("Library record is too large".into());
+ }
+ let item: CachedItem = serde_json::from_str(&json).map_err(|_| "Unreadable Library cache record")?;
+ encoded_item(fid, &item)?;
+ Ok(item)
+}
+
+/// Read only the requested points; a cold feed never loads the entire history.
+pub async fn items(fid: u64, hashes: &[String]) -> Result, String> {
+ prefix("cache", fid)?;
+ let mut unique = std::collections::HashSet::new();
+ let keys: Vec<_> = hashes
+ .iter()
+ .filter(|hash| unique.insert(hash.as_str()))
+ .map(|hash| item_key("cache", fid, hash).map(|key| (hash, key)))
+ .collect::>()?;
+ if keys.is_empty() {
+ return Ok(Vec::new());
+ }
+ let tx = transaction(IdbTransactionMode::Readonly).await?;
+ let store = tx.store()?;
+ for (_, key) in &keys {
+ tx.read(store.get(&JsValue::from_str(key)).map_err(error)?);
+ }
+ let found = tx.await?;
+ let mut items = Vec::new();
+ for ((hash, _), value) in keys.into_iter().zip(found) {
+ if value.is_undefined() {
+ continue;
+ }
+ let item = decoded_item(fid, value)?;
+ if item.server.hash != *hash {
+ return Err("Library cache hash does not match its key".into());
+ }
+ items.push(item);
+ }
+ Ok(items)
+}
+
+/// Cache and outbox updates share one commit; an aborted/quota-limited write
+/// cannot leave one new while the other is old. No pending item is evicted.
+pub async fn keep_item(fid: u64, item: &CachedItem) -> Result<(), String> {
+ encoded_item(fid, item)?;
+ let incoming = item.clone();
+ let (cache, pending) = (item_key("cache", fid, &item.server.hash)?, item_key("pending", fid, &item.server.hash)?);
+ let mut tx = transaction(IdbTransactionMode::Readwrite).await?;
+ let store = tx.store()?;
+ let request = store.get(&JsValue::from_str(&cache)).map_err(error)?;
+ let read = request.clone();
+ let state = Rc::downgrade(&tx.state);
+ let abort = tx.tx.clone();
+ let callback = Closure::::new(move || {
+ // This callback runs while IDB's read/write lock is still active.
+ // Queue both writes synchronously; awaiting between them could commit
+ // the transaction before the second tab's merge is durable.
+ let merged = (|| -> Result<(), String> {
+ let previous = read.result().map_err(error)?;
+ let item = if previous.is_undefined() {
+ incoming.clone()
+ } else {
+ let mut previous = decoded_item(fid, previous)?;
+ if previous.server.hash != incoming.server.hash {
+ return Err("Library cache hash does not match its key".into());
+ }
+ previous.merge_disk(&incoming);
+ previous
+ };
+ // Keep validation and serialization after the merge as well.
+ let json = encoded_item(fid, &item)?;
+ store.put_with_key(&JsValue::from_str(&json), &JsValue::from_str(&cache)).map_err(error)?;
+ if item.pending() {
+ store.put_with_key(&JsValue::from_str(&json), &JsValue::from_str(&pending)).map_err(error)?;
+ } else {
+ store.delete(&JsValue::from_str(&pending)).map_err(error)?;
+ }
+ Ok(())
+ })();
+ if let Err(error) = merged {
+ if let Some(state) = state.upgrade() {
+ Completion::finish(&state, Err(error));
+ }
+ let _ = abort.abort();
+ }
+ });
+ tx.on_success(request, callback)?;
+ tx.await.map(|_| ())
+}
+
+/// Scan just this account's outbox, at most100 records; the cache can be much
+/// larger. A caller drains later pages by acknowledging/removing pending intent.
+pub async fn pending(fid: u64, limit: u32) -> Result, String> {
+ let lower = prefix("pending", fid)?;
+ let limit = limit.min(MAX_PENDING);
+ if limit == 0 {
+ return Ok(Vec::new());
+ }
+ let range = IdbKeyRange::bound(&JsValue::from_str(&lower), &JsValue::from_str(&format!("{lower}\u{ffff}")))
+ .map_err(error)?;
+ let tx = transaction(IdbTransactionMode::Readonly).await?;
+ tx.read(tx.store()?.get_all_with_key_and_limit(range.as_ref(), limit).map_err(error)?);
+ let rows = tx.await?;
+ let values = Array::from(rows.first().ok_or("Library outbox did not answer")?);
+ let mut found = Vec::new();
+ for value in values.iter() {
+ let item = decoded_item(fid, value)?;
+ if !item.pending() {
+ return Err("Library outbox contains a settled record".into());
+ }
+ found.push(item);
+ }
+ Ok(found)
+}
+
+fn account_json(fid: u64, json: &str) -> Result<(), String> {
+ prefix("account", fid)?;
+ if json.len() > MAX_RECORD {
+ return Err("Library account metadata is too large".into());
+ }
+ let value: serde_json::Value = serde_json::from_str(json).map_err(|_| "Unreadable Library account metadata")?;
+ if !value.is_object() || value.get("fid").and_then(serde_json::Value::as_u64) != Some(fid) {
+ return Err("Library account metadata belongs to another account".into());
+ }
+ Ok(())
+}
+
+pub async fn account(fid: u64) -> Result, String> {
+ let key = prefix("account", fid)?;
+ let tx = transaction(IdbTransactionMode::Readonly).await?;
+ tx.read(tx.store()?.get(&JsValue::from_str(&key)).map_err(error)?);
+ let rows = tx.await?;
+ let value = rows.first().ok_or("Library account metadata did not answer")?;
+ if value.is_undefined() {
+ return Ok(None);
+ }
+ let json = value.as_string().ok_or("Unreadable Library account metadata")?;
+ account_json(fid, &json)?;
+ Ok(Some(json))
+}
+
+pub async fn keep_account(fid: u64, value: &str) -> Result<(), String> {
+ account_json(fid, value)?;
+ let incoming: crate::library::Metadata =
+ serde_json::from_str(value).map_err(|_| "Unreadable Library account metadata")?;
+ let key = prefix("account", fid)?;
+ let mut tx = transaction(IdbTransactionMode::Readwrite).await?;
+ let store = tx.store()?;
+ let request = store.get(&JsValue::from_str(&key)).map_err(error)?;
+ let read = request.clone();
+ let state = Rc::downgrade(&tx.state);
+ let abort = tx.tx.clone();
+ let callback = Closure::::new(move || {
+ let result = (|| -> Result<(), String> {
+ let previous = read.result().map_err(error)?;
+ let mut merged = if previous.is_undefined() {
+ incoming.clone()
+ } else {
+ let json = previous.as_string().ok_or("Unreadable Library account metadata")?;
+ account_json(fid, &json)?;
+ let mut previous: crate::library::Metadata =
+ serde_json::from_str(&json).map_err(|_| "Unreadable Library account metadata")?;
+ previous.merge_disk(&incoming);
+ previous
+ };
+ merged.fid = fid;
+ let json = serde_json::to_string(&merged).map_err(|e| e.to_string())?;
+ account_json(fid, &json)?;
+ store.put_with_key(&JsValue::from_str(&json), &JsValue::from_str(&key)).map_err(error)?;
+ Ok(())
+ })();
+ if let Err(error) = result {
+ if let Some(state) = state.upgrade() {
+ Completion::finish(&state, Err(error));
+ }
+ let _ = abort.abort();
+ }
+ });
+ tx.on_success(request, callback)?;
+ tx.await.map(|_| ())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ };
+ use std::task::Wake;
+
+ struct Woken(AtomicUsize);
+ impl Wake for Woken {
+ fn wake(self: Arc) {
+ self.0.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+
+ #[test]
+ fn aborted_commit_cannot_be_replaced_by_a_late_success_callback() {
+ let woke = Arc::new(Woken(AtomicUsize::new(0)));
+ let state =
+ RefCell::new(Completion:: { waker: Some(Waker::from(woke.clone())), ..Completion::default() });
+ Completion::finish(&state, Err("QuotaExceededError".into()));
+ Completion::finish(&state, Ok(7));
+ assert_eq!(state.borrow_mut().result.take(), Some(Err("QuotaExceededError".into())));
+ assert!(state.borrow().settled);
+ assert_eq!(woke.0.load(Ordering::Relaxed), 1);
+ }
+
+ #[test]
+ fn completion_before_the_first_poll_is_retained() {
+ let state = RefCell::new(Completion::default());
+ Completion::finish(&state, Ok(9usize));
+ let woke = Arc::new(Woken(AtomicUsize::new(0)));
+ let waker = Waker::from(woke.clone());
+ assert_eq!(Completion::poll(&state, &Context::from_waker(&waker)), Poll::Ready(Ok(9)));
+ assert!(state.borrow().settled);
+ assert_eq!(woke.0.load(Ordering::Relaxed), 0);
+ }
+
+ #[test]
+ fn outbox_prefixes_are_account_exact_and_records_are_typed() {
+ let hash = format!("0x{:040x}", 1);
+ let key = item_key("pending", 7, &hash).unwrap();
+ assert!(key.starts_with(&prefix("pending", 7).unwrap()));
+ for other in [1, 70, 71, u64::MAX] {
+ assert!(!key.starts_with(&prefix("pending", other).unwrap()));
+ }
+ assert!(item_key("cache", 0, &hash).is_err());
+ assert!(item_key("cache", 7, "malformed").is_err());
+ let mut item = CachedItem::default();
+ item.server.fid = 7;
+ item.server.hash = hash;
+ assert!(encoded_item(7, &item).is_ok());
+ assert!(encoded_item(8, &item).is_err());
+ assert!(account_json(7, r#"{"fid":7,"server":{"generation":1},"operation_sequence":20}"#).is_ok());
+ for json in [r#"{"fid":8}"#, r#"{"fid":"7"}"#, "[]", "{"] {
+ assert!(account_json(7, json).is_err());
+ }
+ assert!(account_json(7, &format!(r#"{{"fid":7,"padding":"{}"}}"#, "x".repeat(MAX_RECORD))).is_err());
+ }
+}
diff --git a/crates/allthing-web/src/notification_sync.rs b/crates/allthing-web/src/notification_sync.rs
index ccfae413..6f32626d 100644
--- a/crates/allthing-web/src/notification_sync.rs
+++ b/crates/allthing-web/src/notification_sync.rs
@@ -34,7 +34,8 @@ struct Sync {
}
struct Inner {
- not_before: f64,
+ not_before: Cell,
+ urgent: Cell,
stopped: Cell,
connecting: Cell,
disconnected: Cell,
@@ -60,6 +61,7 @@ fn visible_online() -> bool {
/// Called by the app's account effect, including sign-out. A stopped
/// instance fences every asynchronous completion, even after A → B → A.
pub fn account(fid: Option, notifications: Notifications) {
+ crate::library::account(fid);
if CURRENT.with(|s| s.borrow().as_ref().map(|s| s.fid)) == fid {
return;
}
@@ -71,7 +73,8 @@ pub fn account(fid: Option, notifications: Notifications) {
fid,
notifications,
inner: Arc::new(SendWrapper::new(Inner {
- not_before: js_sys::Date::now() + 2000.0,
+ not_before: Cell::new(js_sys::Date::now() + 2000.0),
+ urgent: Cell::new(false),
stopped: Cell::new(false),
connecting: Cell::new(false),
disconnected: Cell::new(false),
@@ -111,6 +114,17 @@ pub fn wake() {
}
}
+/// Foreground Library/feed readiness must not wait for an unrelated signer
+/// request. It still yields to browser idle and requires a visible online page;
+/// background-only notification sync retains its API-quiet startup delay.
+pub(crate) fn wake_urgent() {
+ if let Some(sync) = CURRENT.with(|s| s.borrow().clone()) {
+ sync.inner.urgent.set(true);
+ sync.inner.not_before.set(0.0);
+ wake();
+ }
+}
+
impl Sync {
fn active(&self) -> bool {
!self.inner.stopped.get() && self.notifications.are_for(self.fid)
@@ -136,13 +150,15 @@ impl Sync {
}
if sync.inner.ready.get() {
sync.flush();
- } else if !api::quiet() {
+ } else if !sync.inner.urgent.get() && !api::quiet() {
sync.schedule(250);
} else {
crate::components::cast::idle(move || sync.connect());
}
},
- Duration::from_millis(millis.max((self.inner.not_before - js_sys::Date::now()).max(0.0).ceil() as u64)),
+ Duration::from_millis(
+ millis.max((self.inner.not_before.get() - js_sys::Date::now()).max(0.0).ceil() as u64),
+ ),
);
*self.inner.timer.borrow_mut() = timer.ok();
}
@@ -190,11 +206,13 @@ impl Sync {
sync.detach();
return;
}
- if result.is_err() {
+ if let Err(error) = result {
+ crate::library::connection_failed(sync.fid, error);
sync.retry();
return;
}
sync.inner.failures.set(0);
+ sync.inner.urgent.set(false);
sync.inner.ready.set(true);
sync.flush();
});
@@ -213,14 +231,31 @@ impl Sync {
// service. Unconfigured servers/fixtures never load the SDK or dial.
#[derive(serde::Deserialize)]
struct Status {
+ #[serde(default)]
spacetime: Capability,
}
- #[derive(serde::Deserialize)]
+ #[derive(Default, serde::Deserialize)]
struct Capability {
+ #[serde(default)]
attest: bool,
+ #[serde(default)]
+ library: bool,
}
- let status = api::get::("/api/v1/status").await.map_err(|e| e.message())?;
+ let status = match api::get::("/api/v1/status").await {
+ Ok(status) => status,
+ Err(api::Failure::NotFound(error)) => {
+ crate::library::capability(self.fid, Some(false));
+ self.inner.urgent.set(false);
+ return Err(error);
+ }
+ Err(error) => return Err(error.message()),
+ };
self.check()?;
+ let library = status.data.spacetime.library;
+ crate::library::capability(self.fid, Some(library));
+ if !library {
+ self.inner.urgent.set(false);
+ }
if !status.data.spacetime.attest {
return Err("Notification sync is not configured".into());
}
@@ -278,8 +313,23 @@ impl Sync {
)
.await?;
self.check()?;
- bounded(self.raw("subscribe", &[QUERY.into()])?).await?;
+ if library {
+ crate::library::bind(
+ self.fid,
+ self.inner.attempt.get(),
+ self.inner.bridge.borrow().clone().ok_or("Not connected")?,
+ );
+ }
+ let query = if library {
+ "[\"SELECT * FROM my_notification_state\",\"SELECT * FROM my_library_state\"]"
+ } else {
+ QUERY
+ };
+ bounded(self.raw("subscribe", &[query.into()])?).await?;
self.check()?;
+ if library {
+ crate::library::base_applied(self.fid)?;
+ }
self.inner.state.borrow_mut().hydrated();
let state = self.inner.state.borrow();
self.notifications.synced(self.fid, state.seen(), state.baseline());
@@ -297,6 +347,7 @@ impl Sync {
}
fn event(&self, json: &str) {
+ crate::library::event(self.fid, json);
#[derive(serde::Deserialize)]
struct Event {
t: String,
@@ -312,6 +363,10 @@ impl Sync {
let Ok(event) = serde_json::from_str::(json) else { return };
if event.t == "disconnected" {
self.inner.disconnected.set(true);
+ crate::library::connection_failed(
+ self.fid,
+ "Library connection interrupted; your offline changes are kept".into(),
+ );
self.retry();
} else if event.table == "my_notification_state"
&& matches!(event.t.as_str(), "insert" | "update")
@@ -350,7 +405,7 @@ impl Sync {
}
/// A stalled connection/subscription/reducer cannot hold a retry forever.
-async fn bounded(value: JsValue) -> Result {
+pub(crate) async fn bounded(value: JsValue) -> Result {
let timer = RefCell::new(None);
let timeout = Promise::new(&mut |_, reject| {
let reject = reject.bind1(&JsValue::NULL, &"Notification sync timed out".into());
diff --git a/crates/allthing-web/src/pages/home.rs b/crates/allthing-web/src/pages/home.rs
index 23db731e..0fc05278 100644
--- a/crates/allthing-web/src/pages/home.rs
+++ b/crates/allthing-web/src/pages/home.rs
@@ -53,7 +53,8 @@ thread_local! {
#[component]
pub fn Home() -> impl IntoView {
let query = use_query_map();
- let sort = Memo::new(move |_| Sort::parse(query.read().get("sort").as_deref(), Sort::Hot));
+ let feed = feed_memo();
+ let sort = Memo::new(move |_| feed.get().sort(query.read().get("sort").as_deref()));
let range = Memo::new(move |_| TimeRange::parse(query.read().get("t").as_deref()));
// Its title whenever it shows; kept while another page shows (kept.rs),
// it leaves that page's alone.
@@ -77,7 +78,6 @@ pub fn Home() -> impl IntoView {
// between them re-sorts in place). Following lists the casts of whom a
// signed-in reader follows, in all three.
let newest = Memo::new(move |_| listed.get() == Sort::New);
- let feed = feed_memo();
let who = reader();
let previous = StoredValue::new(who.get_untracked());
let identity = Memo::new(move |_| {
@@ -615,13 +615,13 @@ fn Heading(sort: Memo, range: Memo) -> impl IntoView {
let client = client_param(&query.read()).map(|fid| fid.to_string());
// So does the tab.
let tab = feed.get().param();
- let sort_kept = (sort.get() != Sort::Hot).then(|| sort.get().as_str().to_owned());
+ let default_sort = feed.get().default_sort();
view! {
- {signed_in.get().then(|| view! { })}
+ {signed_in.get().then(|| view! { })}
-
+
{
- view! { }
+ view! { }
}
}
@@ -632,10 +632,10 @@ fn Heading(sort: Memo, range: Memo) -> impl IntoView {
/// A signed-in reader's two feeds, as X's: **For you** (the trending pool,
/// with the casts of whom they follow lifted) and **Following** (only
-/// theirs, `?feed=following`). Links, keeping the sort, the time range and
-/// the client filter; signed out there is only For you, and no tabs.
+/// theirs, `?feed=following`). Entering a tab selects its default sort while
+/// keeping the time range and client filter. Signed out there are no tabs.
#[component]
-fn FeedTabs(current: Feed, sort: Option, t: Option, client: Option) -> impl IntoView {
+fn FeedTabs(current: Feed, t: Option, client: Option) -> impl IntoView {
view! {
{[Feed::ForYou, Feed::Following]
@@ -643,7 +643,7 @@ fn FeedTabs(current: Feed, sort: Option, t: Option, client: Opti
.map(|feed| {
let to = with_query(
&href("/"),
- &[("sort", sort.clone()), ("t", t.clone()), ("client", client.clone()), (FEED_PARAM, feed.param())],
+ &[("t", t.clone()), ("client", client.clone()), (FEED_PARAM, feed.param())],
);
view! {
diff --git a/crates/allthing-web/src/pages/library.rs b/crates/allthing-web/src/pages/library.rs
new file mode 100644
index 00000000..99b18cfc
--- /dev/null
+++ b/crates/allthing-web/src/pages/library.rs
@@ -0,0 +1,585 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! Saved posts, public reactions and private reading history, with bounded pages.
+
+use leptos::prelude::*;
+
+pub fn page() -> AnyView {
+ #[cfg(not(feature = "static-host"))]
+ {
+ crate::split::page(with_worker::library_page()).into_any()
+ }
+ #[cfg(feature = "static-host")]
+ {
+ crate::util::set_title("Library");
+ view! {
+
+ }
+ .into_any()
+ }
+}
+
+/// Called by the account controller, including sign-out and A → B → A.
+/// In-flight page work also carries this epoch, so it cannot refill the cache.
+#[cfg(not(feature = "static-host"))]
+pub fn clear_cached() {
+ with_worker::clear_cached();
+}
+
+#[cfg(not(feature = "static-host"))]
+pub mod with_worker {
+ use std::cell::{Cell, RefCell};
+ use std::collections::{BTreeMap, HashMap, HashSet};
+ use std::sync::Arc;
+
+ use allthing_core::api::{LibraryCastsPayload, LibraryPayload, ReactionKind, library_casts_path, library_path};
+ use allthing_core::model::Cast;
+ use leptos::prelude::*;
+ use leptos::task::spawn_local;
+ use leptos_router::NavigateOptions;
+
+ use crate::Notes;
+ use crate::api;
+ use crate::components::bookmark::BookmarkButton;
+ use crate::components::cast::{CastList, CastSkeletons};
+ use crate::components::chips::TimeAgo;
+ use crate::components::signin::use_session;
+ use crate::library::{Library, PrivateCursor, PrivateKind, SyncStatus, use_library};
+ use crate::util::{cast_href, element_by_id, library_href, set_title, use_go, use_query_param, with_query};
+
+ const PANEL: &str = "library-panel";
+ const CACHE_MS: f64 = 300_000.0;
+ // Retain ordinary deep Back visits without keeping an entire account archive.
+ const CACHE_ROWS: usize = 1_000;
+
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+ enum Tab {
+ Bookmarks,
+ Likes,
+ Recasts,
+ Seen,
+ }
+
+ impl Tab {
+ const ALL: [Self; 4] = [Self::Bookmarks, Self::Likes, Self::Recasts, Self::Seen];
+ fn parse(value: Option<&str>) -> Self {
+ match value {
+ Some("likes") => Self::Likes,
+ Some("recasts") => Self::Recasts,
+ Some("seen") => Self::Seen,
+ _ => Self::Bookmarks,
+ }
+ }
+ fn query(self) -> &'static str {
+ match self {
+ Self::Bookmarks => "bookmarks",
+ Self::Likes => "likes",
+ Self::Recasts => "recasts",
+ Self::Seen => "seen",
+ }
+ }
+ fn label(self) -> &'static str {
+ match self {
+ Self::Bookmarks => "Bookmarks",
+ Self::Likes => "Likes",
+ Self::Recasts => "Recasts",
+ Self::Seen => "Seen",
+ }
+ }
+ fn private(self) -> Option {
+ match self {
+ Self::Bookmarks => Some(PrivateKind::Bookmarks),
+ Self::Seen => Some(PrivateKind::Seen),
+ _ => None,
+ }
+ }
+ fn reaction(self) -> Option {
+ match self {
+ Self::Likes => Some(ReactionKind::Likes),
+ Self::Recasts => Some(ReactionKind::Recasts),
+ _ => None,
+ }
+ }
+ fn empty(self) -> &'static str {
+ match self {
+ Self::Bookmarks => "No bookmarks yet. Save a post to find it here.",
+ Self::Likes => "No likes were returned by Farcaster.",
+ Self::Recasts => "No recasts were returned by Farcaster.",
+ Self::Seen => "Your reading history will appear here as you read posts.",
+ }
+ }
+ fn activity(self) -> &'static str {
+ match self {
+ Self::Bookmarks => "Saved",
+ Self::Likes => "Liked",
+ Self::Recasts => "Recast",
+ Self::Seen => "Seen",
+ }
+ }
+ }
+
+ fn tab_href(tab: Tab, query: &str) -> String {
+ with_query(
+ &library_href(),
+ &[
+ ("tab", (tab != Tab::Bookmarks).then(|| tab.query().to_owned())),
+ ("q", (!query.trim().is_empty()).then(|| query.trim().chars().take(200).collect())),
+ ],
+ )
+ }
+
+ #[derive(Clone)]
+ enum Cursor {
+ Private(PrivateCursor),
+ Public(String),
+ }
+
+ #[derive(Clone, Default)]
+ struct Listing {
+ pool: Vec,
+ times: BTreeMap,
+ unavailable: Vec,
+ filtered: Vec,
+ notes: Notes,
+ next: Option,
+ loaded: bool,
+ // A cached private page keeps the revision at which it was read. Back
+ // must offer a refresh if remote activity changed while it was away.
+ private_revision: Option,
+ stale: bool,
+ at: f64,
+ }
+
+ thread_local! {
+ static CACHE: RefCell> = RefCell::new(HashMap::new());
+ static EPOCH: Cell = const { Cell::new(0) };
+ }
+
+ pub fn clear_cached() {
+ EPOCH.with(|epoch| epoch.set(epoch.get().wrapping_add(1)));
+ CACHE.with(|cache| cache.borrow_mut().clear());
+ }
+
+ fn remember(fid: u64, tab: Tab, listing: &Listing) {
+ CACHE.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ cache.retain(|(owner, _), value| *owner == fid && js_sys::Date::now() - value.at < CACHE_MS);
+ if listing.times.len() <= CACHE_ROWS {
+ cache.insert((fid, tab), listing.clone());
+ } else {
+ cache.remove(&(fid, tab));
+ }
+ });
+ }
+
+ fn append(current: &mut Listing, page: Listing, reset: bool) {
+ if reset {
+ *current = Listing::default();
+ }
+ let mut hashes: HashSet = current.pool.iter().map(|cast| cast.hash.clone()).collect();
+ current.pool.extend(page.pool.into_iter().filter(|cast| hashes.insert(cast.hash.clone())));
+ current.times.extend(page.times);
+ for (into, extra) in [(&mut current.unavailable, page.unavailable), (&mut current.filtered, page.filtered)] {
+ let mut known: HashSet = into.iter().cloned().collect();
+ into.extend(extra.into_iter().filter(|hash| known.insert(hash.clone())));
+ }
+ Arc::make_mut(&mut current.notes).merge(&page.notes);
+ current.next = page.next;
+ current.loaded = true;
+ current.stale |= page.stale;
+ current.at = js_sys::Date::now();
+ }
+
+ async fn fetch_page(library: Library, fid: u64, tab: Tab, cursor: Option) -> Result {
+ if let Some(kind) = tab.private() {
+ let cursor = match cursor {
+ None => None,
+ Some(Cursor::Private(value)) => Some(value),
+ _ => return Err("Invalid private Library cursor.".into()),
+ };
+ let page = library.page(kind, cursor).await?;
+ let hashes: Vec = page.entries.iter().map(|entry| entry.hash.clone()).collect();
+ let times = page.entries.into_iter().map(|entry| (entry.hash, entry.at)).collect();
+ if hashes.is_empty() {
+ return Ok(Listing { times, next: page.next.map(Cursor::Private), ..Default::default() });
+ }
+ let fetched =
+ api::get::(&library_casts_path(&hashes)).await.map_err(|error| error.message())?;
+ let data = &fetched.data;
+ Ok(Listing {
+ pool: data.pool.clone(),
+ times,
+ unavailable: data.unavailable.clone(),
+ filtered: data.filtered.clone(),
+ notes: data.notes.clone().into(),
+ next: page.next.map(Cursor::Private),
+ stale: fetched.stale,
+ ..Default::default()
+ })
+ } else {
+ let kind = tab.reaction().ok_or("Unknown Library tab.")?;
+ let cursor = match cursor {
+ None => None,
+ Some(Cursor::Public(value)) => Some(value),
+ _ => return Err("Invalid reaction cursor.".into()),
+ };
+ let fetched = api::get::(&library_path(fid, kind, cursor.as_deref()))
+ .await
+ .map_err(|error| error.message())?;
+ let data = &fetched.data;
+ if data.fid != fid || data.kind != kind {
+ return Err("The Library answer belongs to another account or tab.".into());
+ }
+ Ok(Listing {
+ pool: data.pool.clone(),
+ times: data.entries.iter().map(|entry| (entry.hash.clone(), entry.at)).collect(),
+ unavailable: data.unavailable.clone(),
+ filtered: data.filtered.clone(),
+ notes: data.notes.clone().into(),
+ next: data.next.clone().map(Cursor::Public),
+ stale: fetched.stale,
+ ..Default::default()
+ })
+ }
+ }
+
+ #[leptos::wasm_split::wasm_split(library, wasm_split_path = ::leptos::wasm_split, fallible, preload(preload_library))]
+ pub fn library_page() -> Result {
+ Ok(view! { }.into_any())
+ }
+
+ #[component]
+ fn LibraryPage() -> impl IntoView {
+ set_title("Library");
+ let session = use_session();
+ let tab = use_query_param("tab");
+ let query = use_query_param("q");
+ let selected = Memo::new(move |_| tab.with(|tab| Tab::parse(tab.as_deref())));
+ let whose = Memo::new(move |_| session.user.with(|user| user.as_ref().map(|user| user.fid)));
+ view! {
+
+
"Library"
+ "Bookmarks and seen history are private to your account. Likes and recasts are public on Farcaster."
+
+
+
+
>()}
+ key=|(fid, tab)| (*fid, *tab)
+ children={move |(fid, tab)| view! { }}
+ />
+
+
+
"Sign in to see your Library."
+
"Sign in"
+
+
+
+
+ }
+ }
+
+ #[component]
+ fn Tabs(selected: Memo, query: Memo>) -> impl IntoView {
+ let go = use_go();
+ let keys = move |event: leptos::ev::KeyboardEvent| {
+ let at = Tab::ALL.iter().position(|tab| *tab == selected.get_untracked()).unwrap_or(0);
+ let next = match event.key().as_str() {
+ "ArrowRight" => (at + 1) % 4,
+ "ArrowLeft" => (at + 3) % 4,
+ "Home" => 0,
+ "End" => 3,
+ _ => return,
+ };
+ event.prevent_default();
+ let to = Tab::ALL[next];
+ go(
+ &tab_href(to, &query.get_untracked().unwrap_or_default()),
+ NavigateOptions { scroll: false, ..Default::default() },
+ );
+ request_animation_frame(move || {
+ if let Some(element) = element_by_id::(&format!("library-tab-{}", to.query())) {
+ let _ = element.focus();
+ }
+ });
+ };
+ view! {
+
+ {Tab::ALL.into_iter().map(|tab| view! {
+
{tab.label()}
+ }).collect_view()}
+
+ }
+ }
+
+ #[component]
+ fn Filter(selected: Memo, query: Memo>) -> impl IntoView {
+ let input = NodeRef::::new();
+ let go = use_go();
+ view! {
+
+ }
+ }
+
+ #[component]
+ fn List(fid: u64, tab: Tab, query: Memo>) -> impl IntoView {
+ let session = use_session();
+ let library = use_library();
+ let cached = CACHE
+ .with(|cache| {
+ cache.borrow().get(&(fid, tab)).filter(|value| js_sys::Date::now() - value.at < CACHE_MS).cloned()
+ })
+ .unwrap_or_default();
+ let listing = RwSignal::new(cached);
+ let loading = RwSignal::new(false);
+ let failure = RwSignal::new(None::);
+ let message = RwSignal::new(None::);
+ let changed = RwSignal::new(false);
+ let request = StoredValue::new(0_u64);
+ let retry_reset = StoredValue::new(true);
+ let loads = RwSignal::new(0_u32);
+ let last_revision = StoredValue::new(
+ listing.with_untracked(|value| value.private_revision).unwrap_or_else(|| untrack(|| library.revision())),
+ );
+ let load = Callback::new(move |reset: bool| {
+ if loading.get_untracked()
+ || session.user.with_untracked(|user| user.as_ref().map(|user| user.fid)) != Some(fid)
+ {
+ return;
+ }
+ if tab.private().is_some() && !matches!(library.status(), SyncStatus::Ready) {
+ library.retry();
+ return;
+ }
+ let cursor = if reset { None } else { listing.with_untracked(|value| value.next.clone()) };
+ request.update_value(|value| *value = value.wrapping_add(1));
+ let id = request.get_value();
+ retry_reset.set_value(reset);
+ let epoch = EPOCH.with(Cell::get);
+ loading.set(true);
+ failure.set(None);
+ spawn_local(async move {
+ let result = fetch_page(library, fid, tab, cursor).await;
+ if request.try_get_value() != Some(id)
+ || EPOCH.with(Cell::get) != epoch
+ || session.user.with_untracked(|user| user.as_ref().map(|user| user.fid)) != Some(fid)
+ {
+ return;
+ }
+ match result {
+ Ok(page) => {
+ listing.update(|current| {
+ append(current, page, reset);
+ current.private_revision = tab.private().map(|_| untrack(|| library.revision()));
+ });
+ listing.with_untracked(|current| remember(fid, tab, current));
+ last_revision.set_value(untrack(|| library.revision()));
+ changed.set(false);
+ }
+ Err(error) => failure.set(Some(error)),
+ }
+ loading.set(false);
+ loads.update(|value| *value = value.wrapping_add(1));
+ });
+ });
+ Effect::new(move |_| {
+ let ready = tab.private().is_none() || matches!(library.status(), SyncStatus::Ready);
+ if ready
+ && !listing.with_untracked(|value| value.loaded)
+ && !loading.get_untracked()
+ && failure.with_untracked(Option::is_none)
+ {
+ load.run(true);
+ }
+ });
+ Effect::new(move |_| {
+ let revision = library.revision();
+ if tab.private().is_some()
+ && revision != last_revision.get_value()
+ && listing.with_untracked(|value| value.loaded)
+ {
+ changed.set(true);
+ }
+ });
+ let filtered: Signal> = Memo::new(move |_| {
+ let text = query.get().unwrap_or_default().trim().to_lowercase();
+ listing.with(|value| value.pool.iter().filter(|cast| matches_query(cast, &text)).cloned().collect())
+ })
+ .into();
+ let notes = Signal::derive(move || listing.with(|value| value.notes.clone()));
+ let extra = Callback::new(move |cast: Cast| {
+ let hash = cast.hash.clone();
+ let at = Signal::derive(move || listing.with(|value| value.times.get(&hash).copied()).unwrap_or(0));
+ view! { }.into_any()
+ });
+ view! {
+
+ {move || match library.status() {
+ SyncStatus::SignedOut => "Sign in to sync private Library data.".to_owned(),
+ SyncStatus::Connecting => "Connecting your private Library…".to_owned(),
+ SyncStatus::Ready => "Private Library connected.".to_owned(),
+ SyncStatus::Offline => "Offline. Private Library sync will resume when you're online.".to_owned(),
+ SyncStatus::Failed(_) => "Private Library could not connect.".to_owned(),
+ }}
+
+ "Retry sync"
+
+
+
+
{format!("Most recently {} first.", tab.activity().to_ascii_lowercase())}
+
"Refresh"
+
+
+
+ "Your Library changed on this or another device. "
+ "Refresh this list"
+
+
+
+
+
+
+ "Your private Library is unavailable until sync reconnects. No empty history is assumed."
+
+
+
+ "Showing the last available Farcaster data. Refresh to try for a newer copy."
+
+
+ {move || if query.with(|query| query.as_ref().is_some_and(|query| !query.trim().is_empty())) {
+ "No loaded posts match this filter. Clear it or load older pages."
+ } else { tab.empty() }}
+
+
+
+ "Some loaded posts have mini apps and are hidden by your Settings."
+
+
+ {move || format!("{} loaded posts are hidden by content filters.", listing.with(|value| value.filtered.len()))}
+
+
+ "This post was not returned by Farcaster. It may be unavailable."
+ "Try opening the post"
+
+
+
+ }
+ }/>
+
+
+
+
{move || failure.get()}
+
"Try again"
+
+
+
+
+
+ {move || if loading.get() { "Loading…" } else { "Load more" }}
+
+
+
+ {move || message.get()}
+ {(tab == Tab::Seen).then(|| view! { })}
+ }
+ }
+
+ fn matches_query(cast: &Cast, query: &str) -> bool {
+ query.is_empty()
+ || cast.text.to_lowercase().contains(query)
+ || cast.author.username.to_lowercase().contains(query)
+ || cast.author.display_name.to_lowercase().contains(query)
+ }
+
+ #[component]
+ fn Activity(hash: String, at: Signal, tab: Tab) -> impl IntoView {
+ let library = use_library();
+ let message = RwSignal::new(None::);
+ let key = hash.clone();
+ let marked =
+ Memo::new(move |_| library.seen(&key) == Some(false) && !matches!(library.status(), SyncStatus::Failed(_)));
+ view! {
+
+ {tab.activity()}" "{move || (at.get() > 0).then(|| view! { })}
+ {(tab == Tab::Seen).then(|| view! {
+ message.set(Some("Mark-unread requested. Refresh this list after it syncs.".into())),
+ Err(error) => message.set(Some(error)),
+ }
+ }>{move || if marked.get() { "Unread requested" } else { "Mark unread" }}
+ })}
+ {move || message.get()}
+
+ }
+ }
+
+ #[component]
+ fn ClearHistory(message: RwSignal>) -> impl IntoView {
+ let library = use_library();
+ let armed = RwSignal::new(false);
+ view! {
+
+
"Clear reading history…"
+
+
+
"Clear reading history on all devices? Previously seen posts can appear in Home again. Your bookmarks stay saved."
+
{ message.set(Some("Reading-history reset requested.".into())); armed.set(false); }
+ Err(error) => message.set(Some(error)),
+ }
+ }>"Clear history on all devices"
+
"Cancel"
+
+
+
+ }
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+
+ #[test]
+ fn loaded_filter_covers_unicode_text_and_authors_without_changing_order() {
+ let cast: Cast = serde_json::from_str(r#"{"hash":"0x0000000000000000000000000000000000000001","text":"Café in Белград","ts":1,"author":{"fid":1,"username":"reader","display_name":"Alice Example"}}"#).unwrap();
+ assert!(matches_query(&cast, "белград"));
+ assert!(matches_query(&cast, "alice"));
+ assert!(matches_query(&cast, "reader"));
+ assert!(!matches_query(&cast, "not loaded"));
+ }
+ }
+}
diff --git a/crates/allthing-web/src/pages/mod.rs b/crates/allthing-web/src/pages/mod.rs
index 60f65769..f388ed1c 100644
--- a/crates/allthing-web/src/pages/mod.rs
+++ b/crates/allthing-web/src/pages/mod.rs
@@ -6,6 +6,7 @@ pub mod channels;
pub mod dau;
pub mod follows;
pub mod home;
+pub mod library;
pub mod messages;
pub mod notifications;
pub mod post;
diff --git a/crates/allthing-web/src/seen.rs b/crates/allthing-web/src/seen.rs
index 9973176e..cfa305c2 100644
--- a/crates/allthing-web/src/seen.rs
+++ b/crates/allthing-web/src/seen.rs
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-//! Local reading history: foreground viewport dwell, never fetched payloads.
+//! Foreground viewport dwell, never fetched payloads. Signed-in history is
+//! authoritative in the private Library; anonymous readers keep a local cache.
//! Feed snapshots only change on a new query/load/account or explicit reset.
use std::cell::{Cell, RefCell};
@@ -98,15 +99,47 @@ fn record(scope: &str, hash: &str) {
});
}
+/// Import only the current FID's old browser cache after private metadata has
+/// arrived. The Library fences the migration against remote clears/tombstones.
+#[cfg(not(feature = "static-host"))]
+pub(crate) fn legacy_entries(fid: u64) -> Vec<(String, i64)> {
+ STATE.with(|state| {
+ let mut state = state.borrow_mut();
+ reload(&mut state);
+ state.history.entries(&allthing_core::seen::scope(Some(fid)))
+ })
+}
+
/// Shared account and reset generation. Reading a cast never changes this
/// signal: cards on screen keep their place while the history is recorded.
#[derive(Clone, Copy)]
pub struct Seen {
scope: Memo,
epoch: RwSignal,
+ generation: Memo,
+ #[cfg(not(feature = "static-host"))]
+ library: crate::library::Library,
}
impl Seen {
+ /// A per-card memo keeps unrelated Library updates out of the dwell
+ /// registration effect, but lets an explicit Mark unread start a new read.
+ pub fn read_mark(self, hash: String) -> Memo> {
+ #[cfg(not(feature = "static-host"))]
+ {
+ Memo::new(move |_| self.library.seen(&hash))
+ }
+ #[cfg(feature = "static-host")]
+ {
+ let _ = hash;
+ Memo::new(move |_| None)
+ }
+ }
+
+ fn generation(self) -> u64 {
+ self.generation.get()
+ }
+
pub fn new() -> Self {
#[cfg(not(feature = "static-host"))]
let scope = {
@@ -115,7 +148,19 @@ impl Seen {
};
#[cfg(feature = "static-host")]
let scope = Memo::new(move |_| allthing_core::seen::scope(None));
- let seen = Self { scope, epoch: RwSignal::new(0) };
+ #[cfg(not(feature = "static-host"))]
+ let library = crate::library::use_library();
+ #[cfg(not(feature = "static-host"))]
+ let generation = Memo::new(move |_| library.generation());
+ #[cfg(feature = "static-host")]
+ let generation = Memo::new(move |_| 0);
+ let seen = Self {
+ scope,
+ epoch: RwSignal::new(0),
+ generation,
+ #[cfg(not(feature = "static-host"))]
+ library,
+ };
let _ = window_event_listener(leptos::ev::storage, move |event| {
if event.key().is_none_or(|key| key == KEY) {
STATE.with(|state| {
@@ -157,31 +202,71 @@ pub fn use_seen() -> Seen {
expect_context()
}
-/// Settings' reversible reset affects the current reader only, in this browser.
+/// Signed-in history controls are in Library, where scope and consequences are
+/// explicit. Anonymous/legacy readers can still reset their local cache here.
#[component]
pub fn ResetSeen() -> impl IntoView {
let seen = use_seen();
let cleared = RwSignal::new(false);
+ let remote = move || {
+ #[cfg(not(feature = "static-host"))]
+ {
+ seen.library.remote_required()
+ }
+ #[cfg(feature = "static-host")]
+ {
+ false
+ }
+ };
view! {
- "Reset seen posts"
- {move || if cleared.get() { "Seen history reset for this reader." } else { "Reading history stays in this browser for 30 days." }}
+ "Reset seen posts"
+ {move || if cleared.get() { "Seen history reset for this reader." } else { "This reader's local history stays in this browser for 30 days. Sign in on allthing.social to sync durable history." }}
+ }>
+ "Manage seen posts"
+ "Seen posts stay hidden on future Home visits across your signed-in devices. Manage or clear your private history in Library."
+
}
}
+type FeedKey = (String, u32, String, bool, u64);
+
#[derive(Clone, Default, PartialEq, Eq)]
struct Snapshot {
- key: (String, u32, String),
+ key: FeedKey,
hashes: BTreeSet,
}
+#[derive(Default)]
+struct Pins {
+ key: FeedKey,
+ drawn: BTreeSet,
+ hidden: BTreeSet,
+}
+
+impl Pins {
+ fn select(&mut self, key: &FeedKey) {
+ if &self.key != key {
+ self.key = key.clone();
+ self.drawn.clear();
+ self.hidden.clear();
+ }
+ }
+}
+
#[derive(Clone, Copy)]
pub struct Feed {
snapshot: Memo,
- shown: RwSignal>,
+ shown: RwSignal >,
+ pinned: StoredValue,
+ candidates: Signal>,
+ enabled: bool,
+ #[cfg(not(feature = "static-host"))]
+ library: crate::library::Library,
}
impl Feed {
- pub fn new(enabled: bool) -> Self {
+ pub fn new(enabled: bool, candidates: Signal>) -> Self {
let seen = use_seen();
let url = use_url();
// A query selects a new feed generation. As with Home's ranking,
@@ -204,6 +289,18 @@ impl Feed {
}
});
let previous = StoredValue::new(Snapshot::default());
+ #[cfg(not(feature = "static-host"))]
+ let library = seen.library;
+ #[cfg(not(feature = "static-host"))]
+ if enabled {
+ let owner = format!("feed:{}", js_sys::Math::random());
+ let watched = owner.clone();
+ Effect::new(move |_| {
+ let hashes = candidates.get();
+ untrack(|| library.watch_batch(&watched, &hashes));
+ });
+ on_cleanup(move || library.unwatch_batch(&owner));
+ }
let snapshot = Memo::new(move |_| {
if !enabled {
return Snapshot::default();
@@ -211,27 +308,99 @@ impl Feed {
let before = previous.get_value();
// Home is kept, detached, while reading a post/profile. Preserve
// that generation for Back, rather than shortening its scroll.
- let key = (seen.scope.get(), seen.epoch.get(), query.get());
+ #[cfg(not(feature = "static-host"))]
+ let (remote, generation) = (library.remote_required(), library.generation());
+ #[cfg(feature = "static-host")]
+ let (remote, generation) = (false, 0);
+ let key = (seen.scope.get(), seen.epoch.get(), query.get(), remote, generation);
if key == before.key {
return before;
}
- let hashes = STATE.with(|state| {
- let mut state = state.borrow_mut();
- reload(&mut state);
- state.history.hashes(&key.0)
- });
+ let hashes = if remote {
+ BTreeSet::new()
+ } else {
+ STATE.with(|state| {
+ let mut state = state.borrow_mut();
+ reload(&mut state);
+ state.history.hashes(&key.0)
+ })
+ };
let snapshot = Snapshot { key, hashes };
previous.set_value(snapshot.clone());
snapshot
});
- Self { snapshot, shown: RwSignal::new(None) }
+ Self {
+ snapshot,
+ shown: RwSignal::new(None),
+ pinned: StoredValue::new(Pins::default()),
+ candidates,
+ enabled,
+ #[cfg(not(feature = "static-host"))]
+ library,
+ }
}
pub fn hides(self, hash: &str) -> bool {
+ if !self.enabled {
+ return false;
+ }
self.snapshot.with(|snapshot| {
+ #[cfg(not(feature = "static-host"))]
+ if snapshot.key.3 {
+ if self.pinned.with_value(|pins| pins.key == snapshot.key && pins.drawn.contains(hash)) {
+ return false;
+ }
+ if self.pinned.with_value(|pins| pins.key == snapshot.key && pins.hidden.contains(hash)) {
+ return true;
+ }
+ if self.library.seen(hash) == Some(true) {
+ if self.library.membership_ready(&[hash.to_owned()]) {
+ self.pinned.update_value(|pins| {
+ pins.select(&snapshot.key);
+ pins.hidden.insert(hash.to_owned());
+ });
+ }
+ return true;
+ }
+ return !self.library.membership_ready(&[hash.to_owned()]) || self.library.seen(hash) != Some(false);
+ }
self.shown.get().as_ref() != Some(&snapshot.key) && snapshot.hashes.contains(&hash.to_ascii_lowercase())
})
}
+
+ /// Freeze only cards actually drawn, so reading one never moves the page.
+ /// Candidates below the drawn window still honor newly synced history.
+ pub fn preserve(self, hash: &str) {
+ let snapshot = self.snapshot.get_untracked();
+ self.pinned.update_value(|pins| {
+ pins.select(&snapshot.key);
+ pins.drawn.insert(hash.to_owned());
+ });
+ }
+
+ pub fn pending(self) -> bool {
+ #[cfg(not(feature = "static-host"))]
+ {
+ self.enabled && self.library.remote_required() && !self.library.membership_ready(&self.candidates.get())
+ }
+ #[cfg(feature = "static-host")]
+ {
+ let _ = self.candidates;
+ false
+ }
+ }
+
+ pub fn hidden(self, hash: &str) -> bool {
+ #[cfg(not(feature = "static-host"))]
+ if self.enabled && self.library.remote_required() {
+ return self.hides(hash)
+ && (self.library.seen(hash) == Some(true)
+ || self.pinned.with_value(|pins| {
+ pins.key == self.snapshot.with_untracked(|s| s.key.clone()) && pins.hidden.contains(hash)
+ }));
+ }
+ self.hides(hash)
+ }
}
/// Below the list, so a later page's result cannot move existing cards.
@@ -239,21 +408,50 @@ impl Feed {
pub fn SeenNotice(feed: Feed, #[prop(into)] hidden: Signal, #[prop(into)] empty: Signal) -> impl IntoView {
let seen = use_seen();
view! {
+
+
+
0 }>
{move || if empty.get() { "You're caught up in this feed. Previously seen posts are hidden." } else { "Previously seen posts are hidden." }}
-
"Show seen posts"
-
"Reset seen posts"
+
"View seen posts in Library" }>
+ "Show seen posts"
+ "Reset seen posts"
+
}
}
+#[component]
+fn SyncNotice() -> impl IntoView {
+ #[cfg(not(feature = "static-host"))]
+ {
+ let library = crate::library::use_library();
+ view! {
+
+
{move || match library.status() {
+ crate::library::SyncStatus::Failed(error) => format!("Couldn't sync reading history: {error}"),
+ crate::library::SyncStatus::Offline => "You're offline. New Home posts will wait until your reading history can sync.".to_owned(),
+ _ => "Syncing reading history before showing new posts…".to_owned(),
+ }}
+
"Retry history sync"
+
"Open Library"
+
+ }.into_any()
+ }
+ #[cfg(feature = "static-host")]
+ {
+ ().into_any()
+ }
+}
+
struct Watch {
element: Element,
seen: Seen,
scope: String,
epoch: u32,
+ generation: u64,
started: Option,
timer: Option,
done: bool,
@@ -297,6 +495,7 @@ fn notice(element: &Element) {
if !eligible(element)
|| watch.seen.scope.get_untracked() != watch.scope
|| watch.seen.epoch.get_untracked() != watch.epoch
+ || untrack(|| watch.seen.generation()) != watch.generation
{
stop(watch);
return;
@@ -319,14 +518,21 @@ fn finish(element: &Element) {
|| !eligible(element)
|| watch.seen.scope.get_untracked() != watch.scope
|| watch.seen.epoch.get_untracked() != watch.epoch
+ || untrack(|| watch.seen.generation()) != watch.generation
{
return None;
}
watch.done = true;
- Some((watch.scope.clone(), element.get_attribute("data-hash")?))
+ Some((watch.seen, watch.scope.clone(), element.get_attribute("data-hash")?))
});
- if let Some((scope, hash)) = record {
+ if let Some((seen, scope, hash)) = record {
self::record(&scope, &hash);
+ #[cfg(not(feature = "static-host"))]
+ if seen.library.remote_required() {
+ let _ = seen.library.record_seen(&hash, now());
+ }
+ #[cfg(feature = "static-host")]
+ let _ = seen;
}
}
@@ -358,12 +564,14 @@ pub fn watch(element: &Element, seen: Seen) {
unwatch(element);
let scope = seen.scope.get();
let epoch = seen.epoch.get();
+ let generation = seen.generation();
WATCHED.with(|watched| {
watched.borrow_mut().push(Watch {
element: element.clone(),
seen,
scope,
epoch,
+ generation,
started: None,
timer: None,
done: false,
diff --git a/crates/allthing-web/src/split.rs b/crates/allthing-web/src/split.rs
index e88fa217..3b65fc2b 100644
--- a/crates/allthing-web/src/split.rs
+++ b/crates/allthing-web/src/split.rs
@@ -134,6 +134,7 @@ fn page_part(path: &str) -> Option {
// The static copy has no notifications.
cfg!(not(feature = "static-host")) && path == "/notifications",
follows,
+ cfg!(not(feature = "static-host")) && path == "/library",
]
.iter()
.position(|&is| is)
@@ -155,7 +156,10 @@ pub fn preload_page(path: &str) {
4 => crate::pages::channels::preload_channels().await,
#[cfg(not(feature = "static-host"))]
5 => crate::pages::notifications::with_worker::preload_notifications().await,
- _ => crate::pages::follows::preload_follows().await,
+ 6 => crate::pages::follows::preload_follows().await,
+ #[cfg(not(feature = "static-host"))]
+ 7 => crate::pages::library::with_worker::preload_library().await,
+ _ => return,
};
// Couldn't come: the next intent asks again.
if fetched.is_err() {
diff --git a/crates/allthing-web/src/util.rs b/crates/allthing-web/src/util.rs
index 53c0e964..8e7dfaa1 100644
--- a/crates/allthing-web/src/util.rs
+++ b/crates/allthing-web/src/util.rs
@@ -140,6 +140,11 @@ pub fn dau_href() -> String {
href("/dau")
}
+/// The reader's private bookmarks/history and public Farcaster reactions.
+pub fn library_href() -> String {
+ href("/library")
+}
+
/// The reader's scheduled casts.
#[cfg(not(feature = "static-host"))]
pub fn scheduled_href() -> String {
diff --git a/crates/allthing-worker/src/lib.rs b/crates/allthing-worker/src/lib.rs
index b22ee5fa..412d1dd2 100644
--- a/crates/allthing-worker/src/lib.rs
+++ b/crates/allthing-worker/src/lib.rs
@@ -536,8 +536,8 @@ async fn status(inv: &Invocation, url: &Url, env: &Env) -> Result {
// messages (the ATTEST_KEY secret), and its public half, which the
// module pins (spacetime/spacetimedb/src/attest_key.rs); never the key.
"spacetime": match attest::public_key(env).await {
- Some(public_key) => serde_json::json!({ "attest": true, "public_key": public_key }),
- None => serde_json::json!({ "attest": attest::ready(env) }),
+ Some(public_key) => serde_json::json!({ "attest": true, "library": true, "public_key": public_key }),
+ None => serde_json::json!({ "attest": attest::ready(env), "library": false }),
},
// Whether votes in Allthing's poll are counted: SESSION_SECRET,
// which keys the voters, and D1, which keeps them.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e57cbf91..0efad6a9 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -51,13 +51,19 @@ Rust is the default for new application logic and native tooling. Pure shared be
The JavaScript early-card renderer (`web/paint.js`) is an explicit measured exception: the owner kept it after it beat the Rust prototype in a [paired cold-load comparison](PERFORMANCE.md#measured-exception-the-javascript-first-card-renderer). The small `boot.js` loader and inline data/preload hints also run before WebAssembly can start. Messages and notification read-state sync retain the official TypeScript SDK adapter and generated bindings; browser tests retain Playwright's official TypeScript runner, browser-side probes, independent protocol oracles and the existing Node static test server (`e2e/pages-server.mjs`). The test server stays after an unresolved pre-mount stall during migration CI, with causality unproven; [PERFORMANCE.md](PERFORMANCE.md#retained-node-static-test-server) records the conservative rollback. Wrangler and esbuild remain platform tools. CSS, SQL, HTML, fixtures and configuration stay in their appropriate formats. Keep exceptions narrow and documented; preserve licensing and measured behavior instead of reclassifying handwritten files to change language statistics.
-## Browser-local seen posts
+## Private Library and seen posts
-`allthing-core/src/seen.rs` bounds local history to 2,000 cast hashes and their seen timestamps per reader, 30 days, and four reader scopes (least recently used first to be evicted). A signed-in FID and the anonymous reader are separate. The browser keeps a versioned JSON object in `allthing.seen.v1`; no contents, authors, private messages or history leave the browser. Malformed/oversized/expired data is discarded. Storage denial falls back to memory for this visit.
+`/library` collects Bookmarks, Likes, Recasts and Seen, with URL-selected tabs, a filter explicitly limited to loaded posts, activity times, bounded Load more, retry without losing the cursor, and Back restoration. Missing upstream casts are reported as unavailable, separately from content-filtered rows; neither an upstream failure nor a private sync failure becomes an empty successful history. Mini apps follow Settings. A bookmark is private Allthing state, not a Farcaster reaction, and needs sign-in but no Farcaster write-key grant. The static copy links to the signed-in site.
-Only front-page `CastList`s opt in with `hide_seen=true`. Their actual mounted `CastCard`s use a shared viewport IntersectionObserver and one-second dwell timer (`allthing-web/src/seen.rs`). At least half the card's maximum possible viewport exposure must remain visible (half its own area for ordinary cards; half the viewport-sized portion for cards taller/wider than the screen) in a visible document; leaving that exposure, hiding the document, removing the card, or changing reader cancels the dwell. Early/inert boot cards, fetched off-screen cards, quotes and prefetches never count. The boot handover wakes observers only once the real app is visible. Scroll/resize rechecks are limited to intersecting cards and coalesced to one animation frame, so tall cards count without waiting for an impossible 50% observer ratio. Writes are batched after 500 ms and on pagehide; other tabs' existing history is merged before writing, using the current clock for pruning. A reset revision per reader also discards impressions another tab had pending before that reset. Empty reset tombstones share the same four-reader bound.
+Signed-in reading history and bookmarks live in private SpacetimeDB tables (`spacetime/spacetimedb/src/library.rs`), authenticated by the same immutable identity-to-FID attestation as notification state. Only caller-scoped views are exposed: `my_library_state`, membership of at most 100 supplied hashes, and at most 50 activity rows plus a continuation row. Indexed ordered ranges paginate in descending activity time/hash order, without loading an account's full history. Server history has no 30-day expiry or 2,000-post eviction. Likes and recasts are the currently retained Farcaster reactions, paginated through Hypersnap's single-FID hub endpoint and hydrated in ordered batches; removed or pruned reactions are not an archive.
-A feed snapshots history for its current account and query (tab, sort, range, app filter). Recording more casts never updates that snapshot, so cards do not disappear while read or as pages append. Reload or another query takes a new snapshot. The kept front page preserves its snapshot while a post/profile opens, so Back keeps its scroll position. Suppression only removes rows from the existing order, preserving new's chronology. Show seen posts below a filtered list restores that generation's rows; Reset seen posts there or in Settings clears only this reader's history and starts a new snapshot. Direct posts, profiles, search and threads are unaffected. Early paint skips browsers with a history key, avoiding a flash of posts the app will omit.
+The Rust Library broker shares the notification connection and SDK script, never Messages' credentials, chat subscriptions or DM keys. The Worker advertises `spacetime.library` only with its attestation key available. A missing capability on an older/unconfigured host retains local seen handling. An actual network/server failure is not evidence that a post is unread: a capable signed-in Home waits for authoritative membership before drawing new candidates. Signed-in visits also skip inert early cards, so a fresh device cannot flash a remotely seen cast before the app starts. Anonymous visits retain the measured JavaScript first-card renderer.
+
+IndexedDB `allthing-library-v1` stores per-FID cache rows and a bounded-read durable outbox. Atomic read/merge/write transactions protect acknowledgements and tombstones from stale tabs; writes are considered queued only after transaction commit. Account epochs fence A → B → A and late page/socket responses. Per-post revisions make a later remote remove/mark-unread win over stale offline intent. A global generation and deduplicated clear operation fence old impressions and lost acknowledgements. Clear history keeps bookmarks; individual Mark unread makes a post eligible on a future feed visit. Concurrent conflicts do not silently replay the disconnected device's older decision.
+
+Only front-page `CastList`s opt into recording. Actual mounted cards require one continuous second of at least half their possible viewport exposure in a visible document. Early/inert cards, fetched off-screen rows, quotes and prefetches never count. Account or reset generation changes cancel old dwell timers. Already drawn cards stay in place as reading syncs; future visits, sort/range/tab changes and not-yet-drawn candidates omit seen posts. Kept Home preserves already drawn geometry for Back. Direct posts, profiles, search, threads and Library remain available for deliberate revisiting.
+
+The existing `allthing.seen.v1` localStorage cache remains bounded to 2,000 hashes, 30 days and four reader scopes for anonymous/legacy hosts. A signed-in reader's surviving legacy rows are imported once only after metadata and membership have arrived, respecting server clear times and per-post tombstones. Data that had already expired from that old cache cannot be recovered. Storage denial is surfaced for durable private changes rather than claiming they are saved.
## The API
@@ -65,6 +71,8 @@ All endpoints are `GET` (`HEAD` also works), except the [sign-in](#sign-in) endp
| Endpoint | Payload | Built from |
|---|---|---|
+| `/api/v1/library?fid=&kind=likes\|recasts&cursor=` | `LibraryPayload`: ordered public reactions, hydrated casts, unavailable/filtered hashes and next cursor | Hypersnap `/v1/reactionsByFid`, one page of 25 on the FID's shard, then one `/v2/farcaster/casts` batch and bounded annotations |
+| `/api/v1/library/casts?hashes=` | `LibraryCastsPayload`: up to 25 ordered public casts for a private Library page; no private state | one Hypersnap cast batch and bounded annotations |
| `/api/v1/home` | `HomePayload`: a pool of about 100 trending casts, annotations (with the casts they quote), and on the first page how many voted each way in [Allthing's poll](#allthings-poll) (`poll`, once anyone has) | `feed/trending`, `casts` (quoted casts); the poll's two counts in D1 |
| `/api/v1/channel?id=` | `ChannelPayload`: the channel (with its banner, picture, description, follower and member counts, lead, moderators, pinned cast and link where the Farcaster app has them) and a pool of its casts | `channel`, `feed/parent_urls` (every spelling of the channel's parent URL) and farcaster.xyz `v1/channel` in parallel, then `user/bulk` (lead, moderators, mentions) and `cast` (the pinned cast) |
| `/api/v1/cast?hash=` | `CastPayload`: the thread (5 levels), the casts the reply answers (`ancestors`, root first, at most 8 read, without hidden ones; `earlier` when more couldn't be read), quoted casts, the channel | `cast/conversation`, `channel`, `cast` (once per cast above the reply, in turn, up to 8: the parent first, then up the chain), link pages, `cast` (quotes) |
@@ -453,7 +461,7 @@ A [signed-in](#sign-in) reader's notifications: who liked, recasted, replied to,
- **Grouped as the Farcaster app groups them** (`notifications::group`): likes of one cast together ("alice and 5 others liked your cast"), recasts of one cast together, follows on one UTC day together ("alice and 3 others followed you"), and each reply, mention and quote on its own. A person counts once in a group, which keeps its 5 newest people and how many there are. Groups are in the order of their newest event. A page is a page of Hypersnap's: likes of one cast on two pages are two groups.
- **The payload** (`NotificationsPayload`) holds the groups (each with its kind, people, count, newest time, and the cast it's about: its hash and its start, `notifications::excerpt`), the cursor of the next page, the newest time, and in `notes` the profile cards of the people shown, from the user objects that come with them, and the verdicts on their casts' links, carried to the next build. It is cached at the edge per account and page, fresh for a minute and served while one rebuild runs for ten, and answered `private, no-store`, so no browser or shared cache keeps one reader's for another.
- **Read state follows the account.** The app keeps the newest notification time seen in the existing localStorage cache (`allthing-notifications-seen`, `{"": }`), so local reads and other tabs still work offline. `allthing_core::notification_state::ReadState` merges local progress, acknowledged writes and remote progress by maximum; failed writes remain pending, stale values never move the marker back, and invalid/future clocks cannot poison it. SpacetimeDB keeps only a private identity-to-FID binding (`notification_reader`) and one `notification_state(fid, seen_at)` watermark per account, with no notification content. Its `my_notification_state` view selects only the caller's attested FID. Tabs stay in the URL; only read progress syncs.
-- **Background synchronization** (`notification_sync.rs`) is initially scheduled two seconds after account setup, waits for the page's API work to settle and browser idle time, and connects only while visible and online. A cached `/api/v1/status` capability check must advertise `spacetime.attest` before the shared SDK or socket loads. It uses a notification-only identity/token in IndexedDB `allthing-notification-sync`, store `identities`, then a Worker attestation, `verify_notification_fid` and one subscription to `my_notification_state`. It never registers a chat user, publishes a DM key, subscribes to chat or changes public presence. Writes coalesce for 200 ms with one in flight; remote updates are not echoed. Connection/subscription/reducer waits are bounded to 30 seconds; failures retry with backoff, and online/focus/visibility events wake sync. Sign-out/account changes stop the old connection and fence late completions, including A → B → A.
+- **Background synchronization** (`notification_sync.rs`) is initially scheduled two seconds after account setup, waits for the page's API work to settle and browser idle time, and connects only while visible and online. A cached `/api/v1/status` capability check must advertise `spacetime.attest` before the shared SDK or socket loads. It uses a private synchronization identity/token in IndexedDB `allthing-notification-sync`, store `identities`, then a Worker attestation, `verify_notification_fid` and a subscription to `my_notification_state`. With `spacetime.library`, that socket also carries private Library metadata and bounded membership/page views; foreground Library/Home readiness skips the initial delay and unrelated API waits, while still yielding to browser idle time. It never registers a chat user, publishes a DM key, subscribes to chat or changes public presence. Writes coalesce for 200 ms with one in flight; remote updates are not echoed. Connection/subscription/reducer waits are bounded to 30 seconds; failures retry with backoff, and online/focus/visibility events wake sync. Sign-out/account changes stop the old connection and fence late completions, including A → B → A.
- **The bell** (`components/notifications.rs`), for a signed-in reader, sits beside the account's picture in the header: a link to `/notifications` with a badge of the groups not seen ("9+" past nine), named "Notifications, N unread" for screen readers, and marked as the current page there. The app reads the first page when it starts, every 3 minutes while the page is in view, and when it comes back into view or its window regains focus; a read within a minute is reused (`api`). When someone signs out or someone else signs in, here or in another tab, the app forgets the last reader's answers, and keeps none still on its way, and the page starts its list anew, so nothing of one reader's shows for the next. The bell decodes only each group's time (`notifications::Times`), so the module carries no reader of the whole payload. The static copy has no bell.
- **The page**, `/notifications` (`pages/notifications.rs`), is code of its own (`split_notifications.wasm`), fetched as the bell is pointed at, touched or focused, or with the app when a visit starts on it (`index.html`'s early parts). It has the tabs All, Mentions (mentions, and quotes that mention the reader) and Replies, kept in the URL (`?tab=`) and moved through with the arrow keys, Home and End as a tab list. Each row has its kind's icon, up to 5 faces, its sentence ("**Alice** and 5 others liked your cast"), the cast's start with its Pepe as cards show them (`ExcerptText`), and when. A row opens its cast, or for follows the newest follower's profile; a face or a name links to that profile and opens its profile card. Groups newer than what the reader had seen before the visit are tinted, with an accent bar, while opening the page marks everything up to its newest seen, so the bell clears. If the initial remote watermark arrives after a cold page paints, it reconciles that visit's baseline once before pending local progress is uploaded; subsequent remote reads update the bell/cache without changing that visit's row tint. **Load more** reads the next page by its cursor, after the first; then the first page stays as it is, so nothing slips between two. Signed out, the page asks the reader to sign in; a read that fails says why, with **Try again**; placeholder rows hold the list's place while the data comes. On a touch screen, each face and name takes a 44 px tap of its own.
- **What it can't see.** Recasts come only as Hypersnap's likes do, and the hub's newest 10 a shard, for 6 casts a page; quotes only of the reader's 4 newest casts, with the first page. Hypersnap names no other kinds.
diff --git a/docs/BUDGET.md b/docs/BUDGET.md
index bfc5b77e..3edc9a1c 100644
--- a/docs/BUDGET.md
+++ b/docs/BUDGET.md
@@ -90,11 +90,13 @@ A reader who opens posts they hovered spends nothing extra; idle viewport prefet
**Early rendering:** `web/paint.js` reuses the already-started Home response and performs no additional API, D1, cron, upstream or Worker call. Its fingerprinted JavaScript is a free immutable static asset; no independent early-renderer Wasm is shipped. The static copy omits early rendering. The API/prefetch capacity estimates above exclude the main app's cold `/wasm/*` transport request; count that against the same 100,000-request allowance when estimating cold traffic. Native tooling changes no application request budget.
-**Notification read-state synchronization:** the first attempt is scheduled after two seconds, page API work has settled and browser idle time, while visible and online. The status capability gate runs before loading the shared SDK/socket; a status read costs the existing two cache reads, with no Hypersnap call. One notification-only socket connects directly to SpacetimeDB, verifies its FID and subscribes only to `my_notification_state` (zero or one watermark row). Local advances coalesce for 200 ms, with one write in flight; `mark_notifications_seen` only raises the account's maximum and repeated/remote values add no echo write. Failed writes remain pending and retries may replay them. Retry delays grow from 5 s through 15 s, 1 min and 3 min to a 5-minute cap; connection/subscription/reducer waits are bounded to 30 s. Sync adds no D1, cron or notification-content read. It uses separate credentials and adds no chat subscription, message key or public presence. The capacity estimates above exclude these per-connection status/attestation requests; count them against the same Worker allowance.
+**Notification and Library synchronization:** the initial background attempt is scheduled after two seconds, page API work has settled and browser idle time, while visible and online. Foreground Library/feed readiness can request an earlier attempt without waiting for unrelated API calls, still yielding to browser idle time. The status capability gate runs before loading the shared SDK/socket; a status read costs the existing two cache reads, with no Hypersnap call. One private socket connects directly to SpacetimeDB, verifies its FID and subscribes to `my_notification_state` (zero or one watermark row), plus Library metadata and bounded caller views when `spacetime.library` is available. Local advances coalesce for 200 ms, with one write in flight; `mark_notifications_seen` only raises the account's maximum and repeated/remote values add no echo write. Failed writes remain pending and retries may replay them. Retry delays grow from 5 s through 15 s, 1 min and 3 min to a 5-minute cap; connection/subscription/reducer waits are bounded to 30 s. Sync adds no D1, cron or notification-content read. It uses separate credentials and adds no chat subscription, message key or public presence. The capacity estimates above exclude these per-connection status/attestation requests; count them against the same Worker allowance.
**SDK CSP compatibility:** the earlier closure-based codec change added 449 Brotli bytes (2,111 raw bytes) in its measured build. That historical size predates notification sync; it is not a measurement of the new feature's bundle size. The codec change itself adds no request, subscription, upstream subrequest or server cache key; the shared SDK's notification startup costs are counted above.
-**Seen posts:** reading history adds no API endpoint, request, upstream subrequest or per-reader server cache key. Hashes and timestamps stay in localStorage (at most 2,000 per reader, 30 days, four readers), with writes batched after a 500 ms delay. One shared viewport observer schedules one one-second timer per eligible card; scroll/resize checks only intersecting cards once per animation frame, with no polling loop or fetch marks reading. Existing infinite scroll may reach its next page earlier when prior-seen rows are omitted, under the same page limit and request budgets. Early paint skips browsers with local history instead of drawing a different first screen from the app.
+**Library and seen posts:** signed-in private history and bookmarks share notification sync's attested SpacetimeDB socket and add no Farcaster read, D1 request or per-reader Worker cache key for recording/filtering. A bounded membership subscription checks at most 100 candidate hashes per query; private activity pages return at most 51 rows and retire their subscriptions. The client reads at most 100 pending IndexedDB rows per drain, never an entire archive at startup. Anonymous/legacy seen caches retain the old 2,000-post/30-day/four-reader limit. Only mounted foreground viewport dwell records an impression. Signed-in early paint waits for the app's membership guard; anonymous JavaScript early paint remains.
+
+Library content hydration is one public API request per page. `/api/v1/library?fid=&kind=likes|recasts&cursor=` makes one Hypersnap reaction-page read and, for nonempty pages, one bulk cast read (up to 25 hashes). `/api/v1/library/casts?hashes=` hydrates at most 25 hashes with one bulk read. Existing bounded annotations share the invocation's 45-subrequest ceiling, including cache calls; cursors and ordered canonical hash batches form public cache keys. These endpoints contain public casts/reactions only, never private membership or bookmark data. A user can explicitly load more pages beyond the in-memory Back cache without truncating server history.
**A cold data center's default Home:** a missing first-page 24-hour edge copy, or a retained one at least 600 seconds old, can read the public `home-day-v1` snapshot (one charged D1 read). The previous body's bounded metadata scan distinguishes expired retained entries from normal 60–600-second stale-while-revalidate refreshes, which still build normally without the shared read. A shared copy younger than 60 seconds answers without upstream reads; its unchanged JSON bytes and original build time are preserved, and casts outside the current day or in the future are removed before serving. Bodies are capped at 1 MiB before snapshot parsing. Missing, malformed, future-built or expired shared copies use the normal full build. Untrustworthy previous metadata also takes the normal build. Other windows, offsets and private Following never use it. Edge hits and normal refresh reads are unchanged.
diff --git a/e2e/early-paint.fixture.spec.ts b/e2e/early-paint.fixture.spec.ts
index e55a9911..3786bf8b 100644
--- a/e2e/early-paint.fixture.spec.ts
+++ b/e2e/early-paint.fixture.spec.ts
@@ -9,7 +9,8 @@ import { signIn } from "./helpers/farcaster";
* A single image's tile has its box before the image comes, and no image is
* asked for until the module is in. Then the app's own cards take their
* places, in the same boxes, with no second entrance. Pages it doesn't draw,
- * payloads it can't trust and an app that can't load are here too.
+ * payloads it can't trust and an app that can't load are here too. Signed-in
+ * visits wait for the app to reconcile private reading history before any card.
*/
test.skip(Boolean(process.env.E2E_PAGES), "the static copy has no early fetch");
@@ -152,6 +153,10 @@ const shown = (page: Page, root: string) =>
page.evaluate((root) => {
const markup = (node: Element) => {
const copy = node.cloneNode(true) as Element;
+ // Saving is an app-only private action. Compare the anonymous early
+ // card's original content exactly; whole-card boxes still include the
+ // mounted action, whose existence/accessibility is asserted separately.
+ copy.querySelectorAll(".bookmark-action").forEach((action) => action.remove());
const walker = document.createTreeWalker(copy, NodeFilter.SHOW_ALL);
const drop: Node[] = [];
for (let n: Node | null = walker.currentNode; n; n = walker.nextNode()) {
@@ -271,6 +276,9 @@ for (const width of [1280, 390]) {
await expect(page.locator("#app .cast-card").first()).toBeVisible();
const app = await shown(page, "#app");
expect(app.slice(0, early.length)).toEqual(early);
+ for (const hash of painted) {
+ await expect(page.locator(`#app .cast-list > .cast-card[data-hash="${hash}"]`).getByRole("button", { name: "Save post", exact: true })).toBeVisible();
+ }
const entrances = await page.evaluate(
(n) => [...document.querySelectorAll("#app .page > *, #app .share-bar"), ...[...document.querySelectorAll("#app .cast-list > .cast-card")].slice(0, n)].map((e) => e.getAnimations().length),
painted.length,
@@ -324,16 +332,28 @@ test("top's first cards come in top's order, with the time range the address nam
expect((await shown(page, "#app")).slice(0, early.length)).toEqual(early);
});
+/** Records even a transient boot card: signed-in history must never flash. */
+async function observeSignedInBoot(page: Page) {
+ await page.addInitScript(() => {
+ const hashes: string[] = [];
+ Object.defineProperty(window, "__signedInEarlyHashes", { value: hashes });
+ const record = () => document.querySelectorAll("#boot .cast-card[data-hash]").forEach(card => hashes.push(card.getAttribute("data-hash")!));
+ new MutationObserver(record).observe(document, { childList: true, subtree: true });
+ });
+}
+const observedSignedInBoot = (page: Page) => page.evaluate(() => (window as Window & { __signedInEarlyHashes: string[] }).__signedInEarlyHashes);
+
for (const width of [1280, 390]) {
- test(`a signed-in reader's For you comes early under the app's tabs, whom they follow lifted, at width ${width}`, async ({ page }) => {
+ test(`a signed-in reader's For you waits for the app, which lifts followed accounts at width ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
const held = await hold(page);
- // Only casts inside the day's window, so the share bar counts the same casts as the app's.
- await serve(page, { ...payload, pool: pool.filter((c) => c.ts > now - 20 * HOUR) });
+ const { images, probes } = await serve(page, { ...payload, pool: pool.filter((c) => c.ts > now - 20 * HOUR) });
+ // This legacy-service fixture checks app ordering separately. The real
+ // private SDK cases advertise library:true and prove cold seen exclusion.
+ await page.route("**/api/v1/status", route => route.fulfill({ json: { spacetime: { attest: false, library: false } } }));
await page.route("**/api/v1/notifications**", (route) => route.fulfill({ json: { groups: [], meta }, headers: { "cache-control": "private, no-store" } }));
await signIn(page, { fid: 1234, username: "reader", display_name: "Reader" });
- // Permission discovery may still be pending when hydration finishes.
- // Early and mounted cards must describe the same dialog action then.
+ await observeSignedInBoot(page);
let releaseSigner!: () => void;
const signer = new Promise(resolve => { releaseSigner = resolve; });
await page.route("**/api/v1/signer", async route => {
@@ -341,26 +361,33 @@ for (const width of [1280, 390]) {
await route.fulfill({ status: 404, json: { error: "not set up" } });
});
try {
- // The reader follows dee, as kept from their Following tab (followed.rs).
await page.addInitScript(() => localStorage.setItem("allthing.followed", JSON.stringify({ reader: 1234, at: Date.now(), fids: [4] })));
+ const response = page.waitForResponse(response => new URL(response.url()).pathname === "/api/v1/home");
await page.goto("/");
+ await response;
const boot = page.locator("#boot");
- // Hot, dee followed: dee's quiet pair, 20 times its 5 points, goes first.
- await expect(boot.locator(".cast-card").first()).toHaveAttribute("data-hash", pair.hash);
- await expect(boot.getByRole("link", { name: "For you", includeHidden: true })).toHaveAttribute("aria-current", "page");
- await expect(boot.locator(".feed-tabs a")).toHaveCount(2);
- await moduleIn(page, held);
- await settled(page, "#boot");
- const early = await shown(page, "#boot");
+ await expect(boot).toBeVisible();
+ await expect(boot.locator(".cast-card")).toHaveCount(0);
+ await expect(boot).not.toHaveAttribute("data-painted", "");
+ await expect(page.locator("#app")).toBeEmpty();
+ expect(images).toEqual([]);
+ expect(probes).toEqual([]);
+ // Downloading the module alone still cannot reveal unconfirmed cards.
+ held.wasm("continue");
+ await expect(boot.locator(".cast-card")).toHaveCount(0);
+ expect(await observedSignedInBoot(page)).toEqual([]);
held.js("continue");
await expect(boot).toHaveCount(0);
- await expect(page.locator("#app .cast-card").first()).toBeVisible();
- // The app's tabs and cards, in the same boxes: nothing moves.
- expect((await shown(page, "#app")).slice(0, early.length)).toEqual(early);
+ const app = page.locator("#app");
+ // Hot, dee followed: the quiet pair's five points are weighted 20×.
+ await expect(app.locator(".cast-list > .cast-card").first()).toHaveAttribute("data-hash", pair.hash);
+ await expect(app.getByRole("link", { name: "For you", exact: true })).toHaveAttribute("aria-current", "page");
+ await expect(app.locator(".feed-tabs a")).toHaveCount(2);
const permissionsRead = page.waitForResponse("**/api/v1/signer");
releaseSigner();
await permissionsRead;
- await expect(page.locator("#app .cast-card").first().getByRole("button", { name: "Like", exact: true })).toHaveAttribute("aria-haspopup", "dialog");
+ await expect(app.locator(".cast-card").first().getByRole("button", { name: "Like", exact: true })).toHaveAttribute("aria-haspopup", "dialog");
+ expect(await observedSignedInBoot(page)).toEqual([]);
} finally {
releaseSigner();
}
@@ -370,16 +397,21 @@ for (const width of [1280, 390]) {
test("a signed-in reader's Following tab isn't drawn early: the app draws it", async ({ page }) => {
const held = await hold(page);
await serve(page);
+ await page.route("**/api/v1/status", route => route.fulfill({ json: { spacetime: { attest: false, library: false } } }));
await page.route("**/api/v1/following**", (route) => route.fulfill({ json: { pool: [pair], notes: {}, meta }, headers: { "cache-control": "private, no-store" } }));
await signIn(page, { fid: 1234, username: "reader", display_name: "Reader" });
+ await observeSignedInBoot(page);
const early = page.waitForResponse((response) => response.url().includes("/api/v1/following"));
await page.goto("/?feed=following");
await early;
- await page.waitForTimeout(300);
await expect(page.locator("#boot .cast-card")).toHaveCount(0);
held.wasm("continue");
held.js("continue");
await expect(page.locator("#boot")).toHaveCount(0);
+ await expect(page.locator("#app .cast-list > .cast-card")).toHaveCount(1);
+ await expect(page.locator("#app .cast-list > .cast-card")).toHaveAttribute("data-hash", pair.hash);
+ await expect(page.getByRole("link", { name: "Following", exact: true })).toHaveAttribute("aria-current", "page");
+ expect(await observedSignedInBoot(page)).toEqual([]);
});
test("an account seen less of is lower in the first cards too, as in the app", async ({ page }) => {
diff --git a/e2e/feed-tabs.fixture.spec.ts b/e2e/feed-tabs.fixture.spec.ts
index 3975030b..9a36438a 100644
--- a/e2e/feed-tabs.fixture.spec.ts
+++ b/e2e/feed-tabs.fixture.spec.ts
@@ -6,7 +6,7 @@ import { signIn } from "./helpers/farcaster";
* The front page's two tabs for a signed-in reader, as X's: For you, the
* trending pool with the casts of whom they follow lifted, and Following,
* only theirs (`?feed=following`). Links with `aria-current`, reached by the
- * keyboard, 44 px tall, keeping the sort and the time range in the address;
+ * keyboard, 44 px tall, keeping the time range and choosing each feed's default sort;
* an old `?feed=everyone` link opens For you. Whom they follow is kept from
* their Following page, renewed once in the background when it's missing
* (followed.rs), and the list on screen isn't re-sorted when it lands. At
@@ -103,25 +103,29 @@ for (const width of [1280, 390]) {
await expectRanking(page, ["Followed cast", "Stranger trending"]);
expect(asked.following).toBe(1);
- // The keyboard reaches the tabs: Following, keeping the sort.
+ // The keyboard reaches Following in chronological order, keeping its range.
await page.goto("/?sort=top&t=week");
await expectRanking(page, ["Stranger trending", "Followed cast"]);
await following.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/[?&]feed=following/);
- await expect(page).toHaveURL(/[?&]sort=top/);
+ expect(new URL(page.url()).searchParams.get("sort")).toBeNull();
+ await expect(page.getByRole("navigation", { name: "Sort" }).getByRole("link", { name: "new", exact: true })).toHaveAttribute("aria-current", "page");
await expect(page).toHaveURL(/[?&]t=week/);
await expect(following).toHaveAttribute("aria-current", "page");
await expectRanking(page, ["Followed cast"]);
// The sort tabs and the time range keep the tab.
await expect(page.locator(".sort-tabs a", { hasText: "hot" })).toHaveAttribute("href", /feed=following/);
+ await expect(page.locator(".sort-tabs a", { hasText: "hot" })).toHaveAttribute("href", /[?&]sort=hot/);
await expect(page.getByRole("combobox", { name: "Time range" })).toBeVisible();
// And back to For you, by the keyboard too.
await forYou.focus();
await page.keyboard.press("Enter");
await expect(page).not.toHaveURL(/feed=/);
await expect(forYou).toHaveAttribute("aria-current", "page");
- await expectRanking(page, ["Stranger trending", "Followed cast"]);
+ await expect(page.getByRole("navigation", { name: "Sort" }).getByRole("link", { name: "hot", exact: true })).toHaveAttribute("aria-current", "page");
+ await expect(page).toHaveURL(/[?&]t=week/);
+ await expectRanking(page, ["Followed cast", "Stranger trending"]);
await context.close();
});
}
diff --git a/e2e/fixtures/library-sync-wire.ts b/e2e/fixtures/library-sync-wire.ts
new file mode 100644
index 00000000..4a36898c
--- /dev/null
+++ b/e2e/fixtures/library-sync-wire.ts
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Independent Node protocol peer; the browser uses the real built SDK bridge.
+import { BinaryReader, BinaryWriter, t } from "spacetimedb";
+import SourceWriter from "../../node_modules/spacetimedb/src/lib/binary_writer";
+import { ServerMessage } from "../../node_modules/spacetimedb/src/sdk/client_api/types";
+import { LibraryAccount, LibraryItem, NotificationState } from "../../web/stdb/module_bindings/types";
+import MarkSeenBatch from "../../web/stdb/module_bindings/mark_seen_batch_reducer";
+import SetBookmark from "../../web/stdb/module_bindings/set_bookmark_reducer";
+import ClearSeen from "../../web/stdb/module_bindings/clear_seen_reducer";
+import ForgetSeen from "../../web/stdb/module_bindings/forget_seen_reducer";
+import VerifyNotificationFid from "../../web/stdb/module_bindings/verify_notification_fid_reducer";
+import MarkNotificationsSeen from "../../web/stdb/module_bindings/mark_notifications_seen_reducer";
+
+export { initial, client, ok, error } from "./notification-sync-wire";
+
+function frame(tag: string, value: unknown) {
+ const writer = new SourceWriter(1024);
+ ServerMessage.serialize(writer, { tag, value } as never);
+ return Buffer.concat([Buffer.from([0]), Buffer.from(writer.getBuffer())]);
+}
+
+function rows(table: string, values: unknown[]) {
+ const schema = table === "my_library_state" ? LibraryAccount : table === "my_notification_state" ? NotificationState : LibraryItem;
+ const buffers = values.map(value => {
+ const writer = new BinaryWriter(256);
+ schema.serialize(writer, value as never);
+ return Buffer.from(writer.getBuffer());
+ });
+ let size = 0;
+ const offsets = buffers.map(buffer => { const offset = BigInt(size); size += buffer.length; return offset; });
+ return { sizeHint: { tag: "RowOffsets", value: offsets }, rowsData: Buffer.concat(buffers) };
+}
+
+export function snapshot(requestId: number, querySetId: unknown, tables: { table: string; values: unknown[] }[]) {
+ return frame("SubscribeApplied", { requestId, querySetId, rows: { tables: tables.map(({ table, values }) => ({ table, rows: rows(table, values) })) } });
+}
+export function update(querySetId: unknown, tables: { table: string; before: unknown[]; after: unknown[] }[]) {
+ return frame("TransactionUpdate", { querySets: [{ querySetId, tables: tables.map(({ table, before, after }) => ({
+ tableName: table, rows: [{ tag: "PersistentTable", value: { deletes: rows(table, before), inserts: rows(table, after) } }],
+ })) }] });
+}
+export function unsubscribed(requestId: number, querySetId: unknown, tables: { table: string; values: unknown[] }[]) {
+ return frame("UnsubscribeApplied", { requestId, querySetId, rows: { tables: tables.map(({ table, values }) => ({ table, rows: rows(table, values) })) } });
+}
+export function reducer(name: string, args: Uint8Array) {
+ const schemas: Record = {
+ verify_notification_fid: VerifyNotificationFid, mark_notifications_seen: MarkNotificationsSeen,
+ mark_seen_batch: MarkSeenBatch, set_bookmark: SetBookmark, clear_seen: ClearSeen, forget_seen: ForgetSeen,
+ };
+ if (!schemas[name]) throw new Error(`Unexpected Library reducer ${name}`);
+ return t.row(schemas[name] as never).deserialize(new BinaryReader(args));
+}
diff --git a/e2e/following-feed.fixture.spec.ts b/e2e/following-feed.fixture.spec.ts
index e36e40a1..cf7c8b08 100644
--- a/e2e/following-feed.fixture.spec.ts
+++ b/e2e/following-feed.fixture.spec.ts
@@ -28,7 +28,7 @@ const me = { fid: 1234, username: "reader", display_name: "Reader" };
// Two casts by accounts the reader follows, and the stranger's busier one.
const followedBusy = { hash: hex(1), author: ann, text: "Followed busy", ts, likes: 30, replies: 0 };
-const followedQuiet = { hash: hex(2), author: bob, text: "Followed quiet", ts, likes: 10, replies: 0 };
+const followedQuiet = { hash: hex(2), author: bob, text: "Followed quiet", ts: ts + 1, likes: 10, replies: 0 };
const trending = { hash: hex(9), author: stranger, text: "Stranger trending", ts, likes: 500, replies: 0 };
const PRIVATE = { "cache-control": "private, no-store" };
@@ -79,16 +79,18 @@ async function signedInSeesOnlyFollowed(page: Page) {
// Nothing beside the feed reaches past the page's width.
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
- // For you: the trending pool, with the sort kept.
+ // Entering For you starts Hot, independently of Following's explicit Top.
await tabs.getByRole("link", { name: "For you" }).click();
await expect(page).not.toHaveURL(/feed=/);
- await expect(page).toHaveURL(/[?&]sort=top/);
- await expect.poll(() => order(page)).toEqual(["Stranger trending", "Followed busy"]);
- // And back, the sort kept.
+ expect(new URL(page.url()).searchParams.get("sort")).toBeNull();
+ await expect(page.getByRole("navigation", { name: "Sort" }).getByRole("link", { name: "hot", exact: true })).toHaveAttribute("aria-current", "page");
+ await expect.poll(() => order(page)).toEqual(["Followed busy", "Stranger trending"]);
+ // Following starts New: a quieter newer cast comes before a busy older one.
await tabs.getByRole("link", { name: "Following" }).click();
await expect(page).toHaveURL(/[?&]feed=following/);
- await expect(page).toHaveURL(/[?&]sort=top/);
- await expect.poll(() => order(page)).toEqual(["Followed busy", "Followed quiet"]);
+ expect(new URL(page.url()).searchParams.get("sort")).toBeNull();
+ await expect(page.getByRole("navigation", { name: "Sort" }).getByRole("link", { name: "new", exact: true })).toHaveAttribute("aria-current", "page");
+ await expect.poll(() => order(page)).toEqual(["Followed quiet", "Followed busy"]);
}
test("a signed-in reader's Following tab ranks only whom they follow, at 1280", async ({ page }) => {
@@ -107,7 +109,7 @@ test("Show less demotes a followed account, and a favorite rises", async ({ page
await serve(page);
await page.goto("/?sort=top&feed=following");
await expect.poll(() => order(page)).toEqual(["Followed quiet", "Followed busy"]);
- await page.goto("/?feed=following");
+ await page.goto("/?sort=hot&feed=following");
await expect.poll(() => order(page)).toEqual(["Followed quiet", "Followed busy"]);
});
@@ -133,8 +135,15 @@ test("Following's new is theirs, newest first", async ({ page }) => {
await signIn(page, me);
const older = { ...followedBusy, ts: ts - 3600 };
await serve(page, { pool: [older, followedQuiet], notes: {}, meta });
- await page.goto("/?sort=new&feed=following");
+ await page.goto("/?feed=following");
+ await expect(page.getByRole("navigation", { name: "Sort" }).getByRole("link", { name: "new", exact: true })).toHaveAttribute("aria-current", "page");
await expect.poll(() => order(page)).toEqual(["Followed quiet", "Followed busy"]);
+ // Explicit overrides still rank the very busy older cast first.
+ for (const sort of ["top", "hot"]) {
+ await page.getByRole("navigation", { name: "Sort" }).getByRole("link", { name: sort, exact: true }).click();
+ await expect(page).toHaveURL(new RegExp(`[?&]sort=${sort}`));
+ await expect.poll(() => order(page)).toEqual(["Followed busy", "Followed quiet"]);
+ }
});
test("signed out, the front page is the trending pool, with no tabs, at 1280 and 390", async ({ page, browser }, testInfo) => {
diff --git a/e2e/following-navigation.fixture.spec.ts b/e2e/following-navigation.fixture.spec.ts
index 08580a33..a2618c79 100644
--- a/e2e/following-navigation.fixture.spec.ts
+++ b/e2e/following-navigation.fixture.spec.ts
@@ -31,16 +31,21 @@ test("Following sort links and time changes never prefetch an unrelated feed", a
const sortLinks = page.getByRole("navigation", { name: "Sort", exact: true });
const range = page.getByRole("combobox", { name: "Time range", exact: true });
+ await expect(sortLinks.getByRole("link", { name: "new", exact: true })).toHaveAttribute("aria-current", "page");
+ // Hot is an explicit override in Following; omitting it would select New.
+ await expect(sortLinks.getByRole("link", { name: "hot", exact: true })).toHaveAttribute("href", /[?&]sort=hot/);
// Actual link presses exercise nav::go/fetch_ahead, unlike direct goto.
for (const sort of ["new", "top", "hot"]) {
await sortLinks.getByRole("link", { name: sort, exact: true }).click();
await expect(page).toHaveURL(/feed=following/);
- await expect.poll(() => new URL(page.url()).searchParams.get("sort") || "hot").toBe(sort);
+ await expect.poll(() => new URL(page.url()).searchParams.get("sort") || "new").toBe(sort);
+ await expect(sortLinks.getByRole("link", { name: sort, exact: true })).toHaveAttribute("aria-current", "page");
await expect(range).toBeVisible();
for (const window of ["6h", "hour"]) {
await range.selectOption(window);
await expect(page).toHaveURL(new RegExp(`t=${window}`));
await expect(following).toHaveAttribute("aria-current", "page");
+ expect(new URL(page.url()).searchParams.get("sort") || "new").toBe(sort);
}
}
// The private answer stays cached while every sort/range is changed.
diff --git a/e2e/helpers/app.ts b/e2e/helpers/app.ts
index cf705fb4..55d2f24f 100644
--- a/e2e/helpers/app.ts
+++ b/e2e/helpers/app.ts
@@ -14,7 +14,8 @@ export async function appCame(page: Page) {
/** Recover a revisited feed through its visible control before testing another feature. */
export async function showSeenPosts(page: Page) {
await appCame(page);
- await expect(page.locator("#app .cast-list .cast-card:not(.skeleton), #app .seen-notice").first()).toBeVisible();
const show = page.getByRole("button", { name: "Show seen posts", exact: true });
+ // The private-history readiness notice is not the local recovery control.
+ await expect(page.locator("#app .cast-list .cast-card[data-hash]").or(show).first()).toBeVisible();
if (await show.isVisible()) await show.click();
}
diff --git a/e2e/library-sync.fixture.spec.ts b/e2e/library-sync.fixture.spec.ts
new file mode 100644
index 00000000..8e35cc90
--- /dev/null
+++ b/e2e/library-sync.fixture.spec.ts
@@ -0,0 +1,616 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { expect, test, type Browser, type BrowserContext, type Page, type WebSocketRoute } from "@playwright/test";
+import { buildSync } from "esbuild";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { createRequire } from "node:module";
+import { join, resolve } from "node:path";
+import { kept, signIn } from "./helpers/farcaster";
+
+// Independent browser contexts share only this controlled authenticated service.
+// Rust/controller, SDK, generated BSATN codecs, DOM dwell and storage are real.
+// Real module authorization/index/transaction behavior is tested separately.
+test.skip(Boolean(process.env.E2E_PAGES), "the static copy has no private Library");
+const temporary = mkdtempSync(join(tmpdir(), "allthing-library-wire-"));
+buildSync({ entryPoints: [resolve("e2e/fixtures/library-sync-wire.ts")], outfile: join(temporary, "wire.cjs"), bundle: true, platform: "node", format: "cjs", logLevel: "silent" });
+const wire = createRequire(resolve("package.json"))(join(temporary, "wire.cjs"));
+test.afterAll(() => rmSync(temporary, { recursive: true, force: true }));
+
+const alice = { fid: 1234, username: "reader", display_name: "Reader" };
+const bob = { fid: 5678, username: "other", display_name: "Other" };
+const author = { fid: 88, username: "fixture", display_name: "Fixture" };
+const at = Math.floor(Date.now() / 1000);
+const hash = (n: number) => `0x${n.toString(16).padStart(40, "0")}`;
+const cast = (n: number) => ({ hash: hash(n), author, text: `Private Library fixture ${n}.`, ts: at - 120, likes: 1, recasts: 0, replies: 0 });
+const meta = () => ({ backend: "hypersnap", fetched_at: Date.now() });
+const key = (value: unknown) => JSON.stringify(value, (_key, value) => typeof value === "bigint" ? value.toString() : value);
+type Account = { fid: number; generation: number; revision: number; clearedAt: number; lastClearId: string };
+type Post = { hash: string; generation: number; seen: boolean; seenAt: number; seenRevision: number; bookmarked: boolean; bookmarkAt: number; bookmarkRevision: number };
+type Selection = { querySetId: unknown; queries: string[]; tables: { table: string; values: object[] }[] };
+type Connection = { id: number; label: string; socket: WebSocketRoute; identity: string; active: boolean; fid?: number; selections: Map };
+type Call = { label: string; name: string; fid?: number; args: Record; rejected?: string };
+
+class Peer {
+ accounts = new Map();
+ posts = new Map();
+ tokens = new Map();
+ authorized = new Map();
+ connections: Connection[] = [];
+ calls: Call[] = [];
+ queries: string[] = [];
+ frames: object[] = [];
+ exchanges: string[] = [];
+ offline = new Set();
+ // Independently delay unsolicited view pushes while requested snapshots still
+ // use current state. Recovery must not depend on a later metadata push.
+ pausedPushes = new Set();
+ holdSeen = new Set();
+ heldSeen = new Map void)[]>();
+ releaseSeen(label: string) {
+ this.holdSeen.delete(label);
+ for (const acknowledge of this.heldSeen.get(label) ?? []) acknowledge();
+ this.heldSeen.delete(label);
+ }
+ account(fid: number) {
+ let value = this.accounts.get(fid);
+ if (!value) { value = { fid, generation: 0, revision: 0, clearedAt: 0, lastClearId: "" }; this.accounts.set(fid, value); }
+ return value;
+ }
+ post(fid: number, value: string): Post {
+ const generation = this.account(fid).generation;
+ const found = this.posts.get(`${fid}:${value}`);
+ if (!found) return { hash: value, generation, seen: false, seenAt: 0, seenRevision: 0, bookmarked: false, bookmarkAt: 0, bookmarkRevision: 0 };
+ return found.generation === generation ? { ...found } : { ...found, generation, seen: false, seenAt: 0, seenRevision: 0 };
+ }
+ save(fid: number, post: Post) { this.posts.set(`${fid}:${post.hash}`, { ...post }); }
+ seedSeen(fid: number, n: number, time = at - 20) {
+ const post = this.post(fid, hash(n)); post.seen = true; post.seenAt = time; post.seenRevision++;
+ this.save(fid, post); this.account(fid).revision++;
+ }
+ seedBookmark(fid: number, n: number, time = at - 10) {
+ const post = this.post(fid, hash(n)); post.bookmarked = true; post.bookmarkAt = time; post.bookmarkRevision++;
+ this.save(fid, post); this.account(fid).revision++;
+ }
+ forFid(fid: number) { return [...this.posts].filter(([id]) => id.startsWith(`${fid}:`)).map(([, post]) => this.post(fid, post.hash)); }
+ verified(label: string) { return this.connections.filter(connection => connection.active && connection.label === label && connection.fid !== undefined); }
+ item(fid: number, post: Post, cursor = "") {
+ return { fid: BigInt(fid), generation: BigInt(post.generation), hash: post.hash, seen: post.seen, seenAt: BigInt(post.seenAt), seenRevision: BigInt(post.seenRevision), bookmarked: post.bookmarked, bookmarkAt: BigInt(post.bookmarkAt), bookmarkRevision: BigInt(post.bookmarkRevision), cursor };
+ }
+ select(connection: Connection, query: string): { table: string; values: object[] } {
+ const parsed = /^\s*select\s+\*\s+from\s+(my_[a-z_]+)(?:\((.*)\))?\s*;?\s*$/i.exec(query);
+ if (!parsed) throw new Error(`Unexpected private SQL: ${query}`);
+ const table = parsed[1].toLowerCase();
+ if (connection.fid === undefined) return { table, values: [] };
+ const fid = connection.fid, state = this.account(fid);
+ if (table === "my_notification_state") return { table, values: [] };
+ if (table === "my_library_state") return { table, values: [{ fid: BigInt(fid), generation: BigInt(state.generation), revision: BigInt(state.revision), seenCount: BigInt(this.forFid(fid).filter(post => post.seen).length), bookmarkCount: BigInt(this.forFid(fid).filter(post => post.bookmarked).length), clearedAt: BigInt(state.clearedAt), lastClearId: state.lastClearId }] };
+ const args = [...(parsed[2] ?? "").matchAll(/'((?:''|[^'])*)'|(-?\d+)/g)].map(match => match[1] === undefined ? Number(match[2]) : match[1].replace(/''/g, "'"));
+ if (table === "my_library_membership") {
+ if (args.length !== 1 || typeof args[0] !== "string") throw new Error("Unexpected membership arguments");
+ const hashes = [...new Set(args[0].split(","))];
+ if (!hashes.length || hashes.length > 100 || hashes.some(value => !/^0x[0-9a-f]{40}$/.test(value))) throw new Error("Unbounded/invalid membership request");
+ return { table, values: hashes.map(value => this.item(fid, this.post(fid, value))) };
+ }
+ if (table !== "my_library_page" || args.length !== 4) throw new Error(`Unexpected Library view ${query}`);
+ const [kind, generation, after, limit] = args;
+ if (![0, 1].includes(Number(kind)) || generation !== state.generation || typeof after !== "string" || typeof limit !== "number" || limit < 1 || limit > 50) return { table, values: [] };
+ let posts = this.forFid(fid).filter(post => kind === 0 ? post.seen : post.bookmarked);
+ const time = (post: Post) => kind === 0 ? post.seenAt : post.bookmarkAt;
+ posts.sort((a, b) => time(b) - time(a) || b.hash.localeCompare(a.hash));
+ if (after) {
+ const colon = after.indexOf(":"), beforeAt = Number(after.slice(0, colon)), beforeHash = after.slice(colon + 1);
+ posts = posts.filter(post => time(post) < beforeAt || (time(post) === beforeAt && post.hash < beforeHash));
+ }
+ return { table, values: posts.slice(0, limit + 1).map(post => this.item(fid, post, `${time(post)}:${post.hash}`)) };
+ }
+ broadcast() {
+ for (const connection of this.connections.filter(connection => connection.active)) {
+ if (this.pausedPushes.has(connection.label)) continue;
+ for (const selection of connection.selections.values()) {
+ const next = selection.queries.map(query => this.select(connection, query));
+ const changed = next.map((table, i) => ({ table: table.table, before: selection.tables[i]?.values ?? [], after: table.values })).filter(table => key(table.before) !== key(table.after));
+ if (changed.length) {
+ this.frames.push(JSON.parse(key({ connection: connection.id, label: connection.label, phase: "update", querySetId: selection.querySetId, tables: changed })));
+ connection.socket.send(wire.update(selection.querySetId, changed));
+ }
+ selection.tables = next;
+ }
+ }
+ }
+ mutate(connection: Connection, name: string, args: Record) {
+ const fid = connection.fid;
+ if (fid === undefined) throw new Error("verify the reader first");
+ const account = { ...this.account(fid) };
+ const now = Math.floor(Date.now() / 1000);
+ if (name === "mark_notifications_seen") return;
+ if (name === "mark_seen_batch") {
+ if (Number(args.generation) !== account.generation) throw new Error("history generation changed");
+ if (!args.entries.length || args.entries.length > 100) throw new Error("invalid seen batch");
+ const changed: Post[] = [], unique = new Set();
+ for (const entry of args.entries) {
+ const post = this.post(fid, entry.hash), expected = Number(entry.expectedRevision), time = Number(entry.at);
+ if (unique.has(entry.hash) || !/^0x[0-9a-f]{40}$/.test(entry.hash) || time < 0 || time > now + 60) throw new Error("invalid seen input");
+ unique.add(entry.hash);
+ if (account.clearedAt > 0 && time <= account.clearedAt) continue;
+ if (expected > post.seenRevision) throw new Error("seen revision changed");
+ if ((!post.seen && expected !== post.seenRevision) || (post.seen && time <= post.seenAt)) continue;
+ post.seen = true; post.seenAt = Math.max(post.seenAt, Math.min(time, now)); post.seenRevision++;
+ changed.push(post);
+ }
+ if (changed.length) { for (const post of changed) this.save(fid, post); account.revision++; }
+ } else if (name === "set_bookmark") {
+ const post = this.post(fid, args.hash), desired = Boolean(args.desired);
+ if (post.bookmarked === desired && (desired || post.bookmarkRevision > 0)) return;
+ if (Number(args.expectedRevision) !== post.bookmarkRevision) throw new Error("bookmark revision changed");
+ post.bookmarked = desired; post.bookmarkRevision++; post.bookmarkAt = desired ? now : 0;
+ this.save(fid, post); account.revision++;
+ } else if (name === "forget_seen") {
+ if (Number(args.generation) !== account.generation) throw new Error("history generation changed");
+ const post = this.post(fid, args.hash);
+ if (!post.seen && post.seenRevision > 0) return;
+ if (Number(args.expectedRevision) !== post.seenRevision) throw new Error("seen revision changed");
+ post.seen = false; post.seenAt = 0; post.seenRevision++;
+ this.save(fid, post); account.revision++;
+ } else if (name === "clear_seen") {
+ const expected = Number(args.expectedGeneration);
+ if (account.lastClearId === args.operationId && expected + 1 === account.generation) return;
+ if (expected !== account.generation) throw new Error("history generation changed");
+ account.generation++; account.revision++; account.clearedAt = Math.max(account.clearedAt, now); account.lastClearId = args.operationId;
+ } else throw new Error(`Unexpected mutation ${name}`);
+ this.accounts.set(fid, account);
+ }
+ async attach(page: Page, label: string) {
+ await page.route("https://maincloud.spacetimedb.com/**", async route => {
+ const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization", "access-control-allow-methods": "POST, OPTIONS" };
+ if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers });
+ const token = (await route.request().headerValue("authorization"))?.replace(/^Bearer /, "");
+ if (new URL(route.request().url()).pathname !== "/v1/identity/websocket-token" || !token || !this.tokens.has(token)) return route.fulfill({ status: 401, json: { error: "Unknown fixture token" }, headers });
+ this.exchanges.push(label);
+ return this.offline.has(label) ? route.fulfill({ status: 503, json: { error: "Fixture offline" }, headers }) : route.fulfill({ json: { token }, headers });
+ });
+ await page.routeWebSocket("wss://maincloud.spacetimedb.com/**", socket => {
+ if (this.offline.has(label)) { void socket.close({ code: 1013, reason: "Fixture offline" }); return; }
+ const requested = new URL(socket.url()).searchParams.get("token");
+ const token = requested && this.tokens.has(requested) ? requested : `library-token-${this.tokens.size + 1}`;
+ let identity = this.tokens.get(token);
+ if (!identity) { identity = `c4${(this.tokens.size + 1).toString(16).padStart(62, "0")}`; this.tokens.set(token, identity); }
+ const connection: Connection = { id: this.connections.length + 1, label, socket, identity, active: true, selections: new Map() };
+ this.connections.push(connection);
+ socket.onClose(() => { connection.active = false; });
+ socket.onMessage(bytes => {
+ const message = wire.client(bytes);
+ if (message.tag === "Subscribe") {
+ const selection = { querySetId: message.value.querySetId, queries: message.value.queryStrings, tables: [] } as Selection;
+ this.queries.push(...selection.queries);
+ selection.tables = selection.queries.map(query => this.select(connection, query));
+ connection.selections.set(key(selection.querySetId), selection);
+ this.frames.push(JSON.parse(key({ connection: connection.id, label, phase: "snapshot", querySetId: selection.querySetId, tables: selection.tables })));
+ socket.send(wire.snapshot(message.value.requestId, selection.querySetId, selection.tables));
+ } else if (message.tag === "Unsubscribe") {
+ const selection = connection.selections.get(key(message.value.querySetId));
+ connection.selections.delete(key(message.value.querySetId));
+ this.frames.push(JSON.parse(key({ connection: connection.id, label, phase: "unsubscribe", querySetId: message.value.querySetId, queries: selection?.queries ?? [] })));
+ socket.send(wire.unsubscribed(message.value.requestId, message.value.querySetId, selection?.tables ?? []));
+ } else if (message.tag === "CallReducer") {
+ const { reducer: name, args, requestId } = message.value;
+ const call: Call = { label, name, fid: connection.fid, args: {} }; this.calls.push(call);
+ try {
+ const decoded = wire.reducer(name, args); call.args = JSON.parse(key(decoded));
+ if (name === "verify_notification_fid") {
+ const fid = Number(decoded.fid); call.fid = fid;
+ if (this.authorized.get(connection.identity) !== fid || (connection.fid !== undefined && connection.fid !== fid)) throw new Error("attestation FID mismatch");
+ connection.fid = fid;
+ } else this.mutate(connection, name, decoded);
+ this.broadcast();
+ const acknowledge = () => { if (connection.active) socket.send(wire.ok(requestId)); };
+ if (name === "mark_seen_batch" && this.holdSeen.has(label)) {
+ const pending = this.heldSeen.get(label) ?? [];
+ pending.push(acknowledge); this.heldSeen.set(label, pending);
+ } else acknowledge();
+ } catch (error) { call.rejected = String(error); socket.send(wire.error(requestId, String(error))); }
+ } else throw new Error(`Unexpected Library protocol ${message.tag}`);
+ });
+ socket.send(wire.initial(identity, token, this.connections.length));
+ });
+ }
+ async disconnect(label: string) {
+ this.offline.add(label);
+ await Promise.all(this.connections.filter(connection => connection.label === label && connection.active).map(async connection => { connection.active = false; await connection.socket.close({ code: 1013, reason: "Fixture offline" }); }));
+ }
+}
+
+function cookieFid(cookie: string) {
+ const value = /(?:^|;\s*)__Host-allthing-user=([^;]*)/.exec(cookie)?.[1];
+ try { return value ? Number(JSON.parse(decodeURIComponent(value)).fid) : 0; } catch { return 0; }
+}
+async function reader(browser: Browser, peer: Peer, label: string, who: typeof alice | null, options: { pool?: ReturnType[]; holdAttestation?: number; blockStorage?: boolean; legacy?: { hash: string; at: number }[] } = {}) {
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage(), errors: string[] = [];
+ page.on("pageerror", error => errors.push(error.message));
+ await page.addInitScript(() => {
+ Object.defineProperty(Document.prototype, "hidden", { configurable: true, get: () => false });
+ Object.defineProperty(Document.prototype, "visibilityState", { configurable: true, get: () => "visible" });
+ (window as any).__libraryPaint = [];
+ new MutationObserver(() => {
+ for (const card of document.querySelectorAll("#boot .cast-card[data-hash], #app .cast-card[data-hash]")) {
+ const value = card.getAttribute("data-hash")!;
+ if (!(window as any).__libraryPaint.includes(value)) (window as any).__libraryPaint.push(value);
+ }
+ }).observe(document, { childList: true, subtree: true });
+ });
+ if (options.blockStorage) await page.addInitScript(() => {
+ (window as any).__libraryStorageBlocked = true;
+ const open = IDBFactory.prototype.open;
+ IDBFactory.prototype.open = function(name: string, version?: number) {
+ if (name === "allthing-library-v1" && (window as any).__libraryStorageBlocked) throw new DOMException("Fixture private storage denied", "SecurityError");
+ return version === undefined ? open.call(this, name) : open.call(this, name, version);
+ };
+ });
+ if (who) await signIn(page, who);
+ if (who && options.legacy) await page.addInitScript(({ fid, posts }) => localStorage.setItem("allthing.seen.v1", JSON.stringify({ version: 1, scopes: [{ name: `fid:${fid}`, touched: Math.max(...posts.map(post => post.at)), posts }] })), { fid: who.fid, posts: options.legacy });
+ let release!: () => void; const held = new Promise(resolve => { release = resolve; });
+ const requests = { attest: [] as number[], batches: [] as string[][], publicWrites: [] as string[] };
+ page.on("request", request => { if (request.method() === "POST" && /submitMessage|auth\/signer/.test(request.url())) requests.publicWrites.push(request.url()); });
+ await page.route("**/api/v1/**", route => route.fulfill({ status: 404, json: { error: "Fixture endpoint" } }));
+ await page.route("**/api/v1/status", route => route.fulfill({ json: { spacetime: { attest: true, library: true } } }));
+ await page.route("**/api/v1/home**", route => route.fulfill({ json: { pool: options.pool ?? [cast(1), cast(2)], notes: {}, meta: meta() } }));
+ await page.route("**/api/v1/latest**", route => route.fulfill({ json: { pool: options.pool ?? [cast(1), cast(2)], cursors: [], notes: {}, meta: meta() } }));
+ await page.route("**/api/v1/following**", route => route.fulfill({ json: { pool: options.pool ?? [cast(1), cast(2)], notes: {}, meta: meta() } }));
+ await page.route("**/api/v1/notifications**", route => route.fulfill({ json: { groups: [], newest: 0, notes: {}, meta: meta() } }));
+ await page.route("**/api/v1/cast?*", route => {
+ const n = Number.parseInt(new URL(route.request().url()).searchParams.get("hash")!.slice(2), 16);
+ return route.fulfill({ json: { thread: { cast: cast(n), children: [] }, ancestors: [], notes: {}, meta: meta() } });
+ });
+ await page.route("**/api/v1/auth/attest", async route => {
+ const fid = cookieFid(await route.request().headerValue("cookie") ?? ""); requests.attest.push(fid);
+ if (fid === options.holdAttestation) await held;
+ const { identity } = route.request().postDataJSON(); peer.authorized.set(identity, fid);
+ return route.fulfill({ json: { identity, fid, expiry: Math.floor(Date.now() / 1000) + 600, signature: "11".repeat(64) } });
+ });
+ await page.route("**/api/v1/library/casts?*", route => {
+ const hashes = new URL(route.request().url()).searchParams.get("hashes")!.split(","); requests.batches.push(hashes);
+ return route.fulfill({ json: { pool: hashes.map(value => cast(Number.parseInt(value.slice(2), 16))), unavailable: [], filtered: [], notes: {}, meta: meta() } });
+ });
+ await page.route("https://haatz.quilibrium.com/**", route => route.fulfill({ headers: { "access-control-allow-origin": "*" }, json: { messages: [] } }));
+ await peer.attach(page, label);
+ return { context, page, errors, requests, releaseAttestation: release };
+}
+type Reader = Awaited>;
+const card = (page: Page, n: number) => page.locator(`#app .cast-card[data-hash="${hash(n)}"]`);
+const saved = (page: Page, n: number, on = false) => card(page, n).getByRole("button", { name: on ? "Remove bookmark" : "Save post", exact: true });
+async function close(...readers: Reader[]) { await Promise.all(readers.map(value => value.context.close())); }
+async function reconnect(peer: Peer, value: Reader, label: string) {
+ peer.offline.delete(label);
+ await value.page.evaluate(() => { window.dispatchEvent(new Event("online")); window.dispatchEvent(new Event("focus")); });
+}
+async function diskRecord(page: Page, key: string) {
+ return page.evaluate(async key => {
+ const db = await new Promise((resolve, reject) => {
+ const open = indexedDB.open("allthing-library-v1", 1);
+ open.onsuccess = () => resolve(open.result); open.onerror = () => reject(open.error);
+ });
+ try { return await new Promise((resolve, reject) => {
+ const tx = db.transaction("records", "readonly"), request = tx.objectStore("records").get(key);
+ tx.oncomplete = () => resolve(typeof request.result === "string" ? JSON.parse(request.result) : null);
+ tx.onabort = () => reject(tx.error);
+ }); } finally { db.close(); }
+ }, key);
+}
+const diskKey = (kind: string, fid: number, value = "") => `${kind}:${String(fid).padStart(20, "0")}:${value}`;
+async function storageFaultAndActivity(page: Page) {
+ await page.addInitScript(() => {
+ const host = window as any;
+ host.__libraryActiveStorage = 0; host.__libraryFailNextOpen = false;
+ const open = IDBFactory.prototype.open;
+ IDBFactory.prototype.open = function(name: string, version?: number) {
+ if (name === "allthing-library-v1" && host.__libraryFailNextOpen) {
+ host.__libraryFailNextOpen = false;
+ throw new DOMException("Transient fixture read denial", "SecurityError");
+ }
+ const request = version === undefined ? open.call(this, name) : open.call(this, name, version);
+ if (name === "allthing-library-v1") {
+ host.__libraryActiveStorage++;
+ let done = false; const finish = () => { if (!done) { done = true; host.__libraryActiveStorage--; } };
+ request.addEventListener("success", finish, { once: true }); request.addEventListener("error", finish, { once: true });
+ }
+ return request;
+ };
+ const transaction = IDBDatabase.prototype.transaction;
+ IDBDatabase.prototype.transaction = function(stores: string | string[], mode?: IDBTransactionMode, options?: IDBTransactionOptions) {
+ const tx = transaction.call(this, stores, mode, options);
+ if (this.name === "allthing-library-v1") {
+ host.__libraryActiveStorage++;
+ let done = false; const finish = () => { if (!done) { done = true; host.__libraryActiveStorage--; } };
+ tx.addEventListener("complete", finish, { once: true }); tx.addEventListener("abort", finish, { once: true });
+ }
+ return tx;
+ };
+ });
+}
+async function diagnose(peer: Peer, ...readers: Reader[]) {
+ await test.info().attach("library-sync-diagnostics", { contentType: "application/json", body: JSON.stringify({
+ calls: peer.calls, queries: peer.queries, exchanges: peer.exchanges, frames: peer.frames.slice(-300),
+ accounts: [...peer.accounts.values()], posts: [...peer.posts.values()],
+ connections: peer.connections.map(connection => ({ id: connection.id, label: connection.label, active: connection.active, fid: connection.fid, selections: [...connection.selections.values()].map(selection => ({ id: selection.querySetId, queries: selection.queries })) })),
+ readers: await Promise.all(readers.map(async reader => ({
+ errors: reader.errors, requests: reader.requests, url: reader.page.url(),
+ text: await reader.page.locator("#app").innerText().catch(() => "unavailable"),
+ cards: await reader.page.locator("#app .cast-card[data-hash]").evaluateAll(cards => cards.map(card => ({ hash: card.getAttribute("data-hash"), buttons: [...card.querySelectorAll("button")].map(button => ({ label: button.getAttribute("aria-label"), pressed: button.getAttribute("aria-pressed"), disabled: button.disabled })) }))).catch(() => []),
+ }))),
+ }, null, 2) });
+}
+
+test("a fresh independent signed-in device never paints remotely seen Home cards, including after sort and reload", async ({ browser }) => {
+ const peer = new Peer(); peer.seedSeen(alice.fid, 1);
+ const a = await reader(browser, peer, "a", alice, { holdAttestation: alice.fid });
+ try {
+ await a.page.goto("/");
+ await expect.poll(() => a.requests.attest.includes(alice.fid)).toBe(true);
+ // A signed-in first frame cannot assume a fresh device has no history.
+ // The real app is mounted, but both candidates await verified membership.
+ await expect(a.page.locator("#app .cast-card[data-hash]")).toHaveCount(0);
+ await expect(a.page.locator("#boot .cast-card")).toHaveCount(0);
+ expect(await a.page.evaluate(() => (window as any).__libraryPaint)).toEqual([]);
+ a.releaseAttestation();
+ await expect(card(a.page, 2)).toBeVisible();
+ await expect(card(a.page, 1)).toHaveCount(0);
+ expect(await a.page.evaluate(() => (window as any).__libraryPaint)).not.toContain(hash(1));
+ await a.page.getByRole("navigation", { name: "Sort", exact: true }).getByRole("link", { name: "top", exact: true }).click();
+ await expect(card(a.page, 1)).toHaveCount(0);
+ await a.page.reload();
+ await expect.poll(() => peer.verified("a").length).toBe(1);
+ await expect(card(a.page, 1)).toHaveCount(0);
+ expect(await a.page.evaluate(() => (window as any).__libraryPaint)).not.toContain(hash(1));
+ expect(a.errors).toEqual([]);
+ } finally { a.releaseAttestation(); await diagnose(peer, a); await close(a); }
+});
+
+test("durable membership beyond30days and2000 reads is point-checked and Seen history hydrates bounded pages", async ({ browser }) => {
+ const peer = new Peer();
+ for (let n = 1; n <= 2102; n++) peer.seedSeen(alice.fid, n, at - 40 * 86400 - n);
+ const a = await reader(browser, peer, "a", alice, { pool: [{ ...cast(1), ts: at - 45 * 86400 }, cast(9999)] });
+ try {
+ await a.page.goto("/?t=all");
+ await expect(card(a.page, 9999)).toBeVisible();
+ await expect(card(a.page, 1)).toHaveCount(0);
+ await a.page.goto("/library?tab=seen");
+ await expect(a.page.locator(".library-panel .cast-card")).toHaveCount(25);
+ await a.page.getByRole("button", { name: "Load more", exact: true }).click();
+ await expect.poll(() => a.requests.batches.flat().length).toBe(50);
+ expect(a.requests.batches.every(batch => batch.length <= 25)).toBe(true);
+ expect(peer.queries.filter(query => query.includes("my_library_membership")).every(query => (query.match(/0x/g) ?? []).length <= 100)).toBe(true);
+ expect(peer.post(alice.fid, hash(1)).seen).toBe(true);
+ } finally { await diagnose(peer, a); await close(a); }
+});
+
+test("private bookmarks propagate between independent sessions without a Farcaster signing key or public reaction", async ({ browser }) => {
+ const peer = new Peer(), a = await reader(browser, peer, "a", alice), b = await reader(browser, peer, "b", alice);
+ try {
+ await Promise.all([a.page.goto("/"), b.page.goto("/")]);
+ await expect(saved(a.page, 1)).toBeEnabled(); await expect(saved(b.page, 1)).toBeEnabled();
+ await saved(a.page, 1).click();
+ await expect(saved(b.page, 1, true)).toHaveAttribute("aria-pressed", "true");
+ expect(peer.post(alice.fid, hash(1)).bookmarked).toBe(true);
+ expect(a.requests.publicWrites.concat(b.requests.publicWrites)).toEqual([]);
+ expect(await kept(a.page, alice.fid)).toBeNull();
+ expect(await kept(b.page, alice.fid)).toBeNull();
+ expect(await a.page.evaluate(async () => (await indexedDB.databases()).map(value => value.name))).not.toContain("allthing-stdb");
+ await a.page.goto("/library");
+ await expect(card(a.page, 1)).toBeVisible();
+ await expect(a.page.getByRole("tab", { name: "Bookmarks", exact: true })).toHaveAttribute("aria-selected", "true");
+ // revision() covers the whole private Library, so an unrelated real Seen
+ // update may already offer refresh. The cached Back still has to observe
+ // the later bookmark removal and retain its row until explicit refresh.
+ // A remote change while this cached list is away must survive Back as a
+ // refresh notice, while preserving its loaded rows and selected tab.
+ await card(a.page, 1).locator("a.when").click();
+ await expect(a.page).toHaveURL(new RegExp(`/p/${hash(1)}$`));
+ await saved(b.page, 1, true).click();
+ await expect.poll(() => peer.post(alice.fid, hash(1)).bookmarked).toBe(false);
+ await a.page.goBack({ waitUntil: "commit" });
+ await expect(card(a.page, 1)).toBeVisible();
+ await expect(saved(a.page, 1)).toHaveAttribute("aria-pressed", "false");
+ await expect(a.page.getByText("Your Library changed on this or another device.", { exact: false })).toBeVisible();
+ await a.page.getByRole("button", { name: "Refresh this list", exact: true }).click();
+ await expect(a.page.getByText("No bookmarks yet. Save a post to find it here.", { exact: true })).toBeVisible();
+ expect(a.errors.concat(b.errors)).toEqual([]);
+ } finally { await diagnose(peer, a, b); await close(a, b); }
+});
+
+test("saving during an in-flight automatic Seen write queues independently and survives reload", async ({ browser }) => {
+ const peer = new Peer(); peer.holdSeen.add("a");
+ const a = await reader(browser, peer, "a", alice, { pool: [cast(1)] });
+ try {
+ await a.page.goto("/");
+ await expect(card(a.page, 1)).toBeVisible();
+ // The real visible-card dwell reaches the server, which broadcasts the
+ // committed Seen row but deliberately holds that reducer's result.
+ await expect.poll(() => peer.calls.some(call => call.label === "a" && call.name === "mark_seen_batch" && (call.args.entries as { hash: string }[]).some(entry => entry.hash === hash(1)))).toBe(true);
+ expect(peer.heldSeen.get("a")?.length).toBe(1);
+ await saved(a.page, 1).click();
+ await expect(saved(a.page, 1, true)).toHaveAttribute("aria-pressed", "true");
+ await expect(card(a.page, 1).getByText("Syncing the previous change", { exact: false })).toHaveCount(0);
+ peer.releaseSeen("a");
+ await expect.poll(() => peer.post(alice.fid, hash(1)).bookmarked).toBe(true);
+ await a.page.getByRole("navigation", { name: "Main", exact: true }).getByRole("link", { name: "Library", exact: true }).click();
+ await expect(a.page).toHaveURL(/\/library$/);
+ await expect(a.page.getByRole("tab", { name: "Bookmarks", exact: true })).toHaveAttribute("aria-selected", "true");
+ await expect(a.page.locator(".library-panel .cast-card[data-hash]")).toHaveCount(1);
+ await expect(saved(a.page, 1, true)).toBeVisible();
+ await a.page.reload();
+ await expect(saved(a.page, 1, true)).toHaveAttribute("aria-pressed", "true");
+ expect(a.requests.publicWrites).toEqual([]);
+ expect(a.errors).toEqual([]);
+ } finally { peer.releaseSeen("a"); await diagnose(peer, a); await close(a); }
+});
+
+test("a queued offline bookmark cannot resurrect a newer removal and stays correct after reload", async ({ browser }) => {
+ const peer = new Peer(), a = await reader(browser, peer, "a", alice), b = await reader(browser, peer, "b", alice);
+ try {
+ await Promise.all([a.page.goto("/"), b.page.goto("/")]);
+ await expect(saved(a.page, 1)).toBeEnabled(); await expect(saved(b.page, 1)).toBeEnabled();
+ await peer.disconnect("b");
+ await saved(b.page, 1).click();
+ await saved(a.page, 1).click(); await expect(saved(a.page, 1, true)).toBeEnabled();
+ await saved(a.page, 1, true).click();
+ await expect.poll(() => peer.post(alice.fid, hash(1)).bookmarked).toBe(false);
+ await reconnect(peer, b, "b");
+ await expect(saved(b.page, 1)).toHaveAttribute("aria-pressed", "false");
+ expect(peer.post(alice.fid, hash(1)).bookmarked).toBe(false);
+ // Reload the intentional saved-post view. Home correctly hides the
+ // now-seen card, so it cannot be used as a bookmark-state oracle.
+ await b.page.getByRole("navigation", { name: "Main", exact: true }).getByRole("link", { name: "Library", exact: true }).click();
+ await expect(b.page).toHaveURL(/\/library$/);
+ await expect(b.page.getByRole("tab", { name: "Bookmarks", exact: true })).toHaveAttribute("aria-selected", "true");
+ await b.page.reload();
+ await expect(b.page.getByText("No bookmarks yet. Save a post to find it here.", { exact: true })).toBeVisible();
+ expect(peer.post(alice.fid, hash(1)).bookmarked).toBe(false);
+ expect(peer.exchanges).toContain("b");
+ } finally { await diagnose(peer, a, b); await close(a, b); }
+});
+
+test("an old attestation cannot hydrate the next account or mix its saved posts", async ({ browser }) => {
+ const peer = new Peer(); peer.seedBookmark(alice.fid, 1); peer.seedBookmark(bob.fid, 9);
+ const a = await reader(browser, peer, "a", alice, { holdAttestation: alice.fid });
+ try {
+ await a.page.goto("/library");
+ await expect.poll(() => a.requests.attest.includes(alice.fid)).toBe(true);
+ await a.page.evaluate(user => {
+ document.cookie = `__Host-allthing-user=${encodeURIComponent(JSON.stringify(user))}; Path=/; Secure; SameSite=Lax`;
+ const channel = new BroadcastChannel("allthing-session"); channel.postMessage("changed"); channel.close();
+ }, bob);
+ await expect(card(a.page, 9)).toBeVisible();
+ a.releaseAttestation();
+ await a.page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ await expect(card(a.page, 1)).toHaveCount(0);
+ expect(a.errors).toEqual([]);
+ } finally { a.releaseAttestation(); await diagnose(peer, a); await close(a); }
+});
+
+test("explicit mark unread changes future Home eligibility while preserving the bookmark", async ({ browser }) => {
+ const peer = new Peer(); peer.seedSeen(alice.fid, 1); peer.seedBookmark(alice.fid, 1);
+ const a = await reader(browser, peer, "a", alice);
+ let fresh: Reader | undefined;
+ try {
+ await a.page.goto("/library?tab=seen");
+ await expect(card(a.page, 1)).toBeVisible();
+ await card(a.page, 1).getByRole("button", { name: "Mark unread", exact: true }).click();
+ await expect.poll(() => peer.post(alice.fid, hash(1)).seen).toBe(false);
+ expect(peer.post(alice.fid, hash(1)).bookmarked).toBe(true);
+ fresh = await reader(browser, peer, "fresh", alice);
+ await fresh.page.goto("/");
+ await expect(card(fresh.page, 1)).toBeVisible();
+ expect(a.errors.concat(fresh.errors)).toEqual([]);
+ } finally { await diagnose(peer, a, ...(fresh ? [fresh] : [])); await close(a, ...(fresh ? [fresh] : [])); }
+});
+
+test("clear history fences a legacy offline queue without deleting private bookmarks", async ({ browser }) => {
+ const peer = new Peer(); peer.seedSeen(alice.fid, 1); peer.seedBookmark(alice.fid, 1); peer.offline.add("b");
+ const a = await reader(browser, peer, "a", alice), b = await reader(browser, peer, "b", alice, { blockStorage: true, legacy: [{ hash: hash(3), at: at - 5 }] });
+ try {
+ await Promise.all([a.page.goto("/library?tab=seen"), b.page.goto("/")]);
+ await expect(card(a.page, 1)).toBeVisible();
+ await a.page.getByRole("button", { name: "Clear reading history…", exact: true }).click();
+ await a.page.getByRole("button", { name: "Clear history on all devices", exact: true }).click();
+ await expect.poll(() => peer.account(alice.fid).generation).toBe(1);
+ // A denied first storage open must fail closed, then recover by explicit
+ // Retry once storage returns; it cannot strand bootstrap or replay old reads.
+ await expect(b.page.locator("#app .cast-card[data-hash]")).toHaveCount(0);
+ await b.page.goto("/library");
+ await expect(b.page.getByRole("button", { name: "Retry sync", exact: true })).toBeVisible();
+ await b.page.evaluate(() => { (window as any).__libraryStorageBlocked = false; });
+ peer.offline.delete("b");
+ await b.page.getByRole("button", { name: "Retry sync", exact: true }).click();
+ await expect.poll(() => peer.verified("b").length).toBe(1);
+ await expect(saved(b.page, 1, true)).toBeEnabled();
+ expect(peer.post(alice.fid, hash(3)).seen).toBe(false);
+ expect(peer.post(alice.fid, hash(1)).bookmarked).toBe(true);
+ expect(peer.calls.filter(call => call.label === "b" && call.name === "mark_seen_batch").every(call => !call.rejected || /generation/.test(call.rejected))).toBe(true);
+ } finally { await diagnose(peer, a, b); await close(a, b); }
+});
+
+test("signed-out visits never start Library attestation, private subscriptions or message keys", async ({ browser }) => {
+ const peer = new Peer(), a = await reader(browser, peer, "a", null);
+ try {
+ await a.page.goto("/library");
+ await expect(a.page.getByText("Sign in to see your Library.", { exact: true })).toBeVisible();
+ expect(a.requests.attest).toEqual([]); expect(peer.connections).toEqual([]); expect(peer.queries).toEqual([]);
+ const databases = await a.page.evaluate(async () => (await indexedDB.databases()).map(value => value.name));
+ expect(databases).not.toContain("allthing-stdb");
+ } finally { await diagnose(peer, a); await close(a); }
+});
+
+
+test("a successful durable drain restores live membership after a transient local read failure", async ({ browser }) => {
+ const peer = new Peer(), a = await reader(browser, peer, "a", alice, { pool: [cast(1)] }), b = await reader(browser, peer, "b", alice, { pool: [cast(1)] });
+ await storageFaultAndActivity(a.page);
+ try {
+ await Promise.all([a.page.goto("/"), b.page.goto("/")]);
+ await expect(saved(a.page, 1)).toBeEnabled(); await expect(saved(b.page, 1)).toBeEnabled();
+ // One reader's accepted Seen update can suppress the other's redundant
+ // write. Fence on authoritative state and A's committed, empty outbox so
+ // the induced failure has no unrelated startup write to revive it.
+ await expect.poll(() => peer.post(alice.fid, hash(1)).seen).toBe(true);
+ await expect.poll(async () => {
+ const item = await diskRecord(a.page, diskKey("cache", alice.fid, hash(1)));
+ return item?.known === true && item.server.fid === alice.fid && item.server.hash === hash(1)
+ && item.server.generation === peer.account(alice.fid).generation && item.server.seen === true
+ && item.server.seen_revision > 0 && item.read === null && item.bookmark === null;
+ }).toBe(true);
+ await a.page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ await expect.poll(() => diskRecord(a.page, diskKey("pending", alice.fid, hash(1)))).toBeNull();
+ await expect.poll(() => a.page.evaluate(() => (window as any).__libraryActiveStorage)).toBe(0);
+ await a.page.evaluate(() => { (window as any).__libraryFailNextOpen = true; });
+ await saved(a.page, 1).click();
+ await expect(a.page.getByRole("button", { name: "Retry history sync", exact: true })).toBeVisible();
+ await expect.poll(() => peer.verified("a").flatMap(c => [...c.selections.values()].flatMap(s => s.queries)).filter(q => q.includes("my_library_membership")).length).toBe(0);
+ peer.pausedPushes.add("a");
+ await saved(b.page, 1).click();
+ await expect.poll(() => peer.post(alice.fid, hash(1)).bookmarked).toBe(true);
+ // A still has its old false point. Saving now is durable but is settled by
+ // an authoritative point response, without another reducer/account update.
+ await saved(a.page, 1).click();
+ await expect(saved(a.page, 1, true)).toHaveAttribute("aria-pressed", "true");
+ await expect.poll(async () => { const item = await diskRecord(a.page, diskKey("cache", alice.fid, hash(1))); return item?.bookmark === null && item?.bookmark_settled > 0; }).toBe(true);
+ await expect.poll(() => diskRecord(a.page, diskKey("pending", alice.fid, hash(1)))).toBeNull();
+ await expect.poll(() => a.page.evaluate(() => (window as any).__libraryActiveStorage)).toBe(0);
+ await a.page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ await expect(a.page.getByRole("button", { name: "Retry history sync", exact: true })).toHaveCount(0);
+ await expect.poll(() => peer.verified("a").flatMap(c => [...c.selections.values()].flatMap(s => s.queries)).filter(q => q.includes("my_library_membership")).length).toBe(1);
+ expect(peer.calls.filter(call => call.label === "a" && call.name === "set_bookmark")).toEqual([]);
+ expect(a.requests.publicWrites).toEqual([]); expect(a.errors.concat(b.errors)).toEqual([]);
+ } finally { peer.pausedPushes.delete("a"); await diagnose(peer, a, b); await close(a, b); }
+});
+
+test("a clear requested during a Seen flight precedes later durable bookmark intent without manual retry", async ({ browser }) => {
+ const peer = new Peer(); peer.holdSeen.add("a");
+ const a = await reader(browser, peer, "a", alice, { pool: [cast(1)] });
+ await storageFaultAndActivity(a.page);
+ try {
+ await a.page.goto("/"); await expect(card(a.page, 1)).toBeVisible();
+ await expect.poll(() => peer.heldSeen.get("a")?.length ?? 0).toBe(1);
+ await a.page.getByRole("navigation", { name: "Main", exact: true }).getByRole("link", { name: "Library", exact: true }).click();
+ await expect(a.page).toHaveURL(/\/library$/);
+ await a.page.getByRole("tab", { name: "Seen", exact: true }).click();
+ await expect(card(a.page, 1)).toBeVisible();
+ await a.page.getByRole("button", { name: "Clear reading history…", exact: true }).click();
+ await a.page.getByRole("button", { name: "Clear history on all devices", exact: true }).click();
+ await expect.poll(async () => (await diskRecord(a.page, diskKey("account", alice.fid)))?.pending_clear?.expected_generation).toBe(0);
+ await saved(a.page, 1).click();
+ await expect.poll(async () => (await diskRecord(a.page, diskKey("pending", alice.fid, hash(1))))?.bookmark?.desired).toBe(true);
+ await expect.poll(() => a.page.evaluate(() => (window as any).__libraryActiveStorage)).toBe(0);
+ await a.page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ peer.releaseSeen("a");
+ await expect.poll(() => peer.account(alice.fid).generation).toBe(1);
+ await expect.poll(() => peer.post(alice.fid, hash(1)).bookmarked).toBe(true);
+ await expect.poll(() => diskRecord(a.page, diskKey("pending", alice.fid, hash(1)))).toBeNull();
+ const calls = peer.calls.filter(call => call.label === "a").map(call => call.name);
+ expect(calls.indexOf("clear_seen")).toBeGreaterThan(calls.indexOf("mark_seen_batch"));
+ expect(calls.indexOf("set_bookmark")).toBeGreaterThan(calls.indexOf("clear_seen"));
+ expect(peer.calls.filter(call => call.label === "a" && call.rejected)).toEqual([]);
+ expect(peer.post(alice.fid, hash(1)).seen).toBe(false);
+ await expect(a.page.getByText("Private Library connected.", { exact: true })).toBeVisible();
+ await expect(a.page.getByRole("button", { name: "Retry sync", exact: true })).toHaveCount(0);
+ expect(a.errors).toEqual([]); expect(a.requests.publicWrites).toEqual([]);
+ } finally { peer.releaseSeen("a"); await diagnose(peer, a); await close(a); }
+});
diff --git a/e2e/library.fixture.spec.ts b/e2e/library.fixture.spec.ts
new file mode 100644
index 00000000..bdde0474
--- /dev/null
+++ b/e2e/library.fixture.spec.ts
@@ -0,0 +1,218 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { expect, test, type Page } from "@playwright/test";
+import { appCame } from "./helpers/app";
+import { signIn } from "./helpers/farcaster";
+
+// Public reaction history is a Worker stand-in; all rendering/navigation and
+// account/cursor guards are the built Rust app. Private sync has its own real
+// SDK protocol cases; these fixtures deliberately advertise no Library service.
+const pages = Boolean(process.env.E2E_PAGES);
+const me = { fid: 1234, username: "reader", display_name: "Reader" };
+const other = { fid: 5678, username: "other", display_name: "Other" };
+const author = { fid: 88, username: "fixture", display_name: "Fixture Author" };
+const hash = (n: number) => `0x${n.toString(16).padStart(40, "0")}`;
+const at = Math.floor(Date.now() / 1000);
+const meta = { backend: "hypersnap", fetched_at: Date.now() };
+const cast = (n: number, text = `Library post ${n}.`) => ({ hash: hash(n), author, text, ts: at - n * 60, likes: 1, recasts: 1, replies: 0 });
+const cards = (page: Page) => page.locator("#app .library-panel .cast-card[data-hash]");
+type PublicPage = { fid: number; kind: string; entries: { hash: string; author_fid: number; at: number }[]; pool: ReturnType[]; notes: object; next?: string; unavailable: string[]; filtered: string[]; meta: typeof meta };
+const payload = (fid: number, kind: string, numbers: number[], extra: Partial = {}): PublicPage => ({
+ fid, kind, entries: numbers.map(n => ({ hash: hash(n), author_fid: author.fid, at: at - n })),
+ pool: numbers.map(n => cast(n)), notes: {}, unavailable: [], filtered: [], meta, ...extra,
+});
+
+async function serve(page: Page) {
+ await page.route("**/api/v1/**", route => route.fulfill({ status: 404, json: { error: "Fixture endpoint" } }));
+ await page.route("**/api/v1/status", route => route.fulfill({ json: { spacetime: { attest: false, library: false } } }));
+ await page.route("**/api/v1/home**", route => route.fulfill({ json: { pool: [cast(1)], notes: {}, meta } }));
+ await page.route("**/api/v1/cast?*", route => {
+ const n = Number.parseInt(new URL(route.request().url()).searchParams.get("hash")!.slice(2), 16);
+ return route.fulfill({ json: { thread: { cast: cast(n), children: [] }, ancestors: [], notes: {}, meta } });
+ });
+ await page.route("https://haatz.quilibrium.com/**", route => route.fulfill({ headers: { "access-control-allow-origin": "*" }, json: { messages: [] } }));
+}
+
+async function switchReader(page: Page, user: typeof me | null) {
+ await page.evaluate(user => {
+ document.cookie = user
+ ? `__Host-allthing-user=${encodeURIComponent(JSON.stringify(user))}; Path=/; Secure; SameSite=Lax`
+ : "__Host-allthing-user=; Path=/; Secure; Max-Age=0";
+ const channel = new BroadcastChannel("allthing-session");
+ channel.postMessage("changed");
+ channel.close();
+ }, user);
+}
+
+test.describe("Library public pages and actions", () => {
+ test.skip(pages, "private account routes use the Worker; static behavior is checked separately");
+
+ test("URL tabs and Unicode filters apply only to loaded posts without another history request", async ({ page }, info) => {
+ await signIn(page, me);
+ await serve(page);
+ const reads: { kind: string; cursor: string | null }[] = [];
+ await page.route("**/api/v1/library?*", route => {
+ const url = new URL(route.request().url());
+ const kind = url.searchParams.get("kind")!;
+ reads.push({ kind, cursor: url.searchParams.get("cursor") });
+ return route.fulfill({ json: payload(me.fid, kind, [1, 2], { pool: [cast(1, "Café in Белград"), cast(2, "Another saved post")] }) });
+ });
+ await page.goto("/library?tab=likes");
+ await expect(cards(page)).toHaveCount(2);
+ await expect(page.getByRole("tab", { name: "Likes", exact: true })).toHaveAttribute("aria-selected", "true");
+ await expect(page.getByText("This filters the pages you've loaded. Load more to include older posts.", { exact: true })).toBeVisible();
+ await page.locator(".library-page").evaluate(node => Promise.all(node.getAnimations({ subtree: true }).filter(animation => animation.effect?.getTiming().iterations !== Infinity).map(animation => animation.finished.catch(() => {}))));
+ await page.screenshot({ path: info.outputPath("library-desktop.png"), fullPage: true });
+ await page.setViewportSize({ width: 390, height: 844 });
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ await page.screenshot({ path: info.outputPath("library-phone390.png"), fullPage: true });
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.getByRole("searchbox", { name: "Filter loaded posts", exact: true }).fill("белград");
+ await page.getByRole("button", { name: "Filter", exact: true }).click();
+ await expect(page).toHaveURL(/tab=likes.*q=/);
+ await expect(cards(page)).toHaveCount(1);
+ await expect(cards(page)).toContainText("Café in Белград");
+ expect(reads).toEqual([{ kind: "likes", cursor: null }]);
+ await page.getByRole("link", { name: "Clear", exact: true }).click();
+ await expect(cards(page)).toHaveCount(2);
+ await page.getByRole("tab", { name: "Likes", exact: true }).focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(page).toHaveURL(/tab=recasts/);
+ await expect(page.getByRole("tab", { name: "Recasts", exact: true })).toBeFocused();
+ await expect(cards(page)).toHaveCount(2);
+ expect(reads.map(read => read.kind)).toEqual(["likes", "recasts"]);
+ });
+
+ test("a failed later page keeps cards and its cursor; explicit retry deduplicates the overlap", async ({ page }) => {
+ await signIn(page, me);
+ await serve(page);
+ let more = 0;
+ const cursors: (string | null)[] = [];
+ await page.route("**/api/v1/library?*", route => {
+ const cursor = new URL(route.request().url()).searchParams.get("cursor");
+ cursors.push(cursor);
+ if (!cursor) return route.fulfill({ json: payload(me.fid, "likes", [1, 2], { next: "cGFnZTI=" }) });
+ expect(cursor).toBe("cGFnZTI=");
+ if (++more === 1) return route.fulfill({ status: 503, headers: { "cache-control": "no-store" }, json: { error: "Library fixture temporarily unavailable" } });
+ return route.fulfill({ json: payload(me.fid, "likes", [2, 3]) });
+ });
+ await page.goto("/library?tab=likes");
+ await expect(cards(page)).toHaveCount(2);
+ const first = await cards(page).first().elementHandle();
+ await page.getByRole("button", { name: "Load more", exact: true }).click();
+ await expect(page.getByRole("alert")).toContainText("Library fixture temporarily unavailable");
+ await expect(cards(page)).toHaveCount(2);
+ await page.getByRole("button", { name: "Try again", exact: true }).click();
+ await expect(cards(page)).toHaveCount(3);
+ expect(await first!.evaluate(node => node.isConnected)).toBe(true);
+ expect(await cards(page).evaluateAll(nodes => nodes.map(node => node.getAttribute("data-hash")))).toEqual([1, 2, 3].map(hash));
+ expect(cursors).toEqual([null, "cGFnZTI=", "cGFnZTI="]);
+ await expect(page.getByRole("button", { name: "Load more", exact: true })).toHaveCount(0);
+ });
+
+ test("unavailable and content-filtered history is explained instead of reported as an empty or deleted archive", async ({ page }) => {
+ await signIn(page, me);
+ await serve(page);
+ await page.route("**/api/v1/library?*", route => route.fulfill({ json: payload(me.fid, "likes", [1, 2], { pool: [], unavailable: [hash(1)], filtered: [hash(2)] }) }));
+ await page.goto("/library?tab=likes");
+ await expect(page.getByText("This post was not returned by Farcaster. It may be unavailable.", { exact: true })).toBeVisible();
+ await expect(page.getByText("1 loaded posts are hidden by content filters.", { exact: true })).toBeVisible();
+ await expect(page.getByText("No likes were returned by Farcaster.", { exact: true })).toHaveCount(0);
+ await expect(page.getByRole("link", { name: "Try opening the post", exact: true })).toHaveAttribute("href", `/p/${hash(1)}`);
+ });
+
+ test("a post opened deep in loaded reaction pages returns to the same Library cards and scroll anchor", async ({ page }) => {
+ await signIn(page, me);
+ await serve(page);
+ const reads: string[] = [];
+ await page.route("**/api/v1/library?*", route => {
+ const cursor = new URL(route.request().url()).searchParams.get("cursor") ?? "";
+ reads.push(cursor);
+ const from = cursor ? 26 : 1;
+ return route.fulfill({ json: payload(me.fid, "likes", Array.from({ length: 25 }, (_, n) => from + n), cursor ? {} : { next: "cGFnZTI=" }) });
+ });
+ await page.goto("/library?tab=likes");
+ await expect(cards(page)).toHaveCount(25);
+ await page.getByRole("button", { name: "Load more", exact: true }).click();
+ const target = page.locator(`.library-panel .cast-card[data-hash="${hash(40)}"]`);
+ await target.scrollIntoViewIfNeeded();
+ await expect(target).toBeVisible();
+ await target.evaluate(node => Promise.all(node.getAnimations().filter(animation => animation.effect?.getTiming().iterations !== Infinity).map(animation => animation.finished.catch(() => {}))));
+ const before = await target.evaluate(node => node.getBoundingClientRect().top);
+ await target.locator("a.when").click();
+ await expect(page).toHaveURL(new RegExp(`/p/${hash(40)}$`));
+ await page.goBack({ waitUntil: "commit" });
+ await expect(target).toBeVisible();
+ await expect.poll(async () => Math.abs((await target.evaluate(node => node.getBoundingClientRect().top)) - before)).toBeLessThanOrEqual(2);
+ expect(reads).toEqual(["", "cGFnZTI="]);
+ await expect(page.getByRole("tab", { name: "Likes", exact: true })).toHaveAttribute("aria-selected", "true");
+ });
+
+ test("a delayed prior-account reaction page cannot enter the next reader's Library", async ({ page }) => {
+ await signIn(page, me);
+ await serve(page);
+ let arrived!: () => void, release!: () => void;
+ const asked = new Promise(resolve => { arrived = resolve; });
+ const hold = new Promise(resolve => { release = resolve; });
+ const errors: string[] = [];
+ page.on("pageerror", error => errors.push(error.message));
+ await page.route("**/api/v1/library?*", async route => {
+ const fid = Number(new URL(route.request().url()).searchParams.get("fid"));
+ if (fid === me.fid) { arrived(); await hold; }
+ return route.fulfill({ json: payload(fid, "likes", [fid === me.fid ? 1 : 2]) });
+ });
+ await page.goto("/library?tab=likes");
+ await asked;
+ await switchReader(page, other);
+ await expect(cards(page)).toContainText("Library post 2.");
+ release();
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ await expect(cards(page)).toHaveCount(1);
+ await expect(cards(page)).not.toContainText("Library post 1.");
+ expect(errors).toEqual([]);
+ await switchReader(page, null);
+ await expect(cards(page)).toHaveCount(0);
+ await expect(page.getByText("Sign in to see your Library.", { exact: true })).toBeVisible();
+ });
+
+ test("saving while signed out opens sign-in rather than a public Farcaster reaction or write-key approval", async ({ page }) => {
+ await serve(page);
+ const writes: string[] = [];
+ page.on("request", request => {
+ if (request.method() === "POST" && (/submitMessage|auth\/signer/.test(request.url()))) writes.push(request.url());
+ });
+ await page.route("**/api/v1/auth/start", route => route.fulfill({ status: 503, json: { error: "Fixture sign-in service unavailable" } }));
+ await page.goto("/");
+ await appCame(page);
+ await page.locator("#app .cast-card").first().getByRole("button", { name: "Save post", exact: true }).click();
+ await expect(page.getByRole("dialog", { name: "Sign in with Farcaster", exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Approve Allthing to write from here", exact: true })).toHaveCount(0);
+ expect(writes).toEqual([]);
+ expect(new URL(page.url()).pathname).toBe("/");
+ });
+
+ test("a fresh unconfigured private Library reports unavailable without claiming the history is empty", async ({ page }) => {
+ await signIn(page, me);
+ await serve(page);
+ let reads = 0;
+ await page.route("**/api/v1/library**", route => { reads++; return route.fulfill({ status: 500, json: {} }); });
+ await page.goto("/library");
+ await expect(page.getByRole("tab", { name: "Bookmarks", exact: true })).toHaveAttribute("aria-selected", "true");
+ await expect(page.getByText("Your private Library is unavailable until sync reconnects. No empty history is assumed.", { exact: true })).toBeVisible();
+ await expect(page.getByText("No bookmarks yet. Save a post to find it here.", { exact: true })).toHaveCount(0);
+ expect(reads).toBe(0);
+ });
+});
+
+test.describe("Library static boundary", () => {
+ test.skip(!pages, "public-only static copy");
+ test("the static Library explains its sign-in boundary and makes no private SDK or API calls", async ({ page }) => {
+ const privateRequests: string[] = [];
+ page.on("request", request => { if (/maincloud\.spacetimedb|\/auth\/attest|\/api\/v1\/library/.test(request.url())) privateRequests.push(request.url()); });
+ await page.goto("/Allthing/library");
+ await expect(page.getByRole("heading", { name: "Library", exact: true })).toBeVisible();
+ await expect(page.getByText("Your Library needs a sign-in on Allthing's server.", { exact: true })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Open your Library on allthing.social", exact: true })).toHaveAttribute("href", "https://allthing.social/library");
+ await expect(page.getByRole("button", { name: "Save post", exact: true })).toHaveCount(0);
+ expect(privateRequests).toEqual([]);
+ });
+});
diff --git a/e2e/like-recast.fixture.spec.ts b/e2e/like-recast.fixture.spec.ts
index 2346f8d9..18a9e7da 100644
--- a/e2e/like-recast.fixture.spec.ts
+++ b/e2e/like-recast.fixture.spec.ts
@@ -239,8 +239,8 @@ test("on a server that endorses no keys, a signed-in reader likes and recasts in
/** Revisit the posts through the reader's recovery control before checking keys. */
async function showSeen(page: Page) {
- await expect(page.locator("#app .cast-list .cast-card:not(.skeleton), #app .seen-notice").first()).toBeVisible();
const show = page.getByRole("button", { name: "Show seen posts", exact: true });
+ await expect(page.locator("#app .cast-list .cast-card[data-hash]").or(show).first()).toBeVisible();
if (await show.isVisible()) await show.click();
}
diff --git a/e2e/presence.fixture.spec.ts b/e2e/presence.fixture.spec.ts
index 60d37467..8899300d 100644
--- a/e2e/presence.fixture.spec.ts
+++ b/e2e/presence.fixture.spec.ts
@@ -274,6 +274,8 @@ test.describe("in front of the Worker", () => {
await expect(chip).toBeVisible({ timeout: 15_000 });
await page.getByRole("link", { name: "Home", exact: true }).focus();
await page.keyboard.press("Tab");
+ await expect(page.getByRole("link", { name: "Library", exact: true })).toBeFocused();
+ await page.keyboard.press("Tab");
await expect(chip).toBeFocused();
// Focus alone doesn't open it: Enter does, as a press would.
await page.waitForTimeout(400);
diff --git a/e2e/stdb-attest.fixture.spec.ts b/e2e/stdb-attest.fixture.spec.ts
index 0fe730fd..ad01d7fe 100644
--- a/e2e/stdb-attest.fixture.spec.ts
+++ b/e2e/stdb-attest.fixture.spec.ts
@@ -84,7 +84,7 @@ async function attest(body: unknown, { fid = 1234 as number | null, origin = tru
test("status reports the attestation key's public half, never the key", async () => {
const text = await (await fetch(`${ORIGIN}/api/v1/status`)).text();
- expect(JSON.parse(text).spacetime).toEqual({ attest: true, public_key: PUBLIC });
+ expect(JSON.parse(text).spacetime).toEqual({ attest: true, library: true, public_key: PUBLIC });
expect(text).not.toContain(SEED);
});
diff --git a/e2e/tap-targets.fixture.spec.ts b/e2e/tap-targets.fixture.spec.ts
index dbc250c5..f4ae29a1 100644
--- a/e2e/tap-targets.fixture.spec.ts
+++ b/e2e/tap-targets.fixture.spec.ts
@@ -36,7 +36,7 @@ async function serve(page: Page) {
/** For the screenshots only: each tap band, outlined. */
const outlined = [
".main-nav a", ".sign-in-button", ".sort-tabs a", ".tabs a", ".engagement .act", ".engagement .popover-trigger",
- ".reply-button", ".follow-button", ".favorite-button", ".show-less-button", ".site-footer a",
+ ".reply-button", ".bookmark-button", ".follow-button", ".favorite-button", ".show-less-button", ".site-footer a",
].map((target) => `${target}::after`).join(", ") + " { outline: 1px dashed #d6247a; outline-offset: -1px; }";
/** The tap band of `target`: its ::after, as laid out. */
@@ -83,13 +83,14 @@ test("at 390 on a touch screen, the small targets take a 44 px tap", async ({ br
};
await page.goto("/");
- await check("Home", page.locator(".main-nav a"));
+ await check("Home", page.getByRole("link", { name: "Home", exact: true }));
await check("Sign in", page.locator(".sign-in-button"), { wide: true });
await check("hot", page.locator(".sort-tabs a").first());
const card = page.locator(`.cast-card[data-hash="${cast.hash}"]`);
await check("the heart", card.getByRole("button", { name: "Like", exact: true }));
await check("the likes count", card.locator(".stat-likes .popover-trigger"));
await check("the replies count", card.locator(".stat-replies"));
+ await check("Save", card.getByRole("button", { name: "Save post", exact: true }), { wide: true });
await page.screenshot({ path: info.outputPath("tap-targets-390-feed.png"), style: outlined });
await page.goto(`/p/${cast.hash}`);
diff --git a/spacetime/HANDOFF.md b/spacetime/HANDOFF.md
index 75de4de3..20ae0406 100644
--- a/spacetime/HANDOFF.md
+++ b/spacetime/HANDOFF.md
@@ -54,8 +54,17 @@ The UI and encryption logic are Rust. `web/stdb/bridge.ts` is the narrow adapter
4. Subscribe to `room`, `chat_message WHERE room_id=…`, `typing`, `user`, `user_key`, `my_dm_inbox`, `my_dm_outbox`.
5. Throttle `set_typing` (~every 3 s while typing). DMs are encrypted/decrypted entirely on the client.
-### Notification-only client
+### Private notification and Library client
-`notification_sync.rs` uses the same SDK module script through `stdb_bridge`, but a separate connection and per-FID `{identity, token}` record in IndexedDB `allthing-notification-sync`, store `identities` (no DM key). After its delayed/API-quiet/browser-idle status capability check, it obtains a fresh Worker attestation, calls `verify_notification_fid` and subscribes only to `SELECT * FROM my_notification_state`. It never calls `register` or `set_public_key` or subscribes to public chat/presence tables.
+`notification_sync.rs` uses the same SDK module script through `stdb_bridge`, but a separate connection and per-FID `{identity, token}` record in IndexedDB `allthing-notification-sync`, store `identities` (no DM key). After its delayed/API-quiet/browser-idle status capability check, it obtains a fresh Worker attestation, calls `verify_notification_fid` and subscribes to `SELECT * FROM my_notification_state`. When `spacetime.library` is advertised, the same private socket also follows caller Library metadata and bounded membership/page queries. Foreground Library/feed readiness skips the initial delay and unrelated API waits, while still yielding to browser idle time. It never calls `register` or `set_public_key` or subscribes to public chat/presence tables.
The existing localStorage notification watermark is the offline/legacy cache. Rust `ReadState` merges it with the initial private snapshot before uploading local progress, retains failed writes, and takes maxima on acknowledgements/remote updates without echoing them. One pending maximum is written at a time, coalesced after 200 ms; 30-second operation deadlines, retry backoff capped at five minutes and online/focus/visibility wakeups handle disconnects. Sign-out/account-generation changes fence stale responses and detach the old socket. The page reconciles its initial remote baseline once; later remote reads update its badge/cache while preserving that visit's row tint. Tab selection remains in the URL.
+
+
+### Private Library
+
+`library.rs` adds private `library_account` / `library_post` state, reusing `notification_reader` identity attestation. `my_library_state()` returns only caller metadata; `my_library_membership(hashes_csv)` accepts at most 100 canonical unique hashes and returns an explicit default row for absent membership; `my_library_page(kind,generation,after,limit)` uses bounded indexed ranges, at most 50 rows plus one continuation. Kind 0 is seen, kind 1 bookmarks; cursor is `:<0xhash>`. SDK view rows are a set: sort the bounded result descending by time/hash and filter the exact caller, generation, kind and cursor before exposing it. No external SQL WHERE/ORDER/LIMIT belongs on parameterized views.
+
+`mark_seen_batch` uses per-entry expected revisions and an account generation; `forget_seen` retains a revision tombstone. `set_bookmark` is compare-and-set with explicit false tombstones. `clear_seen` checks expected generation and an operation ID, making a lost-ack retry idempotent while fencing stale offline impressions. Clear does not remove bookmarks. Seen timestamps at or before `cleared_at` are intentionally ignored, including the same whole second. There is no history TTL or post-count eviction. These tables are never public; verified other FIDs cannot query a reader's view data.
+
+Publish additively with the matching 2.13 CLI, `--delete-data=never`, before merging the Worker capability flag. Inspect the migration plan; preserve the pinned attestation key and existing notification/chat/DM schema and reducers. Never use a database clear or break-clients migration to ship Library.
diff --git a/spacetime/spacetimedb/src/lib.rs b/spacetime/spacetimedb/src/lib.rs
index e665b3bd..bc4b1d6b 100644
--- a/spacetime/spacetimedb/src/lib.rs
+++ b/spacetime/spacetimedb/src/lib.rs
@@ -3,6 +3,7 @@
use spacetimedb::{Identity, ReducerContext, Table, TimeDuration, Timestamp, ViewContext, view};
mod attest_key;
+mod library;
pub use attest_key::ATTEST_PUBLIC_KEY;
const MAX_MSG: usize = 4000;
diff --git a/spacetime/spacetimedb/src/library.rs b/spacetime/spacetimedb/src/library.rs
new file mode 100644
index 00000000..174499e2
--- /dev/null
+++ b/spacetime/spacetimedb/src/library.rs
@@ -0,0 +1,549 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! Durable private reading history and bookmarks. Only bounded indexed views
+//! are exposed; an account's complete history is never a bootstrap payload.
+
+use super::*;
+use std::collections::BTreeSet;
+
+const MAX_BATCH: usize = 100;
+const MAX_PAGE: u32 = 50;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+#[spacetimedb::table(accessor = library_account)]
+pub struct LibraryAccount {
+ #[primary_key]
+ pub fid: u64,
+ pub generation: u64,
+ pub revision: u64,
+ pub seen_count: u64,
+ pub bookmark_count: u64,
+ pub cleared_at: i64,
+ pub last_clear_id: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+#[spacetimedb::table(accessor = library_post,
+ index(accessor = by_seen, btree(columns = [fid, generation, seen, seen_order])),
+ index(accessor = by_bookmark, btree(columns = [fid, bookmarked, bookmark_order])))]
+pub struct LibraryPost {
+ #[primary_key]
+ pub key: String,
+ pub fid: u64,
+ pub hash: String,
+ pub generation: u64,
+ pub seen: bool,
+ pub seen_at: i64,
+ pub seen_revision: u64,
+ pub seen_order: String,
+ pub bookmarked: bool,
+ pub bookmark_at: i64,
+ pub bookmark_revision: u64,
+ pub bookmark_order: String,
+}
+
+#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq, Eq)]
+pub struct SeenInput {
+ pub hash: String,
+ pub at: i64,
+ pub expected_revision: u64,
+}
+
+/// Flat rows keep the existing SDK boundary small. `cursor` is empty for a
+/// point result and is the exclusive keyset cursor for a paged result.
+#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq, Eq)]
+pub struct LibraryItem {
+ pub fid: u64,
+ pub generation: u64,
+ pub hash: String,
+ pub seen: bool,
+ pub seen_at: i64,
+ pub seen_revision: u64,
+ pub bookmarked: bool,
+ pub bookmark_at: i64,
+ pub bookmark_revision: u64,
+ pub cursor: String,
+}
+
+fn account(fid: u64) -> LibraryAccount {
+ LibraryAccount {
+ fid,
+ generation: 0,
+ revision: 0,
+ seen_count: 0,
+ bookmark_count: 0,
+ cleared_at: 0,
+ last_clear_id: String::new(),
+ }
+}
+
+fn hash(value: &str) -> Result {
+ if value.len() != 42 || !value.starts_with("0x") || !value[2..].bytes().all(|b| b.is_ascii_hexdigit()) {
+ return Err("cast hash must be 0x followed by 40 hex digits".into());
+ }
+ Ok(value.to_ascii_lowercase())
+}
+
+fn key(fid: u64, hash: &str) -> String {
+ format!("{fid}:{hash}")
+}
+
+fn increment(value: u64) -> Result {
+ value.checked_add(1).ok_or_else(|| "library revision or count exhausted".into())
+}
+
+fn decrement(value: u64) -> Result {
+ value.checked_sub(1).ok_or_else(|| "library count is inconsistent".into())
+}
+
+/// Reverse both components so a forward B-tree scan is DESC(time, hash).
+fn order(at: i64, hash: &str) -> String {
+ let reversed: String = hash[2..]
+ .bytes()
+ .map(|b| char::from(b"fedcba9876543210"[char::from(b).to_digit(16).expect("validated hash") as usize]))
+ .collect();
+ format!("{:020}:{reversed}", u64::MAX - at as u64)
+}
+
+fn after_key(cursor: &str) -> Result {
+ if cursor.len() > 63 {
+ return Err("invalid library cursor".into());
+ }
+ let (at, value) = cursor.split_once(':').ok_or("invalid library cursor")?;
+ let at: i64 = at.parse().map_err(|_| "invalid library cursor")?;
+ if at < 0 {
+ return Err("invalid library cursor".into());
+ }
+ Ok(order(at, &hash(value)?))
+}
+
+fn normalized(state: &LibraryAccount, hash: String, old: Option) -> LibraryPost {
+ let mut post = old.unwrap_or_else(|| LibraryPost {
+ key: key(state.fid, &hash),
+ fid: state.fid,
+ hash,
+ generation: state.generation,
+ seen: false,
+ seen_at: 0,
+ seen_revision: 0,
+ seen_order: String::new(),
+ bookmarked: false,
+ bookmark_at: 0,
+ bookmark_revision: 0,
+ bookmark_order: String::new(),
+ });
+ if post.generation != state.generation {
+ post.generation = state.generation;
+ post.seen = false;
+ post.seen_at = 0;
+ post.seen_revision = 0;
+ post.seen_order.clear();
+ }
+ post
+}
+
+fn item(state: &LibraryAccount, post: LibraryPost, kind: Option