Skip to main content

feather_reader/
store.rs

1//! SQLite persistence layer (via `sqlx`, runtime queries).
2//!
3//! FeatherReader keeps the source of truth for *what a user follows* and *their
4//! read-position* in the user's own atproto PDS (as `community.lexicon.rss.*`
5//! records). This module is the **local per-DID cache + debounce
6//! buffer**: a single SQLite file that holds
7//!
8//! * `feeds` + `entries` — a shared cache of feed metadata and articles, keyed by
9//!   feed URL / feed-native GUID and **shared across every DID** that follows the
10//!   same feed (many users on one instance don't multiply fetch load), and
11//! * `entry_state` + `read_cursor` — per-DID read/star state and the per-feed
12//!   read cursor that the (v1.1) batched flusher syncs up to the PDS.
13//!
14//! All queries here are **runtime** queries (`sqlx::query` / `sqlx::query_as`),
15//! not the compile-time `query!` macros — so the crate builds with no
16//! `DATABASE_URL` and no offline metadata. Schema creation is idempotent
17//! (`CREATE TABLE IF NOT EXISTS`) and runs inside [`init`].
18//!
19//! Errors propagate as [`anyhow::Result`]; nothing in the non-test paths panics.
20
21use anyhow::{Context, Result};
22use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
23use sqlx::{ConnectOptions, FromRow, Row};
24use std::str::FromStr;
25
26use crate::config::Config;
27
28/// Typed failure modes for [`redeem_code`]. Distinct variants so the web layer
29/// can map each to the right user-facing message / HTTP status without string
30/// matching. Everything else (a real SQLite error) still propagates as
31/// [`anyhow::Error`] out of the `Result`.
32#[derive(Debug, thiserror::Error, PartialEq, Eq)]
33pub enum RedeemError {
34    /// No invite code with that value exists.
35    #[error("invite code not found")]
36    NotFound,
37    /// The code exists but is past its `expires_at` (or already flipped to
38    /// `expired`).
39    #[error("invite code expired")]
40    Expired,
41    /// The code has already been redeemed (or is otherwise not `active`).
42    #[error("invite code already redeemed")]
43    AlreadyRedeemed,
44    /// The closed-beta seat cap ([`Config`]'s `FEATHERREADER_BETA_CAP`) is full.
45    #[error("beta is at capacity")]
46    CapacityFull,
47}
48
49/// The SQLite connection pool type the rest of the crate refers to as
50/// [`Pool`]. A thin alias over [`SqlitePool`] so [`crate::AppState`] and the web
51/// layer name one stable type; if the backend ever changes, this is the single
52/// place to swap it.
53pub type Pool = SqlitePool;
54
55/// A cached syndication feed, shared across all DIDs that subscribe to its URL.
56///
57/// This mirrors the PDS-side `community.lexicon.rss.subscription.url`; the row is
58/// created/updated by the poller, never owned by a single user.
59#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
60pub struct Feed {
61    pub id: i64,
62    pub url: String,
63    pub title: Option<String>,
64    pub site_url: Option<String>,
65    /// HTTP `ETag` from the last successful fetch, for conditional GET.
66    pub etag: Option<String>,
67    /// HTTP `Last-Modified` from the last successful fetch, for conditional GET.
68    pub last_modified: Option<String>,
69    /// When we last polled this feed (RFC3339), or `None` if never.
70    pub last_polled: Option<String>,
71    /// When this feed is next due to be polled (RFC3339), or `None`.
72    pub next_poll: Option<String>,
73    /// Count of consecutive poll FAILURES since the last success/304. Drives the
74    /// exponential poll backoff (reset to 0 on any success or 304).
75    #[sqlx(default)]
76    pub consecutive_errors: i64,
77}
78
79/// A cached article/item belonging to a [`Feed`]. Shared cache (not per-DID).
80#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
81pub struct Entry {
82    pub id: i64,
83    pub feed_id: i64,
84    /// Feed-native GUID/id, unique within a feed (used for dedup on re-fetch).
85    pub guid: String,
86    pub url: Option<String>,
87    pub title: Option<String>,
88    pub author: Option<String>,
89    /// Publication time as reported by the feed (RFC3339), or `None`.
90    pub published: Option<String>,
91    /// Article body HTML, **already sanitized** (ammonia) before it reaches here.
92    pub content_html: Option<String>,
93    /// When FeatherReader first fetched/stored this entry (RFC3339).
94    pub fetched_at: String,
95}
96
97/// Per-`(did, entry)` read/star state — the fast in-session working copy that the
98/// batched flusher later syncs to the PDS as a per-feed read cursor.
99#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
100pub struct EntryState {
101    pub did: String,
102    pub entry_id: i64,
103    pub read: bool,
104    pub starred: bool,
105    pub updated_at: String,
106}
107
108/// Per-`(did, feed_url)` read cursor — the local mirror of the PDS
109/// `community.lexicon.rss.readState` record plus flush bookkeeping.
110///
111/// `read_ids` / `unread_ids` are stored as JSON arrays of entry ids (the two
112/// bounded exception sets around the `read_through` high-water-mark); `dirty`
113/// marks that local `entry_state` has changed since the last PDS flush.
114#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
115pub struct ReadCursor {
116    pub did: String,
117    pub feed_url: String,
118    /// High-water-mark (RFC3339): every entry seen/published `<=` this is read.
119    pub read_through: Option<String>,
120    /// JSON array of entry ids newer than `read_through` that are also read.
121    pub read_ids: String,
122    /// JSON array of entry ids older than `read_through` explicitly kept unread.
123    pub unread_ids: String,
124    /// Set when `entry_state` changed since the last flush (debounce trigger).
125    pub dirty: bool,
126    /// Whether this cursor's `readState` record has been CREATED in the PDS yet.
127    /// The first flush of a feed must emit an `applyWrites#create` (an `#update`
128    /// errors on a record that does not pre-exist, and applyWrites is atomic
129    /// per-repo, so one not-yet-created cursor would drop the whole DID batch).
130    /// Flipped to `true` on the flush that creates it.
131    #[sqlx(default)]
132    pub pds_created: bool,
133    pub updated_at: String,
134}
135
136/// New-feed payload for [`upsert_feed`] (id is assigned by SQLite).
137#[derive(Debug, Clone, Default)]
138pub struct NewFeed {
139    pub url: String,
140    pub title: Option<String>,
141    pub site_url: Option<String>,
142    pub etag: Option<String>,
143    pub last_modified: Option<String>,
144    pub last_polled: Option<String>,
145    pub next_poll: Option<String>,
146}
147
148/// New-entry payload for [`insert_entries`] (id is assigned by SQLite,
149/// `fetched_at` defaults to "now" when not supplied).
150#[derive(Debug, Clone, Default)]
151pub struct NewEntry {
152    pub guid: String,
153    pub url: Option<String>,
154    pub title: Option<String>,
155    pub author: Option<String>,
156    pub published: Option<String>,
157    /// Already-sanitized HTML.
158    pub content_html: Option<String>,
159    /// Optional explicit fetch time (RFC3339); defaults to now if `None`.
160    pub fetched_at: Option<String>,
161}
162
163/// The SQLite schema. Idempotent — safe to run on every startup.
164///
165/// `feeds`/`entries` are the shared cache; `entry_state`/`read_cursor` are
166/// per-DID. Indices cover the scheduler's due-feed query, the read/unread list
167/// query, and the flusher's dirty-cursor scan.
168const SCHEMA: &str = r#"
169PRAGMA foreign_keys = ON;
170
171CREATE TABLE IF NOT EXISTS feeds (
172    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
173    url                TEXT NOT NULL UNIQUE,
174    title              TEXT,
175    site_url           TEXT,
176    etag               TEXT,
177    last_modified      TEXT,
178    last_polled        TEXT,
179    next_poll          TEXT,
180    consecutive_errors INTEGER NOT NULL DEFAULT 0
181);
182CREATE INDEX IF NOT EXISTS idx_feeds_next_poll ON feeds (next_poll);
183
184CREATE TABLE IF NOT EXISTS entries (
185    id           INTEGER PRIMARY KEY AUTOINCREMENT,
186    feed_id      INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
187    guid         TEXT NOT NULL,
188    url          TEXT,
189    title        TEXT,
190    author       TEXT,
191    published    TEXT,
192    content_html TEXT,
193    fetched_at   TEXT NOT NULL,
194    UNIQUE (feed_id, guid)
195);
196CREATE INDEX IF NOT EXISTS idx_entries_feed_published ON entries (feed_id, published);
197
198CREATE TABLE IF NOT EXISTS entry_state (
199    did        TEXT NOT NULL,
200    entry_id   INTEGER NOT NULL REFERENCES entries (id) ON DELETE CASCADE,
201    read       INTEGER NOT NULL DEFAULT 0,
202    starred    INTEGER NOT NULL DEFAULT 0,
203    updated_at TEXT NOT NULL,
204    PRIMARY KEY (did, entry_id)
205);
206CREATE INDEX IF NOT EXISTS idx_entry_state_did_read ON entry_state (did, read);
207
208-- Per-DID subscription projection. The shared `feeds`/`entries` cache is
209-- deduped by URL and NOT owned by any single DID; `sub_ref` records which
210-- feeds a given DID actually subscribes to (mirrored from the caller's PDS
211-- subscription set on every resolve/sync). Every entry/feed READ and every
212-- read/star MUTATION is scoped through this table so one user can never read
213-- or mutate another user's cached articles. Rows are refreshed by
214-- `replace_sub_refs`.
215CREATE TABLE IF NOT EXISTS sub_ref (
216    did     TEXT NOT NULL,
217    feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
218    PRIMARY KEY (did, feed_id)
219);
220CREATE INDEX IF NOT EXISTS idx_sub_ref_feed ON sub_ref (feed_id);
221
222CREATE TABLE IF NOT EXISTS read_cursor (
223    did          TEXT NOT NULL,
224    feed_url     TEXT NOT NULL,
225    read_through TEXT,
226    read_ids     TEXT NOT NULL DEFAULT '[]',
227    unread_ids   TEXT NOT NULL DEFAULT '[]',
228    dirty        INTEGER NOT NULL DEFAULT 0,
229    pds_created  INTEGER NOT NULL DEFAULT 0,
230    updated_at   TEXT NOT NULL,
231    PRIMARY KEY (did, feed_url)
232);
233CREATE INDEX IF NOT EXISTS idx_read_cursor_dirty ON read_cursor (did, dirty);
234-- The (did, feed_url) PRIMARY KEY can't serve a feed_url-only lookup (did is the
235-- leading column). The retention path's orphan-cursor cleanup filters cursors by
236-- feed_url alone, so give it an index.
237CREATE INDEX IF NOT EXISTS idx_read_cursor_feed_url ON read_cursor (feed_url);
238
239CREATE TABLE IF NOT EXISTS beta_access (
240    did              TEXT PRIMARY KEY,
241    handle           TEXT,
242    granted_by       TEXT NOT NULL,
243    granted_at       INTEGER NOT NULL,
244    invite_code_used TEXT
245);
246
247CREATE TABLE IF NOT EXISTS invite_codes (
248    code        TEXT PRIMARY KEY,
249    creator_did TEXT NOT NULL,
250    status      TEXT NOT NULL,
251    invitee_did TEXT,
252    created_at  INTEGER NOT NULL,
253    expires_at  INTEGER NOT NULL,
254    redeemed_at INTEGER
255);
256CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
257"#;
258
259/// RFC3339 timestamp for "now" (UTC, seconds precision), used as the default for
260/// `*_at` columns. Uses `chrono` to match the shape written by [`crate::feed`]
261/// and [`crate::web`] (one timestamp format across the whole crate).
262fn now_rfc3339() -> String {
263    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
264}
265
266/// Open the per-DID SQLite cache described by [`Config`] (its `db_path`), run
267/// schema creation, and return the pool.
268///
269/// This is the entrypoint `main` calls: it derives the sqlx SQLite URL from the
270/// configured filesystem path and delegates to [`init_url`]. Kept separate from
271/// [`init_url`] so tests can open an in-memory database directly.
272pub async fn init(config: &Config) -> Result<Pool> {
273    // sqlx wants a `sqlite://<path>` URL; build it from the configured path.
274    let db_url = format!("sqlite://{}", config.db_path.display());
275    init_url(&db_url).await
276}
277
278/// Open (creating if needed) the SQLite database at `db_url`, run schema
279/// creation, and return a connection pool.
280///
281/// `db_url` is a sqlx SQLite URL, e.g. `sqlite://featherreader.db` or
282/// `sqlite::memory:` for an ephemeral in-memory database. The file is created
283/// if it does not exist; WAL journaling is enabled for on-disk databases and
284/// foreign keys are enforced on every connection.
285pub async fn init_url(db_url: &str) -> Result<Pool> {
286    // An in-memory DB must run on a SINGLE connection: each `:memory:` connection
287    // is a *separate* database, and a multi-connection in-memory pool can also
288    // deadlock a writer against an idle pooled connection's shared-cache table
289    // read-lock (SQLITE_LOCKED, code 262 — which `busy_timeout` does NOT retry;
290    // seen as a Linux-only flaky failure in redeem_code's UPDATE). On-disk uses
291    // WAL + a 5-connection pool as normal.
292    let is_memory = db_url.contains(":memory:");
293    let mut opts = SqliteConnectOptions::from_str(db_url)
294        .with_context(|| format!("invalid sqlite url: {db_url}"))?
295        .create_if_missing(true)
296        .foreign_keys(true);
297    // WAL is a no-op / unsupported for :memory:, so only request it on-disk.
298    if !is_memory {
299        opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
300    }
301    // Under a concurrent write burst (the poller's insert_entries tx racing the
302    // web layer's mark_read / redeem_code tx) SQLite would otherwise return
303    // SQLITE_BUSY the instant a writer holds the lock. `busy_timeout` makes a
304    // blocked connection WAIT (retry) for up to this long before erroring, so
305    // short lock contention resolves transparently instead of surfacing a
306    // spurious failure. Mirrors the OAuth sidecar's `stores.ts`
307    // (`PRAGMA busy_timeout = 5000`). 5 s is comfortably above any single
308    // FeatherReader transaction.
309    opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
310    // Quiet sqlx's per-statement query logging.
311    opts = opts.log_statements(tracing::log::LevelFilter::Debug);
312
313    let pool = SqlitePoolOptions::new()
314        // Keep at least one connection alive so an in-memory DB isn't dropped
315        // (each `:memory:` connection is a *separate* database otherwise).
316        .min_connections(1)
317        .max_connections(if is_memory { 1 } else { 5 })
318        .connect_with(opts)
319        .await
320        .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
321
322    init_schema(&pool).await?;
323    Ok(pool)
324}
325
326/// Run the idempotent schema creation. Split out so callers/tests can (re)apply
327/// it against an already-open pool.
328pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
329    // `execute` runs the multi-statement batch (sqlite allows this).
330    sqlx::query(SCHEMA)
331        .execute(pool)
332        .await
333        .context("failed to create schema")?;
334    apply_migrations(pool).await?;
335    Ok(())
336}
337
338/// Apply additive, idempotent migrations to bring an EXISTING database up to the
339/// current [`SCHEMA`]. `CREATE TABLE IF NOT EXISTS` never alters a table that
340/// already exists, so a column added to a shipped table must be back-filled here
341/// (SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe `table_info` first).
342async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
343    // feeds.consecutive_errors — drives the exponential poll backoff. Older DBs
344    // predate the column; add it (defaulting to 0) if it is missing.
345    ensure_column(
346        pool,
347        "PRAGMA table_info(feeds)",
348        "consecutive_errors",
349        "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
350    )
351    .await?;
352    // read_cursor.pds_created — tracks whether a feed's readState record has been
353    // created in the PDS, so the first flush emits a `create` (not a bare
354    // `update`, which errors on a not-yet-existing record). Older DBs predate it.
355    ensure_column(
356        pool,
357        "PRAGMA table_info(read_cursor)",
358        "pds_created",
359        "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
360    )
361    .await?;
362    Ok(())
363}
364
365/// Add a column via `alter_sql` iff `info_sql` (a `PRAGMA table_info(<table>)`)
366/// does not already report `column`. All three SQL args are hard-coded internal
367/// literals (never user input), so they are safe `&'static str`s — the table name
368/// can't be a bind parameter in `PRAGMA`, which is why they're passed whole.
369async fn ensure_column(
370    pool: &SqlitePool,
371    info_sql: &'static str,
372    column: &str,
373    alter_sql: &'static str,
374) -> Result<()> {
375    let rows = sqlx::query(info_sql)
376        .fetch_all(pool)
377        .await
378        .with_context(|| format!("{info_sql} failed"))?;
379    let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
380    if !present {
381        sqlx::query(alter_sql)
382            .execute(pool)
383            .await
384            .with_context(|| format!("adding column {column} via {alter_sql}"))?;
385    }
386    Ok(())
387}
388
389/// Insert a feed by URL, or update its metadata if the URL already exists.
390/// Returns the feed's row id (existing or newly assigned).
391pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
392    let row = sqlx::query(
393        r#"
394        INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll)
395        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
396        ON CONFLICT (url) DO UPDATE SET
397            title         = COALESCE(excluded.title, feeds.title),
398            site_url      = COALESCE(excluded.site_url, feeds.site_url),
399            etag          = excluded.etag,
400            last_modified = excluded.last_modified,
401            last_polled   = COALESCE(excluded.last_polled, feeds.last_polled),
402            next_poll     = COALESCE(excluded.next_poll, feeds.next_poll)
403        RETURNING id
404        "#,
405    )
406    .bind(&feed.url)
407    .bind(&feed.title)
408    .bind(&feed.site_url)
409    .bind(&feed.etag)
410    .bind(&feed.last_modified)
411    .bind(&feed.last_polled)
412    .bind(&feed.next_poll)
413    .fetch_one(pool)
414    .await
415    .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
416
417    Ok(row.get::<i64, _>("id"))
418}
419
420/// Fetch a feed by its URL, if present.
421pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
422    let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
423        .bind(url)
424        .fetch_optional(pool)
425        .await
426        .with_context(|| format!("get_feed_by_url failed for {url}"))?;
427    Ok(feed)
428}
429
430/// The scheduler's hot query: feeds whose `next_poll` is due (`<= as_of`, or
431/// never polled), oldest-due first. `as_of` is an RFC3339 timestamp.
432pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
433    let feeds = sqlx::query_as::<_, Feed>(
434        r#"
435        SELECT * FROM feeds
436        WHERE next_poll IS NULL OR next_poll <= ?1
437        ORDER BY next_poll IS NOT NULL, next_poll ASC
438        LIMIT ?2
439        "#,
440    )
441    .bind(as_of)
442    .bind(limit)
443    .fetch_all(pool)
444    .await
445    .context("due_feeds failed")?;
446    Ok(feeds)
447}
448
449/// Record a poll FAILURE for a feed: bump its `consecutive_errors` by one and
450/// return the NEW count. The count drives the exponential poll backoff, so a
451/// persistently-failing feed spaces its retries out toward the ceiling instead of
452/// hammering the 5-minute floor forever. Reset to 0 by [`reset_feed_errors`] on
453/// any success/304.
454pub async fn bump_feed_errors(pool: &SqlitePool, url: &str) -> Result<i64> {
455    let row = sqlx::query(
456        "UPDATE feeds SET consecutive_errors = consecutive_errors + 1 \
457         WHERE url = ?1 RETURNING consecutive_errors",
458    )
459    .bind(url)
460    .fetch_optional(pool)
461    .await
462    .with_context(|| format!("bump_feed_errors failed for {url}"))?;
463    // If the feed row somehow vanished, treat it as the first error.
464    Ok(row
465        .map(|r| r.get::<i64, _>("consecutive_errors"))
466        .unwrap_or(1))
467}
468
469/// Reset a feed's `consecutive_errors` to 0 after a successful poll (or a 304).
470/// A no-op UPDATE if the row is missing.
471pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
472    sqlx::query("UPDATE feeds SET consecutive_errors = 0 WHERE url = ?1")
473        .bind(url)
474        .execute(pool)
475        .await
476        .with_context(|| format!("reset_feed_errors failed for {url}"))?;
477    Ok(())
478}
479
480/// The feeds a `did` currently subscribes to, per its `sub_ref` projection.
481/// Used by the PDS-unreachable fallback in `resolve_subscriptions` to render
482/// the sidebar from the caller's OWN last-known subscriptions (fail closed)
483/// rather than every cached feed.
484pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
485    let feeds = sqlx::query_as::<_, Feed>(
486        r#"
487        SELECT f.* FROM feeds f
488        JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
489        ORDER BY f.title IS NULL, f.title, f.url
490        "#,
491    )
492    .bind(did)
493    .fetch_all(pool)
494    .await
495    .with_context(|| format!("feeds_for_did failed for {did}"))?;
496    Ok(feeds)
497}
498
499/// The feed ids a `did` currently subscribes to (its `sub_ref` rows). Bounded by
500/// the per-DID subscription cap, so callers can safely iterate it — e.g. the
501/// global "mark all read" path fans out over feeds (bounded) rather than over
502/// unread entries (unbounded).
503pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
504    let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
505        .bind(did)
506        .fetch_all(pool)
507        .await
508        .with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
509    Ok(ids)
510}
511
512/// The number of feeds a `did` currently subscribes to (its `sub_ref` rows).
513/// Backs the per-DID subscription cap enforced at the add/import paths.
514pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
515    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
516        .bind(did)
517        .fetch_one(pool)
518        .await
519        .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
520    Ok(n)
521}
522
523/// The number of distinct feeds in the shared cache. Backs the global feeds
524/// ceiling checked before a brand-new feed is inserted.
525pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
526    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
527        .fetch_one(pool)
528        .await
529        .context("count_feeds failed")?;
530    Ok(n)
531}
532
533/// The **used** size of the SQLite database, in bytes, computed as
534/// `(page_count - freelist_count) * page_size`. Backs the DB-size watermark that
535/// disables new polling.
536///
537/// Subtracting the freelist is what keeps the watermark from latching the poller
538/// off: `page_count` counts pages the file has *allocated*, including ones freed
539/// by a `DELETE` but not yet returned to the OS (SQLite keeps them on a freelist
540/// for reuse and never shrinks the file without a VACUUM). Counting only the
541/// live pages means a retention prune (which frees pages, see [`reclaim`]) is
542/// actually reflected here, so the watermark can drop back below its threshold
543/// and polling resumes. Cheap (three `PRAGMA` reads); works for file + `:memory:`.
544pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
545    let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
546        .fetch_one(pool)
547        .await
548        .context("PRAGMA page_count failed")?;
549    let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
550        .fetch_one(pool)
551        .await
552        .context("PRAGMA freelist_count failed")?;
553    let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
554        .fetch_one(pool)
555        .await
556        .context("PRAGMA page_size failed")?;
557    let used_pages = page_count.saturating_sub(freelist_count).max(0);
558    Ok(used_pages.saturating_mul(page_size))
559}
560
561/// Reclaim freed pages so the database file (and its used-page accounting) can
562/// actually shrink after a retention/prune sweep DELETEs rows.
563///
564/// Without this, a `DELETE` moves pages onto the freelist but never shrinks the
565/// file — so once the DB-size watermark trips and retention deletes rows,
566/// `page_count` stays put and [`db_size_bytes`] (well, its raw `page_count`
567/// form) would never fall back below the watermark, latching the poller off
568/// forever. Call this AFTER a prune. It uses incremental vacuum when the database
569/// is in `auto_vacuum = INCREMENTAL` mode (cheap, no full rewrite), and otherwise
570/// falls back to a full `VACUUM`.
571pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
572    let auto_vacuum: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
573        .fetch_one(pool)
574        .await
575        .context("PRAGMA auto_vacuum failed")?;
576    // auto_vacuum: 0 = NONE, 1 = FULL, 2 = INCREMENTAL. `incremental_vacuum` only
577    // does anything in INCREMENTAL mode; in NONE mode a full VACUUM is required to
578    // return freed pages to the OS.
579    if auto_vacuum == 2 {
580        sqlx::query("PRAGMA incremental_vacuum")
581            .execute(pool)
582            .await
583            .context("PRAGMA incremental_vacuum failed")?;
584    } else {
585        sqlx::query("VACUUM")
586            .execute(pool)
587            .await
588            .context("VACUUM failed")?;
589    }
590    Ok(())
591}
592
593/// Insert a batch of entries for `feed_id`, deduping on `(feed_id, guid)`, then
594/// trim the feed to at most [`crate::config`]-configured `max_entries_per_feed`
595/// rows (newest by published date) so one firehose feed can't fill the disk.
596///
597/// On a GUID collision the existing entry is updated in place (title/url/body
598/// may have changed on re-fetch) rather than duplicated. Runs in one
599/// transaction. Returns the number of rows processed.
600///
601/// `max_entries_per_feed <= 0` disables the per-feed trim.
602pub async fn insert_entries(
603    pool: &SqlitePool,
604    feed_id: i64,
605    entries: &[NewEntry],
606    max_entries_per_feed: i64,
607) -> Result<u64> {
608    let mut tx = pool.begin().await.context("begin insert_entries tx")?;
609    let mut count: u64 = 0;
610    for e in entries {
611        let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
612        let res = sqlx::query(
613            r#"
614            INSERT INTO entries
615                (feed_id, guid, url, title, author, published, content_html, fetched_at)
616            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
617            ON CONFLICT (feed_id, guid) DO UPDATE SET
618                url          = excluded.url,
619                title        = excluded.title,
620                author       = excluded.author,
621                published    = excluded.published,
622                content_html = excluded.content_html
623            "#,
624        )
625        .bind(feed_id)
626        .bind(&e.guid)
627        .bind(&e.url)
628        .bind(&e.title)
629        .bind(&e.author)
630        .bind(&e.published)
631        .bind(&e.content_html)
632        .bind(&fetched_at)
633        .execute(&mut *tx)
634        .await
635        .with_context(|| format!("insert entry {} failed", e.guid))?;
636        count += res.rows_affected();
637    }
638
639    // Entries-per-feed cap: keep only the newest `max_entries_per_feed` rows for
640    // this feed, deleting the overflow in the same transaction. "Newest" is
641    // COALESCE(published, fetched_at) so an UNDATED entry (NULL published) sorts
642    // by when we fetched it (NOT NULL) rather than always sorting LAST and being
643    // evicted first — otherwise a feed of undated items would trim its freshest
644    // rows. This bounds a single firehose/misbehaving feed's storage footprint
645    // independent of the global retention sweep. `<= 0` disables it.
646    if max_entries_per_feed > 0 {
647        sqlx::query(
648            r#"
649            DELETE FROM entries
650            WHERE feed_id = ?1
651              AND id NOT IN (
652                  SELECT id FROM entries
653                  WHERE feed_id = ?1
654                  ORDER BY COALESCE(published, fetched_at) DESC, id DESC
655                  LIMIT ?2
656              )
657            "#,
658        )
659        .bind(feed_id)
660        .bind(max_entries_per_feed)
661        .execute(&mut *tx)
662        .await
663        .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
664    }
665
666    // Per-feed trim above may have DELETEd entries; their ids can linger in the
667    // read_cursor exception sets (read_ids/unread_ids have no FK to entries), so
668    // scrub the orphaned ids out of THIS feed's cursors in the same transaction.
669    // Bounds id-set growth and keeps the flushed PDS record from referencing
670    // entries that no longer exist. Scoped to the one feed for cheapness.
671    if max_entries_per_feed > 0 {
672        prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
673    }
674
675    tx.commit().await.context("commit insert_entries tx")?;
676    Ok(count)
677}
678
679/// Delete entries whose age exceeds the retention window — the shared cache's
680/// **rolling window**. "Age" is `COALESCE(published, fetched_at)` so an UNDATED
681/// entry falls back to when it was fetched (never NULL) rather than being treated
682/// as infinitely old. `entry_state` cascades via its `ON DELETE CASCADE` FK.
683///
684/// After the delete, orphaned entry ids are scrubbed out of every affected feed's
685/// `read_cursor` exception sets (which have no FK to `entries`) so the id-sets do
686/// not grow without bound and the flushed PDS record never references a vanished
687/// entry. The caller (the retention sweep) should follow a non-zero return with
688/// [`reclaim`] so freed pages return to the OS. `days == 0` is a no-op (retention
689/// disabled). Returns the number of entry rows deleted.
690pub async fn prune_old_entries(pool: &SqlitePool, days: i64) -> Result<u64> {
691    if days <= 0 {
692        return Ok(0);
693    }
694    let cutoff = (chrono::Utc::now() - chrono::Duration::days(days))
695        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
696
697    let mut tx = pool.begin().await.context("begin prune_old_entries tx")?;
698    let res = sqlx::query("DELETE FROM entries WHERE COALESCE(published, fetched_at) < ?1")
699        .bind(&cutoff)
700        .execute(&mut *tx)
701        .await
702        .with_context(|| format!("prune_old_entries delete (cutoff {cutoff})"))?;
703    let deleted = res.rows_affected();
704
705    // Only touch cursors when rows actually went away.
706    if deleted > 0 {
707        prune_orphan_cursor_ids_tx(&mut tx, None).await?;
708    }
709
710    tx.commit().await.context("commit prune_old_entries tx")?;
711    Ok(deleted)
712}
713
714/// Scrub entry ids that no longer exist out of `read_cursor.read_ids` /
715/// `unread_ids`. `read_cursor` is keyed by `(did, feed_url)` and its id-sets have
716/// NO foreign key to `entries`, so a prune/trim that deletes entries would
717/// otherwise leave dangling ids that (a) grow the sets without bound and (b) get
718/// flushed to the PDS as references to vanished entries.
719///
720/// When `feed_id` is `Some`, only that feed's cursors are examined (the cheap
721/// path used right after a per-feed trim); `None` scans every cursor (the
722/// retention sweep, which can delete across many feeds at once). A cursor whose
723/// sets actually change is rewritten and marked `dirty` so the flusher resyncs
724/// it; unchanged cursors are left untouched (no spurious dirtying / PDS writes).
725/// Returns the number of cursor rows modified.
726async fn prune_orphan_cursor_ids_tx(
727    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
728    feed_id: Option<i64>,
729) -> Result<u64> {
730    // The set of live entry ids we prune against. Scope to the feed's URL when a
731    // feed_id is given so we filter only that feed's cursors against that feed's
732    // entries; otherwise consider all cursors / all entries.
733    let feed_url = match feed_id {
734        Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
735            Some(u) => Some(u),
736            None => return Ok(0), // feed vanished mid-tx; nothing to prune
737        },
738        None => None,
739    };
740
741    // Load the (did, feed_url, read_ids, unread_ids) of the candidate cursors.
742    let cursors: Vec<(String, String, String, String)> = match &feed_url {
743        Some(url) => sqlx::query(
744            "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
745        )
746        .bind(url)
747        .fetch_all(&mut **tx)
748        .await
749        .context("prune_orphan_cursor_ids: load feed cursors")?,
750        None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
751            .fetch_all(&mut **tx)
752            .await
753            .context("prune_orphan_cursor_ids: load all cursors")?,
754    }
755    .into_iter()
756    .map(|r| {
757        (
758            r.get::<String, _>("did"),
759            r.get::<String, _>("feed_url"),
760            r.get::<String, _>("read_ids"),
761            r.get::<String, _>("unread_ids"),
762        )
763    })
764    .collect();
765
766    if cursors.is_empty() {
767        return Ok(0);
768    }
769
770    let now = now_rfc3339();
771    let mut changed: u64 = 0;
772    for (did, curl, read_ids, unread_ids) in cursors {
773        // The live entry ids for THIS cursor's feed (join by URL — the cursor key).
774        let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
775            "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
776        )
777        .bind(&curl)
778        .fetch_all(&mut **tx)
779        .await
780        .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
781        .into_iter()
782        .collect();
783
784        let new_read = filter_id_set_to_live(&read_ids, &live);
785        let new_unread = filter_id_set_to_live(&unread_ids, &live);
786        if new_read == read_ids && new_unread == unread_ids {
787            continue; // nothing orphaned — leave the cursor (and its dirty flag) alone
788        }
789        sqlx::query(
790            "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
791             WHERE did = ?1 AND feed_url = ?2",
792        )
793        .bind(&did)
794        .bind(&curl)
795        .bind(&new_read)
796        .bind(&new_unread)
797        .bind(&now)
798        .execute(&mut **tx)
799        .await
800        .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
801        changed += 1;
802    }
803    Ok(changed)
804}
805
806/// Filter a JSON id-array string down to only ids present in `live`, returning
807/// the canonical JSON-array-of-strings form (matching [`json_id_set_toggle`]). A
808/// malformed input yields `[]`.
809fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
810    let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
811        .ok()
812        .map(|vals| {
813            vals.into_iter()
814                .filter_map(|v| match v {
815                    serde_json::Value::Number(n) => n.as_i64(),
816                    serde_json::Value::String(s) => s.parse::<i64>().ok(),
817                    _ => None,
818                })
819                .filter(|id| live.contains(id))
820                .collect()
821        })
822        .unwrap_or_default();
823    let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
824    serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
825}
826
827/// Replace the per-DID subscription projection (`sub_ref`) for `did` with
828/// exactly `feed_ids`, in one transaction.
829///
830/// Called from the web layer's subscription-resolve/sync path so `sub_ref`
831/// always mirrors the caller's *current* PDS subscription set. This is the
832/// authority every scoped read/mutation checks against — a feed the caller no
833/// longer subscribes to drops out of their read surface immediately.
834pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
835    let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
836    sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
837        .bind(did)
838        .execute(&mut *tx)
839        .await
840        .with_context(|| format!("clear sub_ref for {did}"))?;
841    for &feed_id in feed_ids {
842        sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
843            .bind(did)
844            .bind(feed_id)
845            .execute(&mut *tx)
846            .await
847            .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
848    }
849    tx.commit().await.context("commit replace_sub_refs tx")?;
850    Ok(())
851}
852
853/// Whether `did` currently subscribes to the feed `feed_id` owns
854/// (i.e. a `sub_ref` row exists). The authorization primitive behind every
855/// per-DID scoped read/mutation.
856pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
857    let found: Option<i64> = sqlx::query_scalar(
858        r#"
859        SELECT 1
860        FROM entries e
861        JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
862        WHERE e.id = ?2
863        "#,
864    )
865    .bind(did)
866    .bind(entry_id)
867    .fetch_optional(pool)
868    .await
869    .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
870    Ok(found.is_some())
871}
872
873/// All entries for a feed, newest-published first — scoped to `did`'s
874/// subscriptions. Returns an empty vec if `did` does not subscribe to the feed.
875pub async fn entries_for_feed(pool: &SqlitePool, did: &str, feed_id: i64) -> Result<Vec<Entry>> {
876    let entries = sqlx::query_as::<_, Entry>(
877        r#"
878        SELECT e.* FROM entries e
879        WHERE e.feed_id = ?2
880          AND EXISTS (
881              SELECT 1 FROM sub_ref sr
882              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
883          )
884        ORDER BY e.published DESC, e.id DESC
885        "#,
886    )
887    .bind(did)
888    .bind(feed_id)
889    .fetch_all(pool)
890    .await
891    .context("entries_for_feed failed")?;
892    Ok(entries)
893}
894
895/// Mark a single entry read/unread for a DID, upserting the per-DID state row
896/// and stamping `updated_at`. Preserves any existing `starred` bit. Also
897/// projects the change into the per-`(did, feed_url)` [`ReadCursor`] and marks
898/// it `dirty` so the batched flusher pushes it to the PDS (see
899/// [`project_entry_into_cursor`]).
900///
901/// AUTHORIZED per-DID: the upsert only touches an entry the caller subscribes
902/// to (`sub_ref`). Returns `true` if a row was written, `false` if `did` does
903/// not subscribe to the entry's feed (the web layer maps that to a 404 —
904/// a non-subscriber can never mutate another user's state).
905pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
906    let now = now_rfc3339();
907    let mut tx = pool.begin().await.context("begin mark_read tx")?;
908    let res = sqlx::query(
909        r#"
910        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
911        SELECT ?1, e.id, ?3, 0, ?4
912        FROM entries e
913        WHERE e.id = ?2
914          AND EXISTS (
915              SELECT 1 FROM sub_ref sr
916              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
917          )
918        ON CONFLICT (did, entry_id) DO UPDATE SET
919            read       = excluded.read,
920            updated_at = excluded.updated_at
921        "#,
922    )
923    .bind(did)
924    .bind(entry_id)
925    .bind(read)
926    .bind(&now)
927    .execute(&mut *tx)
928    .await
929    .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
930
931    if res.rows_affected() == 0 {
932        // Not authorized (no `sub_ref`) — nothing written, no cursor to dirty.
933        tx.rollback().await.ok();
934        return Ok(false);
935    }
936
937    // Project the read/unread into this feed's read cursor (dirty=1) so the
938    // flusher syncs it to the PDS. Same tx as the state write so a crash can't
939    // leave the two out of step.
940    project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
941
942    tx.commit().await.context("commit mark_read tx")?;
943    Ok(true)
944}
945
946/// Star/unstar a single entry for a DID (upsert, preserving `read`).
947///
948/// AUTHORIZED per-DID like [`mark_read`]: only touches an entry the caller
949/// subscribes to. Returns `true` if a row was written, `false` if `did` does
950/// not subscribe (→ 404 at the web layer).
951pub async fn mark_starred(
952    pool: &SqlitePool,
953    did: &str,
954    entry_id: i64,
955    starred: bool,
956) -> Result<bool> {
957    let res = sqlx::query(
958        r#"
959        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
960        SELECT ?1, e.id, 0, ?3, ?4
961        FROM entries e
962        WHERE e.id = ?2
963          AND EXISTS (
964              SELECT 1 FROM sub_ref sr
965              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
966          )
967        ON CONFLICT (did, entry_id) DO UPDATE SET
968            starred    = excluded.starred,
969            updated_at = excluded.updated_at
970        "#,
971    )
972    .bind(did)
973    .bind(entry_id)
974    .bind(starred)
975    .bind(now_rfc3339())
976    .execute(pool)
977    .await
978    .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
979    Ok(res.rows_affected() > 0)
980}
981
982/// Mark every entry of a feed read (or unread) for a DID in one statement —
983/// backs the "mark-all-read (per feed)" action. Also projects the change into
984/// the feed's per-DID [`ReadCursor`] (dirty=1) so the batched flusher syncs the
985/// new read-state to the PDS.
986pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
987    let now = now_rfc3339();
988    let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
989    let res = sqlx::query(
990        r#"
991        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
992        SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
993        WHERE e.feed_id = ?4
994          AND EXISTS (
995              SELECT 1 FROM sub_ref sr
996              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
997          )
998        ON CONFLICT (did, entry_id) DO UPDATE SET
999            read       = excluded.read,
1000            updated_at = excluded.updated_at
1001        "#,
1002    )
1003    .bind(did)
1004    .bind(read)
1005    .bind(&now)
1006    .bind(feed_id)
1007    .execute(&mut *tx)
1008    .await
1009    .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
1010
1011    if res.rows_affected() > 0 {
1012        // Project every affected entry into this feed's read cursor. `feed_id`
1013        // maps to exactly one feed URL, so this is a single per-feed cursor —
1014        // batched, not per-article. Only runs when the caller was authorized
1015        // (some rows changed), so an unsubscribed feed leaves no cursor behind.
1016        project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
1017    }
1018
1019    tx.commit().await.context("commit mark_feed_read tx")?;
1020    Ok(res.rows_affected())
1021}
1022
1023// ---------------------------------------------------------------------------
1024// Read-cursor projection (wires the local read/unread mutation into the
1025// PDS-bound `read_cursor`, so the batched flusher actually pushes read-state)
1026// ---------------------------------------------------------------------------
1027
1028/// Add or remove an entry id from a JSON id-array string, returning the new JSON.
1029/// Membership is set-like (no duplicates) and order-stable (append on add). A
1030/// malformed input is treated as empty so a cosmetic parse issue never blocks a
1031/// projection.
1032fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
1033    let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
1034        .ok()
1035        .map(|vals| {
1036            vals.into_iter()
1037                .filter_map(|v| match v {
1038                    serde_json::Value::Number(n) => n.as_i64(),
1039                    serde_json::Value::String(s) => s.parse::<i64>().ok(),
1040                    _ => None,
1041                })
1042                .collect()
1043        })
1044        .unwrap_or_default();
1045    if present {
1046        if !ids.contains(&id) {
1047            ids.push(id);
1048        }
1049    } else {
1050        ids.retain(|&x| x != id);
1051    }
1052    // Serialize as a JSON array of strings (the shape the flusher / lexicon
1053    // expect — `community.lexicon.rss.readState.readIds` is a string array).
1054    let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
1055    serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
1056}
1057
1058/// The feed URL owning `feed_id`, if the row exists (cursors are keyed by URL,
1059/// not feed id — they mirror the PDS-side `readState.feedUrl`).
1060async fn feed_url_for_id_tx(
1061    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1062    feed_id: i64,
1063) -> Result<Option<String>> {
1064    let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
1065        .bind(feed_id)
1066        .fetch_optional(&mut **tx)
1067        .await
1068        .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
1069    Ok(url)
1070}
1071
1072/// Fetch the (read_through, read_ids, unread_ids) of an existing cursor, or the
1073/// empty defaults if there is none yet.
1074async fn cursor_sets(
1075    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1076    did: &str,
1077    feed_url: &str,
1078) -> Result<(Option<String>, String, String)> {
1079    let row = sqlx::query(
1080        "SELECT read_through, read_ids, unread_ids FROM read_cursor \
1081         WHERE did = ?1 AND feed_url = ?2",
1082    )
1083    .bind(did)
1084    .bind(feed_url)
1085    .fetch_optional(&mut **tx)
1086    .await
1087    .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
1088    Ok(match row {
1089        Some(r) => (
1090            r.get::<Option<String>, _>("read_through"),
1091            r.get::<String, _>("read_ids"),
1092            r.get::<String, _>("unread_ids"),
1093        ),
1094        None => (None, "[]".to_string(), "[]".to_string()),
1095    })
1096}
1097
1098/// Upsert the cursor row for `(did, feed_url)` with the given exception sets,
1099/// stamping `updated_at` and marking it `dirty` so `dirty_cursors` returns it.
1100async fn write_cursor_sets(
1101    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1102    did: &str,
1103    feed_url: &str,
1104    read_through: Option<&str>,
1105    read_ids: &str,
1106    unread_ids: &str,
1107    now: &str,
1108) -> Result<()> {
1109    sqlx::query(
1110        r#"
1111        INSERT INTO read_cursor
1112            (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
1113        VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
1114        ON CONFLICT (did, feed_url) DO UPDATE SET
1115            read_through = excluded.read_through,
1116            read_ids     = excluded.read_ids,
1117            unread_ids   = excluded.unread_ids,
1118            dirty        = 1,
1119            updated_at   = excluded.updated_at
1120        "#,
1121    )
1122    .bind(did)
1123    .bind(feed_url)
1124    .bind(read_through)
1125    .bind(read_ids)
1126    .bind(unread_ids)
1127    .bind(now)
1128    .execute(&mut **tx)
1129    .await
1130    .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
1131    Ok(())
1132}
1133
1134/// Project a single entry's read/unread flip into its feed's read cursor.
1135///
1136/// The cursor mirrors `community.lexicon.rss.readState`: a `read_through`
1137/// high-water-mark plus two bounded exception sets. A per-article flip is
1138/// recorded in those sets (`read_ids` when read, `unread_ids` when unread), the
1139/// opposite set is cleared of the id, and the cursor is stamped + marked dirty.
1140/// This keeps the write batched by touching only the ONE per-feed cursor. (Note:
1141/// there is no compaction step yet that folds covered ids back into
1142/// `read_through`; the exception sets are expected to stay well under the cap.)
1143async fn project_entry_into_cursor(
1144    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1145    did: &str,
1146    entry_id: i64,
1147    read: bool,
1148    now: &str,
1149) -> Result<()> {
1150    // The entry's feed id → feed URL (the cursor key).
1151    let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
1152        .bind(entry_id)
1153        .fetch_optional(&mut **tx)
1154        .await
1155        .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
1156    let feed_id = match feed_id {
1157        Some(f) => f,
1158        None => return Ok(()), // entry vanished mid-tx; nothing to project
1159    };
1160    let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
1161        Some(u) => u,
1162        None => return Ok(()),
1163    };
1164
1165    let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
1166    // read=true: id joins read_ids, leaves unread_ids. read=false: the inverse.
1167    let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
1168    let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
1169    write_cursor_sets(
1170        tx,
1171        did,
1172        &feed_url,
1173        read_through.as_deref(),
1174        &read_ids,
1175        &unread_ids,
1176        now,
1177    )
1178    .await
1179}
1180
1181/// Project a mark-all-feed-read/unread into that feed's single read cursor.
1182///
1183/// Every entry the caller subscribes to on `feed_id` is folded into the cursor
1184/// in one write: on mark-all-READ each id joins `read_ids` (and leaves
1185/// `unread_ids`); on mark-all-UNREAD the inverse. Still ONE per-feed cursor row
1186/// (batched), stamped + dirtied for the flusher.
1187async fn project_feed_into_cursor(
1188    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1189    did: &str,
1190    feed_id: i64,
1191    read: bool,
1192    now: &str,
1193) -> Result<()> {
1194    let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
1195        Some(u) => u,
1196        None => return Ok(()),
1197    };
1198
1199    // The entry ids on this feed the caller is authorized for (subscribes to).
1200    let ids: Vec<i64> = sqlx::query_scalar(
1201        r#"
1202        SELECT e.id FROM entries e
1203        WHERE e.feed_id = ?2
1204          AND EXISTS (
1205              SELECT 1 FROM sub_ref sr
1206              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1207          )
1208        "#,
1209    )
1210    .bind(did)
1211    .bind(feed_id)
1212    .fetch_all(&mut **tx)
1213    .await
1214    .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
1215
1216    let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
1217    for id in ids {
1218        read_ids = json_id_set_toggle(&read_ids, id, read);
1219        unread_ids = json_id_set_toggle(&unread_ids, id, !read);
1220    }
1221    write_cursor_sets(
1222        tx,
1223        did,
1224        &feed_url,
1225        read_through.as_deref(),
1226        &read_ids,
1227        &unread_ids,
1228        now,
1229    )
1230    .await
1231}
1232
1233/// Unread entries for a DID: entries with no `entry_state` row for that DID, or
1234/// one where `read = 0`. Newest-published first. This is the daily-driver list
1235/// query, so it's a `LEFT JOIN` (an entry with no state row is unread).
1236pub async fn get_unread_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
1237    let entries = sqlx::query_as::<_, Entry>(
1238        r#"
1239        SELECT e.*
1240        FROM entries e
1241        LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
1242        WHERE COALESCE(s.read, 0) = 0
1243          AND EXISTS (
1244              SELECT 1 FROM sub_ref sr
1245              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1246          )
1247        ORDER BY e.published DESC, e.id DESC
1248        "#,
1249    )
1250    .bind(did)
1251    .fetch_all(pool)
1252    .await
1253    .with_context(|| format!("get_unread_for_did failed for {did}"))?;
1254    Ok(entries)
1255}
1256
1257/// Starred entries for a DID, newest-published first.
1258pub async fn get_starred_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
1259    let entries = sqlx::query_as::<_, Entry>(
1260        r#"
1261        SELECT e.*
1262        FROM entries e
1263        JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
1264        WHERE s.starred = 1
1265          AND EXISTS (
1266              SELECT 1 FROM sub_ref sr
1267              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1268          )
1269        ORDER BY e.published DESC, e.id DESC
1270        "#,
1271    )
1272    .bind(did)
1273    .fetch_all(pool)
1274    .await
1275    .with_context(|| format!("get_starred_for_did failed for {did}"))?;
1276    Ok(entries)
1277}
1278
1279/// Insert or update a per-`(did, feed_url)` read cursor, stamping `updated_at`.
1280/// The write path for local mark-read updates (and the seam a login-time PDS
1281/// merge would use, once that is wired).
1282pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
1283    sqlx::query(
1284        r#"
1285        INSERT INTO read_cursor
1286            (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
1287        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1288        ON CONFLICT (did, feed_url) DO UPDATE SET
1289            read_through = excluded.read_through,
1290            read_ids     = excluded.read_ids,
1291            unread_ids   = excluded.unread_ids,
1292            dirty        = excluded.dirty,
1293            updated_at   = excluded.updated_at
1294        "#,
1295    )
1296    .bind(&cursor.did)
1297    .bind(&cursor.feed_url)
1298    .bind(&cursor.read_through)
1299    .bind(&cursor.read_ids)
1300    .bind(&cursor.unread_ids)
1301    .bind(cursor.dirty)
1302    .bind(&cursor.updated_at)
1303    .execute(pool)
1304    .await
1305    .with_context(|| {
1306        format!(
1307            "upsert_cursor failed for {}/{}",
1308            cursor.did, cursor.feed_url
1309        )
1310    })?;
1311    Ok(())
1312}
1313
1314/// Fetch a single read cursor, if present.
1315pub async fn get_cursor(
1316    pool: &SqlitePool,
1317    did: &str,
1318    feed_url: &str,
1319) -> Result<Option<ReadCursor>> {
1320    let cursor = sqlx::query_as::<_, ReadCursor>(
1321        "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
1322    )
1323    .bind(did)
1324    .bind(feed_url)
1325    .fetch_optional(pool)
1326    .await
1327    .context("get_cursor failed")?;
1328    Ok(cursor)
1329}
1330
1331/// The flusher's hot query: every cursor with `dirty = 1` for a DID — the ones
1332/// whose read-state changed since the last batched PDS flush.
1333pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
1334    let cursors =
1335        sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
1336            .bind(did)
1337            .fetch_all(pool)
1338            .await
1339            .with_context(|| format!("dirty_cursors failed for {did}"))?;
1340    Ok(cursors)
1341}
1342
1343/// Mark a cursor's PDS `readState` record as CREATED after the flush that first
1344/// created it, so subsequent flushes emit an `update` instead of another
1345/// `create`. Idempotent; a no-op if the row is gone.
1346pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
1347    sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
1348        .bind(did)
1349        .bind(feed_url)
1350        .execute(pool)
1351        .await
1352        .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
1353    Ok(())
1354}
1355
1356/// Clear the `dirty` flag on a cursor after a successful PDS flush — but ONLY if
1357/// the row still carries the exact `flushed_updated_at` snapshot we flushed.
1358///
1359/// The flusher reads a cursor, sends it to the PDS (a network round-trip), then
1360/// clears `dirty`. A concurrent [`upsert_cursor`] (a fresh mark-read) can land
1361/// DURING that in-flight write, bumping `updated_at` and re-setting `dirty = 1`
1362/// for reads that were NOT in the flushed snapshot. An unconditional
1363/// `SET dirty = 0` would silently drop those reads. Guarding on the snapshot's
1364/// `updated_at` makes this a compare-and-swap: if `updated_at` changed under us,
1365/// zero rows update, the row stays dirty, and it re-flushes next round.
1366pub async fn clear_cursor_dirty(
1367    pool: &SqlitePool,
1368    did: &str,
1369    feed_url: &str,
1370    flushed_updated_at: &str,
1371) -> Result<()> {
1372    sqlx::query(
1373        "UPDATE read_cursor SET dirty = 0 \
1374         WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
1375    )
1376    .bind(did)
1377    .bind(feed_url)
1378    .bind(flushed_updated_at)
1379    .execute(pool)
1380    .await
1381    .context("clear_cursor_dirty failed")?;
1382    Ok(())
1383}
1384
1385// ---------------------------------------------------------------------------
1386// Closed-beta invite gate (beta_access + invite_codes)
1387// ---------------------------------------------------------------------------
1388//
1389// Ported in SHAPE from a prior Go beta-gate (RedeemCode / CreateInviteCode /
1390// code_gen) but deliberately trimmed for FeatherReader's before-public
1391// experiment: NO viral invite-budget tree, NO generation cap, NO waitlist /
1392// invite-request table, and SQLite instead of Mongo. A code is minted by an
1393// existing member (or admin), and redeeming it grants a seat while seats remain
1394// under the configured cap.
1395
1396/// Unix-epoch seconds for "now" — the integer time base for the beta tables.
1397fn now_unix() -> i64 {
1398    chrono::Utc::now().timestamp()
1399}
1400
1401/// The invite-code alphabet: uppercase letters + digits with the
1402/// visually-ambiguous glyphs removed (`I`, `O`, `0`, `1`) so a code read aloud
1403/// or copied by hand is unambiguous.
1404const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1405
1406/// Human-facing prefix so a FeatherReader invite code is recognisable at a
1407/// glance.
1408const CODE_PREFIX: &str = "FEATHER-";
1409
1410/// Number of random characters after the prefix.
1411const CODE_BODY_LEN: usize = 8;
1412
1413/// Generate a random, unguessable invite code of the form `FEATHER-XXXXXXXX`.
1414///
1415/// Draws from the OS CSPRNG (`getrandom`) and maps each byte onto
1416/// [`CODE_ALPHABET`] via rejection sampling so the alphabet distribution is
1417/// uniform (no modulo bias). Infallible in practice; a `getrandom` failure
1418/// (no entropy source) propagates as an error rather than a weak code.
1419pub fn generate_invite_code() -> Result<String> {
1420    let n = CODE_ALPHABET.len() as u16; // 31
1421                                        // Largest multiple of `n` that fits in a byte; bytes at or above it are
1422                                        // rejected so every accepted byte maps uniformly onto the alphabet.
1423    let limit = 256 / n * n; // 256 - (256 % n)
1424    let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
1425    out.push_str(CODE_PREFIX);
1426    let mut got = 0;
1427    let mut buf = [0u8; 1];
1428    while got < CODE_BODY_LEN {
1429        getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
1430        let b = buf[0] as u16;
1431        if b < limit {
1432            out.push(CODE_ALPHABET[(b % n) as usize] as char);
1433            got += 1;
1434        }
1435    }
1436    Ok(out)
1437}
1438
1439/// Whether a DID currently holds a beta seat.
1440pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
1441    let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
1442        .bind(did)
1443        .fetch_optional(pool)
1444        .await
1445        .with_context(|| format!("has_beta_access failed for {did}"))?;
1446    Ok(row.is_some())
1447}
1448
1449/// Count the beta seats currently granted — the numerator checked against the
1450/// configured cap on redeem.
1451pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
1452    let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
1453        .fetch_one(pool)
1454        .await
1455        .context("count_beta_access failed")?;
1456    Ok(row.get::<i64, _>("n"))
1457}
1458
1459/// Grant a beta seat directly (admin / seed path — no code consumed). Idempotent
1460/// on `did` (re-granting updates the row rather than erroring).
1461pub async fn grant_access(
1462    pool: &SqlitePool,
1463    did: &str,
1464    handle: Option<&str>,
1465    granted_by: &str,
1466    invite_code_used: Option<&str>,
1467) -> Result<()> {
1468    sqlx::query(
1469        r#"
1470        INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1471        VALUES (?1, ?2, ?3, ?4, ?5)
1472        ON CONFLICT (did) DO UPDATE SET
1473            handle           = COALESCE(excluded.handle, beta_access.handle),
1474            granted_by       = excluded.granted_by,
1475            invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
1476        "#,
1477    )
1478    .bind(did)
1479    .bind(handle)
1480    .bind(granted_by)
1481    .bind(now_unix())
1482    .bind(invite_code_used)
1483    .execute(pool)
1484    .await
1485    .with_context(|| format!("grant_access failed for {did}"))?;
1486    Ok(())
1487}
1488
1489/// Mint a new `active` invite code owned by `creator_did`, expiring `ttl_secs`
1490/// from now. Returns the generated code string.
1491pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
1492    let code = generate_invite_code()?;
1493    let now = now_unix();
1494    let expires_at = now.saturating_add(ttl_secs.max(0));
1495    sqlx::query(
1496        r#"
1497        INSERT INTO invite_codes
1498            (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
1499        VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)
1500        "#,
1501    )
1502    .bind(&code)
1503    .bind(creator_did)
1504    .bind(now)
1505    .bind(expires_at)
1506    .execute(pool)
1507    .await
1508    .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
1509    Ok(code)
1510}
1511
1512/// Atomically redeem an invite code for `did`, granting a beta seat.
1513///
1514/// Runs entirely in one transaction so the capacity check and the seat grant
1515/// cannot race (two redeems can't both slip past a `cap - 1` count). Steps:
1516/// 1. verify the code exists, is `active`, and is not past `expires_at`;
1517/// 2. verify the current seat count is `< cap`;
1518/// 3. flip the code `active`→`redeemed` (stamping `invitee_did` + `redeemed_at`);
1519/// 4. insert the `beta_access` row.
1520///
1521/// On a policy failure returns the matching [`RedeemError`] (the tx rolls back);
1522/// a real SQLite error propagates as the outer [`anyhow::Error`].
1523pub async fn redeem_code(
1524    pool: &SqlitePool,
1525    code: &str,
1526    did: &str,
1527    handle: Option<&str>,
1528    cap: i64,
1529) -> Result<std::result::Result<(), RedeemError>> {
1530    let now = now_unix();
1531    let mut tx = pool.begin().await.context("begin redeem_code tx")?;
1532
1533    // Take the write lock at the START of the transaction. sqlx issues a plain
1534    // deferred BEGIN, so without this the capacity SELECT below runs under a read
1535    // snapshot: two concurrent redeems could both pass the gate, and the loser's
1536    // later UPDATE would fail with SQLITE_BUSY_SNAPSHOT (which busy_timeout does
1537    // NOT retry) — an opaque error instead of a clean CapacityFull. A leading
1538    // no-op write against the target row acquires the RESERVED lock immediately
1539    // (SQLite locks on any write statement, even one matching zero rows), so the
1540    // second redeem blocks on the first, then reads the post-commit seat count
1541    // and returns CapacityFull. (The cap already held via snapshot isolation;
1542    // this upgrades the failure mode from a hard error to the right one.)
1543    sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
1544        .bind(code)
1545        .execute(&mut *tx)
1546        .await
1547        .context("redeem_code: acquire write lock")?;
1548
1549    // 1. Look the code up.
1550    let row = sqlx::query("SELECT status, expires_at FROM invite_codes WHERE code = ?1")
1551        .bind(code)
1552        .fetch_optional(&mut *tx)
1553        .await
1554        .context("redeem_code: lookup")?;
1555    let row = match row {
1556        Some(r) => r,
1557        None => return Ok(Err(RedeemError::NotFound)),
1558    };
1559    let status: String = row.get("status");
1560    let expires_at: i64 = row.get("expires_at");
1561
1562    // Status gate: only an `active` code is redeemable. Anything already
1563    // redeemed/revoked is "already redeemed" from the redeemer's view; an
1564    // `expired` status (or a past expiry) is "expired".
1565    if status == "expired" || now > expires_at {
1566        return Ok(Err(RedeemError::Expired));
1567    }
1568    if status != "active" {
1569        return Ok(Err(RedeemError::AlreadyRedeemed));
1570    }
1571
1572    // 2. Capacity gate (inside the tx so it can't race a concurrent redeem).
1573    let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
1574        .fetch_one(&mut *tx)
1575        .await
1576        .context("redeem_code: count")?
1577        .get("n");
1578    if count >= cap {
1579        return Ok(Err(RedeemError::CapacityFull));
1580    }
1581
1582    // 3. Flip the code active→redeemed. The `status = 'active'` guard in the
1583    // WHERE makes this a compare-and-swap: if a concurrent tx already flipped it
1584    // (despite the read above), zero rows change and we treat it as redeemed.
1585    let flipped = sqlx::query(
1586        r#"
1587        UPDATE invite_codes
1588        SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
1589        WHERE code = ?1 AND status = 'active'
1590        "#,
1591    )
1592    .bind(code)
1593    .bind(did)
1594    .bind(now)
1595    .execute(&mut *tx)
1596    .await
1597    .context("redeem_code: flip")?;
1598    if flipped.rows_affected() == 0 {
1599        return Ok(Err(RedeemError::AlreadyRedeemed));
1600    }
1601
1602    // 4. Grant the seat.
1603    sqlx::query(
1604        r#"
1605        INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1606        VALUES (?1, ?2, ?3, ?4, ?5)
1607        ON CONFLICT (did) DO UPDATE SET
1608            handle           = COALESCE(excluded.handle, beta_access.handle),
1609            invite_code_used = excluded.invite_code_used
1610        "#,
1611    )
1612    .bind(did)
1613    .bind(handle)
1614    // granted_by is the code's creator; look it up in-tx to keep provenance.
1615    .bind(
1616        sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
1617            .bind(code)
1618            .fetch_one(&mut *tx)
1619            .await
1620            .context("redeem_code: creator lookup")?
1621            .get::<String, _>("creator_did"),
1622    )
1623    .bind(now)
1624    .bind(code)
1625    .execute(&mut *tx)
1626    .await
1627    .context("redeem_code: grant")?;
1628
1629    tx.commit().await.context("commit redeem_code tx")?;
1630    Ok(Ok(()))
1631}
1632
1633/// Sweep: flip every `active` code whose `expires_at` is in the past to
1634/// `expired`. Returns the number of codes expired. Called periodically by the
1635/// scheduler.
1636pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
1637    let now = now_unix();
1638    let res = sqlx::query(
1639        "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
1640    )
1641    .bind(now)
1642    .execute(pool)
1643    .await
1644    .context("expire_old_codes failed")?;
1645    Ok(res.rows_affected())
1646}
1647
1648/// Seed the admin-bootstrap DIDs: for each, insert a `beta_access` row
1649/// (`granted_by = 'admin'`) if one does not already exist. Idempotent — an
1650/// existing seat is left untouched. Returns how many new seats were created.
1651pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
1652    let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
1653    let now = now_unix();
1654    let mut created = 0u64;
1655    for did in dids {
1656        let res = sqlx::query(
1657            r#"
1658            INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1659            VALUES (?1, NULL, 'admin', ?2, NULL)
1660            ON CONFLICT (did) DO NOTHING
1661            "#,
1662        )
1663        .bind(did)
1664        .bind(now)
1665        .execute(&mut *tx)
1666        .await
1667        .with_context(|| format!("ensure_seed insert failed for {did}"))?;
1668        created += res.rows_affected();
1669    }
1670    tx.commit().await.context("commit ensure_seed tx")?;
1671    Ok(created)
1672}
1673
1674/// The row counts purged by [`purge_did_data`], for a confirmable success
1675/// message and for assertions in tests.
1676#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1677pub struct PurgeCounts {
1678    /// `entry_state` rows removed (per-DID read/star flags).
1679    pub entry_state: u64,
1680    /// `read_cursor` rows removed (per-DID per-feed read cursors).
1681    pub read_cursor: u64,
1682    /// `sub_ref` rows removed (the DID's subscription projection).
1683    pub sub_ref: u64,
1684    /// `beta_access` rows removed (the DID's closed-beta seat: 0 or 1).
1685    pub beta_access: u64,
1686    /// `invite_codes` rows removed (codes this DID *created*).
1687    pub invite_codes: u64,
1688    /// `invite_codes` rows *scrubbed* (the code this DID *redeemed* to join —
1689    /// its `invitee_did` back-reference cleared to NULL, row kept).
1690    pub invitee_scrubbed: u64,
1691    /// `beta_access` rows *scrubbed* (seats this DID *granted* to others — the
1692    /// `granted_by` back-reference redacted to a sentinel, row kept).
1693    pub granted_by_scrubbed: u64,
1694}
1695
1696impl PurgeCounts {
1697    /// Total rows removed across every per-DID table. (Scrub counts are tracked
1698    /// separately — those rows belong to *other* DIDs and are redacted, not
1699    /// deleted — so they are excluded from the delete total.)
1700    pub fn total(&self) -> u64 {
1701        self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
1702    }
1703}
1704
1705/// Sentinel written into `beta_access.granted_by` when the granting DID deletes
1706/// its data: the column is `NOT NULL`, so we redact rather than NULL it. Keeps
1707/// the grantee's seat valid while removing the departed DID's back-reference.
1708pub const REDACTED_DID: &str = "__redacted__";
1709
1710/// Delete **all** local rows owned by `did` in a single transaction: the
1711/// per-DID read/star state (`entry_state`), per-feed read cursors
1712/// (`read_cursor`), the subscription projection (`sub_ref`), the closed-beta
1713/// seat (`beta_access`), and any invite codes this DID *created*
1714/// (`invite_codes`). The shared `feeds`/`entries` cache is intentionally left
1715/// intact — it is deduped and not owned by any single DID.
1716///
1717/// This is the local half of "delete my data": the caller pairs it with a
1718/// sidecar `POST /internal/revoke` so the OAuth tokens + sidecar session rows
1719/// are dropped too. Idempotent — deleting a DID with no rows returns all-zero
1720/// counts.
1721pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
1722    let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
1723
1724    let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
1725        .bind(did)
1726        .execute(&mut *tx)
1727        .await
1728        .with_context(|| format!("purge entry_state for {did}"))?
1729        .rows_affected();
1730
1731    let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
1732        .bind(did)
1733        .execute(&mut *tx)
1734        .await
1735        .with_context(|| format!("purge read_cursor for {did}"))?
1736        .rows_affected();
1737
1738    let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
1739        .bind(did)
1740        .execute(&mut *tx)
1741        .await
1742        .with_context(|| format!("purge sub_ref for {did}"))?
1743        .rows_affected();
1744
1745    let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
1746        .bind(did)
1747        .execute(&mut *tx)
1748        .await
1749        .with_context(|| format!("purge beta_access for {did}"))?
1750        .rows_affected();
1751
1752    let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
1753        .bind(did)
1754        .execute(&mut *tx)
1755        .await
1756        .with_context(|| format!("purge invite_codes for {did}"))?
1757        .rows_affected();
1758
1759    // Scrub the DID's back-references from rows that belong to OTHER DIDs so no
1760    // per-DID residue survives the delete:
1761    //   * the invite code this DID *redeemed* to join lives on the inviter's
1762    //     row (`invitee_did`) — NULL it out (column is nullable).
1763    //   * seats this DID *granted* to others carry `granted_by = <this did>` —
1764    //     redact to a sentinel (column is NOT NULL) so the grantee keeps access
1765    //     without retaining the departed DID.
1766    let invitee_scrubbed =
1767        sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
1768            .bind(did)
1769            .execute(&mut *tx)
1770            .await
1771            .with_context(|| format!("scrub invitee_did for {did}"))?
1772            .rows_affected();
1773
1774    let granted_by_scrubbed =
1775        sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
1776            .bind(did)
1777            .bind(REDACTED_DID)
1778            .execute(&mut *tx)
1779            .await
1780            .with_context(|| format!("scrub granted_by for {did}"))?
1781            .rows_affected();
1782
1783    tx.commit().await.context("commit purge_did_data tx")?;
1784
1785    Ok(PurgeCounts {
1786        entry_state,
1787        read_cursor,
1788        sub_ref,
1789        beta_access,
1790        invite_codes,
1791        invitee_scrubbed,
1792        granted_by_scrubbed,
1793    })
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798    use super::*;
1799
1800    /// Init an in-memory SQLite, insert a feed + entries, read them back.
1801    #[tokio::test]
1802    async fn init_insert_readback() -> Result<()> {
1803        let pool = init_url("sqlite::memory:").await?;
1804
1805        // Insert a feed.
1806        let feed_id = upsert_feed(
1807            &pool,
1808            &NewFeed {
1809                url: "https://example.com/feed.xml".to_string(),
1810                title: Some("Example".to_string()),
1811                site_url: Some("https://example.com".to_string()),
1812                next_poll: Some("2026-07-12T00:00:00Z".to_string()),
1813                ..Default::default()
1814            },
1815        )
1816        .await?;
1817        assert!(feed_id > 0);
1818
1819        // Read the feed back by URL.
1820        let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
1821            .await?
1822            .expect("feed should exist");
1823        assert_eq!(feed.id, feed_id);
1824        assert_eq!(feed.title.as_deref(), Some("Example"));
1825        assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
1826
1827        // Upsert on the same URL updates rather than duplicating.
1828        let feed_id2 = upsert_feed(
1829            &pool,
1830            &NewFeed {
1831                url: "https://example.com/feed.xml".to_string(),
1832                title: Some("Example (renamed)".to_string()),
1833                ..Default::default()
1834            },
1835        )
1836        .await?;
1837        assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
1838
1839        // Insert two entries.
1840        let n = insert_entries(
1841            &pool,
1842            feed_id,
1843            &[
1844                NewEntry {
1845                    guid: "guid-1".to_string(),
1846                    url: Some("https://example.com/a".to_string()),
1847                    title: Some("First".to_string()),
1848                    published: Some("2026-07-10T08:00:00Z".to_string()),
1849                    content_html: Some("<p>hello</p>".to_string()),
1850                    ..Default::default()
1851                },
1852                NewEntry {
1853                    guid: "guid-2".to_string(),
1854                    url: Some("https://example.com/b".to_string()),
1855                    title: Some("Second".to_string()),
1856                    published: Some("2026-07-11T08:00:00Z".to_string()),
1857                    ..Default::default()
1858                },
1859            ],
1860            0, // per-feed trim disabled for this test
1861        )
1862        .await?;
1863        assert_eq!(n, 2);
1864
1865        // The reader must subscribe to the feed for the scoped reads to return
1866        // its entries (per-DID isolation projection).
1867        let did = "did:plc:abc123";
1868        replace_sub_refs(&pool, did, &[feed_id]).await?;
1869
1870        // Read entries back (newest-published first).
1871        let entries = entries_for_feed(&pool, did, feed_id).await?;
1872        assert_eq!(entries.len(), 2);
1873        assert_eq!(entries[0].guid, "guid-2");
1874        assert_eq!(entries[1].guid, "guid-1");
1875        assert_eq!(entries[1].content_html.as_deref(), Some("<p>hello</p>"));
1876
1877        // Re-inserting the same GUID dedups (updates in place, no new row).
1878        let n2 = insert_entries(
1879            &pool,
1880            feed_id,
1881            &[NewEntry {
1882                guid: "guid-1".to_string(),
1883                title: Some("First (edited)".to_string()),
1884                ..Default::default()
1885            }],
1886            0,
1887        )
1888        .await?;
1889        assert_eq!(n2, 1);
1890        assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
1891
1892        // --- per-DID read state ---
1893        let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
1894
1895        // Both entries start unread.
1896        assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
1897
1898        // Mark one read; unread count drops to 1.
1899        mark_read(&pool, did, e1, true).await?;
1900        let unread = get_unread_for_did(&pool, did).await?;
1901        assert_eq!(unread.len(), 1);
1902        assert_eq!(unread[0].guid, "guid-2");
1903
1904        // Star it; it shows in the starred list.
1905        mark_starred(&pool, did, e1, true).await?;
1906        let starred = get_starred_for_did(&pool, did).await?;
1907        assert_eq!(starred.len(), 1);
1908        assert_eq!(starred[0].id, e1);
1909
1910        // Mark-all-read clears the remaining unread.
1911        mark_feed_read(&pool, did, feed_id, true).await?;
1912        assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
1913
1914        // --- read cursor (batched-sync bookkeeping) ---
1915        let cursor = ReadCursor {
1916            did: did.to_string(),
1917            feed_url: "https://example.com/feed.xml".to_string(),
1918            read_through: Some("2026-07-11T08:00:00Z".to_string()),
1919            read_ids: "[]".to_string(),
1920            unread_ids: "[]".to_string(),
1921            dirty: true,
1922            pds_created: false,
1923            updated_at: now_rfc3339(),
1924        };
1925        upsert_cursor(&pool, &cursor).await?;
1926
1927        let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
1928            .await?
1929            .expect("cursor should exist");
1930        assert_eq!(
1931            fetched.read_through.as_deref(),
1932            Some("2026-07-11T08:00:00Z")
1933        );
1934        assert!(fetched.dirty);
1935
1936        // The flusher sees exactly one dirty cursor.
1937        let dirty = dirty_cursors(&pool, did).await?;
1938        assert_eq!(dirty.len(), 1);
1939        let flushed_at = dirty[0].updated_at.clone();
1940
1941        // After a flush, clearing dirty (with the flushed snapshot's updated_at)
1942        // removes it from the flusher's view.
1943        clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
1944        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
1945
1946        Ok(())
1947    }
1948
1949    // -----------------------------------------------------------------------
1950    // Read-state PDS sync wiring: marking read/unread must project into the
1951    // per-feed `read_cursor` and mark it dirty so the batched flusher pushes it.
1952    // Before this wiring `mark_read` touched only `entry_state`; nothing dirtied
1953    // a cursor, so the flusher never synced read-state to the PDS.
1954    // -----------------------------------------------------------------------
1955
1956    #[tokio::test]
1957    async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
1958        let pool = init_url("sqlite::memory:").await?;
1959        let feed_url = "https://example.com/feed.xml";
1960        let feed_id = upsert_feed(
1961            &pool,
1962            &NewFeed {
1963                url: feed_url.to_string(),
1964                title: Some("Example".to_string()),
1965                ..Default::default()
1966            },
1967        )
1968        .await?;
1969        insert_entries(
1970            &pool,
1971            feed_id,
1972            &[
1973                NewEntry {
1974                    guid: "g1".to_string(),
1975                    published: Some("2026-07-10T00:00:00Z".to_string()),
1976                    ..Default::default()
1977                },
1978                NewEntry {
1979                    guid: "g2".to_string(),
1980                    published: Some("2026-07-11T00:00:00Z".to_string()),
1981                    ..Default::default()
1982                },
1983            ],
1984            0,
1985        )
1986        .await?;
1987        let did = "did:plc:reader";
1988        replace_sub_refs(&pool, did, &[feed_id]).await?;
1989
1990        // No cursor exists yet.
1991        assert!(get_cursor(&pool, did, feed_url).await?.is_none());
1992        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
1993
1994        // Mark one entry read → the feed's read_cursor row now exists, dirty=1,
1995        // and dirty_cursors returns it (the exact assertion the fix requires).
1996        let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
1997        assert!(mark_read(&pool, did, e1, true).await?);
1998
1999        let cursor = get_cursor(&pool, did, feed_url)
2000            .await?
2001            .expect("mark_read must create the feed's read_cursor");
2002        assert!(cursor.dirty, "cursor must be dirty after mark_read");
2003        assert!(
2004            cursor.read_ids.contains(&e1.to_string()),
2005            "the read entry id must be in read_ids: {}",
2006            cursor.read_ids
2007        );
2008        let dirty = dirty_cursors(&pool, did).await?;
2009        assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
2010        assert_eq!(dirty[0].feed_url, feed_url);
2011
2012        // Marking it unread again moves the id to unread_ids and keeps it dirty.
2013        assert!(mark_read(&pool, did, e1, false).await?);
2014        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
2015        assert!(cursor.dirty);
2016        assert!(
2017            cursor.unread_ids.contains(&e1.to_string()),
2018            "unread id must be in unread_ids: {}",
2019            cursor.unread_ids
2020        );
2021        assert!(
2022            !cursor.read_ids.contains(&e1.to_string()),
2023            "id must have left read_ids: {}",
2024            cursor.read_ids
2025        );
2026
2027        // mark_feed_read dirties the one per-feed cursor too (batched, not
2028        // per-article).
2029        assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
2030        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
2031        assert!(cursor.dirty);
2032        assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
2033
2034        // A non-subscriber's mark_read is a no-op and dirties NO cursor.
2035        let outsider = "did:plc:outsider";
2036        assert!(!mark_read(&pool, outsider, e1, true).await?);
2037        assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
2038
2039        // The conditional clear only clears when updated_at matches the snapshot.
2040        let snap = dirty_cursors(&pool, did).await?[0].clone();
2041        // A stale updated_at must NOT clear (models a concurrent re-dirty).
2042        clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
2043        assert_eq!(
2044            dirty_cursors(&pool, did).await?.len(),
2045            1,
2046            "stale-snapshot clear must be a no-op"
2047        );
2048        // The matching updated_at clears it.
2049        clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
2050        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
2051
2052        Ok(())
2053    }
2054
2055    #[test]
2056    fn json_id_set_toggle_is_set_like() {
2057        // Add is idempotent, remove drops, output is a JSON string array.
2058        let s = json_id_set_toggle("[]", 5, true);
2059        assert_eq!(s, r#"["5"]"#);
2060        assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); // no dup
2061        let s = json_id_set_toggle(&s, 7, true);
2062        assert_eq!(s, r#"["5","7"]"#);
2063        let s = json_id_set_toggle(&s, 5, false);
2064        assert_eq!(s, r#"["7"]"#);
2065        // Tolerates numeric-array input and malformed input.
2066        assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
2067        assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
2068    }
2069
2070    // -----------------------------------------------------------------------
2071    // Per-DID isolation: the shared cache is one row per URL, but the READ
2072    // SURFACE (entries/unread/starred) and the read/star MUTATIONS are scoped
2073    // to the caller's own subscriptions (`sub_ref`). User A must never see or
2074    // mutate user B's entries.
2075    // -----------------------------------------------------------------------
2076
2077    #[tokio::test]
2078    async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
2079        let pool = init_url("sqlite::memory:").await?;
2080
2081        // Two feeds in the SHARED cache; A subscribes to feed_a, B to feed_b.
2082        let feed_a = upsert_feed(
2083            &pool,
2084            &NewFeed {
2085                url: "https://a.example/feed.xml".to_string(),
2086                title: Some("A".to_string()),
2087                ..Default::default()
2088            },
2089        )
2090        .await?;
2091        let feed_b = upsert_feed(
2092            &pool,
2093            &NewFeed {
2094                url: "https://b.example/feed.xml".to_string(),
2095                title: Some("B".to_string()),
2096                ..Default::default()
2097            },
2098        )
2099        .await?;
2100
2101        insert_entries(
2102            &pool,
2103            feed_a,
2104            &[NewEntry {
2105                guid: "a-1".to_string(),
2106                url: Some("https://a.example/1".to_string()),
2107                title: Some("A one".to_string()),
2108                published: Some("2026-07-10T00:00:00Z".to_string()),
2109                content_html: Some("<p>secret A body</p>".to_string()),
2110                ..Default::default()
2111            }],
2112            0,
2113        )
2114        .await?;
2115        insert_entries(
2116            &pool,
2117            feed_b,
2118            &[NewEntry {
2119                guid: "b-1".to_string(),
2120                url: Some("https://b.example/1".to_string()),
2121                title: Some("B one".to_string()),
2122                published: Some("2026-07-11T00:00:00Z".to_string()),
2123                content_html: Some("<p>secret B body</p>".to_string()),
2124                ..Default::default()
2125            }],
2126            0,
2127        )
2128        .await?;
2129
2130        let did_a = "did:plc:aaaa";
2131        let did_b = "did:plc:bbbb";
2132        replace_sub_refs(&pool, did_a, &[feed_a]).await?;
2133        replace_sub_refs(&pool, did_b, &[feed_b]).await?;
2134
2135        // The id of B's only entry (the one A must not be able to touch).
2136        let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
2137
2138        // --- entries_for_feed is scoped: A sees A's feed, not B's ------------
2139        assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
2140        assert!(
2141            entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
2142            "A must not read entries of a feed it does not subscribe to"
2143        );
2144
2145        // --- unread list is scoped -------------------------------------------
2146        let unread_a = get_unread_for_did(&pool, did_a).await?;
2147        assert_eq!(unread_a.len(), 1);
2148        assert_eq!(unread_a[0].guid, "a-1");
2149        let unread_b = get_unread_for_did(&pool, did_b).await?;
2150        assert_eq!(unread_b.len(), 1);
2151        assert_eq!(unread_b[0].guid, "b-1");
2152
2153        // --- did_subscribes_to_entry authorizes correctly --------------------
2154        assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
2155        assert!(
2156            !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
2157            "A does not subscribe to B's feed"
2158        );
2159
2160        // --- mark_read is authorized: A CANNOT mark B's entry ----------------
2161        assert!(
2162            !mark_read(&pool, did_a, b_entry_id, true).await?,
2163            "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
2164        );
2165        // B's unread list is untouched by A's attempt.
2166        assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
2167        // A subscriber CAN mark it.
2168        assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
2169        assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
2170
2171        // --- toggle_star is authorized the same way --------------------------
2172        assert!(
2173            !mark_starred(&pool, did_a, b_entry_id, true).await?,
2174            "non-subscriber mark_starred must be a no-op (→ 404)"
2175        );
2176        assert!(
2177            get_starred_for_did(&pool, did_a).await?.is_empty(),
2178            "A's starred list stays empty after the rejected attempt"
2179        );
2180        assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
2181        assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
2182        // B's star never leaks into A's starred list.
2183        assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
2184
2185        // --- feeds_for_did is scoped to the DID's OWN sub_ref ----------------
2186        // This is the PDS-unreachable fallback's projection: it must NEVER
2187        // widen a DID's surface to feeds it does not subscribe to. A sees only
2188        // feed_a; B (still subscribed to feed_b here) sees only feed_b.
2189        let a_feeds = feeds_for_did(&pool, did_a).await?;
2190        assert_eq!(a_feeds.len(), 1);
2191        assert_eq!(a_feeds[0].id, feed_a);
2192        let b_feeds = feeds_for_did(&pool, did_b).await?;
2193        assert_eq!(b_feeds.len(), 1);
2194        assert_eq!(b_feeds[0].id, feed_b);
2195
2196        // --- resync drops a feed from the surface when the sub goes away ------
2197        replace_sub_refs(&pool, did_b, &[]).await?;
2198        assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
2199        assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
2200        assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
2201        // And the fallback projection is empty too — fail CLOSED, not open.
2202        assert!(feeds_for_did(&pool, did_b).await?.is_empty());
2203
2204        Ok(())
2205    }
2206
2207    // -----------------------------------------------------------------------
2208    // PDS-outage authorization (fail CLOSED). REGRESSION GUARD for the past
2209    // FAIL-OPEN bug (fixed in 2e53e0e): `resolve_subscriptions`' PDS/sidecar-
2210    // unreachable fallback used to synthesize a DID's `sub_ref` from EVERY
2211    // cached feed (`due_feeds(.., i64::MAX)`), granting cross-tenant read +
2212    // mutate during any outage. The fix serves the DID's OWN last-known
2213    // `sub_ref` via `feeds_for_did(did)` and NEVER widens it.
2214    //
2215    // This test replays that fixed fallback at the store layer — the seam the
2216    // web handler drives when `list_subscriptions_sorted(did) -> Err`. The
2217    // key adversarial shape is an ORPHAN cached feed (in the shared cache but
2218    // subscribed by NO ONE): the old fail-open code would have folded it into
2219    // the caller's surface. If the fail-open is reintroduced, `feeds_for_did`
2220    // would include that orphan and every assertion below flips — so this is a
2221    // real guard, not a tautology.
2222    // -----------------------------------------------------------------------
2223
2224    #[tokio::test]
2225    async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
2226        let pool = init_url("sqlite::memory:").await?;
2227
2228        let did_a = "did:plc:aaaa";
2229
2230        // feed_a: A's own subscription (its last-known `sub_ref`; the fallback
2231        // may serve this stale but must not widen past it).
2232        let feed_a = upsert_feed(
2233            &pool,
2234            &NewFeed {
2235                url: "https://a.example/feed.xml".to_string(),
2236                title: Some("A".to_string()),
2237                ..Default::default()
2238            },
2239        )
2240        .await?;
2241        // feed_orphan: present in the SHARED cache but subscribed by NO DID.
2242        // This is exactly what the fail-open path would have leaked to A.
2243        let feed_orphan = upsert_feed(
2244            &pool,
2245            &NewFeed {
2246                url: "https://orphan.example/feed.xml".to_string(),
2247                title: Some("Orphan".to_string()),
2248                ..Default::default()
2249            },
2250        )
2251        .await?;
2252
2253        insert_entries(
2254            &pool,
2255            feed_a,
2256            &[NewEntry {
2257                guid: "a-1".to_string(),
2258                url: Some("https://a.example/1".to_string()),
2259                title: Some("A one".to_string()),
2260                published: Some("2026-07-10T00:00:00Z".to_string()),
2261                content_html: Some("<p>A body</p>".to_string()),
2262                ..Default::default()
2263            }],
2264            0,
2265        )
2266        .await?;
2267        insert_entries(
2268            &pool,
2269            feed_orphan,
2270            &[NewEntry {
2271                guid: "orphan-1".to_string(),
2272                url: Some("https://orphan.example/1".to_string()),
2273                title: Some("Orphan one".to_string()),
2274                published: Some("2026-07-11T00:00:00Z".to_string()),
2275                content_html: Some("<p>secret orphan body</p>".to_string()),
2276                ..Default::default()
2277            }],
2278            0,
2279        )
2280        .await?;
2281
2282        // A's last-known subscription set is feed_a ONLY. No `sub_ref` row ever
2283        // points any DID at feed_orphan.
2284        replace_sub_refs(&pool, did_a, &[feed_a]).await?;
2285
2286        // Grab the orphan entry id via a transient sub so we can address it,
2287        // then drop the sub — nobody subscribes to feed_orphan afterwards.
2288        replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
2289        let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
2290        replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
2291
2292        // --- Replay the FIXED fallback projection ----------------------------
2293        // This is what `resolve_subscriptions` serves on the Err (outage) path:
2294        // the caller's OWN feeds, never widened. It must contain feed_a and
2295        // NEVER the orphan. (The old fail-open synthesized from every cached
2296        // feed → this vec would have held feed_orphan too.)
2297        let fallback = feeds_for_did(&pool, did_a).await?;
2298        let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
2299        assert_eq!(
2300            fallback_ids,
2301            vec![feed_a],
2302            "outage fallback must serve ONLY A's own last-known sub_ref, \
2303             never widen to the orphan cached feed"
2304        );
2305        assert!(
2306            !fallback_ids.contains(&feed_orphan),
2307            "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
2308             cached feed into A's surface"
2309        );
2310
2311        // --- With that projection in place, EVERY scoped read denies A -------
2312        assert!(
2313            !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
2314            "A must not be authorized for an orphan feed's entry during an outage"
2315        );
2316        assert!(
2317            entries_for_feed(&pool, did_a, feed_orphan)
2318                .await?
2319                .is_empty(),
2320            "entries_for_feed must not expose the orphan feed to A during an outage"
2321        );
2322        // Neither the unread nor the starred list may surface the orphan entry.
2323        let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
2324            .await?
2325            .into_iter()
2326            .map(|e| e.guid)
2327            .collect();
2328        assert!(
2329            !unread_guids.iter().any(|g| g == "orphan-1"),
2330            "orphan entry leaked into A's unread list during an outage"
2331        );
2332        assert!(
2333            get_starred_for_did(&pool, did_a).await?.is_empty(),
2334            "A has no starred entries; the orphan must not appear"
2335        );
2336
2337        // --- And EVERY scoped mutation is a no-op (→ 404 at the web layer) ---
2338        assert!(
2339            !mark_read(&pool, did_a, orphan_entry_id, true).await?,
2340            "A must not mark an orphan feed's entry read during an outage"
2341        );
2342        assert!(
2343            !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
2344            "A must not star an orphan feed's entry during an outage"
2345        );
2346        assert_eq!(
2347            mark_feed_read(&pool, did_a, feed_orphan, true).await?,
2348            0,
2349            "A must not mark-all-read the orphan feed during an outage"
2350        );
2351
2352        // Nothing was written for A against the orphan entry.
2353        let es_count: i64 =
2354            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
2355                .bind(did_a)
2356                .bind(orphan_entry_id)
2357                .fetch_one(&pool)
2358                .await?;
2359        assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
2360
2361        Ok(())
2362    }
2363
2364    // -----------------------------------------------------------------------
2365    // Closed-beta invite gate
2366    // -----------------------------------------------------------------------
2367
2368    #[test]
2369    fn code_gen_shape_and_alphabet() {
2370        for _ in 0..200 {
2371            let code = generate_invite_code().unwrap();
2372            assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
2373            let body = &code["FEATHER-".len()..];
2374            assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
2375            // Every body char must be from the ambiguity-free alphabet — in
2376            // particular NEVER I/O/0/1.
2377            for c in body.chars() {
2378                assert!(
2379                    CODE_ALPHABET.contains(&(c as u8)),
2380                    "char {c:?} not in alphabet ({code})"
2381                );
2382                assert!(
2383                    !matches!(c, 'I' | 'O' | '0' | '1'),
2384                    "ambiguous char {c:?} leaked into {code}"
2385                );
2386            }
2387        }
2388        // Two codes in a row must differ (unguessable / random).
2389        assert_ne!(
2390            generate_invite_code().unwrap(),
2391            generate_invite_code().unwrap()
2392        );
2393    }
2394
2395    #[tokio::test]
2396    async fn busy_timeout_is_applied() -> Result<()> {
2397        // Opening an on-disk DB and reading back the PRAGMA proves the pool
2398        // carries busy_timeout = 5000 ms.
2399        let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
2400        std::fs::create_dir_all(&dir).ok();
2401        let path = dir.join("busy.db");
2402        let url = format!("sqlite://{}", path.display());
2403        let pool = init_url(&url).await?;
2404        let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
2405        let timeout: i64 = row.get(0);
2406        assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
2407        pool.close().await;
2408        std::fs::remove_dir_all(&dir).ok();
2409        Ok(())
2410    }
2411
2412    #[tokio::test]
2413    async fn redeem_valid_grants_seat() -> Result<()> {
2414        let pool = init_url("sqlite::memory:").await?;
2415        let code = mint_code(&pool, "did:plc:creator", 3600).await?;
2416        assert!(!has_beta_access(&pool, "did:plc:new").await?);
2417
2418        let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
2419        assert_eq!(out, Ok(()));
2420        assert!(has_beta_access(&pool, "did:plc:new").await?);
2421        assert_eq!(count_beta_access(&pool).await?, 1);
2422
2423        // The code is now spent — a second redeem is AlreadyRedeemed.
2424        let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
2425        assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
2426        Ok(())
2427    }
2428
2429    #[tokio::test]
2430    async fn redeem_not_found() -> Result<()> {
2431        let pool = init_url("sqlite::memory:").await?;
2432        let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
2433        assert_eq!(out, Err(RedeemError::NotFound));
2434        Ok(())
2435    }
2436
2437    /// Insert an already-expired `active` code directly (mint_code clamps a
2438    /// negative ttl to 0, so the past-expiry case is set up by hand).
2439    async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
2440        let now = now_unix();
2441        sqlx::query(
2442            r#"INSERT INTO invite_codes
2443               (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
2444               VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
2445        )
2446        .bind(code)
2447        .bind(creator)
2448        .bind(now - 100)
2449        .bind(now - 10) // expires_at in the past
2450        .execute(pool)
2451        .await?;
2452        Ok(())
2453    }
2454
2455    #[tokio::test]
2456    async fn redeem_expired() -> Result<()> {
2457        let pool = init_url("sqlite::memory:").await?;
2458        insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
2459        let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
2460        assert_eq!(out, Err(RedeemError::Expired));
2461        // No seat granted.
2462        assert_eq!(count_beta_access(&pool).await?, 0);
2463        Ok(())
2464    }
2465
2466    #[tokio::test]
2467    async fn redeem_capacity_full() -> Result<()> {
2468        let pool = init_url("sqlite::memory:").await?;
2469        // Cap of 1, one seat already taken by an admin seed.
2470        ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
2471        assert_eq!(count_beta_access(&pool).await?, 1);
2472
2473        let code = mint_code(&pool, "did:plc:admin", 3600).await?;
2474        let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
2475        assert_eq!(out, Err(RedeemError::CapacityFull));
2476        // Seat NOT granted and the code NOT consumed (tx rolled back).
2477        assert!(!has_beta_access(&pool, "did:plc:new").await?);
2478        // Raising the cap lets the same code redeem.
2479        let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
2480        assert_eq!(ok, Ok(()));
2481        Ok(())
2482    }
2483
2484    #[tokio::test]
2485    async fn expire_and_seed() -> Result<()> {
2486        let pool = init_url("sqlite::memory:").await?;
2487        // An already-expired code is swept to `expired`.
2488        insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
2489        let live = mint_code(&pool, "did:plc:creator", 3600).await?;
2490        let n = expire_old_codes(&pool).await?;
2491        assert_eq!(n, 1, "exactly the past-expiry code should flip");
2492        // The live code still redeems.
2493        assert_eq!(
2494            redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
2495            Ok(())
2496        );
2497
2498        // ensure_seed is idempotent.
2499        let created = ensure_seed(
2500            &pool,
2501            &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
2502        )
2503        .await?;
2504        assert_eq!(created, 2);
2505        let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
2506        assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
2507        assert!(has_beta_access(&pool, "did:plc:seed1").await?);
2508        Ok(())
2509    }
2510
2511    // -----------------------------------------------------------------------
2512    // Hardening caps: per-DID sub count, global feed count, per-feed entry trim.
2513    // -----------------------------------------------------------------------
2514
2515    #[tokio::test]
2516    async fn count_helpers_track_feeds_and_subs() -> Result<()> {
2517        let pool = init_url("sqlite::memory:").await?;
2518        assert_eq!(count_feeds(&pool).await?, 0);
2519
2520        let mut ids = Vec::new();
2521        for i in 0..3 {
2522            let id = upsert_feed(
2523                &pool,
2524                &NewFeed {
2525                    url: format!("https://f{i}.example/feed.xml"),
2526                    ..Default::default()
2527                },
2528            )
2529            .await?;
2530            ids.push(id);
2531        }
2532        assert_eq!(count_feeds(&pool).await?, 3);
2533
2534        let did = "did:plc:capcheck";
2535        assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
2536        replace_sub_refs(&pool, did, &ids).await?;
2537        assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
2538        Ok(())
2539    }
2540
2541    #[tokio::test]
2542    async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
2543        let pool = init_url("sqlite::memory:").await?;
2544        let feed_id = upsert_feed(
2545            &pool,
2546            &NewFeed {
2547                url: "https://firehose.example/feed.xml".to_string(),
2548                ..Default::default()
2549            },
2550        )
2551        .await?;
2552
2553        // Insert 5 entries with ascending published dates, cap retained to 2.
2554        let batch: Vec<NewEntry> = (0..5)
2555            .map(|i| NewEntry {
2556                guid: format!("g-{i}"),
2557                title: Some(format!("E{i}")),
2558                published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
2559                ..Default::default()
2560            })
2561            .collect();
2562        insert_entries(&pool, feed_id, &batch, 2).await?;
2563
2564        let did = "did:plc:trim";
2565        replace_sub_refs(&pool, did, &[feed_id]).await?;
2566        let kept = entries_for_feed(&pool, did, feed_id).await?;
2567        assert_eq!(
2568            kept.len(),
2569            2,
2570            "over-cap feed trimmed to the newest 2 entries"
2571        );
2572        // Newest first: g-4 (2026-07-05), g-3 (2026-07-04).
2573        assert_eq!(kept[0].guid, "g-4");
2574        assert_eq!(kept[1].guid, "g-3");
2575        Ok(())
2576    }
2577
2578    /// Regression: an UNDATED entry (NULL `published`) that was fetched most
2579    /// recently must NOT be evicted in favour of an older *dated* entry. The
2580    /// trim orders by `COALESCE(published, fetched_at) DESC`; under the old
2581    /// `ORDER BY published DESC` a NULL-published row sorts LAST and is dropped
2582    /// first even when it is the freshest thing in the feed.
2583    #[tokio::test]
2584    async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
2585        let pool = init_url("sqlite::memory:").await?;
2586        let feed_id = upsert_feed(
2587            &pool,
2588            &NewFeed {
2589                url: "https://undated.example/feed.xml".to_string(),
2590                ..Default::default()
2591            },
2592        )
2593        .await?;
2594
2595        // Two OLD dated entries (fetched long ago), plus one UNDATED entry
2596        // fetched most recently. Cap = 2, so exactly one row must be evicted.
2597        let batch = vec![
2598            NewEntry {
2599                guid: "old-dated-1".to_string(),
2600                title: Some("Old A".to_string()),
2601                published: Some("2026-07-01T00:00:00Z".to_string()),
2602                fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
2603                ..Default::default()
2604            },
2605            NewEntry {
2606                guid: "old-dated-2".to_string(),
2607                title: Some("Old B".to_string()),
2608                published: Some("2026-07-02T00:00:00Z".to_string()),
2609                fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
2610                ..Default::default()
2611            },
2612            NewEntry {
2613                guid: "fresh-undated".to_string(),
2614                title: Some("Fresh undated".to_string()),
2615                published: None,
2616                fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
2617                ..Default::default()
2618            },
2619        ];
2620        insert_entries(&pool, feed_id, &batch, 2).await?;
2621
2622        let did = "did:plc:undated";
2623        replace_sub_refs(&pool, did, &[feed_id]).await?;
2624        let kept = entries_for_feed(&pool, did, feed_id).await?;
2625        assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
2626        let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
2627        assert!(
2628            guids.contains(&"fresh-undated"),
2629            "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
2630        );
2631        assert!(
2632            guids.contains(&"old-dated-2"),
2633            "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
2634        );
2635        assert!(
2636            !guids.contains(&"old-dated-1"),
2637            "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
2638        );
2639        Ok(())
2640    }
2641
2642    #[tokio::test]
2643    async fn db_size_is_positive_and_grows() -> Result<()> {
2644        let pool = init_url("sqlite::memory:").await?;
2645        let before = db_size_bytes(&pool).await?;
2646        assert!(before > 0, "a schema-initialised DB has a non-zero size");
2647        Ok(())
2648    }
2649
2650    /// `purge_did_data` removes every per-DID row the caller owns (read/star
2651    /// state, cursors, sub_ref projection, beta seat, created invite codes) —
2652    /// and touches no other DID's rows nor the shared feeds/entries cache.
2653    #[tokio::test]
2654    async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
2655        let pool = init_url("sqlite::memory:").await?;
2656
2657        // A shared feed + entry both DIDs can subscribe to.
2658        let feed_id = upsert_feed(
2659            &pool,
2660            &NewFeed {
2661                url: "https://example.com/feed.xml".to_string(),
2662                title: Some("Example".to_string()),
2663                ..Default::default()
2664            },
2665        )
2666        .await?;
2667        insert_entries(
2668            &pool,
2669            feed_id,
2670            &[NewEntry {
2671                guid: "g-1".to_string(),
2672                url: Some("https://example.com/a".to_string()),
2673                title: Some("First".to_string()),
2674                published: Some("2026-07-10T08:00:00Z".to_string()),
2675                ..Default::default()
2676            }],
2677            0,
2678        )
2679        .await?;
2680        let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
2681            .fetch_one(&pool)
2682            .await?;
2683
2684        let victim = "did:plc:victim";
2685        let bystander = "did:plc:bystander";
2686
2687        // Seed BOTH DIDs with a full spread of per-DID rows.
2688        for did in [victim, bystander] {
2689            replace_sub_refs(&pool, did, &[feed_id]).await?;
2690            assert!(mark_read(&pool, did, entry_id, true).await?);
2691            assert!(mark_starred(&pool, did, entry_id, true).await?);
2692            upsert_cursor(
2693                &pool,
2694                &ReadCursor {
2695                    did: did.to_string(),
2696                    feed_url: "https://example.com/feed.xml".to_string(),
2697                    read_through: Some("2026-07-10T08:00:00Z".to_string()),
2698                    read_ids: "[]".to_string(),
2699                    unread_ids: "[]".to_string(),
2700                    dirty: false,
2701                    pds_created: false,
2702                    updated_at: now_rfc3339(),
2703                },
2704            )
2705            .await?;
2706            grant_access(&pool, did, Some("h.example"), "admin", None).await?;
2707            mint_code(&pool, did, 3600).await?;
2708        }
2709
2710        // Purge only the victim.
2711        let counts = purge_did_data(&pool, victim).await?;
2712        assert_eq!(
2713            counts.entry_state, 1,
2714            "one entry_state row (read+star merge)"
2715        );
2716        assert_eq!(counts.read_cursor, 1);
2717        assert_eq!(counts.sub_ref, 1);
2718        assert_eq!(counts.beta_access, 1);
2719        assert_eq!(counts.invite_codes, 1);
2720        assert_eq!(counts.total(), 5);
2721
2722        // The victim has zero rows left in every per-DID table.
2723        let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
2724            .bind(victim)
2725            .fetch_one(&pool)
2726            .await?;
2727        assert_eq!(es, 0, "victim still had entry_state rows");
2728        let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
2729            .bind(victim)
2730            .fetch_one(&pool)
2731            .await?;
2732        assert_eq!(rc, 0, "victim still had read_cursor rows");
2733        let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
2734            .bind(victim)
2735            .fetch_one(&pool)
2736            .await?;
2737        assert_eq!(sr, 0, "victim still had sub_ref rows");
2738        assert!(
2739            !has_beta_access(&pool, victim).await?,
2740            "victim still had a beta seat"
2741        );
2742        let victim_codes: i64 =
2743            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2744                .bind(victim)
2745                .fetch_one(&pool)
2746                .await?;
2747        assert_eq!(victim_codes, 0);
2748
2749        // The bystander is untouched.
2750        assert!(has_beta_access(&pool, bystander).await?);
2751        let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
2752        assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
2753        let bystander_codes: i64 =
2754            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2755                .bind(bystander)
2756                .fetch_one(&pool)
2757                .await?;
2758        assert_eq!(bystander_codes, 1);
2759
2760        // The shared cache is intact.
2761        assert_eq!(count_feeds(&pool).await?, 1);
2762
2763        // Idempotent: purging again removes nothing.
2764        let again = purge_did_data(&pool, victim).await?;
2765        assert_eq!(again.total(), 0);
2766
2767        Ok(())
2768    }
2769
2770    /// A departing DID leaves back-references on rows that belong to OTHER DIDs:
2771    ///   * the invite code it *redeemed* to join (inviter's row: `invitee_did`);
2772    ///   * seats it *granted* to others (`beta_access.granted_by`).
2773    /// `purge_did_data` must scrub both so no per-DID residue survives, while
2774    /// leaving those other DIDs' rows otherwise intact (their access is kept).
2775    #[tokio::test]
2776    async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
2777        let pool = init_url("sqlite::memory:").await?;
2778
2779        let inviter = "did:plc:inviter";
2780        let leaver = "did:plc:leaver";
2781        let friend = "did:plc:friend";
2782
2783        // inviter mints a code; leaver redeems it to join (stamps invitee_did).
2784        let inviter_code = mint_code(&pool, inviter, 3600).await?;
2785        grant_access(&pool, inviter, None, "admin", None).await?;
2786        assert_eq!(
2787            redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
2788            Ok(())
2789        );
2790
2791        // leaver mints a code; friend redeems it (stamps friend's granted_by).
2792        let leaver_code = mint_code(&pool, leaver, 3600).await?;
2793        assert_eq!(
2794            redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
2795            Ok(())
2796        );
2797
2798        // Precondition: the leaver DID is present in both back-reference columns.
2799        let invitee_before: i64 =
2800            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
2801                .bind(leaver)
2802                .fetch_one(&pool)
2803                .await?;
2804        assert_eq!(
2805            invitee_before, 1,
2806            "leaver should be an invitee before purge"
2807        );
2808        let granted_before: i64 =
2809            sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
2810                .bind(leaver)
2811                .fetch_one(&pool)
2812                .await?;
2813        assert_eq!(granted_before, 1, "leaver should be a granter before purge");
2814
2815        // Purge the leaver.
2816        let counts = purge_did_data(&pool, leaver).await?;
2817        assert_eq!(
2818            counts.invitee_scrubbed, 1,
2819            "the redeemed code's invitee_did"
2820        );
2821        assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
2822
2823        // No residue: the leaver DID appears in NEITHER back-reference column.
2824        let invitee_after: i64 =
2825            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
2826                .bind(leaver)
2827                .fetch_one(&pool)
2828                .await?;
2829        assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
2830        let granted_after: i64 =
2831            sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
2832                .bind(leaver)
2833                .fetch_one(&pool)
2834                .await?;
2835        assert_eq!(granted_after, 0, "leaver survived in granted_by");
2836
2837        // The other DIDs' rows are kept: the friend still has a seat (redacted
2838        // granter), and the inviter's code row still exists (invitee NULLed).
2839        assert!(
2840            has_beta_access(&pool, friend).await?,
2841            "friend's seat must survive the leaver's scrub"
2842        );
2843        let friend_granted_by: String =
2844            sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
2845                .bind(friend)
2846                .fetch_one(&pool)
2847                .await?;
2848        assert_eq!(friend_granted_by, REDACTED_DID);
2849        let inviter_code_rows: i64 =
2850            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2851                .bind(inviter)
2852                .fetch_one(&pool)
2853                .await?;
2854        assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
2855
2856        Ok(())
2857    }
2858
2859    // -- F2: consecutive-error count drives the poll backoff -----------------
2860
2861    #[tokio::test]
2862    async fn feed_error_count_bumps_and_resets() -> Result<()> {
2863        let pool = init_url("sqlite::memory:").await?;
2864        let url = "https://broken.example/feed.xml";
2865        upsert_feed(
2866            &pool,
2867            &NewFeed {
2868                url: url.to_string(),
2869                ..Default::default()
2870            },
2871        )
2872        .await?;
2873
2874        // A fresh feed starts at 0 errors.
2875        let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
2876        assert_eq!(feed.consecutive_errors, 0);
2877
2878        // N consecutive failures grow the count 1,2,3, and — fed through
2879        // `backoff_for` — the backoff grows with it (never latched at the floor).
2880        let mut last = std::time::Duration::ZERO;
2881        for expected in 1..=3 {
2882            let count = bump_feed_errors(&pool, url).await?;
2883            assert_eq!(count, expected, "bump returns the new count");
2884            let backoff = crate::feed::backoff_for(count as u32);
2885            assert!(
2886                backoff >= last,
2887                "backoff must not shrink as errors accumulate"
2888            );
2889            last = backoff;
2890        }
2891        // Growth actually happened (2 errors backs off longer than 1).
2892        assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
2893        assert_eq!(
2894            get_feed_by_url(&pool, url)
2895                .await?
2896                .unwrap()
2897                .consecutive_errors,
2898            3
2899        );
2900
2901        // A success resets the streak to 0 (back to the normal cadence).
2902        reset_feed_errors(&pool, url).await?;
2903        assert_eq!(
2904            get_feed_by_url(&pool, url)
2905                .await?
2906                .unwrap()
2907                .consecutive_errors,
2908            0
2909        );
2910        Ok(())
2911    }
2912
2913    // -- F3: db_size_bytes ignores freed pages and drops after reclaim -------
2914
2915    #[tokio::test]
2916    async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
2917        // On-disk DB so VACUUM has a file to shrink (in-memory has no freelist to
2918        // speak of the same way). Temp path, cleaned up at the end.
2919        let dir = std::env::temp_dir();
2920        let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
2921        let url = format!("sqlite://{}", path.display());
2922        let pool = init_url(&url).await?;
2923
2924        let feed_id = upsert_feed(
2925            &pool,
2926            &NewFeed {
2927                url: "https://bulk.example/feed.xml".to_string(),
2928                ..Default::default()
2929            },
2930        )
2931        .await?;
2932
2933        // Insert a large batch so the file allocates real pages.
2934        let entries: Vec<NewEntry> = (0..2000)
2935            .map(|i| NewEntry {
2936                guid: format!("guid-{i}"),
2937                title: Some(format!("Entry number {i} with some padding text")),
2938                content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
2939                published: Some("2026-01-01T00:00:00Z".to_string()),
2940                ..Default::default()
2941            })
2942            .collect();
2943        insert_entries(&pool, feed_id, &entries, 0).await?;
2944        let full = db_size_bytes(&pool).await?;
2945        assert!(full > 0);
2946
2947        // Prune: delete every entry (the retention sweep's effect). This frees
2948        // pages onto the freelist but does NOT shrink the file yet.
2949        sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
2950            .bind(feed_id)
2951            .execute(&pool)
2952            .await?;
2953
2954        // Because db_size_bytes subtracts freelist pages, the USED size already
2955        // reflects the delete even before the file shrinks.
2956        let after_delete = db_size_bytes(&pool).await?;
2957        assert!(
2958            after_delete < full,
2959            "used size must drop once rows are deleted (freed pages excluded): \
2960             {after_delete} !< {full}"
2961        );
2962
2963        // Reclaim returns the freed pages to the OS; used size stays low (and the
2964        // file itself shrinks). The key property F3 needs: the watermark can now
2965        // fall back below its threshold instead of latching polling off.
2966        reclaim(&pool).await?;
2967        let after_reclaim = db_size_bytes(&pool).await?;
2968        assert!(
2969            after_reclaim <= after_delete,
2970            "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
2971        );
2972        assert!(
2973            after_reclaim < full,
2974            "after prune+reclaim the DB is smaller than when full: \
2975             {after_reclaim} !< {full}"
2976        );
2977
2978        drop(pool);
2979        let _ = std::fs::remove_file(&path);
2980        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
2981        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
2982        Ok(())
2983    }
2984
2985    // -- F4 support: pds_created flag round-trips + flips ---------------------
2986
2987    #[tokio::test]
2988    async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
2989        let pool = init_url("sqlite::memory:").await?;
2990        let did = "did:plc:f4";
2991        let feed_url = "https://example.com/feed.xml";
2992        upsert_cursor(
2993            &pool,
2994            &ReadCursor {
2995                did: did.to_string(),
2996                feed_url: feed_url.to_string(),
2997                read_through: None,
2998                read_ids: r#"["1"]"#.to_string(),
2999                unread_ids: "[]".to_string(),
3000                dirty: true,
3001                pds_created: false,
3002                updated_at: now_rfc3339(),
3003            },
3004        )
3005        .await?;
3006
3007        // A brand-new cursor's PDS record does NOT yet exist.
3008        let c = get_cursor(&pool, did, feed_url).await?.unwrap();
3009        assert!(!c.pds_created, "first flush must emit a create, not update");
3010
3011        // After the create-flush lands, the flag flips so future flushes update.
3012        mark_cursor_pds_created(&pool, did, feed_url).await?;
3013        let c = get_cursor(&pool, did, feed_url).await?.unwrap();
3014        assert!(c.pds_created);
3015        Ok(())
3016    }
3017
3018    // -- STORAGE HYGIENE: retention prune + orphan-id scrub -------------------
3019
3020    /// Count entries currently in the cache.
3021    async fn count_entries(pool: &SqlitePool) -> Result<i64> {
3022        Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
3023            .fetch_one(pool)
3024            .await?)
3025    }
3026
3027    #[tokio::test]
3028    async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
3029        let pool = init_url("sqlite::memory:").await?;
3030        let feed_id = upsert_feed(
3031            &pool,
3032            &NewFeed {
3033                url: "https://ret.example/feed.xml".to_string(),
3034                ..Default::default()
3035            },
3036        )
3037        .await?;
3038
3039        let recent = now_rfc3339();
3040        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3041            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3042
3043        // One fresh (published now), one ancient (published a year ago), and one
3044        // UNDATED-but-freshly-fetched (published NULL, fetched_at now) — the last
3045        // must survive because COALESCE falls back to fetched_at, not to "old".
3046        insert_entries(
3047            &pool,
3048            feed_id,
3049            &[
3050                NewEntry {
3051                    guid: "fresh".into(),
3052                    published: Some(recent.clone()),
3053                    fetched_at: Some(recent.clone()),
3054                    ..Default::default()
3055                },
3056                NewEntry {
3057                    guid: "ancient".into(),
3058                    published: Some(ancient.clone()),
3059                    fetched_at: Some(ancient.clone()),
3060                    ..Default::default()
3061                },
3062                NewEntry {
3063                    guid: "undated-fresh".into(),
3064                    published: None,
3065                    fetched_at: Some(recent.clone()),
3066                    ..Default::default()
3067                },
3068            ],
3069            0,
3070        )
3071        .await?;
3072        assert_eq!(count_entries(&pool).await?, 3);
3073        // Subscribe so mark_read is authorized to write an entry_state row.
3074        replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
3075
3076        // Give the ancient entry an entry_state row so we can prove the FK cascade.
3077        let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
3078            .fetch_one(&pool)
3079            .await?;
3080        let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
3081        assert!(wrote, "mark_read must write with a sub_ref in place");
3082        let state_before: i64 =
3083            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
3084                .bind(ancient_id)
3085                .fetch_one(&pool)
3086                .await?;
3087        assert_eq!(state_before, 1);
3088
3089        // Prune at a 90-day window: only the ancient entry is old.
3090        let deleted = prune_old_entries(&pool, 90).await?;
3091        assert_eq!(deleted, 1, "only the year-old entry should be pruned");
3092        assert_eq!(
3093            count_entries(&pool).await?,
3094            2,
3095            "fresh + undated-fresh survive"
3096        );
3097
3098        // The surviving guids are exactly the two fresh ones.
3099        let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
3100            .fetch_all(&pool)
3101            .await?;
3102        assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
3103
3104        // entry_state for the deleted entry cascaded away via the FK.
3105        let state_after: i64 =
3106            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
3107                .bind(ancient_id)
3108                .fetch_one(&pool)
3109                .await?;
3110        assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
3111
3112        // days == 0 disables retention (no-op).
3113        assert_eq!(prune_old_entries(&pool, 0).await?, 0);
3114        assert_eq!(count_entries(&pool).await?, 2);
3115        Ok(())
3116    }
3117
3118    #[tokio::test]
3119    async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
3120        let pool = init_url("sqlite::memory:").await?;
3121        let did = "did:plc:reader";
3122        let feed_url = "https://orphan.example/feed.xml";
3123        let feed_id = upsert_feed(
3124            &pool,
3125            &NewFeed {
3126                url: feed_url.to_string(),
3127                ..Default::default()
3128            },
3129        )
3130        .await?;
3131        // Caller subscribes so mark-read is authorized to project into the cursor.
3132        replace_sub_refs(&pool, did, &[feed_id]).await?;
3133
3134        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3135            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3136        let recent = now_rfc3339();
3137        insert_entries(
3138            &pool,
3139            feed_id,
3140            &[
3141                NewEntry {
3142                    guid: "old".into(),
3143                    published: Some(ancient.clone()),
3144                    fetched_at: Some(ancient.clone()),
3145                    ..Default::default()
3146                },
3147                NewEntry {
3148                    guid: "new".into(),
3149                    published: Some(recent.clone()),
3150                    fetched_at: Some(recent.clone()),
3151                    ..Default::default()
3152                },
3153            ],
3154            0,
3155        )
3156        .await?;
3157        let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
3158            .fetch_one(&pool)
3159            .await?;
3160        let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
3161            .fetch_one(&pool)
3162            .await?;
3163
3164        // Mark BOTH read — the cursor's read_ids now references both entry ids.
3165        mark_read(&pool, did, old_id, true).await?;
3166        mark_read(&pool, did, new_id, true).await?;
3167        let before = get_cursor(&pool, did, feed_url).await?.unwrap();
3168        let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
3169        assert!(ids_before.contains(&old_id.to_string()));
3170        assert!(ids_before.contains(&new_id.to_string()));
3171
3172        // Prune the old entry — its id must be scrubbed from the cursor's id-set.
3173        let deleted = prune_old_entries(&pool, 90).await?;
3174        assert_eq!(deleted, 1);
3175        let after = get_cursor(&pool, did, feed_url).await?.unwrap();
3176        let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
3177        assert_eq!(
3178            ids_after,
3179            vec![new_id.to_string()],
3180            "orphaned (deleted) entry id must be removed; live id kept"
3181        );
3182        // The scrub re-dirties the cursor so the flusher resyncs the PDS record.
3183        assert!(
3184            after.dirty,
3185            "cursor must be marked dirty after orphan scrub"
3186        );
3187        Ok(())
3188    }
3189
3190    #[tokio::test]
3191    async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
3192        // The per-feed max_entries trim path must ALSO scrub orphaned cursor ids.
3193        let pool = init_url("sqlite::memory:").await?;
3194        let did = "did:plc:reader";
3195        let feed_url = "https://trim.example/feed.xml";
3196        let feed_id = upsert_feed(
3197            &pool,
3198            &NewFeed {
3199                url: feed_url.to_string(),
3200                ..Default::default()
3201            },
3202        )
3203        .await?;
3204        replace_sub_refs(&pool, did, &[feed_id]).await?;
3205
3206        // Two entries, cap of 2 for now (no trim yet).
3207        insert_entries(
3208            &pool,
3209            feed_id,
3210            &[
3211                NewEntry {
3212                    guid: "a".into(),
3213                    published: Some("2026-01-01T00:00:00Z".into()),
3214                    ..Default::default()
3215                },
3216                NewEntry {
3217                    guid: "b".into(),
3218                    published: Some("2026-01-02T00:00:00Z".into()),
3219                    ..Default::default()
3220                },
3221            ],
3222            2,
3223        )
3224        .await?;
3225        let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
3226            .fetch_one(&pool)
3227            .await?;
3228        mark_read(&pool, did, a_id, true).await?;
3229
3230        // Insert a newer entry with cap=1 → the oldest ('a') is trimmed away.
3231        insert_entries(
3232            &pool,
3233            feed_id,
3234            &[NewEntry {
3235                guid: "c".into(),
3236                published: Some("2026-01-03T00:00:00Z".into()),
3237                ..Default::default()
3238            }],
3239            1,
3240        )
3241        .await?;
3242        // 'a' is gone.
3243        let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
3244            .fetch_one(&pool)
3245            .await?;
3246        assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
3247
3248        // The cursor no longer references the trimmed id.
3249        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
3250        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
3251        assert!(
3252            !ids.contains(&a_id.to_string()),
3253            "trimmed entry id must be scrubbed from the cursor"
3254        );
3255        Ok(())
3256    }
3257
3258    #[tokio::test]
3259    async fn prune_and_reclaim_drops_db_size() -> Result<()> {
3260        // On-disk DB so VACUUM has a file to shrink.
3261        let dir = std::env::temp_dir();
3262        let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
3263        let url = format!("sqlite://{}", path.display());
3264        let pool = init_url(&url).await?;
3265
3266        let feed_id = upsert_feed(
3267            &pool,
3268            &NewFeed {
3269                url: "https://bulk.example/feed.xml".to_string(),
3270                ..Default::default()
3271            },
3272        )
3273        .await?;
3274        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3275            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3276        let entries: Vec<NewEntry> = (0..2000)
3277            .map(|i| NewEntry {
3278                guid: format!("guid-{i}"),
3279                content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
3280                published: Some(ancient.clone()),
3281                fetched_at: Some(ancient.clone()),
3282                ..Default::default()
3283            })
3284            .collect();
3285        insert_entries(&pool, feed_id, &entries, 0).await?;
3286        let full = db_size_bytes(&pool).await?;
3287        assert!(full > 0);
3288
3289        // A retention sweep prunes every (year-old) entry, then reclaim shrinks.
3290        let deleted = prune_old_entries(&pool, 90).await?;
3291        assert_eq!(deleted, 2000);
3292        reclaim(&pool).await?;
3293        let after = db_size_bytes(&pool).await?;
3294        assert!(
3295            after < full,
3296            "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
3297        );
3298
3299        drop(pool);
3300        let _ = std::fs::remove_file(&path);
3301        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
3302        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
3303        Ok(())
3304    }
3305}