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 batched read-state 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, sanitized (ammonia) by ingest before it is stored.
92    ///
93    /// **Not trusted on the way out.** The column is plain `TEXT`, so nothing
94    /// here can prove which writer produced a row. The reader re-cleans it at
95    /// render with the same sanitizer, through
96    /// [`crate::sanitized_html::SanitizedHtml`], and never emits this `String`
97    /// unescaped (#151). Ingest sanitizing is still what keeps the stored
98    /// value clean, and a clean value is what re-cleans byte-identically.
99    pub content_html: Option<String>,
100    /// When FeatherReader first fetched/stored this entry (RFC3339).
101    pub fetched_at: String,
102}
103
104/// One row of a LIST view — deliberately **without** `content_html`.
105///
106/// The list queries used to be `SELECT e.*` into [`Entry`], which carries the
107/// sanitized article body. The body is essentially the whole of a cached entry
108/// (measured: 11.9 KB/entry), and no list surface has ever rendered it — the
109/// reader's `EntryRow` reads id, title, feed title, date, read, starred and
110/// link, and nothing else. So every article on every page load was read off
111/// disk, allocated, and dropped unexamined. On a 512 MB box with 250 concurrent
112/// requests permitted, one reader with a large backlog could ask for hundreds of
113/// megabytes in a single handler, and the resulting OOM/restart looked like a
114/// healthy machine that simply fell over.
115///
116/// `read` / `starred` come from the same `LEFT JOIN` that filters the view, so a
117/// caller does not have to fetch the whole unread or starred set a second time
118/// just to decorate the rows it is showing.
119///
120/// [`Entry`] is still the right type for the single-entry reader, which is the
121/// one surface that genuinely needs the body.
122#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
123pub struct EntryListRow {
124    pub id: i64,
125    pub feed_id: i64,
126    /// Feed-native GUID — used to match a cached entry against a PDS saved record.
127    pub guid: String,
128    pub url: Option<String>,
129    pub title: Option<String>,
130    pub published: Option<String>,
131    /// This DID's read bit. `false` when there is no `entry_state` row at all.
132    pub read: bool,
133    /// This DID's star bit. `false` when there is no `entry_state` row at all.
134    pub starred: bool,
135}
136
137/// Which list [`list_entries`] (and its siblings) is producing.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ListView {
140    /// No `entry_state` row for this DID, or one with `read = 0`.
141    Unread,
142    /// An `entry_state` row with `starred = 1`.
143    Starred,
144    /// Every subscribed entry, read or not.
145    All,
146}
147
148impl ListView {
149    /// The `WHERE` fragment that selects this view, given `s` as the per-DID
150    /// `entry_state` LEFT JOIN alias.
151    fn predicate(self) -> &'static str {
152        match self {
153            // An entry with no state row is unread — hence LEFT JOIN + COALESCE
154            // rather than a join that would drop never-touched entries.
155            ListView::Unread => "COALESCE(s.read, 0) = 0",
156            ListView::Starred => "COALESCE(s.starred, 0) = 1",
157            ListView::All => "1 = 1",
158        }
159    }
160}
161
162/// Per-`(did, entry)` read/star state — the fast in-session working copy that the
163/// batched flusher later syncs to the PDS as a per-feed read cursor.
164#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
165pub struct EntryState {
166    pub did: String,
167    pub entry_id: i64,
168    pub read: bool,
169    pub starred: bool,
170    pub updated_at: String,
171}
172
173/// Per-`(did, feed_url)` read cursor — the local mirror of the PDS
174/// `community.lexicon.rss.readState` record plus flush bookkeeping.
175///
176/// `read_ids` / `unread_ids` are stored as JSON arrays of entry ids (the two
177/// bounded exception sets around the `read_through` high-water-mark); `dirty`
178/// marks that local `entry_state` has changed since the last PDS flush.
179#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
180pub struct ReadCursor {
181    pub did: String,
182    pub feed_url: String,
183    /// High-water-mark (RFC3339): every entry seen/published `<=` this is read.
184    pub read_through: Option<String>,
185    /// JSON array of entry ids newer than `read_through` that are also read.
186    pub read_ids: String,
187    /// JSON array of entry ids older than `read_through` explicitly kept unread.
188    pub unread_ids: String,
189    /// Set when `entry_state` changed since the last flush (debounce trigger).
190    pub dirty: bool,
191    /// Whether this cursor's `readState` record has been CREATED in the PDS yet.
192    /// The first flush of a feed must emit an `applyWrites#create` (an `#update`
193    /// errors on a record that does not pre-exist, and applyWrites is atomic
194    /// per-repo, so one not-yet-created cursor would drop the whole DID batch).
195    /// Flipped to `true` on the flush that creates it.
196    #[sqlx(default)]
197    pub pds_created: bool,
198    pub updated_at: String,
199}
200
201/// The `network_stat` key the relay adoption probe writes under.
202///
203/// Lives here, beside [`NetworkStat`], because **both** the writer (the
204/// scheduler's probe, compiled into the binary) and the reader (`web::about`,
205/// compiled into the library) name it — a literal in either place would be two
206/// strings free to drift apart.
207pub const ADOPTION_STAT_KEY: &str = "adoption.subscription";
208
209/// One relay's observation of how many repos hold a collection
210/// (`design/NETWORK-SPEC.md` §4.3). A projection: droppable, rebuildable from
211/// the network, and never read by anything on the reading path.
212#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
213pub struct NetworkStat {
214    /// The metric key, e.g. [`ADOPTION_STAT_KEY`].
215    pub key: String,
216    /// The relay base URL the number came from.
217    pub source: String,
218    /// The observed count.
219    pub value: i64,
220    /// Set when the probe hit its page cap: the value is a floor, not a count.
221    pub truncated: bool,
222    /// When the observation was taken (RFC3339, UTC).
223    pub observed_at: String,
224}
225
226/// New-feed payload for [`upsert_feed`] (id is assigned by SQLite).
227#[derive(Debug, Clone, Default)]
228pub struct NewFeed {
229    pub url: String,
230    pub title: Option<String>,
231    pub site_url: Option<String>,
232    pub etag: Option<String>,
233    pub last_modified: Option<String>,
234    pub last_polled: Option<String>,
235    pub next_poll: Option<String>,
236}
237
238/// New-entry payload for [`insert_entries`] (id is assigned by SQLite,
239/// `fetched_at` defaults to "now" when not supplied).
240#[derive(Debug, Clone, Default)]
241pub struct NewEntry {
242    pub guid: String,
243    pub url: Option<String>,
244    pub title: Option<String>,
245    pub author: Option<String>,
246    pub published: Option<String>,
247    /// Already-sanitized HTML. `insert_entries` stores it as given; the
248    /// reader re-cleans it at render regardless (see [`Entry::content_html`]).
249    pub content_html: Option<String>,
250    /// Optional explicit fetch time (RFC3339); defaults to now if `None`.
251    pub fetched_at: Option<String>,
252    /// When the entry is already stored, keep its `content_html` rather than
253    /// overwrite it with this one's. Set when this poll could not sanitize the
254    /// body in time (#226): a body that exists must not be replaced by none. A
255    /// new entry is inserted with `content_html` as given.
256    pub keep_stored_content: bool,
257}
258
259/// The SQLite schema. Idempotent — safe to run on every startup.
260///
261/// `feeds`/`entries` are the shared cache; `entry_state`/`read_cursor` are
262/// per-DID. Indices cover the scheduler's due-feed query, the read/unread list
263/// query, and the flusher's dirty-cursor scan.
264const SCHEMA: &str = r#"
265PRAGMA foreign_keys = ON;
266
267CREATE TABLE IF NOT EXISTS feeds (
268    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
269    url                TEXT NOT NULL UNIQUE,
270    title              TEXT,
271    site_url           TEXT,
272    etag               TEXT,
273    last_modified      TEXT,
274    last_polled        TEXT,
275    next_poll          TEXT,
276    consecutive_errors INTEGER NOT NULL DEFAULT 0,
277    last_error_kind    TEXT,
278    last_error         TEXT,
279    -- What the poller does with this row; see `feed::FeedKind`. Written by the
280    -- Rust side at insert so SQL never re-derives it from the URL.
281    kind               TEXT NOT NULL DEFAULT 'rss'
282);
283CREATE INDEX IF NOT EXISTS idx_feeds_next_poll ON feeds (next_poll);
284-- NOTE: `idx_feeds_kind` is created in `apply_migrations`, AFTER `kind` is
285-- ensured, for the same reason as the `intended_did` indexes below. 0.3.9 put
286-- it here and crash-looped production on its first boot: on an existing volume
287-- the CREATE TABLE above is a no-op, so the column does not exist yet.
288
289CREATE TABLE IF NOT EXISTS entries (
290    id           INTEGER PRIMARY KEY AUTOINCREMENT,
291    feed_id      INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
292    guid         TEXT NOT NULL,
293    url          TEXT,
294    title        TEXT,
295    author       TEXT,
296    published    TEXT,
297    content_html TEXT,
298    fetched_at   TEXT NOT NULL,
299    UNIQUE (feed_id, guid)
300);
301-- The list and prev/next queries order on `COALESCE(published, fetched_at)`
302-- (#187). Measured on the real query shape (LEFT JOIN entry_state, EXISTS
303-- sub_ref), this index serves them as well as it served bare `published`; a
304-- `(feed_id, published, fetched_at)` replacement was tried and was ~3.8x
305-- slower on the default prev/next query, which never chose it (review of #213).
306CREATE INDEX IF NOT EXISTS idx_entries_feed_published ON entries (feed_id, published);
307
308CREATE TABLE IF NOT EXISTS entry_state (
309    did        TEXT NOT NULL,
310    entry_id   INTEGER NOT NULL REFERENCES entries (id) ON DELETE CASCADE,
311    read       INTEGER NOT NULL DEFAULT 0,
312    starred    INTEGER NOT NULL DEFAULT 0,
313    updated_at TEXT NOT NULL,
314    PRIMARY KEY (did, entry_id)
315);
316CREATE INDEX IF NOT EXISTS idx_entry_state_did_read ON entry_state (did, read);
317-- The FK child key. `entry_id` is the TRAILING column of the primary key, so
318-- without this index it is not the leading column of anything and SQLite must
319-- FULL SCAN entry_state for EVERY row deleted from `entries` to service
320-- ON DELETE CASCADE.
321--
322-- That is not theoretical. Measured on 600k entry_state rows: 500 deletes took
323-- 10.3s and 2,000 took 38.3s, against a busy_timeout of 5s — so any retention
324-- sweep removing more than roughly 260 entries made every concurrent writer
325-- (star, mark-read, OAuth session write) fail with SQLITE_BUSY. With this index
326-- the same 32,850-row delete goes from ~10 minutes to 0.7s.
327--
328-- It also fixes the per-feed trim, whose starred-sparing subquery scans
329-- entry_state on every poll of every feed and scales with TOTAL rows across all
330-- users rather than with the feed being trimmed (2ms -> 21ms at 1M rows).
331CREATE INDEX IF NOT EXISTS idx_entry_state_entry_id ON entry_state (entry_id);
332
333-- Per-DID subscription projection. The shared `feeds`/`entries` cache is
334-- deduped by URL and NOT owned by any single DID; `sub_ref` records which
335-- feeds a given DID actually subscribes to (mirrored from the caller's PDS
336-- subscription set on every resolve/sync). Every entry/feed READ and every
337-- read/star MUTATION is scoped through this table so one user can never read
338-- or mutate another user's cached articles. Rows are refreshed by
339-- `replace_sub_refs`.
340CREATE TABLE IF NOT EXISTS sub_ref (
341    did     TEXT NOT NULL,
342    feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
343    PRIMARY KEY (did, feed_id)
344);
345CREATE INDEX IF NOT EXISTS idx_sub_ref_feed ON sub_ref (feed_id);
346
347CREATE TABLE IF NOT EXISTS read_cursor (
348    did          TEXT NOT NULL,
349    feed_url     TEXT NOT NULL,
350    read_through TEXT,
351    read_ids     TEXT NOT NULL DEFAULT '[]',
352    unread_ids   TEXT NOT NULL DEFAULT '[]',
353    dirty        INTEGER NOT NULL DEFAULT 0,
354    pds_created  INTEGER NOT NULL DEFAULT 0,
355    updated_at   TEXT NOT NULL,
356    PRIMARY KEY (did, feed_url)
357);
358CREATE INDEX IF NOT EXISTS idx_read_cursor_dirty ON read_cursor (did, dirty);
359-- The (did, feed_url) PRIMARY KEY can't serve a feed_url-only lookup (did is the
360-- leading column). The retention path's orphan-cursor cleanup filters cursors by
361-- feed_url alone, so give it an index.
362CREATE INDEX IF NOT EXISTS idx_read_cursor_feed_url ON read_cursor (feed_url);
363
364CREATE TABLE IF NOT EXISTS beta_access (
365    did              TEXT PRIMARY KEY,
366    handle           TEXT,
367    granted_by       TEXT NOT NULL,
368    granted_at       INTEGER NOT NULL,
369    invite_code_used TEXT
370);
371
372CREATE TABLE IF NOT EXISTS invite_codes (
373    code         TEXT PRIMARY KEY,
374    creator_did  TEXT NOT NULL,
375    status       TEXT NOT NULL,
376    invitee_did  TEXT,
377    -- The follower DID a bot-minted claim was minted FOR (recorded at mint time,
378    -- distinct from `invitee_did` which is stamped at redeem). This is the
379    -- server-side idempotency key: a second `POST /bot/claims` for a DID that
380    -- already holds an outstanding active code returns the SAME code instead of
381    -- minting a duplicate, so a bot-host state loss cannot re-mint per follower.
382    intended_did TEXT,
383    created_at   INTEGER NOT NULL,
384    expires_at   INTEGER NOT NULL,
385    redeemed_at  INTEGER
386);
387CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
388-- NOTE: the `intended_did` indexes are created in `apply_migrations`, AFTER the
389-- `intended_did` column is ensured. They MUST NOT live in this base SCHEMA batch:
390-- on an existing pre-0.2.2 volume the `CREATE TABLE IF NOT EXISTS invite_codes`
391-- above is a no-op (the table already exists without `intended_did`), so a
392-- `CREATE INDEX ... (intended_did, ...)` here would fail with "no such column"
393-- and crash-loop the boot before migrations ever run.
394
395-- Network-observation counters (v0.2.8, design/NETWORK-SPEC.md §4.3). One row
396-- per (metric, relay): the adoption probe records how many repos a given relay
397-- has INDEXED as holding a collection. We store the COUNT, never the DID list —
398-- persisting the DIDs would build a durable register of "accounts that use an
399-- RSS reader" on our disk for a feature whose only output is an integer. This
400-- table is a PROJECTION, not a source of truth: `DROP TABLE` it and the next
401-- probe rebuilds it, and nothing in the reader path reads it. Bounded forever at
402-- (metrics × relays) rows, so it never interacts with the DB-size watermark.
403CREATE TABLE IF NOT EXISTS network_stat (
404    key         TEXT NOT NULL,   -- e.g. 'adoption.subscription'
405    source      TEXT NOT NULL,   -- the relay host the number came from
406    value       INTEGER NOT NULL,
407    truncated   INTEGER NOT NULL DEFAULT 0,
408    observed_at TEXT NOT NULL,
409    PRIMARY KEY (key, source)
410);
411-- Repo-operation timings, for comparing the two backends across a CUTOVER.
412--
413-- Persisted rather than held in memory because flipping the backend requires a
414-- restart, and an in-memory table would lose the outgoing backend's numbers at
415-- exactly the moment they became worth comparing against. These rows are the
416-- only reason a "side by side" table can show two backends at once.
417--
418-- `repo_timing` is a bounded window of recent samples (pruned per backend+op);
419-- `repo_timing_total` carries the all-time counts, which must survive that
420-- pruning or a long-running backend would appear to have served fewer calls
421-- than a fresh one.
422CREATE TABLE IF NOT EXISTS repo_timing (
423    id       INTEGER PRIMARY KEY AUTOINCREMENT,
424    backend  TEXT    NOT NULL,
425    op       TEXT    NOT NULL,
426    micros   INTEGER NOT NULL,
427    ok       INTEGER NOT NULL,
428    at       INTEGER NOT NULL
429);
430
431CREATE INDEX IF NOT EXISTS idx_repo_timing_key ON repo_timing(backend, op, id);
432
433CREATE TABLE IF NOT EXISTS repo_timing_total (
434    backend    TEXT    NOT NULL,
435    op         TEXT    NOT NULL,
436    ok_count   INTEGER NOT NULL DEFAULT 0,
437    err_count  INTEGER NOT NULL DEFAULT 0,
438    PRIMARY KEY (backend, op)
439);
440
441"#;
442
443/// RFC3339 timestamp for "now" (UTC, seconds precision), used as the default for
444/// `*_at` columns. Uses `chrono` to match the shape written by [`crate::feed`]
445/// and [`crate::web`] (one timestamp format across the whole crate).
446fn now_rfc3339() -> String {
447    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
448}
449
450/// Open the per-DID SQLite cache described by [`Config`] (its `db_path`), run
451/// schema creation, and return the pool.
452///
453/// This is the entrypoint `main` calls: it derives the sqlx SQLite URL from the
454/// configured filesystem path and delegates to [`init_url`]. Kept separate from
455/// [`init_url`] so tests can open an in-memory database directly.
456pub async fn init(config: &Config) -> Result<Pool> {
457    // sqlx wants a `sqlite://<path>` URL; build it from the configured path.
458    let db_url = format!("sqlite://{}", config.db_path.display());
459    init_url(&db_url).await
460}
461
462/// Open (creating if needed) the SQLite database at `db_url`, run schema
463/// creation, and return a connection pool.
464///
465/// `db_url` is a sqlx SQLite URL, e.g. `sqlite://featherreader.db` or
466/// `sqlite::memory:` for an ephemeral in-memory database. The file is created
467/// if it does not exist; WAL journaling is enabled for on-disk databases and
468/// foreign keys are enforced on every connection.
469/// Ceiling the WAL is truncated back to at each checkpoint.
470///
471/// The WAL lives on the same volume as the database and counts against the same
472/// 1 GB, but nothing bounded it: SQLite grows the WAL to fit the largest
473/// transaction it has ever seen and never shrinks it again without this limit.
474const WAL_SIZE_LIMIT_BYTES: i64 = 64 * 1024 * 1024;
475
476pub async fn init_url(db_url: &str) -> Result<Pool> {
477    // An in-memory DB must run on a SINGLE connection: each `:memory:` connection
478    // is a *separate* database, and a multi-connection in-memory pool can also
479    // deadlock a writer against an idle pooled connection's shared-cache table
480    // read-lock (SQLITE_LOCKED, code 262 — which `busy_timeout` does NOT retry;
481    // seen as a Linux-only flaky failure in redeem_code's UPDATE). On-disk uses
482    // WAL + a 5-connection pool as normal.
483    let is_memory = db_url.contains(":memory:");
484    let mut opts = SqliteConnectOptions::from_str(db_url)
485        .with_context(|| format!("invalid sqlite url: {db_url}"))?
486        .create_if_missing(true)
487        .foreign_keys(true);
488    // WAL is a no-op / unsupported for :memory:, so only request it on-disk.
489    if !is_memory {
490        opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
491        // **Incremental auto-vacuum, set at CREATION.**
492        //
493        // `auto_vacuum` was read by `reclaim` and never set anywhere, so every
494        // database ran in SQLite's default NONE mode and `reclaim` always took
495        // its full-`VACUUM` branch — daily, and again after every prune. A full
496        // VACUUM needs free disk roughly equal to the live database because it
497        // writes a whole new file, which is exactly what is scarce under the
498        // disk pressure that triggers a sweep; on a ~700 MiB database on a 1 GB
499        // volume it cannot complete at all.
500        //
501        // This pragma only takes effect on a database with no tables yet, so it
502        // fixes NEW instances permanently and does nothing to existing ones —
503        // deliberately. Changing it on a populated database requires running the
504        // very full VACUUM that is unsafe here, so that is a separate,
505        // operator-invoked step: see [`migrate_to_incremental_vacuum`].
506        opts = opts.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental);
507        // Truncate the WAL back down at checkpoints. Without a limit, a WAL
508        // grown once by a single large transaction stays that size for the life
509        // of the file — permanently occupying volume the watermark is trying to
510        // protect. The batched retention deletes keep transactions small now, so
511        // in practice the WAL should rarely approach this; the limit is what
512        // makes that a guarantee rather than a hope.
513        opts = opts.pragma("journal_size_limit", WAL_SIZE_LIMIT_BYTES.to_string());
514    }
515    // Under a concurrent write burst (the poller's insert_entries tx racing the
516    // web layer's mark_read / redeem_code tx) SQLite would otherwise return
517    // SQLITE_BUSY the instant a writer holds the lock. `busy_timeout` makes a
518    // blocked connection WAIT (retry) for up to this long before erroring, so
519    // short lock contention resolves transparently instead of surfacing a
520    // spurious failure. Mirrors the OAuth sidecar's `stores.ts`
521    // (`PRAGMA busy_timeout = 5000`). 5 s is comfortably above any single
522    // FeatherReader transaction.
523    opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
524    // Quiet sqlx's per-statement query logging.
525    opts = opts.log_statements(tracing::log::LevelFilter::Debug);
526
527    let pool = SqlitePoolOptions::new()
528        // Keep at least one connection alive so an in-memory DB isn't dropped
529        // (each `:memory:` connection is a *separate* database otherwise).
530        .min_connections(1)
531        .max_connections(if is_memory { 1 } else { 5 })
532        .connect_with(opts)
533        .await
534        .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
535
536    init_schema(&pool).await?;
537    Ok(pool)
538}
539
540/// Run the idempotent schema creation. Split out so callers/tests can (re)apply
541/// it against an already-open pool.
542pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
543    // `execute` runs the multi-statement batch (sqlite allows this).
544    sqlx::query(SCHEMA)
545        .execute(pool)
546        .await
547        .context("failed to create schema")?;
548    apply_migrations(pool).await?;
549    // The Rust OAuth client's tables live in the same database. Created
550    // UNCONDITIONALLY, not only when that backend is selected: the tables are
551    // empty and harmless under the sidecar, whereas creating them lazily would
552    // make the first request after a cutover flip fail with "no such table" --
553    // at the one moment nobody wants to discover a migration was missed.
554    crate::oauth::store::init_schema(pool)
555        .await
556        .context("failed to create the OAuth schema")?;
557    Ok(())
558}
559
560/// Apply additive, idempotent migrations to bring an EXISTING database up to the
561/// current [`SCHEMA`]. `CREATE TABLE IF NOT EXISTS` never alters a table that
562/// already exists, so a column added to a shipped table must be back-filled here
563/// (SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe `table_info` first).
564async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
565    // feeds.consecutive_errors — drives the exponential poll backoff. Older DBs
566    // predate the column; add it (defaulting to 0) if it is missing.
567    ensure_column(
568        pool,
569        "PRAGMA table_info(feeds)",
570        "consecutive_errors",
571        "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
572    )
573    .await?;
574    // feeds.last_error_kind / feeds.last_error — WHY a feed is failing, not just
575    // how often. `consecutive_errors` recorded a count and nothing else, which is
576    // how a systematic defect across sixty feeds stayed indistinguishable from
577    // sixty dead blogs until #159: every one of them was our own 304 handling,
578    // and the table could not say so. Nullable, and NULL once a poll succeeds.
579    ensure_column(
580        pool,
581        "PRAGMA table_info(feeds)",
582        "last_error_kind",
583        "ALTER TABLE feeds ADD COLUMN last_error_kind TEXT",
584    )
585    .await?;
586    ensure_column(
587        pool,
588        "PRAGMA table_info(feeds)",
589        "last_error",
590        "ALTER TABLE feeds ADD COLUMN last_error TEXT",
591    )
592    .await?;
593
594    // feeds.kind — what the poller does with a row. Older DBs predate it and
595    // get `'rss'` from the DEFAULT, which is wrong for the at:// rows, so it is
596    // back-filled below.
597    ensure_column(
598        pool,
599        "PRAGMA table_info(feeds)",
600        "kind",
601        "ALTER TABLE feeds ADD COLUMN kind TEXT NOT NULL DEFAULT 'rss'",
602    )
603    .await?;
604    // Here, not in the base SCHEMA batch: it names a column that only exists
605    // after the line above. See the note beside `idx_feeds_next_poll`.
606    sqlx::query("CREATE INDEX IF NOT EXISTS idx_feeds_kind ON feeds (kind)")
607        .execute(pool)
608        .await
609        .context("creating idx_feeds_kind")?;
610
611    // **Re-derived in Rust, every row, every start — not translated once.**
612    //
613    // `kind` is a pure function of `url`, so it is a cache, and a cache that is
614    // only ever written forward goes stale the moment the function changes.
615    // The first version of this was a one-directional SQL `UPDATE` carrying its
616    // own copy of the rule as a string predicate: it agreed with
617    // `FeedKind::of` on the day it was written, translated `rss` to
618    // `publication` and never the reverse, and had no way to notice either
619    // fact. Asking the Rust classifier about every row instead means the column
620    // cannot disagree with the one function that defines it, and a future kind
621    // — or a corrected rule — needs no migration of its own.
622    //
623    // Cheap by shape, not by assumption: it writes only rows that are actually
624    // wrong, so the steady state is a single scan of a table that holds one row
625    // per subscribed feed.
626    let rows = sqlx::query("SELECT id, url, kind FROM feeds")
627        .fetch_all(pool)
628        .await
629        .context("reading feeds to re-derive kind")?;
630    let mut tx = pool.begin().await.context("begin kind re-derivation")?;
631    let (mut to_pollable, mut to_unpollable, mut unreadable) = (0u64, 0u64, 0u64);
632    for row in rows {
633        // **A row we cannot read is skipped, not fatal.** This runs on the boot
634        // path, so anything that returns `Err` here is the difference between a
635        // wedged poller and a site that will not start. A `url` or `kind` that
636        // is not decodable as text takes no opinion from us and keeps whatever
637        // it has; every reader downstream already treats an unknown kind as
638        // unpollable. Nothing sqlx writes produces such a row — it binds `&str`
639        // as TEXT everywhere — so reaching this means the file was edited by
640        // hand, which is exactly when refusing to boot is the least helpful
641        // thing to do.
642        let (Ok(id), Ok(url), Ok(kind)) = (
643            row.try_get::<i64, _>("id"),
644            row.try_get::<String, _>("url"),
645            row.try_get::<String, _>("kind"),
646        ) else {
647            unreadable += 1;
648            continue;
649        };
650        let want = crate::feed::FeedKind::of(&url);
651        if kind == want.as_str() {
652            continue;
653        }
654        sqlx::query("UPDATE feeds SET kind = ?1 WHERE id = ?2")
655            .bind(want.as_str())
656            .bind(id)
657            .execute(&mut *tx)
658            .await
659            .with_context(|| format!("re-deriving kind for feed {id}"))?;
660        if crate::feed::FeedKind::POLLABLE.contains(&want) {
661            to_pollable += 1;
662        } else {
663            // **Declaring a row unpollable orphans its poll state, so clear
664            // it.** A backoff horizon and an error count belong to a feed the
665            // scheduler selects; on a row it will never select again they are
666            // dead, and not inert. They are hidden from `/stats` and the cause
667            // histogram, which filter on kind, so they rot unseen — and if a
668            // later rule change makes the row pollable again it resumes at
669            // `backoff_for(n)` on an `n` earned under a classification that no
670            // longer applies, which for seven prior errors is a first retry ten
671            // hours out instead of five minutes.
672            //
673            // Narrower than the step below, deliberately: that one clears only
674            // rows we never polled, on the grounds that a real feed's history
675            // still means something. This clears rows whose history can no
676            // longer mean anything, because nothing will add to it or act on
677            // it.
678            sqlx::query(
679                "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, \
680                 last_error = NULL, next_poll = NULL WHERE id = ?1",
681            )
682            .bind(id)
683            .execute(&mut *tx)
684            .await
685            .with_context(|| format!("clearing orphaned poll state for feed {id}"))?;
686            to_unpollable += 1;
687        }
688    }
689    tx.commit().await.context("commit kind re-derivation")?;
690    // Quiet in the steady state, which is every boot where nothing changed.
691    // Split by direction because the two mean opposite things to an operator:
692    // one puts feeds back in the poller's queue, the other takes them out of
693    // every figure `/stats` reports.
694    if to_pollable > 0 || to_unpollable > 0 {
695        tracing::info!(
696            to_pollable,
697            to_unpollable,
698            "feeds.kind re-derived from the URL"
699        );
700    }
701    if unreadable > 0 {
702        tracing::warn!(
703            unreadable,
704            "feeds rows are not readable as text; their kind was left alone"
705        );
706    }
707
708    // **Clear failure counts on rows we never actually polled.**
709    //
710    // `due_feeds` excludes them by kind (see `feed::FeedKind`) — but rows
711    // subscribed before the scheme check already carry the errors OUR refusal
712    // produced. Left alone they would count as failing forever, since no poll
713    // that could clear them will ever be scheduled.
714    //
715    // A real feed's history is untouched: it still means something. The
716    // recorded reason goes with the count: a row with no errors must carry no
717    // reason, which is what `reset_feed_errors` promises and a test asserts.
718    //
719    // **Idempotent by predicate.** `last_polled` is set only by a successful
720    // poll — `bump_feed_errors` never touches it — so `last_polled IS NULL`
721    // selects exactly the rows whose every error came from our own refusal.
722    // A row a wired standard.site reader has fetched once keeps its later
723    // failures across restarts; a row that only ever failed under the refusal
724    // is cleared at every boot, including after a rollback to a build that
725    // polled it. A version stamp was the first design and left that rollback
726    // case a permanent hole (re-accumulated errors hidden by the filters,
727    // never cleared). Trade-off accepted: a publication that has never once
728    // succeeded restarts its backoff at the floor on every boot.
729    sqlx::query(sqlx::AssertSqlSafe(format!(
730        "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
731         WHERE kind NOT IN ({POLLABLE_KINDS_SQL}) AND last_polled IS NULL \
732         AND consecutive_errors > 0"
733    )))
734    .execute(pool)
735    .await
736    .context("clearing error counts on unpollable at:// feeds")?;
737    // read_cursor.pds_created — tracks whether a feed's readState record has been
738    // created in the PDS, so the first flush emits a `create` (not a bare
739    // `update`, which errors on a not-yet-existing record). Older DBs predate it.
740    ensure_column(
741        pool,
742        "PRAGMA table_info(read_cursor)",
743        "pds_created",
744        "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
745    )
746    .await?;
747    // invite_codes.intended_did — the follower DID a bot claim was minted for, the
748    // server-side idempotency key for `POST /bot/claims`. Older DBs (before the
749    // follow→invite bot) predate it; it is nullable (browser/admin-minted codes
750    // leave it NULL).
751    ensure_column(
752        pool,
753        "PRAGMA table_info(invite_codes)",
754        "intended_did",
755        "ALTER TABLE invite_codes ADD COLUMN intended_did TEXT",
756    )
757    .await?;
758    // Indexes on `intended_did` are created HERE (not in the base SCHEMA batch)
759    // because they reference a column that only exists after the migration above.
760    // On an existing pre-0.2.2 DB the `invite_codes` CREATE TABLE is a no-op, so
761    // an index on `intended_did` in SCHEMA would fail before this migration ran
762    // (that was blocker B1). All are `IF NOT EXISTS`, so re-running is a no-op.
763    //
764    // Look up an outstanding active claim by the DID it was minted for (bot dedupe).
765    sqlx::query(
766        "CREATE INDEX IF NOT EXISTS idx_invite_codes_intended \
767         ON invite_codes (intended_did, status)",
768    )
769    .execute(pool)
770    .await
771    .context("creating idx_invite_codes_intended")?;
772    // Enforce at MOST one outstanding active claim per intended DID. This makes
773    // the bot's dedupe check-then-mint race-safe: two concurrent `POST /bot/claims`
774    // for the same follower can no longer both insert an active code (the second
775    // INSERT hits this unique constraint). Partial so it only constrains active
776    // bot-minted rows — redeemed/expired rows and NULL-intended (admin/browser)
777    // codes are unconstrained. (Blocker/should-fix S4.)
778    sqlx::query(
779        "CREATE UNIQUE INDEX IF NOT EXISTS idx_invite_codes_intended_active \
780         ON invite_codes (intended_did) \
781         WHERE intended_did IS NOT NULL AND status = 'active'",
782    )
783    .execute(pool)
784    .await
785    .context("creating idx_invite_codes_intended_active")?;
786
787    // **Re-date rows stored with a future date before ingest refused them**
788    // (#188). An item dated 2999 that has since left its feed is never polled
789    // again to be corrected, so it would stay first in the list and survive the
790    // per-feed cap. Cleared, `fetched_at` dates it. The same bound ingest uses;
791    // a no-op once there are none.
792    let ceiling = (chrono::Utc::now()
793        + chrono::Duration::days(crate::feed::MAX_FUTURE_PUBLISHED_DAYS))
794    .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
795    sqlx::query("UPDATE entries SET published = NULL WHERE published > ?1")
796        .bind(&ceiling)
797        .execute(pool)
798        .await
799        .context("clearing stored future publication dates")?;
800    Ok(())
801}
802
803/// Add a column via `alter_sql` iff `info_sql` (a `PRAGMA table_info(<table>)`)
804/// does not already report `column`. All three SQL args are hard-coded internal
805/// literals (never user input), so they are safe `&'static str`s — the table name
806/// can't be a bind parameter in `PRAGMA`, which is why they're passed whole.
807async fn ensure_column(
808    pool: &SqlitePool,
809    info_sql: &'static str,
810    column: &str,
811    alter_sql: &'static str,
812) -> Result<()> {
813    let rows = sqlx::query(info_sql)
814        .fetch_all(pool)
815        .await
816        .with_context(|| format!("{info_sql} failed"))?;
817    let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
818    if !present {
819        sqlx::query(alter_sql)
820            .execute(pool)
821            .await
822            .with_context(|| format!("adding column {column} via {alter_sql}"))?;
823    }
824    Ok(())
825}
826
827/// Insert a feed by URL, or update its metadata if the URL already exists.
828/// Returns the feed's row id (existing or newly assigned).
829///
830/// EVERY updatable column is COALESCE'd, so `None` means "leave alone" for all
831/// of them and a partial upsert cannot clobber a field it never mentioned.
832///
833/// `etag`/`last_modified` were the exception until now, and the exception was
834/// silently disabling conditional GET for the entire instance. `set_next_poll`
835/// in the scheduler supplies only `url` + `next_poll` after every single poll,
836/// which wrote both validators back to NULL — so `304 Not Modified` was
837/// unreachable and every feed was re-downloaded, re-parsed, re-sanitised and
838/// re-inserted in full, hourly, forever. `feed::touch_polled` had discovered the
839/// same trap earlier and worked around it in its own caller by re-reading the
840/// row first; that local fix is what let the next caller walk into it.
841///
842/// A stale validator is not a hazard: if the origin no longer issues one it
843/// ignores our `If-None-Match` and returns `200`, and if it still matches then
844/// `304` was the correct answer anyway.
845pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
846    let row = sqlx::query(
847        r#"
848        INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll, kind)
849        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
850        ON CONFLICT (url) DO UPDATE SET
851            title         = COALESCE(excluded.title, feeds.title),
852            site_url      = COALESCE(excluded.site_url, feeds.site_url),
853            etag          = COALESCE(excluded.etag, feeds.etag),
854            last_modified = COALESCE(excluded.last_modified, feeds.last_modified),
855            last_polled   = COALESCE(excluded.last_polled, feeds.last_polled),
856            next_poll     = COALESCE(excluded.next_poll, feeds.next_poll),
857            -- Not COALESCE: `kind` is derived from the URL, and `excluded`
858            -- always carries the current answer. Preserving the stored value
859            -- would make a row's classification a function of when it was
860            -- first subscribed rather than of what it is.
861            kind          = excluded.kind
862        RETURNING id
863        "#,
864    )
865    .bind(&feed.url)
866    .bind(&feed.title)
867    .bind(&feed.site_url)
868    .bind(&feed.etag)
869    .bind(&feed.last_modified)
870    .bind(&feed.last_polled)
871    .bind(&feed.next_poll)
872    // Decided once, in Rust, and never re-derived from the URL by SQL.
873    .bind(crate::feed::FeedKind::of(&feed.url).as_str())
874    .fetch_one(pool)
875    .await
876    .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
877
878    Ok(row.get::<i64, _>("id"))
879}
880
881/// Fetch a feed by its URL, if present.
882pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
883    let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
884        .bind(url)
885        .fetch_optional(pool)
886        .await
887        .with_context(|| format!("get_feed_by_url failed for {url}"))?;
888    Ok(feed)
889}
890
891/// The `kind` values the scheduler may select, as a SQL list.
892///
893/// Pinned against [`crate::feed::FeedKind::POLLABLE`] by
894/// `the_sql_kind_list_matches_the_rust_one` — a literal here and a slice there
895/// is exactly the drift the column was introduced to end, so the two are
896/// asserted equal rather than trusted. Wiring the standard.site reader means
897/// changing both, and that test is what makes forgetting one a failure.
898pub(crate) const POLLABLE_KINDS_SQL: &str = "'rss', 'publication'";
899
900/// The `kind` values the retention **window** applies to, as a SQL list.
901///
902/// Pinned against [`crate::feed::FeedKind::AGED`] by
903/// `the_sql_aged_kind_list_matches_the_rust_one`, for the same reason
904/// [`POLLABLE_KINDS_SQL`] is pinned against `POLLABLE`.
905///
906/// Why a publication is not in it: see `FeedKind::AGED`. Measured — a 14-day
907/// window stored zero rows from every real publication tried, because their
908/// newest documents were 109 to 241 days old.
909pub(crate) const AGED_KINDS_SQL: &str = "'rss'";
910
911/// How many rows the poller will never select — the capacity consumed by feeds
912/// that cannot be fetched.
913///
914/// Rendered on `/admin/metrics` because the global ceiling counts these rows
915/// (see [`count_feeds`]) while `/stats` does not, so without this the cap could
916/// be reached with every public number saying otherwise.
917pub async fn unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
918    sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
919        "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
920    )))
921    .fetch_one(pool)
922    .await
923    .context("counting unpollable feeds")
924}
925
926/// How many rows the poller will never select. Test-only: the assertion the
927/// at:// tests make, spelled once, against the predicate the code uses.
928#[cfg(test)]
929pub(crate) async fn count_unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
930    sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
931        "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
932    )))
933    .fetch_one(pool)
934    .await
935    .context("counting unpollable feeds")
936}
937
938/// The scheduler's hot query: feeds whose `next_poll` is due (`<= as_of`, or
939/// never polled), oldest-due first. `as_of` is an RFC3339 timestamp.
940pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
941    let sql = format!(
942        r#"
943        SELECT * FROM feeds
944        WHERE (next_poll IS NULL OR next_poll <= ?1)
945          -- Only POLLABLE kinds are due; an `unsupported` row is skipped, not
946          -- failed. The why lives on `feed::FeedKind::POLLABLE`.
947          AND kind IN ({POLLABLE_KINDS_SQL})
948        ORDER BY next_poll IS NOT NULL, next_poll ASC
949        LIMIT ?2
950        "#
951    );
952    let feeds = sqlx::query_as::<_, Feed>(sqlx::AssertSqlSafe(sql))
953        .bind(as_of)
954        .bind(limit)
955        .fetch_all(pool)
956        .await
957        .context("due_feeds failed")?;
958    Ok(feeds)
959}
960
961/// One failing feed, named, for the ADMIN view only.
962///
963/// The public `/stats` histogram is counts by cause and nothing else, by that
964/// page's own stated promise. This is the other half: the coarse bucket
965/// `fetch` covers DNS failure, timeout, SSRF refusal and — as #159 proved —
966/// this reader's own bugs, so a count alone cannot separate "the publishers are
967/// gone" from "we are broken". The detail can, and it lives behind the
968/// `ALLOWED_DIDS` gate where per-feed data is already permitted.
969#[derive(Debug, Clone, PartialEq, Eq)]
970pub struct FailingFeed {
971    pub url: String,
972    pub consecutive_errors: i64,
973    /// `None` for a row that predates the column — see the `unknown` bucket.
974    pub kind: Option<String>,
975    pub detail: Option<String>,
976}
977
978/// Every currently-failing feed with its recorded cause, worst first.
979///
980/// **Admin-gated callers only.** Bounded because this renders into one response
981/// and a large instance should not be able to make that response unbounded.
982pub async fn failing_feeds(pool: &SqlitePool, limit: i64) -> Result<Vec<FailingFeed>> {
983    // The same exclusion as `poll_health`: a row the poller never selects
984    // can never have its errors cleared, so listing it here would pin it to
985    // the top of the operator's page for good.
986    let sql = format!(
987        r#"
988        SELECT url, consecutive_errors, last_error_kind, last_error
989        FROM feeds
990        WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
991        ORDER BY consecutive_errors DESC, url ASC
992        LIMIT ?1
993        "#
994    );
995    let rows: Vec<(String, i64, Option<String>, Option<String>)> =
996        sqlx::query_as(sqlx::AssertSqlSafe(sql))
997            .bind(limit)
998            .fetch_all(pool)
999            .await
1000            .context("listing failing feeds")?;
1001    Ok(rows
1002        .into_iter()
1003        .map(|(url, consecutive_errors, kind, detail)| FailingFeed {
1004            url,
1005            consecutive_errors,
1006            kind,
1007            detail,
1008        })
1009        .collect())
1010}
1011
1012/// Cap on the stored `last_error` detail. Remote text on an unattended path.
1013const MAX_ERROR_DETAIL_CHARS: usize = 300;
1014
1015/// Record a poll FAILURE for a feed: bump its `consecutive_errors` by one and
1016/// return the NEW count. The count drives the exponential poll backoff, so a
1017/// persistently-failing feed spaces its retries out toward the ceiling instead of
1018/// hammering the 5-minute floor forever. Reset to 0 by [`reset_feed_errors`] on
1019/// any success/304.
1020pub async fn bump_feed_errors(
1021    pool: &SqlitePool,
1022    url: &str,
1023    kind: crate::feed::FailureKind,
1024    detail: &str,
1025) -> Result<i64> {
1026    let row = sqlx::query(
1027        "UPDATE feeds SET consecutive_errors = consecutive_errors + 1, \
1028         last_error_kind = ?2, last_error = ?3 \
1029         WHERE url = ?1 RETURNING consecutive_errors",
1030    )
1031    .bind(url)
1032    .bind(kind.as_str())
1033    // **Truncated.** This is a remote server's error text on an unattended path;
1034    // an upstream that returns a megabyte of prose should cost a bounded row,
1035    // not an unbounded one.
1036    .bind(
1037        detail
1038            .chars()
1039            .take(MAX_ERROR_DETAIL_CHARS)
1040            .collect::<String>(),
1041    )
1042    .fetch_optional(pool)
1043    .await
1044    .with_context(|| format!("bump_feed_errors failed for {url}"))?;
1045    // If the feed row somehow vanished, treat it as the first error.
1046    Ok(row
1047        .map(|r| r.get::<i64, _>("consecutive_errors"))
1048        .unwrap_or(1))
1049}
1050
1051/// Schedule a feed's next poll `delay` from now.
1052///
1053/// Lived as a private fn in the scheduler until `web::add_subscription`
1054/// needed it too: a poll taken off the scheduler settled the error columns but
1055/// never rescheduled, so a re-subscribed working feed stayed parked on its stale
1056/// backoff horizon for up to 24h. One implementation, two callers.
1057///
1058/// `upsert_feed` COALESCEs unset fields, so supplying only url + next_poll bumps
1059/// the schedule without clobbering title/validators/last_polled.
1060pub async fn set_next_poll(pool: &SqlitePool, url: &str, delay: std::time::Duration) -> Result<()> {
1061    let next = chrono::Utc::now()
1062        + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::hours(1));
1063    let next_poll = next.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1064    let nf = NewFeed {
1065        url: url.to_string(),
1066        next_poll: Some(next_poll),
1067        ..Default::default()
1068    };
1069    upsert_feed(pool, &nf).await.map(|_| ())
1070}
1071
1072/// [`due_feeds`] for one kind only. The RSS poller and the publication poller
1073/// each select their own, so neither can be held by the other's reads.
1074pub async fn due_feeds_of_kind(
1075    pool: &SqlitePool,
1076    as_of: &str,
1077    kind: crate::feed::FeedKind,
1078    limit: i64,
1079) -> Result<Vec<Feed>> {
1080    sqlx::query_as::<_, Feed>(
1081        "SELECT * FROM feeds WHERE (next_poll IS NULL OR next_poll <= ?1) AND kind = ?2 \
1082         ORDER BY next_poll IS NOT NULL, next_poll ASC LIMIT ?3",
1083    )
1084    .bind(as_of)
1085    .bind(kind.as_str())
1086    .bind(limit)
1087    .fetch_all(pool)
1088    .await
1089    .context("due_feeds_of_kind failed")
1090}
1091
1092/// Spread the first polls of never-polled `kind` rows across `spread`.
1093///
1094/// **Admitting a kind to the poller makes every row of it due at once.**
1095/// `due_feeds` sorts `next_poll IS NULL` ahead of every dated row, and rows that
1096/// were never pollable have no schedule, so the boot that admits them hands the
1097/// poller a block that outranks every regular feed — including an overdue one —
1098/// until it drains (`feed::FeedKind::POLLABLE` documents the measurement). This
1099/// gives each such row its own slot in `[now, now + spread)`, in id order, so
1100/// the block arrives as a trickle. Rows that have been polled, or already carry
1101/// a schedule, are untouched. Returns how many rows were scheduled.
1102pub async fn stagger_unscheduled(
1103    pool: &SqlitePool,
1104    kind: crate::feed::FeedKind,
1105    spread: std::time::Duration,
1106) -> Result<u64> {
1107    let ids: Vec<i64> = sqlx::query_scalar(
1108        "SELECT id FROM feeds WHERE kind = ?1 AND next_poll IS NULL AND last_polled IS NULL \
1109         ORDER BY id",
1110    )
1111    .bind(kind.as_str())
1112    .fetch_all(pool)
1113    .await
1114    .context("listing unscheduled feeds to stagger")?;
1115    if ids.is_empty() {
1116        return Ok(0);
1117    }
1118    let now = chrono::Utc::now();
1119    let spread = chrono::Duration::from_std(spread).unwrap_or_else(|_| chrono::Duration::hours(1));
1120    let n = ids.len() as i32;
1121    let mut tx = pool.begin().await.context("begin stagger")?;
1122    for (i, id) in ids.iter().enumerate() {
1123        // Evenly spaced, the first due now: slot i of n across the spread.
1124        let at = now + spread * i as i32 / n;
1125        let next_poll = at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1126        sqlx::query("UPDATE feeds SET next_poll = ?1 WHERE id = ?2 AND next_poll IS NULL")
1127            .bind(next_poll)
1128            .bind(id)
1129            .execute(&mut *tx)
1130            .await
1131            .context("staggering a feed's first poll")?;
1132    }
1133    tx.commit().await.context("commit stagger")?;
1134    Ok(ids.len() as u64)
1135}
1136
1137/// Reset a feed's `consecutive_errors` to 0 after a successful poll (or a 304).
1138/// A no-op UPDATE if the row is missing.
1139pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
1140    // **Clears the reason too.** A stale `last_error` on a feed that is now
1141    // succeeding is worse than none: it is the aggregate below reporting a cause
1142    // that stopped applying, which is the failure this column exists to end.
1143    sqlx::query(
1144        "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
1145         WHERE url = ?1",
1146    )
1147    .bind(url)
1148    .execute(pool)
1149    .await
1150    .with_context(|| format!("reset_feed_errors failed for {url}"))?;
1151    Ok(())
1152}
1153
1154/// The feeds a `did` currently subscribes to, per its `sub_ref` projection.
1155/// Used by the PDS-unreachable fallback in `resolve_subscriptions` to render
1156/// the sidebar from the caller's OWN last-known subscriptions (fail closed)
1157/// rather than every cached feed.
1158pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
1159    let feeds = sqlx::query_as::<_, Feed>(
1160        r#"
1161        SELECT f.* FROM feeds f
1162        JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
1163        ORDER BY f.title IS NULL, f.title, f.url
1164        "#,
1165    )
1166    .bind(did)
1167    .fetch_all(pool)
1168    .await
1169    .with_context(|| format!("feeds_for_did failed for {did}"))?;
1170    Ok(feeds)
1171}
1172
1173/// The feed ids a `did` currently subscribes to (its `sub_ref` rows).
1174///
1175/// **Not bounded by `max_subs_per_did`.** This comment used to claim it was, and
1176/// callers leaned on that: the cap is enforced on the ADD and OPML paths only,
1177/// never on read, and `sub_ref` is rebuilt from whatever the PDS returns — which
1178/// any client can write to, bounded only by the list-pages ceiling at 20,000
1179/// records. A claim in a comment is not a bound.
1180///
1181/// Callers must therefore not assume a small result. The one that cared — the
1182/// list views' scope filter — no longer does: it passes the whole set as a
1183/// single `json_each` bind rather than one SQL placeholder per feed.
1184pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
1185    let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
1186        .bind(did)
1187        .fetch_all(pool)
1188        .await
1189        .with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
1190    Ok(ids)
1191}
1192
1193/// The number of feeds a `did` currently subscribes to (its `sub_ref` rows).
1194/// Backs the per-DID subscription cap enforced at the add/import paths.
1195pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
1196    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
1197        .bind(did)
1198        .fetch_one(pool)
1199        .await
1200        .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
1201    Ok(n)
1202}
1203
1204/// The number of distinct feeds in the shared cache. Backs the global feeds
1205/// ceiling checked before a brand-new feed is inserted.
1206pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
1207    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
1208        .fetch_one(pool)
1209        .await
1210        .context("count_feeds failed")?;
1211    Ok(n)
1212}
1213
1214/// The **used** size of the SQLite database, in bytes, computed as
1215/// `(page_count - freelist_count) * page_size`. Backs the DB-size watermark that
1216/// disables new polling.
1217///
1218/// Subtracting the freelist is what keeps the watermark from latching the poller
1219/// off: `page_count` counts pages the file has *allocated*, including ones freed
1220/// by a `DELETE` but not yet returned to the OS (SQLite keeps them on a freelist
1221/// for reuse and never shrinks the file without a VACUUM). Counting only the
1222/// live pages means a retention prune (which frees pages, see [`reclaim`]) is
1223/// actually reflected here, so the watermark can drop back below its threshold
1224/// and polling resumes. Cheap (three `PRAGMA` reads); works for file + `:memory:`.
1225///
1226/// **The WAL counts too.** This is the number the DB-size watermark compares
1227/// against a VOLUME size, and in WAL mode the `-wal` sidecar sits on that same
1228/// volume — so leaving it out understated exactly the quantity the watermark
1229/// exists to bound. It is added back below, best-effort: a WAL that cannot be
1230/// stat'd contributes zero rather than failing the check, since a watermark that
1231/// errors is worse than one that is slightly optimistic.
1232pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
1233    let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
1234        .fetch_one(pool)
1235        .await
1236        .context("PRAGMA page_count failed")?;
1237    let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1238        .fetch_one(pool)
1239        .await
1240        .context("PRAGMA freelist_count failed")?;
1241    let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
1242        .fetch_one(pool)
1243        .await
1244        .context("PRAGMA page_size failed")?;
1245    let used_pages = page_count.saturating_sub(freelist_count).max(0);
1246    Ok(used_pages
1247        .saturating_mul(page_size)
1248        .saturating_add(wal_bytes(pool).await))
1249}
1250
1251/// Bytes the write-ahead log currently occupies on the database's volume, or 0
1252/// when there is no WAL (`:memory:`, non-WAL journal modes) or it cannot be
1253/// stat'd. Best-effort by design — see [`db_size_bytes`].
1254async fn wal_bytes(pool: &SqlitePool) -> i64 {
1255    let Some(path) = main_db_path(pool).await else {
1256        return 0;
1257    };
1258    std::fs::metadata(format!("{path}-wal"))
1259        .map(|m| i64::try_from(m.len()).unwrap_or(i64::MAX))
1260        .unwrap_or(0)
1261}
1262
1263/// The main database's file path, or `None` for `:memory:`.
1264async fn main_db_path(pool: &SqlitePool) -> Option<String> {
1265    sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main' AND file <> ''")
1266        .fetch_optional(pool)
1267        .await
1268        .ok()
1269        .flatten()
1270}
1271
1272/// Freelist pages returned to the OS per `incremental_vacuum` step. At a 4 KiB
1273/// page that is ~8 MiB per batch — a short lock hold, and few enough steps that
1274/// a large reclaim is tens of statements rather than thousands.
1275const RECLAIM_BATCH_PAGES: i64 = 2_000;
1276
1277/// Backstop on the reclaim loop. `freelist_count == 0` and the no-progress check
1278/// are the real terminators; at [`RECLAIM_BATCH_PAGES`] this is 2M pages (~8 GiB),
1279/// far past anything a 1 GB volume holds.
1280const RECLAIM_MAX_BATCHES: usize = 1_000;
1281
1282/// Reclaim freed pages so the database file (and its used-page accounting) can
1283/// actually shrink after a retention/prune sweep DELETEs rows.
1284///
1285/// Without this, a `DELETE` moves pages onto the freelist but never shrinks the
1286/// file — so once the DB-size watermark trips and retention deletes rows,
1287/// `page_count` stays put and [`db_size_bytes`] (well, its raw `page_count`
1288/// form) would never fall back below the watermark, latching the poller off
1289/// forever. Call this AFTER a prune. It uses incremental vacuum when the database
1290/// is in `auto_vacuum = INCREMENTAL` mode (cheap, no full rewrite), and otherwise
1291/// falls back to a full `VACUUM`.
1292pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
1293    match auto_vacuum_mode(pool).await? {
1294        AutoVacuum::Incremental => {
1295            // **Bounded, like the deletes that precede it.**
1296            //
1297            // With no page argument this reclaims the ENTIRE freelist in one
1298            // transaction — handing straight back the write-lock hold that
1299            // batching the retention deletes had just won, immediately after the
1300            // sweep that created the freelist in the first place. Same shape as
1301            // `delete_in_batches`: a bounded unit of work, then an explicit
1302            // hand-off so a waiting writer actually gets in.
1303            // **Both early exits are LOUD.** Failing to reclaim is the failure
1304            // this function exists to prevent: `db_size_bytes` stays high,
1305            // `poll_due_once` keeps polling paused, and `/stats` says "paused"
1306            // with nothing anywhere saying reclaim gave up. Exiting silently
1307            // makes that indistinguishable from a sweep that had nothing to do.
1308            let mut drained = true;
1309            for batch in 0..RECLAIM_MAX_BATCHES {
1310                let before: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1311                    .fetch_one(pool)
1312                    .await
1313                    .context("PRAGMA freelist_count failed")?;
1314                if before == 0 {
1315                    break;
1316                }
1317                // A PRAGMA argument cannot be a bind parameter, and this one is
1318                // a `const i64` declared in this file — nothing external reaches
1319                // it.
1320                sqlx::query(sqlx::AssertSqlSafe(format!(
1321                    "PRAGMA incremental_vacuum({RECLAIM_BATCH_PAGES})"
1322                )))
1323                .execute(pool)
1324                .await
1325                .context("PRAGMA incremental_vacuum failed")?;
1326                let after: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1327                    .fetch_one(pool)
1328                    .await
1329                    .context("PRAGMA freelist_count failed")?;
1330                // No progress: either nothing more can be freed, or a
1331                // concurrent retention delete pushed `after` back up. Both leave
1332                // pages allocated, which is what an operator needs to know.
1333                //
1334                // This comment previously also claimed "a long-lived WAL read
1335                // snapshot pins freelist pages". MEASURED AND FALSE: with a
1336                // reader holding a snapshot taken BEFORE the delete, the
1337                // freelist still drained 2000 → 0 and `page_count` halved. A
1338                // reader blocks the CHECKPOINT, not the incremental vacuum — so
1339                // that case exits this loop through the SUCCESS path and is
1340                // reported below, not here.
1341                if after >= before {
1342                    tracing::warn!(
1343                        freelist_pages = after,
1344                        batches_run = batch + 1,
1345                        "reclaim stopped making progress with pages still on the \
1346                         freelist; the file will not shrink and the DB-size watermark \
1347                         may stay engaged until the next sweep"
1348                    );
1349                    drained = false;
1350                    break;
1351                }
1352                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1353                // `after > 0` matters: the final batch can drain the freelist
1354                // completely, in which case the loop reaches here having
1355                // SUCCEEDED and would otherwise log "with pages still on the
1356                // freelist" for an empty one — and suppress the success line.
1357                // This is the same guard `delete_in_batches` carries, and the
1358                // same defect it already had; reproduced here verbatim by
1359                // copying the loop's shape without its condition.
1360                if batch + 1 == RECLAIM_MAX_BATCHES && after > 0 {
1361                    tracing::warn!(
1362                        batches_run = batch + 1,
1363                        freelist_pages = after,
1364                        "reclaim hit its batch backstop with pages still on the \
1365                         freelist; the rest waits for the next sweep"
1366                    );
1367                    drained = false;
1368                }
1369            }
1370            if drained {
1371                tracing::debug!("reclaim: freelist drained");
1372            }
1373        }
1374        // SQLite already returns freed pages at every commit in this mode.
1375        // Nothing to do, and a VACUUM would be pure cost.
1376        AutoVacuum::Full => {}
1377        // **Deliberately a no-op, where this used to run a full VACUUM.**
1378        //
1379        // Nothing ever set `auto_vacuum`, so NONE was the mode every database
1380        // actually ran in — which made the full-VACUUM branch the one that
1381        // always executed, daily and after every prune. A full VACUUM writes a
1382        // complete second copy of the database, so it needs free disk roughly
1383        // equal to the live file; that is precisely what is missing under the
1384        // disk pressure that triggers a retention sweep. `poll_due_once` already
1385        // carries a comment explaining this danger and removed VACUUM from the
1386        // poll path — while leaving it in the retention path that runs under the
1387        // same pressure.
1388        //
1389        // Skipping it does NOT latch the DB-size watermark, which is the failure
1390        // this branch was written to prevent: `db_size_bytes` subtracts the
1391        // freelist, so a DELETE lowers the measured size with no VACUUM at all.
1392        // What is lost is the FILE shrinking, and the fix for that is to get the
1393        // database into INCREMENTAL mode — see `migrate_to_incremental_vacuum`,
1394        // which is operator-invoked precisely because it needs the one operation
1395        // that is unsafe to attempt automatically.
1396        AutoVacuum::None => {
1397            tracing::warn!(
1398                "auto_vacuum=NONE: skipping reclaim. Freed pages stay allocated and \
1399                 the file will not shrink. Run `featherreader --migrate-auto-vacuum` \
1400                 once, while the volume has headroom, to move this database to \
1401                 INCREMENTAL mode."
1402            );
1403        }
1404    }
1405
1406    // Truncate the WAL as well. It lives on the same volume and is counted by
1407    // `db_size_bytes`, so reclaiming database pages while leaving a WAL grown by
1408    // the sweep that just ran would give back part of the space and hold the
1409    // rest. Worth doing even in the NONE branch above, where it is the only
1410    // space this function can return at all.
1411    //
1412    // **A blocked checkpoint is the real way the file stays big, so it warns.**
1413    //
1414    // Measured: with a reader holding an open snapshot, `incremental_vacuum`
1415    // still drains the freelist and `page_count` halves — but the main file
1416    // stayed at 16.4 MB until the reader released and the checkpoint could
1417    // truncate it to 8.2 MB. So a reader does not stop the reclaim; it stops the
1418    // SHRINK. That is the operator-visible outcome (`db_size_bytes` counts the
1419    // WAL, and the watermark is compared against a volume), and it used to be
1420    // reported at `debug!` — below any realistic filter — while the loop above
1421    // warned loudly about a mechanism that does not actually occur.
1422    //
1423    // Not an error: the next sweep checkpoints again once the reader is gone.
1424    match checkpoint_wal(pool).await {
1425        Ok(true) => {}
1426        Ok(false) => tracing::warn!(
1427            "the WAL could not be truncated after reclaim (busy: a concurrent reader \
1428             OR writer held it); the freed pages are gone but the file has not \
1429             shrunk yet, and the DB-size watermark may stay engaged until the next \
1430             sweep"
1431        ),
1432        Err(err) => tracing::warn!(%err, "wal checkpoint after reclaim failed"),
1433    }
1434    Ok(())
1435}
1436
1437/// Run a truncating WAL checkpoint. `Ok(false)` means SQLite declined because a
1438/// reader held the WAL.
1439///
1440/// **The busy case is a ROW, not an error.** `PRAGMA wal_checkpoint` returns
1441/// `(busy, log_frames, checkpointed_frames)` and sets `busy = 1` when it could
1442/// not run — measured: `(1, 3, 3)` with one open read transaction versus
1443/// `(0, 0, 0)` without. So `if let Err(..)` never fires on the case it was
1444/// written for, and a caller that depends on the WAL actually being truncated
1445/// (the migration's size report does) would silently get the untruncated one.
1446async fn checkpoint_wal<'e, E>(conn: E) -> Result<bool>
1447where
1448    E: sqlx::Executor<'e, Database = sqlx::Sqlite>,
1449{
1450    let row: (i64, i64, i64) = sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
1451        .fetch_one(conn)
1452        .await
1453        .context("PRAGMA wal_checkpoint(TRUNCATE) failed")?;
1454    Ok(row.0 == 0)
1455}
1456
1457/// A database's `auto_vacuum` mode.
1458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1459pub enum AutoVacuum {
1460    /// 0 — freed pages stay on the freelist; only a full `VACUUM` returns them.
1461    None,
1462    /// 1 — SQLite returns freed pages at every commit.
1463    Full,
1464    /// 2 — freed pages are returned on demand by `PRAGMA incremental_vacuum`.
1465    Incremental,
1466}
1467
1468/// Read the database's `auto_vacuum` mode.
1469pub async fn auto_vacuum_mode(pool: &SqlitePool) -> Result<AutoVacuum> {
1470    let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1471        .fetch_one(pool)
1472        .await
1473        .context("PRAGMA auto_vacuum failed")?;
1474    Ok(match mode {
1475        1 => AutoVacuum::Full,
1476        2 => AutoVacuum::Incremental,
1477        _ => AutoVacuum::None,
1478    })
1479}
1480
1481/// What [`migrate_to_incremental_vacuum`] did.
1482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1483pub enum VacuumMigration {
1484    /// Already in a mode that reclaims; nothing was run.
1485    NotNeeded(AutoVacuum),
1486    /// Refused: not enough free space on the volume to hold the rebuilt file.
1487    ///
1488    /// `file_bytes` is the on-disk size, reported alongside the live-page figure
1489    /// the requirement is computed from, because on exactly this population
1490    /// (`NONE` mode, large freelist) the two differ a lot and only one of them
1491    /// matches what `ls -l` says.
1492    RefusedNoHeadroom {
1493        needed: u64,
1494        available: u64,
1495        file_bytes: Option<u64>,
1496    },
1497    /// Ran the pragma + full VACUUM; the database is now INCREMENTAL.
1498    Migrated {
1499        bytes_before: i64,
1500        bytes_after: i64,
1501        file_before: Option<u64>,
1502        file_after: Option<u64>,
1503    },
1504}
1505
1506/// Move a populated database from `auto_vacuum = NONE` to `INCREMENTAL`.
1507///
1508/// **Why this cannot happen at boot.** SQLite ignores `PRAGMA auto_vacuum` on a
1509/// database that already has tables unless it is followed by a full `VACUUM`,
1510/// which rebuilds the file. So the migration off the dangerous mode requires the
1511/// exact operation that is dangerous — a genuine chicken-and-egg, and the reason
1512/// this is an explicit operator step run when the volume has headroom rather
1513/// than something attempted lazily on a machine that is already under pressure.
1514///
1515/// Doing it automatically would also reintroduce the failure shape T2.1 just
1516/// removed: a boot-time VACUUM that cannot complete on a full volume, on a
1517/// supervisor that restarts the machine whenever a child exits, is a crash loop.
1518///
1519/// `available_bytes` is the caller's measurement of free space on the database's
1520/// volume (`None` where the platform cannot report it). The check is a refusal,
1521/// not a warning: starting a VACUUM that cannot finish wastes I/O on a box that
1522/// has none to spare. `VACUUM` itself is atomic — an interrupted one leaves the
1523/// original database intact — so the risk being managed here is wasted work and
1524/// a long write-lock hold, not corruption.
1525pub async fn migrate_to_incremental_vacuum(
1526    pool: &SqlitePool,
1527    available_bytes: Option<u64>,
1528) -> Result<VacuumMigration> {
1529    let mode = auto_vacuum_mode(pool).await?;
1530    if mode != AutoVacuum::None {
1531        return Ok(VacuumMigration::NotNeeded(mode));
1532    }
1533
1534    // **The on-disk file, not the live-page count.** `db_size_bytes` subtracts
1535    // the freelist, and the population this migration exists for is precisely
1536    // `auto_vacuum = NONE` with a large freelist — so the live size can be far
1537    // smaller than the file, and an operator comparing the refusal message to
1538    // `ls -l` would not trust either number. The rebuild is sized by the LIVE
1539    // pages (that is what gets copied), but the report shows both.
1540    let bytes_before = db_size_bytes(pool).await?;
1541    let file_before = main_db_file_bytes(pool).await;
1542    // Resolved BEFORE a connection is acquired below. Asking the pool for
1543    // anything while holding one of its connections deadlocks a saturated pool —
1544    // and a single-connection pool is always saturated. The first version of the
1545    // temp-directory block did exactly that, and because `main_db_path` swallows
1546    // errors into `None` it did not even fail loudly: it stalled for the full
1547    // acquire timeout and then silently skipped setting the directory, which is
1548    // the one thing it exists to do.
1549    let temp_dir = main_db_path(pool).await.and_then(|p| {
1550        std::path::Path::new(&p)
1551            .parent()
1552            .map(std::path::Path::to_path_buf)
1553    });
1554    let needed = (bytes_before.max(0) as u64).saturating_mul(2);
1555    if let Some(available) = available_bytes {
1556        if available < needed {
1557            return Ok(VacuumMigration::RefusedNoHeadroom {
1558                needed,
1559                available,
1560                file_bytes: file_before,
1561            });
1562        }
1563    }
1564
1565    // **One connection for both statements.**
1566    //
1567    // `PRAGMA auto_vacuum` on a populated database is connection-scoped INTENT
1568    // that only takes effect when the SAME connection runs the VACUUM. Issued
1569    // against the pool they can land on different connections, and the rebuild
1570    // then happens in NONE mode — caught by the `ensure!` below, so loud rather
1571    // than silent, but the operator has paid a whole-file rewrite for nothing on
1572    // a box chosen for being short of disk.
1573    let mut conn = pool
1574        .acquire()
1575        .await
1576        .context("acquiring a connection for the auto_vacuum migration")?;
1577
1578    // **Put the temp copy on the DATABASE's volume.**
1579    //
1580    // A VACUUM rebuilds through a temporary database, and the headroom check
1581    // above measures the data volume. `temp_store = FILE` alone only chooses
1582    // file-over-memory; it does NOT choose which filesystem, so the temp copy
1583    // resolved via `SQLITE_TMPDIR`/`TMPDIR`/`/var/tmp`/`/tmp` — the container
1584    // rootfs. The check could pass on `/data` and the VACUUM still hit
1585    // `SQLITE_FULL`, or fill the rootfs out from under Caddy.
1586    //
1587    // `temp_store_directory` is the pragma that actually decides — measured:
1588    // setting it alone moves the file, setting `temp_store = FILE` alone does
1589    // not. It is deprecated but fully functional in the bundled SQLite (3.51.3,
1590    // built without `SQLITE_OMIT_DEPRECATED`), and there is no non-deprecated
1591    // equivalent reachable from a connection.
1592    //
1593    // `temp_store = FILE` is kept as belt-and-braces rather than because it is
1594    // needed: this build's compile-time default is already FILE, but a build
1595    // defaulting to MEMORY would silently ignore the directory entirely.
1596    //
1597    // Note it sets the PROCESS-GLOBAL `sqlite3_temp_directory`, not connection
1598    // state — visible on other connections and other pools. Harmless because
1599    // this function is only reachable from the one-shot `--migrate-auto-vacuum`
1600    // CLI path, which does nothing else.
1601    sqlx::query("PRAGMA temp_store = FILE")
1602        .execute(&mut *conn)
1603        .await
1604        .context("PRAGMA temp_store = FILE failed")?;
1605    if let Some(dir) = temp_dir.clone() {
1606        // The path comes from SQLite's own `database_list`, not from a caller.
1607        let quoted = dir.display().to_string().replace('\'', "''");
1608        if let Err(err) = sqlx::query(sqlx::AssertSqlSafe(format!(
1609            "PRAGMA temp_store_directory = '{quoted}'"
1610        )))
1611        .execute(&mut *conn)
1612        .await
1613        {
1614            // Not fatal: the VACUUM can still succeed if the default temp
1615            // location happens to have room. But the headroom check is then
1616            // measuring the wrong filesystem, so say so.
1617            tracing::warn!(
1618                %err, dir = %dir.display(),
1619                "could not point SQLite's temp storage at the database volume; the \
1620                 headroom check may not cover where the VACUUM actually writes"
1621            );
1622        }
1623    }
1624
1625    // Order matters: the pragma records the INTENT, and the VACUUM is what
1626    // actually rewrites the file in the new mode. Reversed, the VACUUM would
1627    // rebuild in NONE mode and the pragma would then be ignored again.
1628    sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
1629        .execute(&mut *conn)
1630        .await
1631        .context("PRAGMA auto_vacuum = INCREMENTAL failed")?;
1632    sqlx::query("VACUUM")
1633        .execute(&mut *conn)
1634        .await
1635        .context("VACUUM failed during the auto_vacuum migration")?;
1636
1637    // Fold the WAL back in BEFORE measuring. A VACUUM in WAL mode writes the
1638    // entire rebuilt database through the WAL, which keeps that high-water size
1639    // until a truncating checkpoint — and `db_size_bytes` now counts the WAL. So
1640    // the one number this command reports read as "the migration doubled my
1641    // database", which is the opposite of what it did.
1642    match checkpoint_wal(&mut *conn).await {
1643        Ok(true) => {}
1644        // Reported, because the size this function returns is computed straight
1645        // after and would otherwise read as "the migration doubled my database"
1646        // with nothing saying why.
1647        Ok(false) => tracing::warn!(
1648            "the WAL could not be truncated (a concurrent reader OR writer held it), \
1649             so the reported size below includes it"
1650        ),
1651        Err(err) => tracing::warn!(%err, "post-migration wal checkpoint failed"),
1652    }
1653
1654    // Verified on the HELD connection, then released before anything that goes
1655    // back to the pool. The test pool is single-connection, and so is a
1656    // production pool that happens to be saturated — reaching for a second one
1657    // while still holding the first is a deadlock waiting for a busy moment.
1658    let after_raw: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1659        .fetch_one(&mut *conn)
1660        .await
1661        .context("PRAGMA auto_vacuum failed after the migration")?;
1662    drop(conn);
1663    let after = match after_raw {
1664        1 => AutoVacuum::Full,
1665        2 => AutoVacuum::Incremental,
1666        _ => AutoVacuum::None,
1667    };
1668    anyhow::ensure!(
1669        after == AutoVacuum::Incremental,
1670        "the auto_vacuum migration ran but the database is still in {after:?} mode"
1671    );
1672    Ok(VacuumMigration::Migrated {
1673        bytes_before,
1674        bytes_after: db_size_bytes(pool).await?,
1675        file_before,
1676        file_after: main_db_file_bytes(pool).await,
1677    })
1678}
1679
1680/// Size of the main database FILE on disk, or `None` for `:memory:` / an
1681/// unstattable path. Distinct from [`db_size_bytes`], which reports live pages.
1682async fn main_db_file_bytes(pool: &SqlitePool) -> Option<u64> {
1683    let path = main_db_path(pool).await?;
1684    std::fs::metadata(path).ok().map(|m| m.len())
1685}
1686
1687/// Insert a batch of entries for `feed_id`, deduping on `(feed_id, guid)`, then
1688/// trim the feed to at most [`crate::config`]-configured `max_entries_per_feed`
1689/// rows (newest by published date) so one firehose feed can't fill the disk.
1690///
1691/// On a GUID collision the existing entry is updated in place (title/url/body
1692/// may have changed on re-fetch) rather than duplicated. Runs in one
1693/// transaction. Returns the number of rows processed.
1694///
1695/// `max_entries_per_feed <= 0` disables the per-feed trim.
1696pub async fn insert_entries(
1697    pool: &SqlitePool,
1698    feed_id: i64,
1699    entries: &[NewEntry],
1700    max_entries_per_feed: i64,
1701) -> Result<u64> {
1702    let mut tx = pool.begin().await.context("begin insert_entries tx")?;
1703    let mut count: u64 = 0;
1704    for e in entries {
1705        let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
1706        let res = sqlx::query(
1707            r#"
1708            INSERT INTO entries
1709                (feed_id, guid, url, title, author, published, content_html, fetched_at)
1710            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1711            ON CONFLICT (feed_id, guid) DO UPDATE SET
1712                url          = excluded.url,
1713                title        = excluded.title,
1714                author       = excluded.author,
1715                published    = excluded.published,
1716                content_html = CASE WHEN ?9 THEN entries.content_html
1717                                    ELSE excluded.content_html END
1718            "#,
1719        )
1720        .bind(feed_id)
1721        .bind(&e.guid)
1722        .bind(&e.url)
1723        .bind(&e.title)
1724        .bind(&e.author)
1725        .bind(&e.published)
1726        .bind(&e.content_html)
1727        .bind(&fetched_at)
1728        .bind(e.keep_stored_content)
1729        .execute(&mut *tx)
1730        .await
1731        .with_context(|| format!("insert entry {} failed", e.guid))?;
1732        count += res.rows_affected();
1733    }
1734
1735    // Entries-per-feed cap: keep only the newest `max_entries_per_feed` rows for
1736    // this feed, deleting the overflow in the same transaction. "Newest" is
1737    // COALESCE(published, fetched_at) so an UNDATED entry (NULL published) sorts
1738    // by when we fetched it (NOT NULL) rather than always sorting LAST and being
1739    // evicted first — otherwise a feed of undated items would trim its freshest
1740    // rows. This bounds a single firehose/misbehaving feed's storage footprint
1741    // independent of the global retention sweep. `<= 0` disables it.
1742    //
1743    // The bound is `2 * max_entries_per_feed`, not `max_entries_per_feed`: the
1744    // newest N by date, plus up to N starred. See the sparing subquery below.
1745    if max_entries_per_feed > 0 {
1746        sqlx::query(
1747            r#"
1748            DELETE FROM entries
1749            WHERE feed_id = ?1
1750              AND id NOT IN (
1751                  SELECT id FROM entries
1752                  WHERE feed_id = ?1
1753                  ORDER BY COALESCE(published, fetched_at) DESC, id DESC
1754                  LIMIT ?2
1755              )
1756              -- Starred entries survive the per-feed trim, exactly as they
1757              -- survive the retention sweep. This predicate was added to the
1758              -- sweep and NOT here, which left the documented guarantee
1759              -- ("starred entries are never evicted") false — and made this
1760              -- path, which runs on every poll of every feed rather than daily,
1761              -- the main producer of the very "starred but not cached" case the
1762              -- saved-record rendering exists to paper over.
1763              --
1764              -- The sparing is BOUNDED and SCOPED, and both matter:
1765              --
1766              -- Bounded, because the first version spared every starred row
1767              -- without limit, which did not weaken the cap so much as remove
1768              -- it — measured at cap=5 with 50 starred rows, 55 survived, 11x
1769              -- the cap. That is the same unbounded-sparing mistake the
1770              -- retention hard ceiling was added to fix, reintroduced in the
1771              -- other sweep. Worst case is now cap + cap.
1772              --
1773              -- Scoped, because `SELECT entry_id FROM entry_state WHERE
1774              -- starred = 1` reads EVERY starred row on the instance, for every
1775              -- poll of every feed — cost scaling with total users rather than
1776              -- with the feed being trimmed.
1777              AND id NOT IN (
1778                  SELECT e2.id FROM entries e2
1779                  WHERE e2.feed_id = ?1
1780                    AND EXISTS (
1781                        SELECT 1 FROM entry_state s
1782                        WHERE s.entry_id = e2.id AND s.starred = 1
1783                    )
1784                  ORDER BY COALESCE(e2.published, e2.fetched_at) DESC, e2.id DESC
1785                  LIMIT ?2
1786              )
1787            "#,
1788        )
1789        .bind(feed_id)
1790        .bind(max_entries_per_feed)
1791        .execute(&mut *tx)
1792        .await
1793        .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
1794    }
1795
1796    // Per-feed trim above may have DELETEd entries; their ids can linger in the
1797    // read_cursor exception sets (read_ids/unread_ids have no FK to entries), so
1798    // scrub the orphaned ids out of THIS feed's cursors in the same transaction.
1799    // Bounds id-set growth and keeps the flushed PDS record from referencing
1800    // entries that no longer exist. Scoped to the one feed for cheapness.
1801    if max_entries_per_feed > 0 {
1802        prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
1803    }
1804
1805    tx.commit().await.context("commit insert_entries tx")?;
1806    Ok(count)
1807}
1808
1809/// Make a feed due for polling on the next tick.
1810///
1811/// Used when a saved article is missing from the cache: if the reader still
1812/// subscribes to the feed, the poller may be able to bring the article back on
1813/// its own. Clearing `next_poll` is the whole mechanism — `due_feeds` treats
1814/// NULL as due — so this adds no synthetic rows and no special-case fetch path.
1815///
1816/// **Rate-limited by `not_polled_since`**, and that is not a nicety.
1817///
1818/// `due_feeds` treats a NULL `next_poll` as due immediately, so clearing it
1819/// unconditionally from a page handler meant every reload of the starred view
1820/// made those feeds due again — bypassing the poll interval entirely. That is
1821/// outbound amplification against third-party feed origins, and it lets one
1822/// reader's feeds monopolise a poll budget that is shared and already the
1823/// binding constraint on how many readers an instance can serve.
1824///
1825/// A feed polled within the window is left alone: if the article was not in the
1826/// feed a minute ago, another fetch now will not find it either. The nudge is
1827/// therefore worth at most one extra poll per feed per interval, which is the
1828/// cadence the poller already targets.
1829///
1830/// A no-op if the URL is not a known feed.
1831pub async fn mark_feed_due(
1832    pool: &SqlitePool,
1833    feed_url: &str,
1834    not_polled_since: &str,
1835) -> Result<()> {
1836    sqlx::query(
1837        "UPDATE feeds SET next_poll = NULL \
1838         WHERE url = ?1 AND (last_polled IS NULL OR last_polled < ?2)",
1839    )
1840    .bind(feed_url)
1841    .bind(not_polled_since)
1842    .execute(pool)
1843    .await
1844    .context("marking a feed due")?;
1845    Ok(())
1846}
1847
1848/// Delete entries whose age exceeds the retention window — the shared cache's
1849/// **rolling window** — except those a reader has starred or not yet read. "Age" is `COALESCE(published, fetched_at)` so an UNDATED
1850/// entry falls back to when it was fetched (never NULL) rather than being treated
1851/// as infinitely old. `entry_state` cascades via its `ON DELETE CASCADE` FK.
1852///
1853/// After the delete, orphaned entry ids are scrubbed out of every affected feed's
1854/// `read_cursor` exception sets (which have no FK to `entries`) so the id-sets do
1855/// not grow without bound and the flushed PDS record never references a vanished
1856/// entry. The caller (the retention sweep) should follow a non-zero return with
1857/// [`reclaim`] so freed pages return to the OS.
1858///
1859/// The two knobs are **independent**. `days == 0` disables the rolling window and
1860/// nothing else; `hard_days == 0` disables the ceiling and nothing else. Only
1861/// when both are off is this a no-op. Returns the number of entry rows deleted.
1862pub async fn prune_old_entries(
1863    pool: &SqlitePool,
1864    days: i64,
1865    hard_days: i64,
1866    publication_days: i64,
1867) -> Result<u64> {
1868    let now = chrono::Utc::now();
1869    // **A window too large to be a date disables that pass; it must not panic.**
1870    //
1871    // `chrono::Duration::days` and `DateTime - TimeDelta` both panic out of
1872    // range, and every knob here parses from a `u32` with no upper bound — so
1873    // `FEATHERREADER_RETENTION_DAYS=1000000000` (a plausible unit slip: seconds or
1874    // milliseconds typed into a days field) panicked this function. Measured:
1875    // anything past roughly 96 million days overflows, and `u32::MAX` does.
1876    //
1877    // The consequence was not a crash an operator would notice. This runs in a
1878    // spawned task, so tokio catches the panic and the retention sweeper simply
1879    // stops for the life of the process — silently, permanently, and taking the
1880    // release valve for `db_size_watermark_bytes` with it, which is the one thing
1881    // that stops polling for every reader.
1882    //
1883    // Disabled-not-panicking is also the answer `standard_site::ingest_floor`
1884    // already gives for the same input, and the two are supposed to mirror each
1885    // other — `Config::retention_for` exists to keep them agreeing. An
1886    // unrepresentable window meant "store everything" there and "panic" here.
1887    let at = |d: i64, knob: &str| -> Option<String> {
1888        let cutoff = chrono::Duration::try_days(d).and_then(|w| now.checked_sub_signed(w));
1889        if cutoff.is_none() {
1890            tracing::warn!(
1891                days = d,
1892                knob,
1893                "retention window is too large to express as a date; treating it as \
1894                 disabled for this sweep rather than failing the sweeper"
1895            );
1896        }
1897        cutoff.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
1898    };
1899
1900    let cutoff = (days > 0).then(|| at(days, "retention_days")).flatten();
1901    // **The third window, for the kinds age does not bound.** See
1902    // [`AGED_KINDS_SQL`] and `FeedKind::AGED`: a publication's entries are
1903    // bounded by COUNT (the per-feed trim), because a 14-day window stored zero
1904    // rows from every real publication measured. This is the backstop that keeps
1905    // "not aged out" from meaning "immortal" — the per-feed trim only runs when a
1906    // poll stores something, so rows belonging to a feed nobody polls any more
1907    // have nothing else to reap them.
1908    let publication_cutoff = (publication_days > 0)
1909        .then(|| at(publication_days, "publication_retention_days"))
1910        .flatten();
1911    // The ceiling only means anything if it is STRICTLY OLDER than the window.
1912    // At `0 < hard_days <= days` the two cutoffs coincide, and since the hard
1913    // delete spares nothing, it would delete exactly the rows the soft delete
1914    // exists to spare — turning the whole starred/unread exception into a no-op.
1915    // With no window at all (`days <= 0`) there is nothing to be inside of, so a
1916    // positive ceiling stands on its own.
1917    //
1918    // This used to be `hard_days.max(days)`, which clamps the wrong way: it made
1919    // `0` — the value an operator reaches for to turn a ceiling OFF, and the
1920    // documented "disabled" value for `RETENTION_DAYS` one line above it in the
1921    // same table — the single most destructive setting available, silently
1922    // purging starred and unread entries at the soft window. Measured: with
1923    // `days=14`, `hard=0` deleted a 30-day starred entry and a 30-day unread one.
1924    //
1925    // `<= 0` now means disabled, consistently with `days`. A contradictory
1926    // positive value is refused rather than reinterpreted downward.
1927    //
1928    // The ceiling is deliberately NOT gated on the window being enabled. It used
1929    // to be — this function returned on `days <= 0` before the ceiling was even
1930    // computed — which made `RETENTION_DAYS=0` mean "no window AND no ceiling":
1931    // the one configuration with no bound on the shared cache whatsoever. That
1932    // became load-bearing when the per-feed trim started sparing starred entries.
1933    // Before, the trim was a backstop for them; now nothing was. "I don't want a
1934    // rolling window" and "I don't want any ceiling at all" are different
1935    // statements, and are now configured separately.
1936    let hard_cutoff = if hard_days > 0 && (days <= 0 || hard_days > days) {
1937        at(hard_days, "retention_hard_days")
1938    } else {
1939        if hard_days > 0 {
1940            tracing::warn!(
1941                hard_days,
1942                days,
1943                "retention hard ceiling is not older than the retention window; \
1944                 ignoring it — set it above the window or to 0 to disable"
1945            );
1946        }
1947        None
1948    };
1949
1950    if cutoff.is_none() && hard_cutoff.is_none() && publication_cutoff.is_none() {
1951        return Ok(0);
1952    }
1953
1954    // **The hard ceiling — the bound that sparing would otherwise remove.**
1955    //
1956    // Sparing `read = 0` is not a small exception: "mark unread" is a one-click
1957    // UI control, and `entries` is SHARED across every reader on the instance.
1958    // Without a ceiling, one person can pin unbounded rows, and the pins are
1959    // permanent.
1960    //
1961    // That matters beyond disk. `poll_due_once` stops ALL polling once the
1962    // database crosses `db_size_watermark_bytes`, and the retention DELETE is
1963    // the documented release valve. Pinned rows can hold the valve shut
1964    // forever, so the failure mode is: one reader pins enough content, the DB
1965    // latches above the watermark, and polling stops for EVERY reader with no
1966    // self-healing path. The window used to be an unconditional bound; sparing
1967    // removed it, and this restores it.
1968    //
1969    // Starred entries go too at this age, and that is now safe: a saved record
1970    // whose entry is gone renders from the PDS record as a link card, so the
1971    // reader keeps the article's identity even when the cache does not keep its
1972    // text.
1973    let hard_deleted = match &hard_cutoff {
1974        Some(cutoff) => {
1975            delete_in_batches(
1976                pool,
1977                // Scoped to the kinds the window applies to. A publication's
1978                // entries answer to `publication_cutoff` below instead, which is
1979                // generous where this is tight — an archive read is not a cache
1980                // of the last few days.
1981                &format!(
1982                    "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
1983                     AND feed_id IN (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL}))"
1984                ),
1985                cutoff,
1986                "hard ceiling",
1987            )
1988            .await?
1989        }
1990        None => 0,
1991    };
1992    // **Entries a reader has DELIBERATELY marked are kept, whatever their age.**
1993    //
1994    // Precisely: an entry is spared when some DID has an `entry_state` row for
1995    // it with `starred = 1` or `read = 0`. An entry nobody has ever touched has
1996    // no `entry_state` row at all and is NOT spared, even though every read path
1997    // treats "no row" as unread.
1998    //
1999    // That asymmetry is deliberate and load-bearing. Sparing every never-touched
2000    // entry would spare essentially the whole table — almost no entry is ever
2001    // interacted with — which would make the window a no-op and leave the hard
2002    // ceiling as the only bound. The window is for evicting cache nobody claimed;
2003    // the exception is for the things a reader acted on.
2004    //
2005    // This comment used to read "starred and unread entries are kept", which is
2006    // the reading that would motivate exactly that change.
2007    //
2008    // The window is a cache eviction policy, not a data-retention policy. The
2009    // PDS is the source of truth for what a reader CHOSE — subscriptions,
2010    // folders, stars, read-state — but the entry CONTENT was never there. It
2011    // exists here and at the origin feed, and a feed typically serves only its
2012    // last few dozen items, so a pruned article is usually unrecoverable.
2013    //
2014    // Deleting indiscriminately therefore lost two things a reader would notice:
2015    // a starred article vanished from the starred view entirely (the view joins
2016    // `entries`, and `entry_state` cascades on the delete, so the star went with
2017    // it), and anything still unread disappeared before it was ever read. Both
2018    // are the opposite of a cache.
2019    //
2020    // This is what the documentation has always described; the query did not
2021    // implement it.
2022    let soft_deleted = match &cutoff {
2023        Some(cutoff) => {
2024            delete_in_batches(
2025                pool,
2026                // **`NOT EXISTS`, not `id NOT IN (…)`.**
2027                //
2028                // The list form materialises the ENTIRE pinned set on every
2029                // batch, and that set scales with total users rather than with
2030                // the feed being swept; this probes `idx_entry_state_entry_id`
2031                // per candidate row instead. Measured on 1M entries with 600k
2032                // `entry_state` rows of which 10% are pinned: **64.8 s as a list,
2033                // 43.6 s as a correlated exists — 1.49x, for no disk and no write
2034                // amplification.**
2035                //
2036                // **An earlier version of this comment claimed 2.4x, and that a
2037                // partial index on the pinned predicate "changed the time by
2038                // nothing at all". Both were artifacts of a bad fixture.** It
2039                // made every `entry_state` row match `starred = 1 OR read = 0` —
2040                // no "read and not starred" rows at all, which is the commonest
2041                // state a reader leaves behind. That inflated the list form's
2042                // cost (the materialised set was the whole table) and made a
2043                // PARTIAL index on that predicate cover 100% of rows, so it could
2044                // not be selective and duly did nothing.
2045                //
2046                // On a realistic distribution the review's proposed index is NOT
2047                // useless: it takes the list form from 64.8 s to 44.0 s, most of
2048                // the way to the rewrite. The rewrite is still the better change
2049                // because it costs no disk and no insert throughput — but it wins
2050                // by less than claimed, against an alternative that was dismissed
2051                // on a measurement of the wrong thing.
2052                //
2053                // Indexes are still declined, now on honest numbers: the pinned
2054                // index buys 12% (43.6 → 38.5 s) for 6.9 MiB, the age index 22%
2055                // (→ 33.9 s) for 27.9 MiB, both with write amplification on a
2056                // poller that inserts constantly, against a daily sweep that is
2057                // already batched and interruptible. See
2058                // `store::tests::r6_measure_retention_sweep`.
2059                //
2060                // Also strictly safer. `NOT IN` against a subquery containing a
2061                // NULL evaluates to NULL for every row, which would silently
2062                // delete nothing. `entry_state.entry_id` is `NOT NULL` today, so
2063                // the two are equivalent — but the equivalence depends on a
2064                // column constraint somewhere else, and `NOT EXISTS` does not.
2065                // `sparing_honours_every_did_not_just_one` pins the multi-DID
2066                // case, which is the only one where the forms could diverge.
2067                &format!(
2068                    "SELECT e.id FROM entries e \
2069                     WHERE COALESCE(e.published, e.fetched_at) < ?1 \
2070                       AND e.feed_id IN \
2071                           (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL})) \
2072                       AND NOT EXISTS ( \
2073                           SELECT 1 FROM entry_state s \
2074                           WHERE s.entry_id = e.id \
2075                             AND (s.starred = 1 OR s.read = 0) \
2076                       )"
2077                ),
2078                cutoff,
2079                "window",
2080            )
2081            .await?
2082        }
2083        None => 0,
2084    };
2085    // **The archive ceiling, for every kind the window does not cover.**
2086    //
2087    // `kind NOT IN` rather than `kind = 'publication'` deliberately: a kind added
2088    // later and left out of `FeedKind::AGED` inherits a bound here rather than
2089    // inheriting immortality. Spares nothing, for the reason the hard ceiling
2090    // spares nothing — a saved record whose entry is gone still renders from the
2091    // PDS record as a link card, so the reader keeps the article's identity.
2092    let publication_deleted = match &publication_cutoff {
2093        Some(cutoff) => {
2094            delete_in_batches(
2095                pool,
2096                &format!(
2097                    "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
2098                     AND feed_id IN (SELECT id FROM feeds WHERE kind NOT IN ({AGED_KINDS_SQL}))"
2099                ),
2100                cutoff,
2101                "archive ceiling",
2102            )
2103            .await?
2104        }
2105        None => 0,
2106    };
2107    let deleted = soft_deleted + hard_deleted + publication_deleted;
2108
2109    // Only touch cursors when rows actually went away — and OUTSIDE the deletes.
2110    //
2111    // This used to run inside the one transaction that wrapped both deletes,
2112    // which made the whole sweep a single write-lock hold: load every
2113    // `read_cursor` row, then issue a fresh per-cursor `SELECT … JOIN … WHERE
2114    // f.url = ?` returning up to `max_entries_per_feed` ids, all before the
2115    // commit. SQLite is single-writer and `busy_timeout` is 5 s, so for that
2116    // whole span every mark-read, every login write and every cursor flush
2117    // failed.
2118    //
2119    // Correctness survives the move because the scrub is idempotent — it
2120    // computes each cursor's surviving ids from what is in `entries` NOW, and
2121    // rewrites only cursors that actually change. If the process dies between
2122    // the deletes and the scrub, the next sweep finishes the job, and in the
2123    // meantime a stale id in an exception set is inert: the flusher sends it,
2124    // and it names an entry nobody can reach.
2125    if deleted > 0 {
2126        if let Err(err) = prune_orphan_cursor_ids(pool, None).await {
2127            // The deletes already committed and are the point of this call.
2128            // A failed scrub leaves stale ids to be cleaned up next sweep.
2129            tracing::warn!(%err, "retention sweep: cursor id scrub failed after the deletes");
2130        }
2131    }
2132
2133    Ok(deleted)
2134}
2135
2136/// Rows deleted per statement by [`delete_in_batches`].
2137///
2138/// Small enough that one batch — including its `entry_state` FK cascade — is a
2139/// short lock hold, large enough that a big sweep is tens of statements rather
2140/// than thousands.
2141const PRUNE_BATCH: i64 = 1_000;
2142
2143/// Backstop against a delete loop that never drains. `rows_affected == 0` is the
2144/// real terminator; this only bounds the damage if a future predicate change
2145/// makes that untrue. At [`PRUNE_BATCH`] this is 10M rows, far past anything a
2146/// 1 GB volume holds.
2147const PRUNE_MAX_BATCHES: usize = 10_000;
2148
2149/// How long [`delete_in_batches`] stands down between batches, so a writer
2150/// waiting on the SQLite write lock actually gets it rather than losing the race
2151/// to the loop's next statement.
2152///
2153/// Named because it is the one thing that makes batching a fix rather than
2154/// bookkeeping, and because `a_writer_gets_through_while_the_sweep_runs` derives
2155/// its "was this sweep long enough to measure" floor from it. A sweep that is
2156/// genuinely batched cannot finish faster than one hand-off per batch; that is a
2157/// structural lower bound, not a number calibrated against a particular machine.
2158const PRUNE_BATCH_HANDOFF: std::time::Duration = std::time::Duration::from_millis(10);
2159
2160/// Delete every entry matched by `select_ids` (a `SELECT id FROM entries …`
2161/// bound to one `?1` cutoff), in bounded batches, **one implicit transaction per
2162/// batch**.
2163///
2164/// The retention sweep used to be a single `DELETE` inside one explicit
2165/// transaction. On a populated instance that is one unbroken write-lock hold
2166/// covering tens of thousands of row deletes plus their `entry_state` cascades —
2167/// measured at ~10 minutes before `idx_entry_state_entry_id` existed, and still
2168/// a single indivisible span after it. Everything else that writes (mark-read,
2169/// login, cursor flush) has a 5 s `busy_timeout` and simply fails for the
2170/// duration.
2171///
2172/// Batching does not make the total work smaller; it makes it INTERRUPTIBLE. A
2173/// writer waiting on the lock gets in between batches instead of timing out, and
2174/// the short sleep below guarantees that window actually exists rather than
2175/// leaving it to chance against a tight loop.
2176///
2177/// A partial sweep is safe: each batch commits on its own, and the predicate is
2178/// a fixed cutoff, so a crash mid-sweep leaves fewer rows deleted and the next
2179/// run finishes the job.
2180async fn delete_in_batches(
2181    pool: &SqlitePool,
2182    select_ids: &str,
2183    cutoff: &str,
2184    label: &str,
2185) -> Result<u64> {
2186    let sql = format!("DELETE FROM entries WHERE id IN ({select_ids} LIMIT {PRUNE_BATCH})");
2187    let mut total: u64 = 0;
2188    for batch in 0..PRUNE_MAX_BATCHES {
2189        let n = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
2190            .bind(cutoff)
2191            .execute(pool)
2192            .await
2193            .with_context(|| format!("prune_old_entries {label} (cutoff {cutoff})"))?
2194            .rows_affected();
2195        total += n;
2196        if n == 0 {
2197            return Ok(total);
2198        }
2199        // Hand the write lock over, so the loop cannot re-acquire it the instant
2200        // it commits and leave a waiting writer to fight for the gap between two
2201        // statements. At `PRUNE_BATCH` rows per batch this adds one
2202        // `PRUNE_BATCH_HANDOFF` per 1,000 deleted rows to a sweep that runs once
2203        // a day.
2204        //
2205        // This comment has twice carried a number it could not support. It first
2206        // said a writer "still starves" without the hand-off; that was replaced
2207        // with "roughly 3x writer throughput", quoting one sample from each of
2208        // two runs. Repeated, the two distributions overlap heavily (medians
2209        // ~1.4 writes/ms with the sleep against ~1.0 without, and several
2210        // sleep-less runs beat the median with it), so 3x is not a figure this
2211        // comment can assert.
2212        //
2213        // What is defensible without a benchmark: removing it lets the loop
2214        // re-acquire immediately, so a waiting writer is left racing the gap
2215        // between two statements instead of being handed a window. Writers do
2216        // still get through either way. `a_writer_gets_through_while_the_sweep_runs`
2217        // catches the removal about three runs in five — see the note there; the
2218        // rest of the time the loop still looks batched, because it is.
2219        tokio::time::sleep(PRUNE_BATCH_HANDOFF).await;
2220        // Only warn if the backstop actually cut the sweep short. A final batch
2221        // that happened to drain the last rows would otherwise log "the rest
2222        // waits for the next run" with nothing left — and an operator who reads
2223        // that during an incident would go looking for a backlog that is not
2224        // there. `n < PRUNE_BATCH` means this batch found fewer rows than it
2225        // asked for, so there are none behind it.
2226        // Still a 1-in-`PRUNE_BATCH` false positive when the final batch drains
2227        // exactly a full batch with nothing behind it — distinguishing that
2228        // needs another COUNT per sweep, which is not worth paying to make a
2229        // backstop message that has never fired slightly more precise.
2230        if batch + 1 == PRUNE_MAX_BATCHES && n == PRUNE_BATCH as u64 {
2231            tracing::warn!(
2232                label,
2233                total,
2234                "retention sweep hit its batch backstop; the rest waits for the next run"
2235            );
2236        }
2237    }
2238    Ok(total)
2239}
2240
2241/// Scrub entry ids that no longer exist out of `read_cursor.read_ids` /
2242/// `unread_ids`. `read_cursor` is keyed by `(did, feed_url)` and its id-sets have
2243/// NO foreign key to `entries`, so a prune/trim that deletes entries would
2244/// otherwise leave dangling ids that (a) grow the sets without bound and (b) get
2245/// flushed to the PDS as references to vanished entries.
2246///
2247/// When `feed_id` is `Some`, only that feed's cursors are examined (the cheap
2248/// path used right after a per-feed trim); `None` scans every cursor (the
2249/// retention sweep, which can delete across many feeds at once). A cursor whose
2250/// sets actually change is rewritten and marked `dirty` so the flusher resyncs
2251/// it; unchanged cursors are left untouched (no spurious dirtying / PDS writes).
2252/// Returns the number of cursor rows modified.
2253///
2254/// This is the TRANSACTIONAL variant, used by the per-feed trim inside
2255/// `insert_entries`: it is scoped to one feed, examines that feed's cursors
2256/// only, and genuinely wants to land atomically with the trim that created the
2257/// orphans. The retention sweep uses [`prune_orphan_cursor_ids`] instead —
2258/// global scope inside one transaction is what made the sweep a multi-minute
2259/// write-lock hold.
2260async fn prune_orphan_cursor_ids_tx(
2261    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2262    feed_id: Option<i64>,
2263) -> Result<u64> {
2264    // The set of live entry ids we prune against. Scope to the feed's URL when a
2265    // feed_id is given so we filter only that feed's cursors against that feed's
2266    // entries; otherwise consider all cursors / all entries.
2267    let feed_url = match feed_id {
2268        Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
2269            Some(u) => Some(u),
2270            None => return Ok(0), // feed vanished mid-tx; nothing to prune
2271        },
2272        None => None,
2273    };
2274
2275    // Load the (did, feed_url, read_ids, unread_ids) of the candidate cursors.
2276    let cursors: Vec<(String, String, String, String)> = match &feed_url {
2277        Some(url) => sqlx::query(
2278            "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
2279        )
2280        .bind(url)
2281        .fetch_all(&mut **tx)
2282        .await
2283        .context("prune_orphan_cursor_ids: load feed cursors")?,
2284        None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
2285            .fetch_all(&mut **tx)
2286            .await
2287            .context("prune_orphan_cursor_ids: load all cursors")?,
2288    }
2289    .into_iter()
2290    .map(|r| {
2291        (
2292            r.get::<String, _>("did"),
2293            r.get::<String, _>("feed_url"),
2294            r.get::<String, _>("read_ids"),
2295            r.get::<String, _>("unread_ids"),
2296        )
2297    })
2298    .collect();
2299
2300    if cursors.is_empty() {
2301        return Ok(0);
2302    }
2303
2304    let now = now_rfc3339();
2305    let mut changed: u64 = 0;
2306    for (did, curl, read_ids, unread_ids) in cursors {
2307        // The live entry ids for THIS cursor's feed (join by URL — the cursor key).
2308        let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2309            "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2310        )
2311        .bind(&curl)
2312        .fetch_all(&mut **tx)
2313        .await
2314        .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
2315        .into_iter()
2316        .collect();
2317
2318        let new_read = filter_id_set_to_live(&read_ids, &live);
2319        let new_unread = filter_id_set_to_live(&unread_ids, &live);
2320        if new_read == read_ids && new_unread == unread_ids {
2321            continue; // nothing orphaned — leave the cursor (and its dirty flag) alone
2322        }
2323        sqlx::query(
2324            "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2325             WHERE did = ?1 AND feed_url = ?2",
2326        )
2327        .bind(&did)
2328        .bind(&curl)
2329        .bind(&new_read)
2330        .bind(&new_unread)
2331        .bind(&now)
2332        .execute(&mut **tx)
2333        .await
2334        .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
2335        changed += 1;
2336    }
2337    Ok(changed)
2338}
2339
2340/// [`prune_orphan_cursor_ids_tx`] over the pool — **no enclosing transaction**.
2341///
2342/// Same result, different locking. Each statement commits on its own, so the
2343/// single write lock is taken for one cursor rewrite at a time and released
2344/// between them, and the reads in between block nothing at all in WAL mode.
2345/// That matters because this is the global pass: the retention sweep's version
2346/// loads EVERY `read_cursor` row and then issues one live-ids query per cursor,
2347/// and holding all of that inside a transaction is what made a daily sweep look
2348/// like an outage to every writer on the instance.
2349///
2350/// **Each cursor's read-modify-write is one short transaction**, and that is not
2351/// optional. The first version of this loaded every cursor into a snapshot, then
2352/// walked them issuing an unguarded `UPDATE` per cursor from that snapshot. A
2353/// `mark_read` landing during the walk — seconds, on a global pass — had its new
2354/// id silently overwritten by the stale set, and the rewrite set `dirty = 1`, so
2355/// the flusher then pushed the truncated set to the PDS as authoritative. Local
2356/// `entry_state` still said read, so the loss was invisible here and visible
2357/// only in every OTHER atproto client. The transactional predecessor did not
2358/// have that bug: it held the write lock across the whole pass, so a concurrent
2359/// `mark_read` blocked and applied on top.
2360///
2361/// So the lock is not eliminated, it is SCOPED: one cursor's live-ids query plus
2362/// its update, rather than every cursor's. That keeps what T2.2 was for (a daily
2363/// sweep must not look like an outage) without trading it for lost writes.
2364///
2365/// Re-running is still safe — surviving ids are recomputed from the current
2366/// contents of `entries` — so dying partway just means the next sweep finishes.
2367///
2368/// `feed_id = Some(..)` scopes to one feed; `None` scans every cursor. Returns
2369/// the number of cursor rows modified.
2370async fn prune_orphan_cursor_ids(pool: &SqlitePool, feed_id: Option<i64>) -> Result<u64> {
2371    let feed_url = match feed_id {
2372        Some(fid) => match sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2373            .bind(fid)
2374            .fetch_optional(pool)
2375            .await
2376            .context("prune_orphan_cursor_ids: feed url")?
2377        {
2378            Some(u) => Some(u),
2379            None => return Ok(0),
2380        },
2381        None => None,
2382    };
2383
2384    // Only the KEYS come from this snapshot. The id-sets are deliberately not
2385    // read here — they are re-read inside each cursor's own transaction below,
2386    // because anything read out here is stale by the time it is written back.
2387    let keys: Vec<(String, String)> = match &feed_url {
2388        Some(url) => sqlx::query_as("SELECT did, feed_url FROM read_cursor WHERE feed_url = ?1")
2389            .bind(url)
2390            .fetch_all(pool)
2391            .await
2392            .context("prune_orphan_cursor_ids: load feed cursors")?,
2393        None => sqlx::query_as("SELECT did, feed_url FROM read_cursor")
2394            .fetch_all(pool)
2395            .await
2396            .context("prune_orphan_cursor_ids: load all cursors")?,
2397    };
2398
2399    let mut changed: u64 = 0;
2400    for (did, curl) in keys {
2401        // A cursor that vanished between the key snapshot and now is simply
2402        // skipped; a cursor that APPEARED is missed until the next sweep. Both
2403        // are fine — the scrub is housekeeping, not a correctness barrier.
2404        match scrub_one_cursor(pool, &did, &curl).await {
2405            Ok(true) => changed += 1,
2406            Ok(false) => {}
2407            // One bad cursor must not abandon the rest of the pass.
2408            Err(err) => tracing::warn!(%err, %did, feed = %curl, "cursor id scrub failed"),
2409        }
2410    }
2411    Ok(changed)
2412}
2413
2414/// Scrub one cursor's id-sets inside its own transaction. Returns whether the
2415/// row changed.
2416///
2417/// The read of the id-sets, the live-ids query and the write all happen under
2418/// one transaction, so a `mark_read` that lands mid-sweep either goes first (and
2419/// is included) or waits (and applies on top). Reading the sets outside and
2420/// writing them back later is the lost-update shape this function exists to
2421/// avoid — see [`prune_orphan_cursor_ids`].
2422async fn scrub_one_cursor(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<bool> {
2423    let mut tx = pool.begin().await.context("begin scrub_one_cursor tx")?;
2424
2425    let (_, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2426    // An empty exception set has nothing to orphan, and skipping it avoids the
2427    // live-ids query entirely — the dominant cost of this pass, and the common
2428    // case for a cursor sitting at its high-water mark.
2429    if is_empty_id_set(&read_ids) && is_empty_id_set(&unread_ids) {
2430        return Ok(false);
2431    }
2432
2433    let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2434        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2435    )
2436    .bind(feed_url)
2437    .fetch_all(&mut *tx)
2438    .await
2439    .with_context(|| format!("prune_orphan_cursor_ids: live ids for {feed_url}"))?
2440    .into_iter()
2441    .collect();
2442
2443    let new_read = filter_id_set_to_live(&read_ids, &live);
2444    let new_unread = filter_id_set_to_live(&unread_ids, &live);
2445    if new_read == read_ids && new_unread == unread_ids {
2446        return Ok(false); // nothing orphaned — leave the cursor (and its dirty flag) alone
2447    }
2448    sqlx::query(
2449        "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2450         WHERE did = ?1 AND feed_url = ?2",
2451    )
2452    .bind(did)
2453    .bind(feed_url)
2454    .bind(&new_read)
2455    .bind(&new_unread)
2456    .bind(now_rfc3339())
2457    .execute(&mut *tx)
2458    .await
2459    .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{feed_url}"))?;
2460    tx.commit().await.context("commit scrub_one_cursor tx")?;
2461    Ok(true)
2462}
2463
2464/// Whether a stored id-set is *textually* empty — `[]` or blank.
2465///
2466/// Deliberately NOT a parse: this is a fast pre-filter, and
2467/// [`filter_id_set_to_live`] remains the authority on what a set contains. An
2468/// unparseable value returns `false` here, so it goes through the full path and
2469/// gets canonicalised to `[]` rather than being skipped — the pre-filter fails
2470/// toward doing the work, which is the safe direction.
2471fn is_empty_id_set(raw: &str) -> bool {
2472    let t = raw.trim();
2473    t.is_empty() || t == "[]"
2474}
2475
2476/// Filter a JSON id-array string down to only ids present in `live`, returning
2477/// the canonical JSON-array-of-strings form (matching [`json_id_set_toggle`]). A
2478/// malformed input yields `[]`.
2479fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
2480    let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
2481        .ok()
2482        .map(|vals| {
2483            vals.into_iter()
2484                .filter_map(|v| match v {
2485                    serde_json::Value::Number(n) => n.as_i64(),
2486                    serde_json::Value::String(s) => s.parse::<i64>().ok(),
2487                    _ => None,
2488                })
2489                .filter(|id| live.contains(id))
2490                .collect()
2491        })
2492        .unwrap_or_default();
2493    let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
2494    serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
2495}
2496
2497/// Replace the per-DID subscription projection (`sub_ref`) for `did` with
2498/// exactly `feed_ids`, in one transaction.
2499///
2500/// Called from the web layer's subscription-resolve/sync path so `sub_ref`
2501/// always mirrors the caller's *current* PDS subscription set. This is the
2502/// authority every scoped read/mutation checks against — a feed the caller no
2503/// longer subscribes to drops out of their read surface immediately.
2504pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
2505    let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
2506    sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
2507        .bind(did)
2508        .execute(&mut *tx)
2509        .await
2510        .with_context(|| format!("clear sub_ref for {did}"))?;
2511    for &feed_id in feed_ids {
2512        sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
2513            .bind(did)
2514            .bind(feed_id)
2515            .execute(&mut *tx)
2516            .await
2517            .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
2518    }
2519    tx.commit().await.context("commit replace_sub_refs tx")?;
2520    Ok(())
2521}
2522
2523/// Whether `did` currently subscribes to the feed `feed_id` owns
2524/// (i.e. a `sub_ref` row exists). The authorization primitive behind every
2525/// per-DID scoped read/mutation.
2526pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
2527    let found: Option<i64> = sqlx::query_scalar(
2528        r#"
2529        SELECT 1
2530        FROM entries e
2531        JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
2532        WHERE e.id = ?2
2533        "#,
2534    )
2535    .bind(did)
2536    .bind(entry_id)
2537    .fetch_optional(pool)
2538    .await
2539    .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
2540    Ok(found.is_some())
2541}
2542
2543/// The exact `(sql, bind_count)` `list_entries` runs, for a view and scope.
2544///
2545/// **One path, so a test cannot assert on something the query is free to
2546/// ignore.** A named `LIST_PROJECTION` constant was not enough: the test read
2547/// the constant while `list_entries` passed `list_query_sql` whatever it liked,
2548/// so swapping in an inline literal containing `e.content_html` still shipped
2549/// green. The test now calls this.
2550fn list_entries_sql(view: ListView, feed_ids: Option<&[i64]>) -> (String, usize) {
2551    list_query_sql(Projection::EntryList, view, feed_ids)
2552}
2553
2554/// Which columns a list query may select.
2555///
2556/// **A closed type, not a `&str`.** A named constant was not enough and neither
2557/// was a helper function: both left `list_query_sql` taking an arbitrary string,
2558/// so a call site could pass an inline literal containing `e.content_html` and
2559/// ship green — twice over, which is how this ended up as an enum. The article
2560/// body is up to 20 KB per row and the list renders 50 at a time, so reading it
2561/// is the difference between a bounded response and a megabyte per page.
2562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2563enum Projection {
2564    /// The list view. Deliberately omits `content_html`.
2565    EntryList,
2566    Count,
2567    Ids,
2568    FeedCounts,
2569    StarredUrls,
2570}
2571
2572impl Projection {
2573    const fn columns(self) -> &'static str {
2574        match self {
2575            Projection::EntryList => {
2576                "e.id, e.feed_id, e.guid, e.url, e.title, e.published, \
2577                 COALESCE(s.read, 0) AS read, COALESCE(s.starred, 0) AS starred"
2578            }
2579            Projection::Count => "COUNT(*)",
2580            Projection::Ids => "e.id",
2581            Projection::FeedCounts => "e.feed_id, COUNT(*)",
2582            Projection::StarredUrls => "e.url, e.guid",
2583        }
2584    }
2585}
2586
2587/// The shared body of every list query: the per-DID `entry_state` LEFT JOIN, the
2588/// `sub_ref` authorization predicate, the view predicate and the optional
2589/// feed-id restriction. `projection` is spliced in as the `SELECT` list.
2590///
2591/// Returns the SQL plus the number of feed-id placeholders emitted, so the
2592/// caller knows where its own `LIMIT`/`OFFSET` placeholders start. `?1` is
2593/// always the DID; feed ids are `?2..`.
2594///
2595/// **Why the callers may assert this is SQL-safe.** Only three things vary, and
2596/// none is caller data: `projection` and [`ListView::predicate`] are `&'static
2597/// str` written in this file, and the feed-id restriction contributes only a
2598/// COUNT — the ids themselves are bound, never formatted in. Every runtime value
2599/// (the DID, the ids, the limit, the offset) reaches SQLite as a bind parameter.
2600fn list_query_sql(
2601    projection: Projection,
2602    view: ListView,
2603    feed_ids: Option<&[i64]>,
2604) -> (String, usize) {
2605    let cols = projection.columns();
2606    let scoped = feed_ids.is_some();
2607    let mut sql = format!(
2608        "SELECT {cols} \
2609         FROM entries e \
2610         LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1 \
2611         WHERE {} \
2612           AND EXISTS ( \
2613               SELECT 1 FROM sub_ref sr \
2614               WHERE sr.did = ?1 AND sr.feed_id = e.feed_id \
2615           )",
2616        view.predicate()
2617    );
2618    if scoped {
2619        // **ONE bind parameter for any scope size.**
2620        //
2621        // This used to emit one placeholder per feed id, so the SQL string and
2622        // the bind list both grew with the reader's subscription count — which
2623        // is PDS-supplied and bounded only by the 20,000-record list ceiling.
2624        //
2625        // That was reachable-broken, not merely ugly: `SQLITE_LIMIT_VARIABLE_NUMBER`
2626        // is 32766 on the bundled build, and the ids were bound TWICE per render
2627        // (the count query and the page query), so the effective ceiling was
2628        // ~16,383 feeds — below the list ceiling. Past it, `prepare` fails with
2629        // "too many SQL variables" and the reader's page 500s. Measured: 20,000
2630        // ids through `json_each` is a 108 KB bind that runs in 9.9 ms; 32,767
2631        // placeholders does not prepare at all.
2632        // The first attempt at bounding it truncated the subscription list
2633        // instead, which traded a query-shape problem for an access problem:
2634        // `sync_sub_refs` writes `sub_ref` from that list, so dropped feeds
2635        // became unreadable AND unmutatable. `json_each` removes the need to
2636        // choose — the whole set rides in as one JSON text bind.
2637        sql.push_str(" AND e.feed_id IN (SELECT value FROM json_each(?2))");
2638    }
2639    (sql, usize::from(scoped))
2640}
2641
2642/// Bind the DID and the optional feed-id restriction, in the order
2643/// [`list_query_sql`] emits them — `?1` the DID, `?2` the scope JSON when there
2644/// is one.
2645fn bind_list_scope<'q, O>(
2646    q: sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments>,
2647    did: &'q str,
2648    feed_ids: Option<&[i64]>,
2649) -> sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments> {
2650    let q = q.bind(did);
2651    match feed_ids {
2652        // Serialising i64s cannot fail; the fallback is an empty array, which
2653        // matches nothing — the fail-closed direction for a scope filter.
2654        Some(ids) => q.bind(serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string())),
2655        None => q,
2656    }
2657}
2658
2659/// One page of a list view, newest-published first, scoped to `did`'s
2660/// subscriptions (`sub_ref`) and optionally narrowed to `feed_ids`.
2661///
2662/// **`limit` is a required parameter, not a convenience.** This function
2663/// replaced three `SELECT e.*` queries that had no `LIMIT` at all and pulled the
2664/// article body they never used; leaving an unbounded variant next to the
2665/// bounded one would just be the same trap with a longer name. If a caller wants
2666/// "everything", it has to say how much everything is allowed to be. See
2667/// [`EntryListRow`] for what the projection deliberately omits and why.
2668///
2669/// `feed_ids = Some(&[])` means "no feeds in scope" and returns empty without
2670/// touching the database — distinct from `None`, which means "every feed this
2671/// DID subscribes to".
2672pub async fn list_entries(
2673    pool: &SqlitePool,
2674    did: &str,
2675    view: ListView,
2676    feed_ids: Option<&[i64]>,
2677    limit: i64,
2678    offset: i64,
2679) -> Result<Vec<EntryListRow>> {
2680    if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2681        return Ok(Vec::new());
2682    }
2683    let (mut sql, n) = list_entries_sql(view, feed_ids);
2684    sql.push_str(&format!(
2685        " ORDER BY COALESCE(e.published, e.fetched_at) DESC, e.id DESC LIMIT ?{} OFFSET ?{}",
2686        n + 2,
2687        n + 3
2688    ));
2689    let q = sqlx::query_as::<_, EntryListRow>(sqlx::AssertSqlSafe(sql));
2690    let rows = bind_list_scope(q, did, feed_ids)
2691        .bind(limit)
2692        .bind(offset.max(0))
2693        .fetch_all(pool)
2694        .await
2695        .with_context(|| format!("list_entries({view:?}) failed for {did}"))?;
2696    Ok(rows)
2697}
2698
2699/// How many entries the same scope + view would return, unpaged. Used for the
2700/// "N entries" heading and to decide whether a next-page link is warranted —
2701/// both of which used to read `entries.len()` off a fully materialized list.
2702pub async fn count_entries_for_view(
2703    pool: &SqlitePool,
2704    did: &str,
2705    view: ListView,
2706    feed_ids: Option<&[i64]>,
2707) -> Result<i64> {
2708    if feed_ids.is_some_and(<[i64]>::is_empty) {
2709        return Ok(0);
2710    }
2711    let (sql, _) = list_query_sql(Projection::Count, view, feed_ids);
2712    // `query_as` over a 1-tuple keeps one binding helper for both shapes.
2713    let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2714    let (n,) = bind_list_scope(q, did, feed_ids)
2715        .fetch_one(pool)
2716        .await
2717        .with_context(|| format!("count_entries_for_view({view:?}) failed for {did}"))?;
2718    Ok(n)
2719}
2720
2721/// The ordered entry ids for a scope + view — the same ordering [`list_entries`]
2722/// renders, used for the reader's prev/next links.
2723///
2724/// Ids only: this one genuinely spans the whole list rather than a page (prev/next
2725/// needs the reader's position in it), so it is the one query where row COUNT can
2726/// still be large. An id is 8 bytes against the 11.9 KB row this used to fetch,
2727/// and `limit` bounds it regardless. Past the limit, prev/next simply stops
2728/// finding neighbours — the article still opens.
2729pub async fn list_entry_ids(
2730    pool: &SqlitePool,
2731    did: &str,
2732    view: ListView,
2733    feed_ids: Option<&[i64]>,
2734    limit: i64,
2735) -> Result<Vec<i64>> {
2736    if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2737        return Ok(Vec::new());
2738    }
2739    let (mut sql, n) = list_query_sql(Projection::Ids, view, feed_ids);
2740    sql.push_str(&format!(
2741        " ORDER BY COALESCE(e.published, e.fetched_at) DESC, e.id DESC LIMIT ?{}",
2742        n + 2
2743    ));
2744    let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2745    let rows = bind_list_scope(q, did, feed_ids)
2746        .bind(limit)
2747        .fetch_all(pool)
2748        .await
2749        .with_context(|| format!("list_entry_ids({view:?}) failed for {did}"))?;
2750    Ok(rows.into_iter().map(|(id,)| id).collect())
2751}
2752
2753/// Unread counts per `feed_id` for a DID — the sidebar's per-feed badges.
2754///
2755/// Counted in SQL. The sidebar used to fetch every unread entry (bodies and all)
2756/// and count them in Rust, on every page with chrome, which is the single most
2757/// frequent instance of the projection problem [`EntryListRow`] describes.
2758pub async fn unread_counts_by_feed(
2759    pool: &SqlitePool,
2760    did: &str,
2761) -> Result<std::collections::HashMap<i64, i64>> {
2762    let (sql, _) = list_query_sql(Projection::FeedCounts, ListView::Unread, None);
2763    let rows =
2764        sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!("{sql} GROUP BY e.feed_id")))
2765            .bind(did)
2766            .fetch_all(pool)
2767            .await
2768            .with_context(|| format!("unread_counts_by_feed failed for {did}"))?;
2769    Ok(rows.into_iter().collect())
2770}
2771
2772/// The `(url, guid)` identity pairs of every cached starred entry for a DID.
2773///
2774/// The starred view matches PDS saved records against these to decide which
2775/// records the cache can render itself. It must span the whole starred set, not
2776/// the visible page: a record that looks uncached gets an un-save button that
2777/// deletes the PDS RECORD rather than un-starring the entry, so narrowing this
2778/// set changes what a click destroys. Identity strings only — no bodies.
2779///
2780/// **Truncation is reported, not absorbed.** The `limit` is a memory backstop,
2781/// but hitting it violates the invariant above — and the first version had no
2782/// way to say so and no `ORDER BY`, so it silently returned an ARBITRARY subset
2783/// and every starred article outside it rendered with a record-destroying
2784/// button. `Truncated` lets the caller fail closed instead, and the ordering
2785/// makes the subset at least deterministic across renders rather than
2786/// whatever the query planner felt like returning.
2787pub enum StarredIdentities {
2788    /// The complete set for this DID.
2789    All(Vec<(Option<String>, String)>),
2790    /// `limit` was reached, so this is a partial set and MUST NOT be used to
2791    /// decide that a record is uncached.
2792    Truncated,
2793}
2794
2795pub async fn starred_identities(
2796    pool: &SqlitePool,
2797    did: &str,
2798    limit: i64,
2799) -> Result<StarredIdentities> {
2800    let (mut sql, n) = list_query_sql(Projection::StarredUrls, ListView::Starred, None);
2801    // One past the limit, so reaching it is distinguishable from landing on it
2802    // exactly. Ordered by id so the rows are stable; `url`/`guid` are not
2803    // guaranteed unique or non-NULL, and the id is both.
2804    //
2805    // The placeholder index comes from `list_query_sql` rather than being
2806    // hardcoded: it was `?2` only because this call passes `None` for the scope,
2807    // which is the kind of coupling that breaks silently when the shared builder
2808    // changes shape — as it just did.
2809    sql.push_str(&format!(" ORDER BY e.id LIMIT ?{}", n + 2));
2810    let rows = sqlx::query_as::<_, (Option<String>, String)>(sqlx::AssertSqlSafe(sql))
2811        .bind(did)
2812        .bind(limit.saturating_add(1))
2813        .fetch_all(pool)
2814        .await
2815        .with_context(|| format!("starred_identities failed for {did}"))?;
2816    if rows.len() as i64 > limit {
2817        return Ok(StarredIdentities::Truncated);
2818    }
2819    Ok(StarredIdentities::All(rows))
2820}
2821
2822/// Mark a single entry read/unread for a DID, upserting the per-DID state row
2823/// and stamping `updated_at`. Preserves any existing `starred` bit. Also
2824/// projects the change into the per-`(did, feed_url)` [`ReadCursor`] and marks
2825/// it `dirty` so the batched flusher pushes it to the PDS (see
2826/// `project_entry_into_cursor`).
2827///
2828/// AUTHORIZED per-DID: the upsert only touches an entry the caller subscribes
2829/// to (`sub_ref`). Returns `true` if a row was written, `false` if `did` does
2830/// not subscribe to the entry's feed (the web layer maps that to a 404 —
2831/// a non-subscriber can never mutate another user's state).
2832pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
2833    let now = now_rfc3339();
2834    let mut tx = pool.begin().await.context("begin mark_read tx")?;
2835    let res = sqlx::query(
2836        r#"
2837        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2838        SELECT ?1, e.id, ?3, 0, ?4
2839        FROM entries e
2840        WHERE e.id = ?2
2841          AND EXISTS (
2842              SELECT 1 FROM sub_ref sr
2843              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2844          )
2845        ON CONFLICT (did, entry_id) DO UPDATE SET
2846            read       = excluded.read,
2847            updated_at = excluded.updated_at
2848        "#,
2849    )
2850    .bind(did)
2851    .bind(entry_id)
2852    .bind(read)
2853    .bind(&now)
2854    .execute(&mut *tx)
2855    .await
2856    .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
2857
2858    if res.rows_affected() == 0 {
2859        // Not authorized (no `sub_ref`) — nothing written, no cursor to dirty.
2860        tx.rollback().await.ok();
2861        return Ok(false);
2862    }
2863
2864    // Project the read/unread into this feed's read cursor (dirty=1) so the
2865    // flusher syncs it to the PDS. Same tx as the state write so a crash can't
2866    // leave the two out of step.
2867    project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
2868
2869    tx.commit().await.context("commit mark_read tx")?;
2870    Ok(true)
2871}
2872
2873/// Star/unstar a single entry for a DID (upsert, preserving `read`).
2874///
2875/// AUTHORIZED per-DID like [`mark_read`]: only touches an entry the caller
2876/// subscribes to. Returns `true` if a row was written, `false` if `did` does
2877/// not subscribe (→ 404 at the web layer).
2878pub async fn mark_starred(
2879    pool: &SqlitePool,
2880    did: &str,
2881    entry_id: i64,
2882    starred: bool,
2883) -> Result<bool> {
2884    let res = sqlx::query(
2885        r#"
2886        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2887        SELECT ?1, e.id, 0, ?3, ?4
2888        FROM entries e
2889        WHERE e.id = ?2
2890          AND EXISTS (
2891              SELECT 1 FROM sub_ref sr
2892              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2893          )
2894        ON CONFLICT (did, entry_id) DO UPDATE SET
2895            starred    = excluded.starred,
2896            updated_at = excluded.updated_at
2897        "#,
2898    )
2899    .bind(did)
2900    .bind(entry_id)
2901    .bind(starred)
2902    .bind(now_rfc3339())
2903    .execute(pool)
2904    .await
2905    .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
2906    Ok(res.rows_affected() > 0)
2907}
2908
2909/// Fold ids already covered by a high-water-mark into `read_through`, so the
2910/// exception set stops growing. Returns the new `read_through` when it advanced.
2911///
2912/// **What was wrong.** `read_through` was never COMPUTED — `project_entry_into_cursor`
2913/// only carried an existing value through, and it starts NULL, so in practice it
2914/// was always NULL. That left `read_ids` as the sole mechanism, growing one id
2915/// per article read, bounded only by `max_entries_per_feed` (2000) — while the
2916/// flusher caps the record at `ReadState::MAX_IDS` (1000) keeping the TAIL, with
2917/// no log line. Past 1000 read articles in one feed, the oldest read-state
2918/// silently stopped syncing, and those articles came back UNREAD in any other
2919/// atproto reader. The `cap` helper's own comment assumed "the exception sets
2920/// are expected to stay well under the cap in normal use"; against a 2000-entry
2921/// per-feed ceiling that does not hold.
2922///
2923/// **The rule.** `read_through` means "every entry at or before this time is
2924/// read". So it may advance only to a point with no unread entry at or before
2925/// it. That point is computed here as the newest entry timestamp STRICTLY OLDER
2926/// than the oldest unread entry — strictly, because entries can share a
2927/// timestamp, and a watermark equal to an unread entry's time would assert that
2928/// entry is read.
2929///
2930/// Once the watermark moves, every `read_ids` entry at or before it is
2931/// redundant and is dropped — that is the compaction. `unread_ids` is filtered
2932/// the same way; by construction nothing unread sits at or below the new
2933/// watermark, so it empties, but the filter is written rather than assumed so it
2934/// stays correct if that invariant ever shifts.
2935///
2936/// Timestamps compare lexicographically because every writer normalises to UTC
2937/// `...Z` at seconds precision (`feed::fmt_time`, `now_rfc3339`) — the same
2938/// assumption `poll_health` and the retention window already make.
2939pub async fn compact_cursor(
2940    pool: &SqlitePool,
2941    did: &str,
2942    feed_url: &str,
2943) -> Result<Option<String>> {
2944    let mut tx = pool.begin().await.context("begin compact_cursor tx")?;
2945    let (read_through, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2946
2947    // The oldest entry on this feed that `did` has NOT read. `NULL` = nothing
2948    // unread, in which case the watermark can cover the whole feed.
2949    let oldest_unread: Option<String> = sqlx::query_scalar(
2950        r#"
2951        SELECT MIN(COALESCE(e.published, e.fetched_at))
2952        FROM entries e
2953        JOIN feeds f ON f.id = e.feed_id
2954        LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
2955        WHERE f.url = ?2 AND COALESCE(s.read, 0) = 0
2956        "#,
2957    )
2958    .bind(did)
2959    .bind(feed_url)
2960    .fetch_one(&mut *tx)
2961    .await
2962    .with_context(|| format!("compact_cursor: oldest unread for {did}/{feed_url}"))?;
2963
2964    let watermark: Option<String> = match &oldest_unread {
2965        Some(oldest) => sqlx::query_scalar(
2966            r#"
2967            SELECT MAX(COALESCE(e.published, e.fetched_at))
2968            FROM entries e JOIN feeds f ON f.id = e.feed_id
2969            WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) < ?2
2970            "#,
2971        )
2972        .bind(feed_url)
2973        .bind(oldest)
2974        .fetch_one(&mut *tx)
2975        .await
2976        .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2977        None => sqlx::query_scalar(
2978            r#"
2979            SELECT MAX(COALESCE(e.published, e.fetched_at))
2980            FROM entries e JOIN feeds f ON f.id = e.feed_id
2981            WHERE f.url = ?1
2982            "#,
2983        )
2984        .bind(feed_url)
2985        .fetch_one(&mut *tx)
2986        .await
2987        .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2988    };
2989
2990    // Nothing to cover, or the watermark is already at least this far along.
2991    // Never move it BACKWARDS: that would re-assert articles as unread.
2992    let Some(watermark) = watermark else {
2993        return Ok(None);
2994    };
2995    if read_through
2996        .as_deref()
2997        .is_some_and(|rt| rt >= &watermark[..])
2998    {
2999        return Ok(None);
3000    }
3001
3002    let keep_above = ids_published_after(&mut tx, feed_url, &read_ids, &watermark).await?;
3003    let keep_unread =
3004        ids_published_at_or_before(&mut tx, feed_url, &unread_ids, &watermark).await?;
3005
3006    write_cursor_sets(
3007        &mut tx,
3008        did,
3009        feed_url,
3010        Some(&watermark),
3011        &keep_above,
3012        &keep_unread,
3013        &now_rfc3339(),
3014    )
3015    .await?;
3016    tx.commit().await.context("commit compact_cursor tx")?;
3017    Ok(Some(watermark))
3018}
3019
3020/// The subset of `ids` whose entries are published strictly AFTER `watermark`,
3021/// as the canonical JSON array-of-strings the cursor stores.
3022async fn ids_published_after(
3023    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3024    feed_url: &str,
3025    ids: &str,
3026    watermark: &str,
3027) -> Result<String> {
3028    let live = ids_matching_watermark(tx, feed_url, watermark, true).await?;
3029    Ok(filter_id_set_to_live(ids, &live))
3030}
3031
3032/// The subset of `ids` whose entries are published at or BEFORE `watermark`.
3033async fn ids_published_at_or_before(
3034    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3035    feed_url: &str,
3036    ids: &str,
3037    watermark: &str,
3038) -> Result<String> {
3039    let live = ids_matching_watermark(tx, feed_url, watermark, false).await?;
3040    Ok(filter_id_set_to_live(ids, &live))
3041}
3042
3043/// Entry ids on `feed_url` on one side of `watermark`. `after = true` selects
3044/// strictly newer; `false` selects at-or-older.
3045async fn ids_matching_watermark(
3046    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3047    feed_url: &str,
3048    watermark: &str,
3049    after: bool,
3050) -> Result<std::collections::HashSet<i64>> {
3051    let sql = if after {
3052        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
3053         WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) > ?2"
3054    } else {
3055        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
3056         WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) <= ?2"
3057    };
3058    Ok(sqlx::query_scalar::<_, i64>(sql)
3059        .bind(feed_url)
3060        .bind(watermark)
3061        .fetch_all(&mut **tx)
3062        .await
3063        .context("compact_cursor: ids on one side of the watermark")?
3064        .into_iter()
3065        .collect())
3066}
3067
3068/// Clear `did`'s star on any cached entry matching `url` or `guid`, **ignoring
3069/// the subscription projection**. Returns the number of `entry_state` rows
3070/// changed.
3071///
3072/// This closes a desync between the two places a star lives. The starred view
3073/// matches PDS saved records against cached entries through `sub_ref`, so an
3074/// entry that is cached AND starred in a feed the reader has since UNSUBSCRIBED
3075/// from does not match: it renders as an uncached row whose button is
3076/// `POST /saved/{rkey}/delete`. That deletes the PDS record and used to leave
3077/// `entry_state.starred = 1` behind — invisible, because the starred list is
3078/// `sub_ref`-scoped too, until the reader resubscribes and the star reappears
3079/// with no record backing it.
3080///
3081/// **Why omitting `sub_ref` is safe here, when it is the per-DID isolation hook
3082/// everywhere else.** Every row this can touch is keyed by `did` and this writes
3083/// only `starred = 0`. The worst a caller can do with it is clear one of their
3084/// OWN stars — which is what they just asked for. The predicate that matters for
3085/// isolation is the `did` in the `WHERE`, and it is not optional.
3086///
3087/// Matching on `url` OR `guid` mirrors how the view decides a record is already
3088/// cached, so the removal path and the render path agree on what "the same
3089/// article" means.
3090pub async fn clear_star_by_identity(
3091    pool: &SqlitePool,
3092    did: &str,
3093    url: Option<&str>,
3094    guid: Option<&str>,
3095) -> Result<u64> {
3096    // Neither identifier present: nothing to match on. Running the statement
3097    // would compare NULL to NULL and match nothing, but returning early says so.
3098    if url.is_none_or(str::is_empty) && guid.is_none_or(str::is_empty) {
3099        return Ok(0);
3100    }
3101    let res = sqlx::query(
3102        r#"
3103        UPDATE entry_state
3104        SET starred = 0, updated_at = ?4
3105        WHERE did = ?1
3106          AND starred = 1
3107          AND entry_id IN (
3108              SELECT id FROM entries
3109              WHERE (?2 IS NOT NULL AND url = ?2)
3110                 OR (?3 IS NOT NULL AND guid = ?3)
3111          )
3112        "#,
3113    )
3114    .bind(did)
3115    .bind(url.filter(|u| !u.is_empty()))
3116    .bind(guid.filter(|g| !g.is_empty()))
3117    .bind(now_rfc3339())
3118    .execute(pool)
3119    .await
3120    .with_context(|| format!("clear_star_by_identity failed for {did}"))?;
3121    Ok(res.rows_affected())
3122}
3123
3124/// Mark every entry of a feed read (or unread) for a DID in one statement —
3125/// backs the "mark-all-read (per feed)" action. Also projects the change into
3126/// the feed's per-DID [`ReadCursor`] (dirty=1) so the batched flusher syncs the
3127/// new read-state to the PDS.
3128pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
3129    let now = now_rfc3339();
3130    let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
3131    let res = sqlx::query(
3132        r#"
3133        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
3134        SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
3135        WHERE e.feed_id = ?4
3136          AND EXISTS (
3137              SELECT 1 FROM sub_ref sr
3138              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3139          )
3140        ON CONFLICT (did, entry_id) DO UPDATE SET
3141            read       = excluded.read,
3142            updated_at = excluded.updated_at
3143        "#,
3144    )
3145    .bind(did)
3146    .bind(read)
3147    .bind(&now)
3148    .bind(feed_id)
3149    .execute(&mut *tx)
3150    .await
3151    .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
3152
3153    if res.rows_affected() > 0 {
3154        // Project every affected entry into this feed's read cursor. `feed_id`
3155        // maps to exactly one feed URL, so this is a single per-feed cursor —
3156        // batched, not per-article. Only runs when the caller was authorized
3157        // (some rows changed), so an unsubscribed feed leaves no cursor behind.
3158        project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
3159    }
3160
3161    tx.commit().await.context("commit mark_feed_read tx")?;
3162    Ok(res.rows_affected())
3163}
3164
3165// ---------------------------------------------------------------------------
3166// Read-cursor projection (wires the local read/unread mutation into the
3167// PDS-bound `read_cursor`, so the batched flusher actually pushes read-state)
3168// ---------------------------------------------------------------------------
3169
3170/// Add or remove an entry id from a JSON id-array string, returning the new JSON.
3171/// Membership is set-like (no duplicates) and order-stable (append on add). A
3172/// malformed input is treated as empty so a cosmetic parse issue never blocks a
3173/// projection.
3174fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
3175    let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
3176        .ok()
3177        .map(|vals| {
3178            vals.into_iter()
3179                .filter_map(|v| match v {
3180                    serde_json::Value::Number(n) => n.as_i64(),
3181                    serde_json::Value::String(s) => s.parse::<i64>().ok(),
3182                    _ => None,
3183                })
3184                .collect()
3185        })
3186        .unwrap_or_default();
3187    if present {
3188        if !ids.contains(&id) {
3189            ids.push(id);
3190        }
3191    } else {
3192        ids.retain(|&x| x != id);
3193    }
3194    // Serialize as a JSON array of strings (the shape the flusher / lexicon
3195    // expect — `community.lexicon.rss.readState.readIds` is a string array).
3196    let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
3197    serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
3198}
3199
3200/// The feed URL owning `feed_id`, if the row exists (cursors are keyed by URL,
3201/// not feed id — they mirror the PDS-side `readState.feedUrl`).
3202async fn feed_url_for_id_tx(
3203    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3204    feed_id: i64,
3205) -> Result<Option<String>> {
3206    let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
3207        .bind(feed_id)
3208        .fetch_optional(&mut **tx)
3209        .await
3210        .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
3211    Ok(url)
3212}
3213
3214/// Fetch the (read_through, read_ids, unread_ids) of an existing cursor, or the
3215/// empty defaults if there is none yet.
3216async fn cursor_sets(
3217    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3218    did: &str,
3219    feed_url: &str,
3220) -> Result<(Option<String>, String, String)> {
3221    let row = sqlx::query(
3222        "SELECT read_through, read_ids, unread_ids FROM read_cursor \
3223         WHERE did = ?1 AND feed_url = ?2",
3224    )
3225    .bind(did)
3226    .bind(feed_url)
3227    .fetch_optional(&mut **tx)
3228    .await
3229    .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
3230    Ok(match row {
3231        Some(r) => (
3232            r.get::<Option<String>, _>("read_through"),
3233            r.get::<String, _>("read_ids"),
3234            r.get::<String, _>("unread_ids"),
3235        ),
3236        None => (None, "[]".to_string(), "[]".to_string()),
3237    })
3238}
3239
3240/// Upsert the cursor row for `(did, feed_url)` with the given exception sets,
3241/// stamping `updated_at` and marking it `dirty` so `dirty_cursors` returns it.
3242async fn write_cursor_sets(
3243    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3244    did: &str,
3245    feed_url: &str,
3246    read_through: Option<&str>,
3247    read_ids: &str,
3248    unread_ids: &str,
3249    now: &str,
3250) -> Result<()> {
3251    sqlx::query(
3252        r#"
3253        INSERT INTO read_cursor
3254            (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3255        VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
3256        ON CONFLICT (did, feed_url) DO UPDATE SET
3257            read_through = excluded.read_through,
3258            read_ids     = excluded.read_ids,
3259            unread_ids   = excluded.unread_ids,
3260            dirty        = 1,
3261            updated_at   = excluded.updated_at
3262        "#,
3263    )
3264    .bind(did)
3265    .bind(feed_url)
3266    .bind(read_through)
3267    .bind(read_ids)
3268    .bind(unread_ids)
3269    .bind(now)
3270    .execute(&mut **tx)
3271    .await
3272    .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
3273    Ok(())
3274}
3275
3276/// Project a single entry's read/unread flip into its feed's read cursor.
3277///
3278/// The cursor mirrors `community.lexicon.rss.readState`: a `read_through`
3279/// high-water-mark plus two bounded exception sets. A per-article flip is
3280/// recorded in those sets (`read_ids` when read, `unread_ids` when unread), the
3281/// opposite set is cleared of the id, and the cursor is stamped + marked dirty.
3282/// This keeps the write batched by touching only the ONE per-feed cursor. (Note:
3283/// there is no compaction step yet that folds covered ids back into
3284/// `read_through`; the exception sets are expected to stay well under the cap.)
3285async fn project_entry_into_cursor(
3286    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3287    did: &str,
3288    entry_id: i64,
3289    read: bool,
3290    now: &str,
3291) -> Result<()> {
3292    // The entry's feed id → feed URL (the cursor key).
3293    let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
3294        .bind(entry_id)
3295        .fetch_optional(&mut **tx)
3296        .await
3297        .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
3298    let feed_id = match feed_id {
3299        Some(f) => f,
3300        None => return Ok(()), // entry vanished mid-tx; nothing to project
3301    };
3302    let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3303        Some(u) => u,
3304        None => return Ok(()),
3305    };
3306
3307    let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3308    // read=true: id joins read_ids, leaves unread_ids. read=false: the inverse.
3309    let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
3310    let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
3311    write_cursor_sets(
3312        tx,
3313        did,
3314        &feed_url,
3315        read_through.as_deref(),
3316        &read_ids,
3317        &unread_ids,
3318        now,
3319    )
3320    .await
3321}
3322
3323/// Project a mark-all-feed-read/unread into that feed's single read cursor.
3324///
3325/// Every entry the caller subscribes to on `feed_id` is folded into the cursor
3326/// in one write: on mark-all-READ each id joins `read_ids` (and leaves
3327/// `unread_ids`); on mark-all-UNREAD the inverse. Still ONE per-feed cursor row
3328/// (batched), stamped + dirtied for the flusher.
3329async fn project_feed_into_cursor(
3330    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3331    did: &str,
3332    feed_id: i64,
3333    read: bool,
3334    now: &str,
3335) -> Result<()> {
3336    let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3337        Some(u) => u,
3338        None => return Ok(()),
3339    };
3340
3341    // The entry ids on this feed the caller is authorized for (subscribes to).
3342    let ids: Vec<i64> = sqlx::query_scalar(
3343        r#"
3344        SELECT e.id FROM entries e
3345        WHERE e.feed_id = ?2
3346          AND EXISTS (
3347              SELECT 1 FROM sub_ref sr
3348              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3349          )
3350        "#,
3351    )
3352    .bind(did)
3353    .bind(feed_id)
3354    .fetch_all(&mut **tx)
3355    .await
3356    .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
3357
3358    let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3359    for id in ids {
3360        read_ids = json_id_set_toggle(&read_ids, id, read);
3361        unread_ids = json_id_set_toggle(&unread_ids, id, !read);
3362    }
3363    write_cursor_sets(
3364        tx,
3365        did,
3366        &feed_url,
3367        read_through.as_deref(),
3368        &read_ids,
3369        &unread_ids,
3370        now,
3371    )
3372    .await
3373}
3374
3375/// Test-only unbounded convenience wrappers over [`list_entries`].
3376///
3377/// Production code passes an explicit `limit`, because that is the whole point
3378/// of the change these replaced. Fixtures hold a handful of rows and asserting
3379/// on "the whole list" is what the tests actually mean, so they get a helper
3380/// with a stated ceiling instead of each spelling one out — and the ceiling is
3381/// high enough that a test hitting it is a broken fixture, not a truncation.
3382#[cfg(test)]
3383mod test_helpers {
3384    use super::*;
3385
3386    /// Far above any fixture; a test that reaches it has a bug of its own.
3387    const FIXTURE_MAX: i64 = 10_000;
3388
3389    pub(crate) async fn entries_for_feed(
3390        pool: &SqlitePool,
3391        did: &str,
3392        feed_id: i64,
3393    ) -> Result<Vec<EntryListRow>> {
3394        list_entries(pool, did, ListView::All, Some(&[feed_id]), FIXTURE_MAX, 0).await
3395    }
3396
3397    pub(crate) async fn get_unread_for_did(
3398        pool: &SqlitePool,
3399        did: &str,
3400    ) -> Result<Vec<EntryListRow>> {
3401        list_entries(pool, did, ListView::Unread, None, FIXTURE_MAX, 0).await
3402    }
3403
3404    pub(crate) async fn get_starred_for_did(
3405        pool: &SqlitePool,
3406        did: &str,
3407    ) -> Result<Vec<EntryListRow>> {
3408        list_entries(pool, did, ListView::Starred, None, FIXTURE_MAX, 0).await
3409    }
3410}
3411
3412#[cfg(test)]
3413pub(crate) use test_helpers::{entries_for_feed, get_starred_for_did, get_unread_for_did};
3414
3415/// Insert or update a per-`(did, feed_url)` read cursor, stamping `updated_at`.
3416/// The write path for local mark-read updates (and the seam a login-time PDS
3417/// merge would use, once that is wired).
3418pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
3419    sqlx::query(
3420        r#"
3421        INSERT INTO read_cursor
3422            (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3423        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
3424        ON CONFLICT (did, feed_url) DO UPDATE SET
3425            read_through = excluded.read_through,
3426            read_ids     = excluded.read_ids,
3427            unread_ids   = excluded.unread_ids,
3428            dirty        = excluded.dirty,
3429            updated_at   = excluded.updated_at
3430        "#,
3431    )
3432    .bind(&cursor.did)
3433    .bind(&cursor.feed_url)
3434    .bind(&cursor.read_through)
3435    .bind(&cursor.read_ids)
3436    .bind(&cursor.unread_ids)
3437    .bind(cursor.dirty)
3438    .bind(&cursor.updated_at)
3439    .execute(pool)
3440    .await
3441    .with_context(|| {
3442        format!(
3443            "upsert_cursor failed for {}/{}",
3444            cursor.did, cursor.feed_url
3445        )
3446    })?;
3447    Ok(())
3448}
3449
3450/// Fetch a single read cursor, if present.
3451pub async fn get_cursor(
3452    pool: &SqlitePool,
3453    did: &str,
3454    feed_url: &str,
3455) -> Result<Option<ReadCursor>> {
3456    let cursor = sqlx::query_as::<_, ReadCursor>(
3457        "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
3458    )
3459    .bind(did)
3460    .bind(feed_url)
3461    .fetch_optional(pool)
3462    .await
3463    .context("get_cursor failed")?;
3464    Ok(cursor)
3465}
3466
3467/// The flusher's hot query: every cursor with `dirty = 1` for a DID — the ones
3468/// whose read-state changed since the last batched PDS flush.
3469/// How many DIDs hold read-state that cannot currently be flushed: dirty
3470/// cursors with no OAuth session to send them with.
3471///
3472/// **The visible form of the parked state (#117).** The flusher deliberately
3473/// stops warning about these every round, and quiet-and-invisible would be a
3474/// worse bug than the noisy loop it replaces — so the count is surfaced on
3475/// `/admin/metrics`. A non-zero number is not itself an alarm: it is the normal
3476/// state of anyone signed out with unsynced reads. A number that only ever
3477/// grows is the thing to look at.
3478///
3479/// Rust-backend shaped: it asks about `oauth_session`, which is the Rust
3480/// backend's store. On the sidecar backend it over-reports, since those
3481/// sessions live in the sidecar's own database. Prod runs `rust` and the
3482/// sidecar is removed by #18.
3483pub async fn parked_readstate_dids(pool: &SqlitePool) -> Result<i64> {
3484    let row: (i64,) = sqlx::query_as(
3485        r#"
3486        SELECT COUNT(DISTINCT rc.did)
3487          FROM read_cursor rc
3488         WHERE rc.dirty = 1
3489           AND NOT EXISTS (SELECT 1 FROM oauth_session s WHERE s.sub = rc.did)
3490        "#,
3491    )
3492    .fetch_one(pool)
3493    .await
3494    .context("counting parked read-state DIDs")?;
3495    Ok(row.0)
3496}
3497
3498pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
3499    let cursors =
3500        sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
3501            .bind(did)
3502            .fetch_all(pool)
3503            .await
3504            .with_context(|| format!("dirty_cursors failed for {did}"))?;
3505    Ok(cursors)
3506}
3507
3508// ---------------------------------------------------------------------------
3509// Network observations (the adoption probe's projection)
3510// ---------------------------------------------------------------------------
3511
3512/// Record one relay's observation, keyed by `(key, source)` so each relay's
3513/// number is kept separately (non-archival relays legitimately disagree).
3514///
3515/// An upsert: the table is bounded forever at (metrics × relays) rows — two
3516/// today — so this can never grow the DB. It must stay an upsert and never
3517/// become a per-DID insert.
3518///
3519/// **A truncated observation never lowers a stored count.** A truncated walk
3520/// saw only part of the network, so a smaller number is evidence about the
3521/// *walk*, not about adoption. Without the guard, one slow run that managed a
3522/// single 500-repo page would overwrite a complete 2 000 and drag the published
3523/// "at least N" down — and because `latest_network_stat` takes the max across
3524/// sources, two relays behind the same operator degrade together, so `/about`
3525/// would sit at the lower figure until a full walk succeeded again. A COMPLETE
3526/// observation always wins, even when smaller (repos genuinely can disappear);
3527/// a truncated one may only ever raise the floor — and an EQUAL count raises
3528/// nothing, so it is rejected too. That is why the guard reads `<=` and not
3529/// `<`: the strict form let a truncated walk that merely matched the stored
3530/// number rewrite the row and flip `truncated` on, degrading "2 000" to "at
3531/// least 2 000" with no change in adoption.
3532pub async fn record_network_stat(pool: &SqlitePool, stat: &NetworkStat) -> Result<()> {
3533    sqlx::query(
3534        r#"
3535        INSERT INTO network_stat (key, source, value, truncated, observed_at)
3536        VALUES (?1, ?2, ?3, ?4, ?5)
3537        ON CONFLICT (key, source) DO UPDATE SET
3538            value       = excluded.value,
3539            truncated   = excluded.truncated,
3540            observed_at = excluded.observed_at
3541        WHERE NOT (excluded.truncated = 1 AND excluded.value <= network_stat.value)
3542        "#,
3543    )
3544    .bind(&stat.key)
3545    .bind(&stat.source)
3546    .bind(stat.value)
3547    .bind(stat.truncated)
3548    .bind(&stat.observed_at)
3549    .execute(pool)
3550    .await
3551    .with_context(|| {
3552        format!(
3553            "record_network_stat failed for {}/{}",
3554            stat.key, stat.source
3555        )
3556    })?;
3557    Ok(())
3558}
3559
3560/// The highest observation for `key` across every relay — the number to surface
3561/// (`design/NETWORK-SPEC.md` §4.1: relays disagree; show the max). `None` when no
3562/// probe has ever succeeded.
3563pub async fn latest_network_stat(pool: &SqlitePool, key: &str) -> Result<Option<NetworkStat>> {
3564    let stat = sqlx::query_as::<_, NetworkStat>(
3565        "SELECT key, source, value, truncated, observed_at FROM network_stat \
3566         WHERE key = ?1 ORDER BY value DESC, observed_at DESC LIMIT 1",
3567    )
3568    .bind(key)
3569    .fetch_optional(pool)
3570    .await
3571    .with_context(|| format!("latest_network_stat failed for {key}"))?;
3572    Ok(stat)
3573}
3574
3575/// Mark a cursor's PDS `readState` record as CREATED after the flush that first
3576/// created it, so subsequent flushes emit an `update` instead of another
3577/// `create`. Idempotent; a no-op if the row is gone.
3578pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
3579    sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
3580        .bind(did)
3581        .bind(feed_url)
3582        .execute(pool)
3583        .await
3584        .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
3585    Ok(())
3586}
3587
3588/// Set a cursor's `pds_created` flag to what the PDS was just observed to hold.
3589///
3590/// [`mark_cursor_pds_created`] only ever sets it, because a successful create is
3591/// the only event the flusher used to learn from. The read-state reconcile
3592/// (#241) learns from a listing, and a listing can say the record is GONE —
3593/// deleted by another client or a repo reset — so it needs the other direction
3594/// too, or every later flush sends `#update` to a key that does not exist.
3595pub async fn set_cursor_pds_created(
3596    pool: &SqlitePool,
3597    did: &str,
3598    feed_url: &str,
3599    created: bool,
3600) -> Result<()> {
3601    sqlx::query("UPDATE read_cursor SET pds_created = ?3 WHERE did = ?1 AND feed_url = ?2")
3602        .bind(did)
3603        .bind(feed_url)
3604        .bind(created)
3605        .execute(pool)
3606        .await
3607        .with_context(|| format!("set_cursor_pds_created failed for {did}/{feed_url}"))?;
3608    Ok(())
3609}
3610
3611/// Clear the `dirty` flag on a cursor after a successful PDS flush — but ONLY if
3612/// the row still carries the exact `flushed_updated_at` snapshot we flushed.
3613///
3614/// The flusher reads a cursor, sends it to the PDS (a network round-trip), then
3615/// clears `dirty`. A concurrent [`upsert_cursor`] (a fresh mark-read) can land
3616/// DURING that in-flight write, bumping `updated_at` and re-setting `dirty = 1`
3617/// for reads that were NOT in the flushed snapshot. An unconditional
3618/// `SET dirty = 0` would silently drop those reads. Guarding on the snapshot's
3619/// `updated_at` makes this a compare-and-swap: if `updated_at` changed under us,
3620/// zero rows update, the row stays dirty, and it re-flushes next round.
3621pub async fn clear_cursor_dirty(
3622    pool: &SqlitePool,
3623    did: &str,
3624    feed_url: &str,
3625    flushed_updated_at: &str,
3626) -> Result<()> {
3627    sqlx::query(
3628        "UPDATE read_cursor SET dirty = 0 \
3629         WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
3630    )
3631    .bind(did)
3632    .bind(feed_url)
3633    .bind(flushed_updated_at)
3634    .execute(pool)
3635    .await
3636    .context("clear_cursor_dirty failed")?;
3637    Ok(())
3638}
3639
3640// ---------------------------------------------------------------------------
3641// Closed-beta invite gate (beta_access + invite_codes)
3642// ---------------------------------------------------------------------------
3643//
3644// Ported in SHAPE from a prior Go beta-gate (RedeemCode / CreateInviteCode /
3645// code_gen) but deliberately trimmed for FeatherReader's before-public
3646// experiment: NO viral invite-budget tree, NO generation cap, NO waitlist /
3647// invite-request table, and SQLite instead of Mongo. A code is minted by an
3648// existing member (or admin), and redeeming it grants a seat while seats remain
3649// under the configured cap.
3650
3651/// Unix-epoch seconds for "now" — the integer time base for the beta tables.
3652pub(crate) fn now_unix() -> i64 {
3653    chrono::Utc::now().timestamp()
3654}
3655
3656/// The invite-code alphabet: uppercase letters + digits with the
3657/// visually-ambiguous glyphs removed (`I`, `O`, `0`, `1`) so a code read aloud
3658/// or copied by hand is unambiguous.
3659const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
3660
3661/// Human-facing prefix so a FeatherReader invite code is recognisable at a
3662/// glance.
3663const CODE_PREFIX: &str = "FEATHER-";
3664
3665/// Number of random characters after the prefix.
3666const CODE_BODY_LEN: usize = 8;
3667
3668/// Generate a random, unguessable invite code of the form `FEATHER-XXXXXXXX`.
3669///
3670/// Draws from the OS CSPRNG (`getrandom`) and maps each byte onto
3671/// `CODE_ALPHABET` via rejection sampling so the alphabet distribution is
3672/// uniform (no modulo bias). Infallible in practice; a `getrandom` failure
3673/// (no entropy source) propagates as an error rather than a weak code.
3674pub fn generate_invite_code() -> Result<String> {
3675    let n = CODE_ALPHABET.len() as u16; // 31
3676                                        // Largest multiple of `n` that fits in a byte; bytes at or above it are
3677                                        // rejected so every accepted byte maps uniformly onto the alphabet.
3678    let limit = 256 / n * n; // 256 - (256 % n)
3679    let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
3680    out.push_str(CODE_PREFIX);
3681    let mut got = 0;
3682    let mut buf = [0u8; 1];
3683    while got < CODE_BODY_LEN {
3684        getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
3685        let b = buf[0] as u16;
3686        if b < limit {
3687            out.push(CODE_ALPHABET[(b % n) as usize] as char);
3688            got += 1;
3689        }
3690    }
3691    Ok(out)
3692}
3693
3694/// Whether a DID currently holds a beta seat.
3695pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
3696    let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
3697        .bind(did)
3698        .fetch_optional(pool)
3699        .await
3700        .with_context(|| format!("has_beta_access failed for {did}"))?;
3701    Ok(row.is_some())
3702}
3703
3704/// Count the beta seats currently granted — the numerator checked against the
3705/// configured cap on redeem.
3706pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
3707    let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3708        .fetch_one(pool)
3709        .await
3710        .context("count_beta_access failed")?;
3711    Ok(row.get::<i64, _>("n"))
3712}
3713
3714/// Count `active`, unexpired invite codes — the outstanding-but-unredeemed seats
3715/// a bot has already promised. Added to [`count_beta_access`] this is the "seats
3716/// committed" figure the bot mint path (`POST /bot/claims`) checks against the
3717/// cap, so it doesn't over-promise more claims than seats remain (the redeem-time
3718/// cap in [`redeem_code`] is the hard backstop; this avoids telling a follower
3719/// "you're in" for a seat that will be full by the time they claim it).
3720pub async fn count_active_codes(pool: &SqlitePool) -> Result<i64> {
3721    let now = now_unix();
3722    let row = sqlx::query(
3723        "SELECT COUNT(*) AS n FROM invite_codes WHERE status = 'active' AND expires_at >= ?1",
3724    )
3725    .bind(now)
3726    .fetch_one(pool)
3727    .await
3728    .context("count_active_codes failed")?;
3729    Ok(row.get::<i64, _>("n"))
3730}
3731
3732/// Grant a beta seat directly (admin / seed path — no code consumed). Idempotent
3733/// on `did` (re-granting updates the row rather than erroring).
3734pub async fn grant_access(
3735    pool: &SqlitePool,
3736    did: &str,
3737    handle: Option<&str>,
3738    granted_by: &str,
3739    invite_code_used: Option<&str>,
3740) -> Result<()> {
3741    sqlx::query(
3742        r#"
3743        INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3744        VALUES (?1, ?2, ?3, ?4, ?5)
3745        ON CONFLICT (did) DO UPDATE SET
3746            handle           = COALESCE(excluded.handle, beta_access.handle),
3747            granted_by       = excluded.granted_by,
3748            invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
3749        "#,
3750    )
3751    .bind(did)
3752    .bind(handle)
3753    .bind(granted_by)
3754    .bind(now_unix())
3755    .bind(invite_code_used)
3756    .execute(pool)
3757    .await
3758    .with_context(|| format!("grant_access failed for {did}"))?;
3759    Ok(())
3760}
3761
3762/// Mint a new `active` invite code owned by `creator_did`, expiring `ttl_secs`
3763/// from now. Returns the generated code string. The browser/admin path leaves the
3764/// bot idempotency key (`intended_did`) NULL; see [`mint_code_for_did`] for the
3765/// bot path that records the target follower.
3766pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
3767    mint_code_inner(pool, creator_did, ttl_secs, None).await
3768}
3769
3770/// Like [`mint_code`] but records the follower `intended_did` the code is minted
3771/// FOR, so a later `POST /bot/claims` for the same DID can return the SAME code
3772/// (see [`find_active_code_for_did`]) rather than minting a duplicate. This is the
3773/// app-side idempotency backstop that survives a bot-host state loss.
3774pub async fn mint_code_for_did(
3775    pool: &SqlitePool,
3776    creator_did: &str,
3777    ttl_secs: i64,
3778    intended_did: &str,
3779) -> Result<String> {
3780    mint_code_inner(pool, creator_did, ttl_secs, Some(intended_did)).await
3781}
3782
3783async fn mint_code_inner(
3784    pool: &SqlitePool,
3785    creator_did: &str,
3786    ttl_secs: i64,
3787    intended_did: Option<&str>,
3788) -> Result<String> {
3789    let code = generate_invite_code()?;
3790    let now = now_unix();
3791    let expires_at = now.saturating_add(ttl_secs.max(0));
3792    sqlx::query(
3793        r#"
3794        INSERT INTO invite_codes
3795            (code, creator_did, status, invitee_did, intended_did, created_at, expires_at, redeemed_at)
3796        VALUES (?1, ?2, 'active', NULL, ?3, ?4, ?5, NULL)
3797        "#,
3798    )
3799    .bind(&code)
3800    .bind(creator_did)
3801    .bind(intended_did)
3802    .bind(now)
3803    .bind(expires_at)
3804    .execute(pool)
3805    .await
3806    .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
3807    Ok(code)
3808}
3809
3810/// Does this error chain represent the partial-unique-index conflict raised when
3811/// a SECOND active claim is minted for a DID that already has one
3812/// (`idx_invite_codes_intended_active`)? The web layer uses this to recover from a
3813/// lost mint race (S4): on a conflict it re-reads the winner's code instead of
3814/// 500-ing. Matches on the sqlx `Database` error's UNIQUE-constraint code (SQLite
3815/// 2067 / primary 19) AND the offending COLUMN in the message
3816/// (`invite_codes.intended_did` — SQLite names the column(s), not the index), so an
3817/// unrelated constraint violation (e.g. the `code` PRIMARY KEY) is NOT swallowed.
3818pub fn is_intended_active_conflict(err: &anyhow::Error) -> bool {
3819    for cause in err.chain() {
3820        if let Some(sqlx::Error::Database(db)) = cause.downcast_ref::<sqlx::Error>() {
3821            let msg = db.message();
3822            // SQLite reports UNIQUE violations with (primary) code 19 /
3823            // (extended) 2067; the message names the offending column(s), e.g.
3824            // "UNIQUE constraint failed: invite_codes.intended_did".
3825            let is_unique = db.code().as_deref() == Some("2067")
3826                || db.code().as_deref() == Some("19")
3827                || msg.contains("UNIQUE constraint failed");
3828            // Scope to the intended_did index specifically. Only that index and the
3829            // `code` PRIMARY KEY can raise a UNIQUE error here; the partial unique
3830            // index is the only one over `intended_did`, so the column reference
3831            // uniquely identifies it.
3832            if is_unique && msg.contains("invite_codes.intended_did") {
3833                return true;
3834            }
3835        }
3836    }
3837    false
3838}
3839
3840/// The `code` of an outstanding (`active`, unexpired) invite minted FOR the
3841/// follower `intended_did`, if one exists — the app-side idempotency lookup for
3842/// `POST /bot/claims`. `Some(code)` means "return this existing code, do NOT mint
3843/// a second"; `None` means "no live code for this DID — mint one".
3844///
3845/// S3 — this lookup ONLY returns `active`, UNEXPIRED codes; once a code passes
3846/// `expires_at` (or `expire_old_codes` flips it to `expired`) this returns `None`,
3847/// so the next `POST /bot/claims` MINTS A FRESH code for the DID. There is no
3848/// in-place "refresh" of an expired code (the partial-unique index only constrains
3849/// `active` rows, so a fresh mint after expiry is allowed). The bot then re-posts:
3850/// its record rkey is deterministic per DID, so the existing skeet is UPDATED in
3851/// place with the new claim URL (see the bot's `reconcile_stale_record`, S1) rather
3852/// than a second skeet being posted. NOTE: a bot-`delivered` follower whose link
3853/// expired UNCLAIMED is only re-minted if the bot re-processes that DID (a re-seen
3854/// follow, a `waitlisted` retry, or a bot-store reset); manual recovery is to clear
3855/// the bot's `handled` row for that DID so the next cycle re-mints + re-posts.
3856/// If several live codes somehow exist (a race), the soonest-expiring is returned.
3857pub async fn find_active_code_for_did(
3858    pool: &SqlitePool,
3859    intended_did: &str,
3860) -> Result<Option<String>> {
3861    let now = now_unix();
3862    let row = sqlx::query(
3863        "SELECT code FROM invite_codes
3864         WHERE intended_did = ?1 AND status = 'active' AND expires_at >= ?2
3865         ORDER BY expires_at ASC
3866         LIMIT 1",
3867    )
3868    .bind(intended_did)
3869    .bind(now)
3870    .fetch_optional(pool)
3871    .await
3872    .with_context(|| format!("find_active_code_for_did failed for {intended_did}"))?;
3873    Ok(row.map(|r| r.get::<String, _>("code")))
3874}
3875
3876/// Atomically redeem an invite code for `did`, granting a beta seat.
3877///
3878/// Runs entirely in one transaction so the capacity check and the seat grant
3879/// cannot race (two redeems can't both slip past a `cap - 1` count). Steps:
3880/// 1. verify the code exists, is `active`, and is not past `expires_at`;
3881/// 2. verify the current seat count is `< cap`;
3882/// 3. flip the code `active`→`redeemed` (stamping `invitee_did` + `redeemed_at`);
3883/// 4. insert the `beta_access` row.
3884///
3885/// On a policy failure returns the matching [`RedeemError`] (the tx rolls back);
3886/// a real SQLite error propagates as the outer [`anyhow::Error`].
3887pub async fn redeem_code(
3888    pool: &SqlitePool,
3889    code: &str,
3890    did: &str,
3891    handle: Option<&str>,
3892    cap: i64,
3893) -> Result<std::result::Result<(), RedeemError>> {
3894    let now = now_unix();
3895    let mut tx = pool.begin().await.context("begin redeem_code tx")?;
3896
3897    // Take the write lock at the START of the transaction. sqlx issues a plain
3898    // deferred BEGIN, so without this the capacity SELECT below runs under a read
3899    // snapshot: two concurrent redeems could both pass the gate, and the loser's
3900    // later UPDATE would fail with SQLITE_BUSY_SNAPSHOT (which busy_timeout does
3901    // NOT retry) — an opaque error instead of a clean CapacityFull. A leading
3902    // no-op write against the target row acquires the RESERVED lock immediately
3903    // (SQLite locks on any write statement, even one matching zero rows), so the
3904    // second redeem blocks on the first, then reads the post-commit seat count
3905    // and returns CapacityFull. (The cap already held via snapshot isolation;
3906    // this upgrades the failure mode from a hard error to the right one.)
3907    sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
3908        .bind(code)
3909        .execute(&mut *tx)
3910        .await
3911        .context("redeem_code: acquire write lock")?;
3912
3913    // 1. Look the code up.
3914    let row =
3915        sqlx::query("SELECT status, expires_at, intended_did FROM invite_codes WHERE code = ?1")
3916            .bind(code)
3917            .fetch_optional(&mut *tx)
3918            .await
3919            .context("redeem_code: lookup")?;
3920    let row = match row {
3921        Some(r) => r,
3922        None => return Ok(Err(RedeemError::NotFound)),
3923    };
3924    let status: String = row.get("status");
3925    let expires_at: i64 = row.get("expires_at");
3926    let intended_did: Option<String> = row.get("intended_did");
3927
3928    // DID-binding gate (blocker B2). A bot-minted claim link is posted PUBLICLY
3929    // with a non-confidential token, so anyone who sees a follower's reply could
3930    // redeem it with a throwaway account — defeating the follow-gate, the daily
3931    // sybil budget, and the rate limit. When the code was minted FOR a specific
3932    // follower (`intended_did IS NOT NULL`), only that DID may redeem it; anyone
3933    // else gets a `NotFound` (indistinguishable from a bad code — no oracle).
3934    // Codes with a NULL `intended_did` (admin/browser-minted) stay open, as
3935    // before — those are meant to be sharable.
3936    if let Some(bound) = intended_did.as_deref() {
3937        if bound != did {
3938            return Ok(Err(RedeemError::NotFound));
3939        }
3940    }
3941
3942    // Status gate: only an `active` code is redeemable. Anything already
3943    // redeemed/revoked is "already redeemed" from the redeemer's view; an
3944    // `expired` status (or a past expiry) is "expired".
3945    if status == "expired" || now > expires_at {
3946        return Ok(Err(RedeemError::Expired));
3947    }
3948    if status != "active" {
3949        return Ok(Err(RedeemError::AlreadyRedeemed));
3950    }
3951
3952    // 2. Capacity gate (inside the tx so it can't race a concurrent redeem).
3953    let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3954        .fetch_one(&mut *tx)
3955        .await
3956        .context("redeem_code: count")?
3957        .get("n");
3958    if count >= cap {
3959        return Ok(Err(RedeemError::CapacityFull));
3960    }
3961
3962    // 3. Flip the code active→redeemed. The `status = 'active'` guard in the
3963    // WHERE makes this a compare-and-swap: if a concurrent tx already flipped it
3964    // (despite the read above), zero rows change and we treat it as redeemed.
3965    let flipped = sqlx::query(
3966        r#"
3967        UPDATE invite_codes
3968        SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
3969        WHERE code = ?1 AND status = 'active'
3970        "#,
3971    )
3972    .bind(code)
3973    .bind(did)
3974    .bind(now)
3975    .execute(&mut *tx)
3976    .await
3977    .context("redeem_code: flip")?;
3978    if flipped.rows_affected() == 0 {
3979        return Ok(Err(RedeemError::AlreadyRedeemed));
3980    }
3981
3982    // 4. Grant the seat.
3983    sqlx::query(
3984        r#"
3985        INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3986        VALUES (?1, ?2, ?3, ?4, ?5)
3987        ON CONFLICT (did) DO UPDATE SET
3988            handle           = COALESCE(excluded.handle, beta_access.handle),
3989            invite_code_used = excluded.invite_code_used
3990        "#,
3991    )
3992    .bind(did)
3993    .bind(handle)
3994    // granted_by is the code's creator; look it up in-tx to keep provenance.
3995    .bind(
3996        sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
3997            .bind(code)
3998            .fetch_one(&mut *tx)
3999            .await
4000            .context("redeem_code: creator lookup")?
4001            .get::<String, _>("creator_did"),
4002    )
4003    .bind(now)
4004    .bind(code)
4005    .execute(&mut *tx)
4006    .await
4007    .context("redeem_code: grant")?;
4008
4009    tx.commit().await.context("commit redeem_code tx")?;
4010    Ok(Ok(()))
4011}
4012
4013/// Sweep: flip every `active` code whose `expires_at` is in the past to
4014/// `expired`. Returns the number of codes expired. Called periodically by the
4015/// scheduler.
4016pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
4017    let now = now_unix();
4018    let res = sqlx::query(
4019        "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
4020    )
4021    .bind(now)
4022    .execute(pool)
4023    .await
4024    .context("expire_old_codes failed")?;
4025    Ok(res.rows_affected())
4026}
4027
4028/// Seed the admin-bootstrap DIDs: for each, insert a `beta_access` row
4029/// (`granted_by = 'admin'`) if one does not already exist. Idempotent — an
4030/// existing seat is left untouched. Returns how many new seats were created.
4031pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
4032    let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
4033    let now = now_unix();
4034    let mut created = 0u64;
4035    for did in dids {
4036        let res = sqlx::query(
4037            r#"
4038            INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
4039            VALUES (?1, NULL, 'admin', ?2, NULL)
4040            ON CONFLICT (did) DO NOTHING
4041            "#,
4042        )
4043        .bind(did)
4044        .bind(now)
4045        .execute(&mut *tx)
4046        .await
4047        .with_context(|| format!("ensure_seed insert failed for {did}"))?;
4048        created += res.rows_affected();
4049    }
4050    tx.commit().await.context("commit ensure_seed tx")?;
4051    Ok(created)
4052}
4053
4054/// The row counts purged by [`purge_did_data`], for a confirmable success
4055/// message and for assertions in tests.
4056#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
4057pub struct PurgeCounts {
4058    /// `entry_state` rows removed (per-DID read/star flags).
4059    pub entry_state: u64,
4060    /// `read_cursor` rows removed (per-DID per-feed read cursors).
4061    pub read_cursor: u64,
4062    /// `sub_ref` rows removed (the DID's subscription projection).
4063    pub sub_ref: u64,
4064    /// `beta_access` rows removed (the DID's closed-beta seat: 0 or 1).
4065    pub beta_access: u64,
4066    /// `invite_codes` rows removed (codes this DID *created*).
4067    pub invite_codes: u64,
4068    /// `invite_codes` rows *scrubbed* (the code this DID *redeemed* to join —
4069    /// its `invitee_did` back-reference cleared to NULL, row kept).
4070    pub invitee_scrubbed: u64,
4071    /// `beta_access` rows *scrubbed* (seats this DID *granted* to others — the
4072    /// `granted_by` back-reference redacted to a sentinel, row kept).
4073    pub granted_by_scrubbed: u64,
4074}
4075
4076impl PurgeCounts {
4077    /// Total rows removed across every per-DID table. (Scrub counts are tracked
4078    /// separately — those rows belong to *other* DIDs and are redacted, not
4079    /// deleted — so they are excluded from the delete total.)
4080    pub fn total(&self) -> u64 {
4081        self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
4082    }
4083}
4084
4085/// Sentinel written into `beta_access.granted_by` when the granting DID deletes
4086/// its data: the column is `NOT NULL`, so we redact rather than NULL it. Keeps
4087/// the grantee's seat valid while removing the departed DID's back-reference.
4088pub const REDACTED_DID: &str = "__redacted__";
4089
4090/// Delete **all** local rows owned by `did` in a single transaction: the
4091/// per-DID read/star state (`entry_state`), per-feed read cursors
4092/// (`read_cursor`), the subscription projection (`sub_ref`), the closed-beta
4093/// seat (`beta_access`), and any invite codes this DID *created*
4094/// (`invite_codes`). The shared `feeds`/`entries` cache is intentionally left
4095/// intact — it is deduped and not owned by any single DID.
4096///
4097/// This is the local half of "delete my data": the caller pairs it with a
4098/// sidecar `POST /internal/revoke` so the OAuth tokens + sidecar session rows
4099/// are dropped too. Idempotent — deleting a DID with no rows returns all-zero
4100/// counts.
4101pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
4102    let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
4103
4104    let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
4105        .bind(did)
4106        .execute(&mut *tx)
4107        .await
4108        .with_context(|| format!("purge entry_state for {did}"))?
4109        .rows_affected();
4110
4111    let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
4112        .bind(did)
4113        .execute(&mut *tx)
4114        .await
4115        .with_context(|| format!("purge read_cursor for {did}"))?
4116        .rows_affected();
4117
4118    let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
4119        .bind(did)
4120        .execute(&mut *tx)
4121        .await
4122        .with_context(|| format!("purge sub_ref for {did}"))?
4123        .rows_affected();
4124
4125    let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
4126        .bind(did)
4127        .execute(&mut *tx)
4128        .await
4129        .with_context(|| format!("purge beta_access for {did}"))?
4130        .rows_affected();
4131
4132    let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
4133        .bind(did)
4134        .execute(&mut *tx)
4135        .await
4136        .with_context(|| format!("purge invite_codes for {did}"))?
4137        .rows_affected();
4138
4139    // Scrub the DID's back-references from rows that belong to OTHER DIDs so no
4140    // per-DID residue survives the delete:
4141    //   * the invite code this DID *redeemed* to join lives on the inviter's
4142    //     row (`invitee_did`) — NULL it out (column is nullable).
4143    //   * seats this DID *granted* to others carry `granted_by = <this did>` —
4144    //     redact to a sentinel (column is NOT NULL) so the grantee keeps access
4145    //     without retaining the departed DID.
4146    let invitee_scrubbed =
4147        sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
4148            .bind(did)
4149            .execute(&mut *tx)
4150            .await
4151            .with_context(|| format!("scrub invitee_did for {did}"))?
4152            .rows_affected();
4153
4154    // A departing DID may also be the TARGET of an outstanding bot claim
4155    // (`intended_did`, minted for them before they joined/left) — NULL it so no
4156    // per-DID residue survives. We ALSO expire the orphaned code in the same tx:
4157    // once `intended_did` is NULLed, an `active` row would otherwise keep counting
4158    // against the daily mint cap for its full 14-day TTL (and a re-follow would
4159    // double-count it), so `expired` it now. `redeemed`/already-`expired` rows are
4160    // untouched (the WHERE only matches `active`). (Cheap nit — purge orphan.)
4161    sqlx::query(
4162        "UPDATE invite_codes \
4163         SET intended_did = NULL, \
4164             status = CASE WHEN status = 'active' THEN 'expired' ELSE status END \
4165         WHERE intended_did = ?1",
4166    )
4167    .bind(did)
4168    .execute(&mut *tx)
4169    .await
4170    .with_context(|| format!("scrub intended_did for {did}"))?;
4171
4172    let granted_by_scrubbed =
4173        sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
4174            .bind(did)
4175            .bind(REDACTED_DID)
4176            .execute(&mut *tx)
4177            .await
4178            .with_context(|| format!("scrub granted_by for {did}"))?
4179            .rows_affected();
4180
4181    tx.commit().await.context("commit purge_did_data tx")?;
4182
4183    Ok(PurgeCounts {
4184        entry_state,
4185        read_cursor,
4186        sub_ref,
4187        beta_access,
4188        invite_codes,
4189        invitee_scrubbed,
4190        granted_by_scrubbed,
4191    })
4192}
4193
4194/// Aggregate poll health, for the public stats page.
4195///
4196/// **Deliberately aggregate-only.** No user counts, no error rates, no per-feed
4197/// detail: this is published to anyone, and a reader does not need to know how
4198/// many people use an instance or which feeds are failing. What it does answer
4199/// is the only question the page exists for — is the poller keeping up?
4200#[derive(Debug, Clone, PartialEq, Eq)]
4201pub struct PollHealth {
4202    /// Distinct feeds the poller is responsible for.
4203    pub feeds_tracked: i64,
4204    /// How many were polled within the last hour.
4205    pub polled_last_hour: i64,
4206    /// Feeds whose `next_poll` has passed — the backlog. A healthy instance
4207    /// clears this every tick; a growing number is the signal that the poller
4208    /// cannot keep up with the feed count.
4209    pub overdue: i64,
4210    /// Seconds since the most recent poll of any feed. `None` before the first.
4211    pub last_poll_secs_ago: Option<i64>,
4212    /// Seconds since the LEAST recently polled feed was polled — the worst
4213    /// staleness any reader is currently seeing.
4214    ///
4215    /// `None` when any feed has NEVER been polled, because that is a worse
4216    /// staleness than any finite age and reporting the finite one would make
4217    /// the page read healthiest exactly when it is least healthy.
4218    pub oldest_poll_secs_ago: Option<i64>,
4219    /// How many feeds have never been polled at all.
4220    pub never_polled: i64,
4221    /// Feeds currently in error backoff (`consecutive_errors > 0`).
4222    ///
4223    /// One of the two states that stop feeds updating, and previously visible
4224    /// nowhere: `consecutive_errors` was written by `bump_feed_errors` and read
4225    /// by nothing outside the backoff calculation — no page, no endpoint. Worse,
4226    /// a feed in backoff is NOT counted in `overdue`, because backoff is applied
4227    /// by pushing `next_poll` forward. So the one number a reader might have
4228    /// checked moved the wrong way: a feed failing every fetch made `overdue`
4229    /// look BETTER.
4230    pub in_backoff: i64,
4231    /// Of those, how many have reached `BADLY_BROKEN_ERRORS` consecutive
4232    /// failures — retried 2h40m apart rather than every 5 minutes.
4233    ///
4234    /// Not "will not recover on their own": the backoff ceiling is 24h at ten
4235    /// errors, and any of these recovers on its next successful poll. See
4236    /// `BADLY_BROKEN_ERRORS`.
4237    pub badly_broken: i64,
4238    /// Failing feeds grouped by **cause**, descending, as
4239    /// `(kind, count)` — `fetch`, `status`, `body`, `parse`.
4240    ///
4241    /// **Counts, never identities.** `/stats` is public and states that it
4242    /// reports machines rather than people: no per-feed detail, never which feed
4243    /// and never whose. A cause histogram keeps that promise and still answers
4244    /// the question `badly_broken` could not — whether sixty feeds are failing
4245    /// for sixty reasons or for one. Had this existed, #159 would have read
4246    /// `fetch: 60` on a page anyone could load, instead of costing a production
4247    /// investigation.
4248    pub failure_kinds: Vec<(String, i64)>,
4249}
4250
4251/// `consecutive_errors` at or above which a feed counts as `badly_broken`.
4252///
4253/// Chosen to mean "this is not a transient blip": `feed::backoff_for` climbs
4254/// exponentially, so by this many consecutive failures a feed is being retried
4255/// **2h40m apart** — `backoff_for(6)`.
4256///
4257/// **Not "at or near the ceiling", and not "effectively dead".** `BACKOFF_MAX`
4258/// is 24h and is first reached at *ten* errors, so a feed at this threshold is
4259/// still retried around nine times a day and recovers on its own the moment the
4260/// cause clears. Three doc comments claimed otherwise, and the claim was
4261/// load-bearing in the wrong direction.
4262///
4263/// **It says nothing about whose fault the failure is, and used to claim it
4264/// did.** This comment and the matching `/stats` copy read "almost certainly
4265/// gone rather than flaky" until 2026-09-20, when #159 found that 60-odd feeds
4266/// sat here because `guarded_get` was reading every `304 Not Modified` as a
4267/// malformed redirect. The publishers were live; the reader was broken. That
4268/// assertion is what stopped anyone looking, which is why `last_error_kind`
4269/// now exists — the row can answer the question the count never could.
4270const BADLY_BROKEN_ERRORS: i64 = 6;
4271
4272/// Compute [`PollHealth`] as of `now` (RFC3339, seconds precision — the same
4273/// format the scheduler writes, so the comparisons are lexicographic).
4274pub async fn poll_health(pool: &SqlitePool, now: &str, hour_ago: &str) -> Result<PollHealth> {
4275    // **Only what the poller sees.** `due_feeds` skips `at://` rows, so nothing
4276    // ever advances their `next_poll` or sets `last_polled`; counted here they
4277    // read as overdue and never-polled forever and force "oldest poll" to
4278    // `never` — unsupported shown as broken, on a public page, permanently.
4279    // The same predicate as the scheduler's, so the two cannot disagree.
4280    let aggregate = format!(
4281        r#"
4282        SELECT
4283            COUNT(*),
4284            COALESCE(SUM(CASE WHEN last_polled IS NOT NULL AND last_polled >= ?2 THEN 1 ELSE 0 END), 0),
4285            COALESCE(SUM(CASE WHEN next_poll IS NULL OR next_poll <= ?1 THEN 1 ELSE 0 END), 0),
4286            MAX(last_polled),
4287            -- NULL-AWARE. `MIN` skips NULLs, so an instance where most feeds
4288            -- had NEVER been polled reported the freshest of the few that had —
4289            -- the figure read healthiest in the most degraded state, which is
4290            -- the opposite of what a health page is for. A never-polled feed IS
4291            -- the worst staleness, so it wins outright.
4292            CASE WHEN SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END) > 0
4293                 THEN NULL ELSE MIN(last_polled) END,
4294            SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END),
4295            COALESCE(SUM(CASE WHEN consecutive_errors > 0 THEN 1 ELSE 0 END), 0),
4296            COALESCE(SUM(CASE WHEN consecutive_errors >= ?3 THEN 1 ELSE 0 END), 0)
4297        FROM feeds
4298        WHERE kind IN ({POLLABLE_KINDS_SQL})
4299        "#
4300    );
4301    #[allow(clippy::type_complexity)]
4302    let row: (i64, i64, i64, Option<String>, Option<String>, i64, i64, i64) =
4303        sqlx::query_as(sqlx::AssertSqlSafe(aggregate))
4304            .bind(now)
4305            .bind(hour_ago)
4306            .bind(BADLY_BROKEN_ERRORS)
4307            .fetch_one(pool)
4308            .await
4309            .context("computing poll health")?;
4310
4311    // A second, tiny query rather than a join: the histogram groups rows the
4312    // aggregate above collapses, and one statement doing both would make the
4313    // counts above harder to read than the extra round trip is worth.
4314    //
4315    // **Every failing feed lands in a bucket, so this sums to `in_backoff`.**
4316    //
4317    // A row that predates the column is failing with no recorded cause, and it
4318    // must not be attributed to some other feed's reason — but it must not
4319    // vanish either. Filtering them out made the breakdown silently disagree
4320    // with the `Failing` figure beside it: on a migrated database that is EVERY
4321    // currently-failing feed, so the page would have read "70 failing" next to
4322    // "3 fetch" with 67 unexplained and no indication a remainder existed.
4323    //
4324    // `unknown` is a deliberate bucket rather than an omission. It cannot
4325    // collide with a real kind — `FailureKind::as_str` never returns it, and
4326    // `FailureKind::parse("unknown")` is `None`.
4327    let histogram = format!(
4328        r#"
4329        -- **`failure_kind`, not `kind`.** Aliasing this `kind` collided with
4330        -- the `feeds.kind` column added for the poller: SQLite resolved
4331        -- `GROUP BY kind` to the table column, so every failing feed collapsed
4332        -- into ONE bucket labelled from an arbitrary row — a public page
4333        -- reporting "10 fetch" for ten unrelated causes. Caught by
4334        -- `an_unrecognised_failure_kind_folds_into_unknown`.
4335        SELECT COALESCE(last_error_kind, 'unknown') AS failure_kind, COUNT(*) AS n
4336        FROM feeds
4337        WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
4338        GROUP BY failure_kind
4339        ORDER BY n DESC, failure_kind ASC
4340        "#
4341    );
4342    let kinds: Vec<(String, i64)> = sqlx::query_as(sqlx::AssertSqlSafe(histogram))
4343        .fetch_all(pool)
4344        .await
4345        .context("computing the failure-cause histogram")?;
4346
4347    // **Close the vocabulary where it is READ.** `FailureKind::parse` promised
4348    // that a kind from a newer build would not be attributed to a cause this
4349    // one recognises — but nothing called it, so the raw column reached the
4350    // public template and an unrecognised string rendered as its own bucket.
4351    // Fold anything `parse` rejects into `unknown`, then re-aggregate and
4352    // re-order, so the histogram only ever shows the four kinds this build
4353    // knows plus the one honest bucket for what it does not.
4354    let mut folded: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
4355    for (kind, n) in kinds {
4356        let key = if kind == "unknown" || crate::feed::FailureKind::parse(&kind).is_some() {
4357            kind
4358        } else {
4359            "unknown".to_string()
4360        };
4361        *folded.entry(key).or_insert(0) += n;
4362    }
4363    let mut kinds: Vec<(String, i64)> = folded.into_iter().collect();
4364    kinds.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4365
4366    Ok(PollHealth {
4367        feeds_tracked: row.0,
4368        polled_last_hour: row.1,
4369        overdue: row.2,
4370        last_poll_secs_ago: secs_between(row.3.as_deref(), now),
4371        oldest_poll_secs_ago: secs_between(row.4.as_deref(), now),
4372        never_polled: row.5,
4373        in_backoff: row.6,
4374        badly_broken: row.7,
4375        failure_kinds: kinds,
4376    })
4377}
4378
4379/// Whole seconds from `then` to `now`, or `None` if `then` is absent or
4380/// unparseable. Never negative: a clock skew that puts a poll in the future
4381/// reads as "just now" rather than as a negative age.
4382fn secs_between(then: Option<&str>, now: &str) -> Option<i64> {
4383    let then = chrono::DateTime::parse_from_rfc3339(then?).ok()?;
4384    let now = chrono::DateTime::parse_from_rfc3339(now).ok()?;
4385    Some((now - then).num_seconds().max(0))
4386}
4387
4388#[cfg(test)]
4389mod tests {
4390    use super::*;
4391
4392    const PUB_A: &str = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3laa";
4393
4394    /// Step 2 of the 0.4.0 plan: a stored publication is pollable.
4395    #[tokio::test]
4396    async fn a_due_publication_is_handed_to_the_poller() -> Result<()> {
4397        let pool = init_url("sqlite::memory:").await?;
4398        upsert_feed(
4399            &pool,
4400            &NewFeed {
4401                url: PUB_A.into(),
4402                ..Default::default()
4403            },
4404        )
4405        .await?;
4406        let due = due_feeds(&pool, "2999-01-01T00:00:00Z", 50).await?;
4407        assert!(
4408            due.iter().any(|f| f.url == PUB_A),
4409            "a publication row is not handed to the poller"
4410        );
4411        Ok(())
4412    }
4413
4414    #[tokio::test]
4415    async fn admitting_publications_staggers_their_first_poll() -> Result<()> {
4416        let pool = init_url("sqlite::memory:").await?;
4417        for i in 0..19 {
4418            let url =
4419                format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3l{i:02}");
4420            upsert_feed(
4421                &pool,
4422                &NewFeed {
4423                    url,
4424                    ..Default::default()
4425                },
4426            )
4427            .await?;
4428        }
4429        // Already polled, and an RSS row: neither is touched.
4430        upsert_feed(
4431            &pool,
4432            &NewFeed {
4433                url: "https://rss.example/feed.xml".into(),
4434                ..Default::default()
4435            },
4436        )
4437        .await?;
4438        let n = stagger_unscheduled(
4439            &pool,
4440            crate::feed::FeedKind::Publication,
4441            std::time::Duration::from_secs(3600),
4442        )
4443        .await?;
4444        assert_eq!(n, 19, "not every unscheduled publication was scheduled");
4445        let slots: Vec<String> = sqlx::query_scalar(
4446            "SELECT next_poll FROM feeds WHERE kind = 'publication' ORDER BY next_poll",
4447        )
4448        .fetch_all(&pool)
4449        .await?;
4450        let distinct: std::collections::BTreeSet<_> = slots.iter().collect();
4451        assert_eq!(distinct.len(), 19, "publications share slots: {slots:?}");
4452        let rss: Option<String> = sqlx::query_scalar(
4453            "SELECT next_poll FROM feeds WHERE url = 'https://rss.example/feed.xml'",
4454        )
4455        .fetch_one(&pool)
4456        .await?;
4457        assert_eq!(rss, None, "an RSS row was rescheduled");
4458        let again = stagger_unscheduled(
4459            &pool,
4460            crate::feed::FeedKind::Publication,
4461            std::time::Duration::from_secs(3600),
4462        )
4463        .await?;
4464        assert_eq!(
4465            again, 0,
4466            "a second boot re-staggered rows that already had a slot"
4467        );
4468        Ok(())
4469    }
4470
4471    /// The point of the stagger: an overdue RSS feed is not starved by a block
4472    /// of newly admitted rows.
4473    #[tokio::test]
4474    async fn admitted_rows_do_not_outrank_an_overdue_rss_feed() -> Result<()> {
4475        let pool = init_url("sqlite::memory:").await?;
4476        for i in 0..5 {
4477            let url =
4478                format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3l{i:02}");
4479            upsert_feed(
4480                &pool,
4481                &NewFeed {
4482                    url,
4483                    ..Default::default()
4484                },
4485            )
4486            .await?;
4487        }
4488        upsert_feed(
4489            &pool,
4490            &NewFeed {
4491                url: "https://overdue.example/feed.xml".into(),
4492                next_poll: Some("2000-01-01T00:00:00Z".into()),
4493                ..Default::default()
4494            },
4495        )
4496        .await?;
4497        stagger_unscheduled(
4498            &pool,
4499            crate::feed::FeedKind::Publication,
4500            std::time::Duration::from_secs(3600),
4501        )
4502        .await?;
4503        let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4504        let first = due_feeds(&pool, &now, 1).await?;
4505        assert_eq!(first[0].url, "https://overdue.example/feed.xml");
4506        Ok(())
4507    }
4508
4509    /// **A re-poll refreshes `published`; it never refreshes `fetched_at`.**
4510    ///
4511    /// The asymmetry is the whole reason a date must be stable. `published`
4512    /// comes back from the publisher on every poll, so a value the mapper
4513    /// recomputes — "now", say — is rewritten every hour and the row can never
4514    /// age. `fetched_at` is written once, at first insert, so an entry stored
4515    /// with no date is effectively dated when we first saw it, and that date
4516    /// does hold still. Both the per-feed cap and the retention sweep order on
4517    /// `COALESCE(published, fetched_at)`, so which of the two a row lands in
4518    /// decides whether it can ever be evicted or swept.
4519    #[tokio::test]
4520    async fn a_repoll_refreshes_published_but_never_fetched_at() -> Result<()> {
4521        let pool = init_url("sqlite::memory:").await?;
4522        let feed_id = upsert_feed(
4523            &pool,
4524            &NewFeed {
4525                url: "https://example.com/f.xml".to_string(),
4526                ..Default::default()
4527            },
4528        )
4529        .await?;
4530        let seen = |at: &str| {
4531            vec![NewEntry {
4532                guid: "g".to_string(),
4533                published: Some(at.to_string()),
4534                fetched_at: Some(at.to_string()),
4535                ..Default::default()
4536            }]
4537        };
4538        insert_entries(&pool, feed_id, &seen("2026-01-01T00:00:00Z"), 0).await?;
4539        insert_entries(&pool, feed_id, &seen("2026-09-20T00:00:00Z"), 0).await?;
4540
4541        let (published, fetched_at): (Option<String>, String) =
4542            sqlx::query_as("SELECT published, fetched_at FROM entries WHERE guid = 'g'")
4543                .fetch_one(&pool)
4544                .await?;
4545        assert_eq!(
4546            published.as_deref(),
4547            Some("2026-09-20T00:00:00Z"),
4548            "the second poll's date did not replace the first"
4549        );
4550        assert_eq!(
4551            fetched_at, "2026-01-01T00:00:00Z",
4552            "fetched_at moved, so an undated entry would never age either"
4553        );
4554        Ok(())
4555    }
4556
4557    /// A partial upsert must not erase the conditional-GET validators.
4558    ///
4559    /// `set_next_poll` supplies only `url` + `next_poll` and runs after EVERY
4560    /// poll of EVERY feed. While `upsert_feed` assigned etag/last_modified
4561    /// unconditionally, that call wrote both back to NULL, so `If-None-Match`
4562    /// was never sent, `304` was unreachable, and every feed was re-downloaded
4563    /// and re-parsed in full on every cycle. Nothing failed; it was invisible.
4564    #[tokio::test]
4565    async fn validators_survive_a_partial_upsert() -> Result<()> {
4566        let pool = init_url("sqlite::memory:").await?;
4567        let url = "https://example.com/feed.xml";
4568
4569        upsert_feed(
4570            &pool,
4571            &NewFeed {
4572                url: url.to_string(),
4573                etag: Some("\"abc123\"".to_string()),
4574                last_modified: Some("Wed, 01 Jan 2026 00:00:00 GMT".to_string()),
4575                ..Default::default()
4576            },
4577        )
4578        .await?;
4579
4580        // Exactly what `scheduler::set_next_poll` sends.
4581        upsert_feed(
4582            &pool,
4583            &NewFeed {
4584                url: url.to_string(),
4585                next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4586                ..Default::default()
4587            },
4588        )
4589        .await?;
4590
4591        let feed = get_feed_by_url(&pool, url).await?.expect("feed");
4592        assert_eq!(
4593            feed.etag.as_deref(),
4594            Some("\"abc123\""),
4595            "a partial upsert erased the ETag, disabling conditional GET"
4596        );
4597        assert_eq!(
4598            feed.last_modified.as_deref(),
4599            Some("Wed, 01 Jan 2026 00:00:00 GMT"),
4600            "a partial upsert erased Last-Modified"
4601        );
4602        assert_eq!(feed.next_poll.as_deref(), Some("2026-07-12T00:00:00Z"));
4603        Ok(())
4604    }
4605
4606    /// A hard ceiling that is not strictly older than the window is IGNORED.
4607    ///
4608    /// `hard_days.max(days)` made `0` — the obvious "off" value, and the
4609    /// documented disable value for `RETENTION_DAYS` — collapse the ceiling onto
4610    /// the soft window, where the delete spares nothing. The starred and unread
4611    /// rows the window exists to protect were purged at `retention_days`.
4612    #[tokio::test]
4613    async fn a_ceiling_inside_the_window_is_ignored_not_applied() -> Result<()> {
4614        for hard in [0_i64, 1, 7, 14] {
4615            let pool = init_url("sqlite::memory:").await?;
4616            let feed_id = upsert_feed(
4617                &pool,
4618                &NewFeed {
4619                    url: "https://example.com/f.xml".to_string(),
4620                    ..Default::default()
4621                },
4622            )
4623            .await?;
4624            let old = (chrono::Utc::now() - chrono::Duration::days(30))
4625                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4626            insert_entries(
4627                &pool,
4628                feed_id,
4629                &[
4630                    NewEntry {
4631                        guid: "starred-30d".to_string(),
4632                        published: Some(old.clone()),
4633                        ..Default::default()
4634                    },
4635                    NewEntry {
4636                        guid: "unread-30d".to_string(),
4637                        published: Some(old.clone()),
4638                        ..Default::default()
4639                    },
4640                ],
4641                0,
4642            )
4643            .await?;
4644            // Both need an explicit `entry_state` row: sparing keys off a
4645            // DELIBERATE mark, and an entry with no row at all is unclaimed
4646            // cache that the window is supposed to evict.
4647            sqlx::query(
4648                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4649                 SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z'
4650                 FROM entries WHERE guid = 'starred-30d'",
4651            )
4652            .execute(&pool)
4653            .await?;
4654            sqlx::query(
4655                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4656                 SELECT 'did:plc:x', id, 0, 0, '2026-01-01T00:00:00Z'
4657                 FROM entries WHERE guid = 'unread-30d'",
4658            )
4659            .execute(&pool)
4660            .await?;
4661
4662            prune_old_entries(&pool, 14, hard, 0).await?;
4663
4664            let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4665                .fetch_one(&pool)
4666                .await?;
4667            assert_eq!(
4668                left, 2,
4669                "hard_days={hard} destroyed starred/unread rows at the soft window"
4670            );
4671        }
4672        Ok(())
4673    }
4674
4675    /// Turning the rolling window off must NOT also turn the ceiling off.
4676    ///
4677    /// `prune_old_entries` used to return on `days <= 0` before the ceiling was
4678    /// even computed, so `RETENTION_DAYS=0` — advertised as "disables eviction" —
4679    /// meant no window AND no ceiling. That is the one configuration with no
4680    /// bound on the shared cache at all, and it stopped being survivable when the
4681    /// per-feed trim started sparing starred entries: nothing was left to catch
4682    /// them. The two knobs are independent now.
4683    #[tokio::test]
4684    async fn a_disabled_window_does_not_disable_the_ceiling() -> Result<()> {
4685        let pool = init_url("sqlite::memory:").await?;
4686        let feed_id = upsert_feed(
4687            &pool,
4688            &NewFeed {
4689                url: "https://example.com/f.xml".to_string(),
4690                ..Default::default()
4691            },
4692        )
4693        .await?;
4694        let age = |d: i64| {
4695            (chrono::Utc::now() - chrono::Duration::days(d))
4696                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4697        };
4698        insert_entries(
4699            &pool,
4700            feed_id,
4701            &[
4702                NewEntry {
4703                    guid: "starred-400d".to_string(),
4704                    published: Some(age(400)),
4705                    ..Default::default()
4706                },
4707                NewEntry {
4708                    guid: "starred-30d".to_string(),
4709                    published: Some(age(30)),
4710                    ..Default::default()
4711                },
4712            ],
4713            0,
4714        )
4715        .await?;
4716        // Star both, so only the ceiling can remove either one — the soft
4717        // window's exception would spare them both even if it did run.
4718        sqlx::query(
4719            "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4720             SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z' FROM entries",
4721        )
4722        .execute(&pool)
4723        .await?;
4724
4725        // No rolling window; a 180-day ceiling.
4726        let deleted = prune_old_entries(&pool, 0, 180, 0).await?;
4727
4728        assert_eq!(
4729            deleted, 1,
4730            "retention_days=0 skipped the hard ceiling, leaving the cache unbounded"
4731        );
4732        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
4733            .fetch_all(&pool)
4734            .await?;
4735        assert_eq!(
4736            left,
4737            vec!["starred-30d".to_string()],
4738            "the ceiling removed the wrong rows with the window disabled"
4739        );
4740        Ok(())
4741    }
4742
4743    /// With BOTH knobs off, nothing is deleted — that is the documented
4744    /// "no eviction at all" configuration, and it must stay a true no-op rather
4745    /// than falling through to one of the two deletes with a degenerate cutoff.
4746    #[tokio::test]
4747    async fn both_knobs_off_deletes_nothing() -> Result<()> {
4748        let pool = init_url("sqlite::memory:").await?;
4749        let feed_id = upsert_feed(
4750            &pool,
4751            &NewFeed {
4752                url: "https://example.com/f.xml".to_string(),
4753                ..Default::default()
4754            },
4755        )
4756        .await?;
4757        insert_entries(
4758            &pool,
4759            feed_id,
4760            &[NewEntry {
4761                guid: "ancient".to_string(),
4762                published: Some(
4763                    (chrono::Utc::now() - chrono::Duration::days(9999))
4764                        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4765                ),
4766                ..Default::default()
4767            }],
4768            0,
4769        )
4770        .await?;
4771
4772        assert_eq!(prune_old_entries(&pool, 0, 0, 0).await?, 0);
4773        let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4774            .fetch_one(&pool)
4775            .await?;
4776        assert_eq!(left, 1);
4777        Ok(())
4778    }
4779
4780    /// Starred sparing must not remove the per-feed cap.
4781    ///
4782    /// The first version spared every starred row without limit: at cap=5 with
4783    /// 50 starred entries, 55 survived — 11x the cap, i.e. no cap at all.
4784    #[tokio::test]
4785    async fn per_feed_trim_stays_bounded_when_everything_is_starred() -> Result<()> {
4786        let pool = init_url("sqlite::memory:").await?;
4787        let feed_id = upsert_feed(
4788            &pool,
4789            &NewFeed {
4790                url: "https://example.com/f.xml".to_string(),
4791                ..Default::default()
4792            },
4793        )
4794        .await?;
4795        let entries: Vec<NewEntry> = (0..100)
4796            .map(|i| NewEntry {
4797                guid: format!("g-{i}"),
4798                published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
4799                ..Default::default()
4800            })
4801            .collect();
4802        insert_entries(&pool, feed_id, &entries, 0).await?;
4803        sqlx::query(
4804            "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4805             SELECT 'did:plc:x', id, 0, 1, '2026-01-01T00:00:00Z'
4806             FROM entries LIMIT 50",
4807        )
4808        .execute(&pool)
4809        .await?;
4810
4811        // Re-run the trim with cap = 5.
4812        insert_entries(&pool, feed_id, &[], 5).await?;
4813
4814        let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4815            .fetch_one(&pool)
4816            .await?;
4817        assert!(
4818            left <= 10,
4819            "per-feed trim kept {left} rows for a cap of 5; sparing removed the bound"
4820        );
4821        Ok(())
4822    }
4823
4824    /// Init an in-memory SQLite, insert a feed + entries, read them back.
4825    #[tokio::test]
4826    async fn init_insert_readback() -> Result<()> {
4827        let pool = init_url("sqlite::memory:").await?;
4828
4829        // Insert a feed.
4830        let feed_id = upsert_feed(
4831            &pool,
4832            &NewFeed {
4833                url: "https://example.com/feed.xml".to_string(),
4834                title: Some("Example".to_string()),
4835                site_url: Some("https://example.com".to_string()),
4836                next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4837                ..Default::default()
4838            },
4839        )
4840        .await?;
4841        assert!(feed_id > 0);
4842
4843        // Read the feed back by URL.
4844        let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
4845            .await?
4846            .expect("feed should exist");
4847        assert_eq!(feed.id, feed_id);
4848        assert_eq!(feed.title.as_deref(), Some("Example"));
4849        assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
4850
4851        // Upsert on the same URL updates rather than duplicating.
4852        let feed_id2 = upsert_feed(
4853            &pool,
4854            &NewFeed {
4855                url: "https://example.com/feed.xml".to_string(),
4856                title: Some("Example (renamed)".to_string()),
4857                ..Default::default()
4858            },
4859        )
4860        .await?;
4861        assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
4862
4863        // Insert two entries.
4864        let n = insert_entries(
4865            &pool,
4866            feed_id,
4867            &[
4868                NewEntry {
4869                    guid: "guid-1".to_string(),
4870                    url: Some("https://example.com/a".to_string()),
4871                    title: Some("First".to_string()),
4872                    published: Some("2026-07-10T08:00:00Z".to_string()),
4873                    content_html: Some("<p>hello</p>".to_string()),
4874                    ..Default::default()
4875                },
4876                NewEntry {
4877                    guid: "guid-2".to_string(),
4878                    url: Some("https://example.com/b".to_string()),
4879                    title: Some("Second".to_string()),
4880                    published: Some("2026-07-11T08:00:00Z".to_string()),
4881                    ..Default::default()
4882                },
4883            ],
4884            0, // per-feed trim disabled for this test
4885        )
4886        .await?;
4887        assert_eq!(n, 2);
4888
4889        // The reader must subscribe to the feed for the scoped reads to return
4890        // its entries (per-DID isolation projection).
4891        let did = "did:plc:abc123";
4892        replace_sub_refs(&pool, did, &[feed_id]).await?;
4893
4894        // Read entries back (newest-published first).
4895        let entries = entries_for_feed(&pool, did, feed_id).await?;
4896        assert_eq!(entries.len(), 2);
4897        assert_eq!(entries[0].guid, "guid-2");
4898        assert_eq!(entries[1].guid, "guid-1");
4899        // The body is stored, but it is NOT in the list projection — that is the
4900        // point of `EntryListRow`. Read it the way the single-entry reader does.
4901        let body: Option<String> =
4902            sqlx::query_scalar("SELECT content_html FROM entries WHERE guid = 'guid-1'")
4903                .fetch_one(&pool)
4904                .await?;
4905        assert_eq!(body.as_deref(), Some("<p>hello</p>"));
4906
4907        // Re-inserting the same GUID dedups (updates in place, no new row).
4908        let n2 = insert_entries(
4909            &pool,
4910            feed_id,
4911            &[NewEntry {
4912                guid: "guid-1".to_string(),
4913                title: Some("First (edited)".to_string()),
4914                ..Default::default()
4915            }],
4916            0,
4917        )
4918        .await?;
4919        assert_eq!(n2, 1);
4920        assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
4921
4922        // --- per-DID read state ---
4923        let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
4924
4925        // Both entries start unread.
4926        assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
4927
4928        // Mark one read; unread count drops to 1.
4929        mark_read(&pool, did, e1, true).await?;
4930        let unread = get_unread_for_did(&pool, did).await?;
4931        assert_eq!(unread.len(), 1);
4932        assert_eq!(unread[0].guid, "guid-2");
4933
4934        // Star it; it shows in the starred list.
4935        mark_starred(&pool, did, e1, true).await?;
4936        let starred = get_starred_for_did(&pool, did).await?;
4937        assert_eq!(starred.len(), 1);
4938        assert_eq!(starred[0].id, e1);
4939
4940        // Mark-all-read clears the remaining unread.
4941        mark_feed_read(&pool, did, feed_id, true).await?;
4942        assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
4943
4944        // --- read cursor (batched-sync bookkeeping) ---
4945        let cursor = ReadCursor {
4946            did: did.to_string(),
4947            feed_url: "https://example.com/feed.xml".to_string(),
4948            read_through: Some("2026-07-11T08:00:00Z".to_string()),
4949            read_ids: "[]".to_string(),
4950            unread_ids: "[]".to_string(),
4951            dirty: true,
4952            pds_created: false,
4953            updated_at: now_rfc3339(),
4954        };
4955        upsert_cursor(&pool, &cursor).await?;
4956
4957        let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
4958            .await?
4959            .expect("cursor should exist");
4960        assert_eq!(
4961            fetched.read_through.as_deref(),
4962            Some("2026-07-11T08:00:00Z")
4963        );
4964        assert!(fetched.dirty);
4965
4966        // The flusher sees exactly one dirty cursor.
4967        let dirty = dirty_cursors(&pool, did).await?;
4968        assert_eq!(dirty.len(), 1);
4969        let flushed_at = dirty[0].updated_at.clone();
4970
4971        // After a flush, clearing dirty (with the flushed snapshot's updated_at)
4972        // removes it from the flusher's view.
4973        clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
4974        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
4975
4976        Ok(())
4977    }
4978
4979    // -----------------------------------------------------------------------
4980    // The bounded, body-free list projection.
4981    //
4982    // The three queries these replaced were `SELECT e.*` with no `LIMIT`. Both
4983    // halves of that are load-bearing on a 512 MB box: the projection dragged
4984    // an ~11.9 KB article body per row that no list surface reads, and the
4985    // missing bound let one reader's backlog decide how much a handler
4986    // allocates.
4987    // -----------------------------------------------------------------------
4988
4989    /// Seed `count` entries in one feed, each with a large body, subscribed by
4990    /// `did`. Returns the feed id.
4991    async fn seed_big_entries(pool: &SqlitePool, did: &str, count: usize) -> Result<i64> {
4992        let feed_id = upsert_feed(
4993            pool,
4994            &NewFeed {
4995                url: "https://example.com/big.xml".to_string(),
4996                ..Default::default()
4997            },
4998        )
4999        .await?;
5000        let body = "x".repeat(20_000);
5001        let entries: Vec<NewEntry> = (0..count)
5002            .map(|i| NewEntry {
5003                guid: format!("guid-{i:04}"),
5004                url: Some(format!("https://example.com/a/{i}")),
5005                title: Some(format!("Article {i}")),
5006                // Descending guid order matches descending published order, so
5007                // assertions can name the rows they expect.
5008                published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
5009                content_html: Some(body.clone()),
5010                ..Default::default()
5011            })
5012            .collect();
5013        insert_entries(pool, feed_id, &entries, 0).await?;
5014        replace_sub_refs(pool, did, &[feed_id]).await?;
5015        Ok(feed_id)
5016    }
5017
5018    /// Review of #213: the ceiling only stops NEW future dates. A row stored
5019    /// with one before it, whose item has since left its feed, is never polled
5020    /// again to be corrected — so it stayed first in the list and survived the
5021    /// per-feed cap forever. Startup re-dates it.
5022    #[tokio::test]
5023    async fn a_stored_future_date_is_cleared_at_startup() -> Result<()> {
5024        let pool = init_url("sqlite::memory:").await?;
5025        let feed_id = upsert_feed(
5026            &pool,
5027            &NewFeed {
5028                url: "https://clock.example/f.xml".into(),
5029                ..Default::default()
5030            },
5031        )
5032        .await?;
5033        let tomorrow = (chrono::Utc::now() + chrono::Duration::days(1))
5034            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
5035        for (guid, published) in [
5036            ("bogus", "2999-01-01T00:00:00Z"),
5037            ("soon", tomorrow.as_str()),
5038        ] {
5039            sqlx::query(
5040                "INSERT INTO entries (feed_id, guid, published, fetched_at) \
5041                 VALUES (?1, ?2, ?3, '2026-07-11T00:00:00Z')",
5042            )
5043            .bind(feed_id)
5044            .bind(guid)
5045            .bind(published)
5046            .execute(&pool)
5047            .await?;
5048        }
5049        apply_migrations(&pool).await?;
5050        let dated: Vec<(String, Option<String>)> =
5051            sqlx::query_as("SELECT guid, published FROM entries ORDER BY guid")
5052                .fetch_all(&pool)
5053                .await?;
5054        assert_eq!(
5055            dated[0],
5056            ("bogus".to_string(), None),
5057            "a 2999 date survived startup"
5058        );
5059        assert_eq!(
5060            dated[1].1.as_deref(),
5061            Some(tomorrow.as_str()),
5062            "a near-future date was cleared"
5063        );
5064        Ok(())
5065    }
5066
5067    /// **The cap, the reading list and prev/next must agree about what an undated
5068    /// entry's date IS.** They did not, and the disagreement had a direction.
5069    ///
5070    /// The per-feed keep-set and both retention sweeps order on
5071    /// `COALESCE(published, fetched_at)` — correctly, because a feed of undated
5072    /// items would otherwise trim its own freshest rows. The reading list
5073    /// ordered on bare `e.published DESC`, and in SQLite `NULL` sorts LAST under
5074    /// `DESC`. So one undated entry was simultaneously the NEWEST row in the
5075    /// feed as far as eviction was concerned, and the OLDEST row in every list
5076    /// view — parked below years of read articles where no reader would see it,
5077    /// while the cap declined to drop it to make room for something they would.
5078    ///
5079    /// `site.standard.document` makes `publishedAt` optional, so publication
5080    /// feeds reach this far more readily than RSS ever did.
5081    ///
5082    /// Both directions here: the undated row must come first, AND the two dated
5083    /// rows must stay in their own order, or "order by nothing" would pass.
5084    ///
5085    /// **On the index worry, measured on the query the app actually sends.**
5086    /// #187 flagged that a `COALESCE` in `ORDER BY` cannot use
5087    /// `idx_entries_feed_published` for ordering. The real list and prev/next
5088    /// queries (LEFT JOIN `entry_state`, EXISTS `sub_ref`) did not use it for
5089    /// ordering before this change either, and timing them at 40 feeds x 1,000
5090    /// entries showed the new ordering costs nothing on the existing index. A
5091    /// `(feed_id, published, fetched_at)` index meant to keep them covering was
5092    /// never chosen on the default prev/next query and made it ~3.8x slower,
5093    /// so it was not kept (review of #213).
5094    #[tokio::test]
5095    async fn an_undated_entry_leads_the_reading_list_as_it_leads_the_cap() -> Result<()> {
5096        let pool = init_url("sqlite::memory:").await?;
5097        let did = "did:plc:undated";
5098        let feed_id = upsert_feed(
5099            &pool,
5100            &NewFeed {
5101                url: "https://undated.example/f.xml".to_string(),
5102                ..Default::default()
5103            },
5104        )
5105        .await?;
5106        insert_entries(
5107            &pool,
5108            feed_id,
5109            &[
5110                NewEntry {
5111                    guid: "dated-old".to_string(),
5112                    title: Some("Old".to_string()),
5113                    published: Some("2024-01-01T00:00:00Z".to_string()),
5114                    ..Default::default()
5115                },
5116                NewEntry {
5117                    guid: "dated-new".to_string(),
5118                    title: Some("Newer".to_string()),
5119                    published: Some("2025-01-01T00:00:00Z".to_string()),
5120                    ..Default::default()
5121                },
5122                // No `published` at all — dated by `fetched_at`, which is now,
5123                // so it is the freshest row in the feed.
5124                NewEntry {
5125                    guid: "undated".to_string(),
5126                    title: Some("Undated".to_string()),
5127                    ..Default::default()
5128                },
5129            ],
5130            0,
5131        )
5132        .await?;
5133        replace_sub_refs(&pool, did, &[feed_id]).await?;
5134
5135        let rows = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5136        let order: Vec<&str> = rows.iter().map(|r| r.guid.as_str()).collect();
5137        assert_eq!(
5138            order,
5139            vec!["undated", "dated-new", "dated-old"],
5140            "the list disagrees with the cap about an undated entry's date",
5141        );
5142
5143        // `list_entry_ids` is the sequence PREV/NEXT walks — its only non-test
5144        // caller is `web::neighbors_in_scope`. Ordered differently from the
5145        // list, "next entry" would take the reader somewhere that is not the
5146        // next row on screen. (`mark_read` and `mark_all_read` are id-based and
5147        // never use this ordering; an earlier version of this comment said they
5148        // did, naming a failure that cannot happen and omitting the one that
5149        // can.)
5150        let ids = list_entry_ids(&pool, did, ListView::All, None, 100).await?;
5151        let by_guid: std::collections::HashMap<i64, &str> =
5152            rows.iter().map(|r| (r.id, r.guid.as_str())).collect();
5153        let id_order: Vec<&str> = ids.iter().filter_map(|i| by_guid.get(i).copied()).collect();
5154        assert_eq!(
5155            id_order,
5156            vec!["undated", "dated-new", "dated-old"],
5157            "the id projection orders differently from the list it projects",
5158        );
5159        Ok(())
5160    }
5161
5162    /// `limit` is honoured, and `offset` walks the same ordering without gaps or
5163    /// repeats. Against the unbounded originals the first assertion returned all
5164    /// 250 rows.
5165    #[tokio::test]
5166    async fn list_entries_is_bounded_and_pages_without_overlap() -> Result<()> {
5167        let pool = init_url("sqlite::memory:").await?;
5168        let did = "did:plc:pager";
5169        seed_big_entries(&pool, did, 250).await?;
5170
5171        let page1 = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5172        assert_eq!(page1.len(), 100, "limit was not applied");
5173        let page2 = list_entries(&pool, did, ListView::All, None, 100, 100).await?;
5174        let page3 = list_entries(&pool, did, ListView::All, None, 100, 200).await?;
5175        assert_eq!(page3.len(), 50, "the last page should be the remainder");
5176
5177        let walked: Vec<i64> = page1
5178            .iter()
5179            .chain(&page2)
5180            .chain(&page3)
5181            .map(|e| e.id)
5182            .collect();
5183        let unique: std::collections::HashSet<i64> = walked.iter().copied().collect();
5184        assert_eq!(unique.len(), 250, "paging repeated or skipped rows");
5185
5186        // And the walk is the same order an unpaged read would produce.
5187        let whole = list_entries(&pool, did, ListView::All, None, 1_000, 0).await?;
5188        assert_eq!(
5189            walked,
5190            whole.iter().map(|e| e.id).collect::<Vec<_>>(),
5191            "paging changed the ordering"
5192        );
5193
5194        // **The tie-break is pinned, not left to the engine.** The seed gives
5195        // 250 rows only 28 distinct dates, so the order is mostly ties; with
5196        // the `id DESC` tie-break deleted, SQLite happened to return ties in a
5197        // stable order and both assertions above still held. The expected
5198        // order is computed from the seed pattern here — newest date first,
5199        // then newest id — and must match exactly.
5200        let mut expected: Vec<(i64, i64)> = whole
5201            .iter()
5202            .map(|e| {
5203                let day: i64 = e.published.as_deref().unwrap()[8..10].parse().unwrap();
5204                (day, e.id)
5205            })
5206            .collect();
5207        expected.sort_by(|a, b| b.cmp(a));
5208        assert_eq!(
5209            walked,
5210            expected.iter().map(|(_, id)| *id).collect::<Vec<_>>(),
5211            "ties are not broken by newest id"
5212        );
5213
5214        assert_eq!(
5215            count_entries_for_view(&pool, did, ListView::All, None).await?,
5216            250,
5217            "the unpaged count must survive paging"
5218        );
5219        Ok(())
5220    }
5221
5222    /// The list projection must not read `content_html`.
5223    ///
5224    /// A type-level fact — `EntryListRow` has no body field — so the test proves
5225    /// it the only way that survives a refactor: by asking SQLite what the query
5226    /// it runs actually names. `SELECT e.*` would list every column.
5227    #[tokio::test]
5228    async fn the_list_projection_does_not_name_the_body_column() -> Result<()> {
5229        let pool = init_url("sqlite::memory:").await?;
5230        let did = "did:plc:projection";
5231        seed_big_entries(&pool, did, 3).await?;
5232
5233        // **The projection the query actually runs**, not a copy re-typed here.
5234        // The earlier version passed its own literal to `list_query_sql` and
5235        // asserted on that, so adding `e.content_html` to `list_entries` left
5236        // this green.
5237        let (sql, _) = list_entries_sql(ListView::All, None);
5238        assert!(
5239            !sql.contains("content_html") && !sql.contains("e.*"),
5240            "the list query reads the article body: {sql}"
5241        );
5242
5243        // And the rows really do come back without it, which is what bounds the
5244        // per-request allocation.
5245        let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5246        assert_eq!(rows.len(), 3);
5247        let widest = rows
5248            .iter()
5249            .map(|r| {
5250                r.guid.len()
5251                    + r.url.as_deref().map_or(0, str::len)
5252                    + r.title.as_deref().map_or(0, str::len)
5253            })
5254            .max()
5255            .unwrap_or(0);
5256        assert!(
5257            widest < 1_000,
5258            "a list row carries {widest} bytes of text; the 20,000-byte body leaked in"
5259        );
5260        Ok(())
5261    }
5262
5263    /// **A large scope must not become a large SQL statement.**
5264    ///
5265    /// The scope filter used to emit one placeholder per feed id, so the SQL
5266    /// string and the bind list both grew with a reader's subscription count —
5267    /// which comes from the PDS and is bounded only by a 20,000-record list
5268    /// ceiling. The first attempt at fixing that truncated the subscription
5269    /// list, which silently removed the reader's access to the dropped feeds
5270    /// (`sub_ref` is written from the same list). `json_each` takes the whole
5271    /// set as ONE bind, so neither trade-off is needed.
5272    #[tokio::test]
5273    async fn a_large_scope_is_one_bind_and_still_filters() -> Result<()> {
5274        let pool = init_url("sqlite::memory:").await?;
5275        let did = "did:plc:widescope";
5276
5277        // 300 feeds, one entry each; the scope names 200 of them.
5278        let mut all_ids = Vec::new();
5279        for i in 0..300 {
5280            let feed_id = upsert_feed(
5281                &pool,
5282                &NewFeed {
5283                    url: format!("https://wide{i}.example/f.xml"),
5284                    ..Default::default()
5285                },
5286            )
5287            .await?;
5288            insert_entries(
5289                &pool,
5290                feed_id,
5291                &[NewEntry {
5292                    guid: format!("w-{i}"),
5293                    ..Default::default()
5294                }],
5295                0,
5296            )
5297            .await?;
5298            all_ids.push(feed_id);
5299        }
5300        replace_sub_refs(&pool, did, &all_ids).await?;
5301
5302        let scope: Vec<i64> = all_ids.iter().copied().take(200).collect();
5303        let rows = list_entries(&pool, did, ListView::All, Some(&scope), 1_000, 0).await?;
5304        assert_eq!(rows.len(), 200, "the scope filter did not narrow correctly");
5305        let in_scope: std::collections::HashSet<i64> = scope.iter().copied().collect();
5306        assert!(
5307            rows.iter().all(|r| in_scope.contains(&r.feed_id)),
5308            "a feed outside the scope came back"
5309        );
5310        assert_eq!(
5311            count_entries_for_view(&pool, did, ListView::All, Some(&scope)).await?,
5312            200
5313        );
5314
5315        // The statement itself carries no per-id placeholders — that is the
5316        // property, and it is what stops the SQL growing with the reader.
5317        let (sql, n) = list_query_sql(Projection::Ids, ListView::All, Some(&scope));
5318        assert_eq!(n, 1, "the scope must contribute exactly one placeholder");
5319        assert!(
5320            sql.contains("json_each(?2)") && !sql.contains("?3"),
5321            "the scope is still expanded into per-id placeholders: {sql}"
5322        );
5323        Ok(())
5324    }
5325
5326    /// Scope is applied INSIDE the query, so a page is a page of rows the reader
5327    /// will see. Filtering after the `LIMIT` (what the handler used to do) made
5328    /// pages arbitrarily short for any narrowed scope.
5329    #[tokio::test]
5330    async fn a_feed_scope_narrows_the_query_not_the_page() -> Result<()> {
5331        let pool = init_url("sqlite::memory:").await?;
5332        let did = "did:plc:scope";
5333        let wanted = seed_big_entries(&pool, did, 10).await?;
5334
5335        let other = upsert_feed(
5336            &pool,
5337            &NewFeed {
5338                url: "https://other.example/f.xml".to_string(),
5339                ..Default::default()
5340            },
5341        )
5342        .await?;
5343        let noise: Vec<NewEntry> = (0..40)
5344            .map(|i| NewEntry {
5345                guid: format!("noise-{i}"),
5346                // Newer than everything in `wanted`, so an unscoped query would
5347                // fill the whole page with these.
5348                published: Some("2027-01-01T00:00:00Z".to_string()),
5349                ..Default::default()
5350            })
5351            .collect();
5352        insert_entries(&pool, other, &noise, 0).await?;
5353        replace_sub_refs(&pool, did, &[wanted, other]).await?;
5354
5355        let scoped = list_entries(&pool, did, ListView::All, Some(&[wanted]), 10, 0).await?;
5356        assert_eq!(
5357            scoped.len(),
5358            10,
5359            "the scoped page came back short — the filter ran after the LIMIT"
5360        );
5361        assert!(scoped.iter().all(|e| e.feed_id == wanted));
5362
5363        // An EMPTY scope means "no feeds in scope", not "every feed".
5364        assert!(list_entries(&pool, did, ListView::All, Some(&[]), 10, 0)
5365            .await?
5366            .is_empty());
5367        assert_eq!(
5368            count_entries_for_view(&pool, did, ListView::All, Some(&[])).await?,
5369            0
5370        );
5371        Ok(())
5372    }
5373
5374    /// The per-row `read` / `starred` bits come off the row's own join, matching
5375    /// what the separate full-set queries used to compute — including the
5376    /// "no `entry_state` row means unread" rule the views depend on.
5377    #[tokio::test]
5378    async fn list_rows_carry_their_own_read_and_star_bits() -> Result<()> {
5379        let pool = init_url("sqlite::memory:").await?;
5380        let did = "did:plc:bits";
5381        seed_big_entries(&pool, did, 3).await?;
5382        let ids: Vec<i64> = list_entries(&pool, did, ListView::All, None, 10, 0)
5383            .await?
5384            .iter()
5385            .map(|e| e.id)
5386            .collect();
5387
5388        mark_read(&pool, did, ids[0], true).await?;
5389        mark_starred(&pool, did, ids[1], true).await?;
5390
5391        let all = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5392        let by_id = |id: i64| all.iter().find(|e| e.id == id).expect("row present");
5393        assert!(by_id(ids[0]).read && !by_id(ids[0]).starred);
5394        assert!(!by_id(ids[1]).read && by_id(ids[1]).starred);
5395        // Never touched: no state row at all, which must read as unread.
5396        assert!(!by_id(ids[2]).read && !by_id(ids[2]).starred);
5397
5398        // And the view predicates agree with the bits.
5399        let unread = list_entries(&pool, did, ListView::Unread, None, 10, 0).await?;
5400        assert_eq!(unread.len(), 2);
5401        assert!(unread.iter().all(|e| !e.read));
5402        let starred = list_entries(&pool, did, ListView::Starred, None, 10, 0).await?;
5403        assert_eq!(starred.len(), 1);
5404        assert_eq!(starred[0].id, ids[1]);
5405        Ok(())
5406    }
5407
5408    /// The sidebar's per-feed unread badges, counted in SQL rather than by
5409    /// materializing every unread entry and filtering in Rust.
5410    #[tokio::test]
5411    async fn unread_counts_are_per_feed_and_exclude_read_rows() -> Result<()> {
5412        let pool = init_url("sqlite::memory:").await?;
5413        let did = "did:plc:counts";
5414        let a = seed_big_entries(&pool, did, 5).await?;
5415        let b = upsert_feed(
5416            &pool,
5417            &NewFeed {
5418                url: "https://b.example/f.xml".to_string(),
5419                ..Default::default()
5420            },
5421        )
5422        .await?;
5423        insert_entries(
5424            &pool,
5425            b,
5426            &[
5427                NewEntry {
5428                    guid: "b-1".to_string(),
5429                    ..Default::default()
5430                },
5431                NewEntry {
5432                    guid: "b-2".to_string(),
5433                    ..Default::default()
5434                },
5435            ],
5436            0,
5437        )
5438        .await?;
5439        replace_sub_refs(&pool, did, &[a, b]).await?;
5440
5441        let first_a = list_entries(&pool, did, ListView::All, Some(&[a]), 1, 0).await?[0].id;
5442        mark_read(&pool, did, first_a, true).await?;
5443
5444        let counts = unread_counts_by_feed(&pool, did).await?;
5445        assert_eq!(counts.get(&a).copied(), Some(4));
5446        assert_eq!(counts.get(&b).copied(), Some(2));
5447
5448        // A feed the DID does not subscribe to contributes nothing.
5449        replace_sub_refs(&pool, did, &[b]).await?;
5450        let counts = unread_counts_by_feed(&pool, did).await?;
5451        assert_eq!(counts.get(&a), None);
5452        assert_eq!(counts.get(&b).copied(), Some(2));
5453        Ok(())
5454    }
5455
5456    /// **Read-state compaction: the water-mark must absorb the id set.**
5457    ///
5458    /// `read_through` was never computed, so `read_ids` was the only mechanism
5459    /// and grew one id per article read against a 2000-entry per-feed ceiling —
5460    /// while the flusher truncates the record at 1000, keeping the tail. Past
5461    /// 1000 read articles in a feed, the oldest read-state stopped syncing and
5462    /// those articles came back UNREAD in every other atproto reader.
5463    #[tokio::test]
5464    async fn compaction_folds_read_ids_into_the_water_mark() -> Result<()> {
5465        let pool = init_url("sqlite::memory:").await?;
5466        let did = "did:plc:compact";
5467        let feed_url = "https://compact.example/f.xml";
5468        let feed_id = upsert_feed(
5469            &pool,
5470            &NewFeed {
5471                url: feed_url.to_string(),
5472                ..Default::default()
5473            },
5474        )
5475        .await?;
5476        // 40 entries, oldest first by published date.
5477        let entries: Vec<NewEntry> = (0..40)
5478            .map(|i| NewEntry {
5479                guid: format!("c-{i:03}"),
5480                published: Some(format!("2026-01-{:02}T00:00:00Z", i + 1)),
5481                ..Default::default()
5482            })
5483            .collect();
5484        insert_entries(&pool, feed_id, &entries, 0).await?;
5485        replace_sub_refs(&pool, did, &[feed_id]).await?;
5486
5487        let all = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5488        // Oldest first, so the read prefix is contiguous from the start.
5489        let mut oldest_first = all.clone();
5490        oldest_first.reverse();
5491        for row in oldest_first.iter().take(30) {
5492            mark_read(&pool, did, row.id, true).await?;
5493        }
5494
5495        let before = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5496        assert!(before.read_through.is_none(), "read_through starts unset");
5497        let before_ids: Vec<String> = serde_json::from_str(&before.read_ids)?;
5498        assert_eq!(before_ids.len(), 30, "every read is its own exception");
5499
5500        let watermark = compact_cursor(&pool, did, feed_url)
5501            .await?
5502            .expect("the water-mark must advance");
5503
5504        let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5505        assert_eq!(after.read_through.as_deref(), Some(watermark.as_str()));
5506        let after_ids: Vec<String> = serde_json::from_str(&after.read_ids)?;
5507        assert!(
5508            after_ids.is_empty(),
5509            "a contiguous read prefix must fold entirely into the water-mark, left {after_ids:?}"
5510        );
5511        // The 30th entry is read and the 31st is not, so the mark sits on the
5512        // 30th — STRICTLY below the oldest unread, never equal to it.
5513        assert_eq!(watermark, "2026-01-30T00:00:00Z");
5514        assert!(after.dirty, "a rewritten cursor must be re-flushed");
5515        Ok(())
5516    }
5517
5518    /// The water-mark may never cover an unread entry, and may never move
5519    /// backwards. Both would re-assert articles as read that are not.
5520    #[tokio::test]
5521    async fn compaction_stops_below_the_oldest_unread_entry() -> Result<()> {
5522        let pool = init_url("sqlite::memory:").await?;
5523        let did = "did:plc:gap";
5524        let feed_url = "https://gap.example/f.xml";
5525        let feed_id = upsert_feed(
5526            &pool,
5527            &NewFeed {
5528                url: feed_url.to_string(),
5529                ..Default::default()
5530            },
5531        )
5532        .await?;
5533        let entries: Vec<NewEntry> = (0..10)
5534            .map(|i| NewEntry {
5535                guid: format!("g-{i:02}"),
5536                published: Some(format!("2026-02-{:02}T00:00:00Z", i + 1)),
5537                ..Default::default()
5538            })
5539            .collect();
5540        insert_entries(&pool, feed_id, &entries, 0).await?;
5541        replace_sub_refs(&pool, did, &[feed_id]).await?;
5542
5543        let mut oldest_first = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5544        oldest_first.reverse();
5545        // Read everything EXCEPT the third-oldest: a hole at 2026-02-03.
5546        for (i, row) in oldest_first.iter().enumerate() {
5547            if i != 2 {
5548                mark_read(&pool, did, row.id, true).await?;
5549            }
5550        }
5551
5552        let watermark = compact_cursor(&pool, did, feed_url)
5553            .await?
5554            .expect("advances");
5555        assert_eq!(
5556            watermark, "2026-02-02T00:00:00Z",
5557            "the water-mark jumped the unread hole"
5558        );
5559        let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5560        let kept: Vec<String> = serde_json::from_str(&after.read_ids)?;
5561        assert_eq!(
5562            kept.len(),
5563            7,
5564            "the 7 reads ABOVE the hole must stay as explicit exceptions"
5565        );
5566        // The unread hole is above the water-mark, so it needs no unread
5567        // exception — everything above the mark is unread by default.
5568        let unread: Vec<String> = serde_json::from_str(&after.unread_ids)?;
5569        assert!(
5570            unread.is_empty(),
5571            "redundant unread exceptions survived: {unread:?}"
5572        );
5573
5574        // Idempotent, and never backwards: re-running changes nothing.
5575        assert_eq!(
5576            compact_cursor(&pool, did, feed_url).await?,
5577            None,
5578            "a second compaction moved a water-mark that was already correct"
5579        );
5580        Ok(())
5581    }
5582
5583    /// Nothing read yet, or nothing in the feed: compaction must be a no-op
5584    /// rather than inventing a water-mark that asserts the backlog is read.
5585    #[tokio::test]
5586    async fn compaction_never_invents_a_water_mark() -> Result<()> {
5587        let pool = init_url("sqlite::memory:").await?;
5588        let did = "did:plc:none";
5589        let feed_url = "https://none.example/f.xml";
5590        let feed_id = upsert_feed(
5591            &pool,
5592            &NewFeed {
5593                url: feed_url.to_string(),
5594                ..Default::default()
5595            },
5596        )
5597        .await?;
5598        replace_sub_refs(&pool, did, &[feed_id]).await?;
5599
5600        // Empty feed: no entries at all.
5601        assert_eq!(compact_cursor(&pool, did, feed_url).await?, None);
5602
5603        insert_entries(
5604            &pool,
5605            feed_id,
5606            &[
5607                NewEntry {
5608                    guid: "n-1".to_string(),
5609                    published: Some("2026-03-01T00:00:00Z".to_string()),
5610                    ..Default::default()
5611                },
5612                NewEntry {
5613                    guid: "n-2".to_string(),
5614                    published: Some("2026-03-02T00:00:00Z".to_string()),
5615                    ..Default::default()
5616                },
5617            ],
5618            0,
5619        )
5620        .await?;
5621
5622        // Nothing read: the OLDEST entry is unread, so there is no timestamp
5623        // strictly below it and the mark cannot move at all.
5624        assert_eq!(
5625            compact_cursor(&pool, did, feed_url).await?,
5626            None,
5627            "a water-mark appeared with nothing read — that asserts the backlog is read"
5628        );
5629        Ok(())
5630    }
5631
5632    /// **The unsave desync: clearing a star must work for an UNSUBSCRIBED feed.**
5633    ///
5634    /// That is the whole case. Every other starred path is `sub_ref`-scoped, so
5635    /// an entry that is cached AND starred in a feed the reader has since
5636    /// unsubscribed from is invisible to all of them — including the starred
5637    /// list itself. Its PDS record therefore renders as "not cached", and the
5638    /// button on that row deletes the record. If clearing the local star were
5639    /// `sub_ref`-scoped too, it would silently do nothing, and the star would
5640    /// reappear with no record behind it the moment the reader resubscribed.
5641    #[tokio::test]
5642    async fn a_star_can_be_cleared_after_unsubscribing_from_its_feed() -> Result<()> {
5643        let pool = init_url("sqlite::memory:").await?;
5644        let did = "did:plc:unsub";
5645        let feed_id = seed_big_entries(&pool, did, 3).await?;
5646        let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5647        let target = rows[0].clone();
5648        mark_starred(&pool, did, target.id, true).await?;
5649        assert_eq!(get_starred_for_did(&pool, did).await?.len(), 1);
5650
5651        // Unsubscribe. The entry stays cached and stays starred, but every
5652        // sub_ref-scoped read now skips it.
5653        replace_sub_refs(&pool, did, &[]).await?;
5654        assert!(
5655            get_starred_for_did(&pool, did).await?.is_empty(),
5656            "fixture precondition: the star must be invisible to the scoped read"
5657        );
5658        assert!(
5659            matches!(
5660                starred_identities(&pool, did, 1_000).await?,
5661                StarredIdentities::All(ref v) if v.is_empty()
5662            ),
5663            "fixture precondition: the identity lookup must miss it too"
5664        );
5665        let still_starred: i64 =
5666            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5667                .bind(did)
5668                .fetch_one(&pool)
5669                .await?;
5670        assert_eq!(
5671            still_starred, 1,
5672            "the star is still there, just unreachable"
5673        );
5674
5675        // The removal path must reach it anyway.
5676        let cleared =
5677            clear_star_by_identity(&pool, did, target.url.as_deref(), Some(&target.guid)).await?;
5678        assert_eq!(cleared, 1, "the star survived the unsave");
5679        let after: i64 =
5680            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5681                .bind(did)
5682                .fetch_one(&pool)
5683                .await?;
5684        assert_eq!(after, 0);
5685
5686        // Resubscribing must NOT bring it back.
5687        replace_sub_refs(&pool, did, &[feed_id]).await?;
5688        assert!(
5689            get_starred_for_did(&pool, did).await?.is_empty(),
5690            "the star came back after resubscribing — the desync is still there"
5691        );
5692        Ok(())
5693    }
5694
5695    /// It clears only the CALLER's star, and only for the matching article.
5696    ///
5697    /// Omitting `sub_ref` is safe precisely because `did` is not optional; this
5698    /// pins that, and that a non-matching identity is a no-op rather than a
5699    /// wildcard.
5700    #[tokio::test]
5701    async fn clearing_a_star_touches_only_that_did_and_that_article() -> Result<()> {
5702        let pool = init_url("sqlite::memory:").await?;
5703        let mine = "did:plc:mine";
5704        let theirs = "did:plc:theirs";
5705        let feed_id = seed_big_entries(&pool, mine, 3).await?;
5706        replace_sub_refs(&pool, theirs, &[feed_id]).await?;
5707        let rows = list_entries(&pool, mine, ListView::All, None, 10, 0).await?;
5708
5709        for r in &rows {
5710            mark_starred(&pool, mine, r.id, true).await?;
5711            mark_starred(&pool, theirs, r.id, true).await?;
5712        }
5713
5714        let target = &rows[1];
5715        assert_eq!(
5716            clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5717            1
5718        );
5719
5720        let count = |did: &'static str| {
5721            let pool = pool.clone();
5722            async move {
5723                sqlx::query_scalar::<_, i64>(
5724                    "SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1",
5725                )
5726                .bind(did)
5727                .fetch_one(&pool)
5728                .await
5729                .unwrap()
5730            }
5731        };
5732        assert_eq!(count(mine).await, 2, "it cleared more than the one article");
5733        assert_eq!(count(theirs).await, 3, "it cleared another DID's stars");
5734
5735        // An identity that matches nothing is a no-op, not a wildcard.
5736        assert_eq!(
5737            clear_star_by_identity(&pool, mine, Some("https://nope.example/x"), Some("nope"))
5738                .await?,
5739            0
5740        );
5741        assert_eq!(count(mine).await, 2);
5742        // **Clearing an already-cleared star is a no-op**, reported as one:
5743        // `web::unsave` branches on `Ok(0)` vs `Ok(n)` to decide whether a
5744        // local star was actually cleared. This used to be untested — every
5745        // article here was starred first — so `starred = 1` in the WHERE clause
5746        // could be widened to `IN (0, 1)` with the suite green, rewriting
5747        // `updated_at` on rows that changed nothing and logging clears that
5748        // never happened.
5749        let before: String = sqlx::query_scalar(
5750            "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5751        )
5752        .bind(mine)
5753        .bind(target.id)
5754        .fetch_one(&pool)
5755        .await?;
5756        assert_eq!(
5757            clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5758            0,
5759            "a second clear reported rows it did not change"
5760        );
5761        let after: String = sqlx::query_scalar(
5762            "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5763        )
5764        .bind(mine)
5765        .bind(target.id)
5766        .fetch_one(&pool)
5767        .await?;
5768        assert_eq!(before, after, "a no-op clear rewrote updated_at");
5769
5770        // And neither identifier present does nothing at all.
5771        assert_eq!(clear_star_by_identity(&pool, mine, None, None).await?, 0);
5772        assert_eq!(
5773            clear_star_by_identity(&pool, mine, Some(""), Some("")).await?,
5774            0
5775        );
5776        assert_eq!(count(mine).await, 2);
5777        Ok(())
5778    }
5779
5780    /// `starred_identities` must span the WHOLE starred set, not a page.
5781    ///
5782    /// The starred view matches PDS saved records against it; a cached article
5783    /// missing from the set renders as "not cached", and that row's button
5784    /// deletes the PDS RECORD instead of un-starring the entry. Narrowing this
5785    /// set changes what a click destroys.
5786    #[tokio::test]
5787    async fn starred_identities_span_the_whole_set() -> Result<()> {
5788        let pool = init_url("sqlite::memory:").await?;
5789        let did = "did:plc:ident";
5790        seed_big_entries(&pool, did, 150).await?;
5791        for row in list_entries(&pool, did, ListView::All, None, 1_000, 0).await? {
5792            mark_starred(&pool, did, row.id, true).await?;
5793        }
5794
5795        let identities = match starred_identities(&pool, did, 20_000).await? {
5796            StarredIdentities::All(v) => v,
5797            StarredIdentities::Truncated => panic!("150 rows must not read as truncated"),
5798        };
5799        assert_eq!(
5800            identities.len(),
5801            150,
5802            "the identity set was truncated to a page"
5803        );
5804        assert!(identities
5805            .iter()
5806            .all(|(url, guid)| url.is_some() && !guid.is_empty()));
5807
5808        // **Hitting the cap must be REPORTED, not absorbed.** It used to return
5809        // an arbitrary subset with no way to tell, and every starred article
5810        // outside that subset then rendered an un-save button that deletes the
5811        // PDS record rather than un-starring the entry.
5812        assert!(
5813            matches!(
5814                starred_identities(&pool, did, 10).await?,
5815                StarredIdentities::Truncated
5816            ),
5817            "a truncated identity set reported itself as complete"
5818        );
5819        // Landing EXACTLY on the cap is complete, not truncated — the query asks
5820        // for one extra row precisely so the two are distinguishable.
5821        assert!(
5822            matches!(
5823                starred_identities(&pool, did, 150).await?,
5824                StarredIdentities::All(ref v) if v.len() == 150
5825            ),
5826            "a set exactly at the cap was misreported as truncated"
5827        );
5828        Ok(())
5829    }
5830
5831    /// Prev/next ids are bounded too, and keep the list's ordering.
5832    #[tokio::test]
5833    async fn entry_ids_are_ordered_and_capped() -> Result<()> {
5834        let pool = init_url("sqlite::memory:").await?;
5835        let did = "did:plc:ids";
5836        seed_big_entries(&pool, did, 60).await?;
5837
5838        let capped = list_entry_ids(&pool, did, ListView::All, None, 25).await?;
5839        assert_eq!(capped.len(), 25);
5840
5841        let rows = list_entries(&pool, did, ListView::All, None, 25, 0).await?;
5842        assert_eq!(
5843            capped,
5844            rows.iter().map(|e| e.id).collect::<Vec<_>>(),
5845            "the id list and the row list disagree on ordering"
5846        );
5847        Ok(())
5848    }
5849
5850    // -----------------------------------------------------------------------
5851    // Read-state PDS sync wiring: marking read/unread must project into the
5852    // per-feed `read_cursor` and mark it dirty so the batched flusher pushes it.
5853    // Before this wiring `mark_read` touched only `entry_state`; nothing dirtied
5854    // a cursor, so the flusher never synced read-state to the PDS.
5855    // -----------------------------------------------------------------------
5856
5857    #[tokio::test]
5858    async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
5859        let pool = init_url("sqlite::memory:").await?;
5860        let feed_url = "https://example.com/feed.xml";
5861        let feed_id = upsert_feed(
5862            &pool,
5863            &NewFeed {
5864                url: feed_url.to_string(),
5865                title: Some("Example".to_string()),
5866                ..Default::default()
5867            },
5868        )
5869        .await?;
5870        insert_entries(
5871            &pool,
5872            feed_id,
5873            &[
5874                NewEntry {
5875                    guid: "g1".to_string(),
5876                    published: Some("2026-07-10T00:00:00Z".to_string()),
5877                    ..Default::default()
5878                },
5879                NewEntry {
5880                    guid: "g2".to_string(),
5881                    published: Some("2026-07-11T00:00:00Z".to_string()),
5882                    ..Default::default()
5883                },
5884            ],
5885            0,
5886        )
5887        .await?;
5888        let did = "did:plc:reader";
5889        replace_sub_refs(&pool, did, &[feed_id]).await?;
5890
5891        // No cursor exists yet.
5892        assert!(get_cursor(&pool, did, feed_url).await?.is_none());
5893        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5894
5895        // Mark one entry read → the feed's read_cursor row now exists, dirty=1,
5896        // and dirty_cursors returns it (the exact assertion the fix requires).
5897        let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
5898        assert!(mark_read(&pool, did, e1, true).await?);
5899
5900        let cursor = get_cursor(&pool, did, feed_url)
5901            .await?
5902            .expect("mark_read must create the feed's read_cursor");
5903        assert!(cursor.dirty, "cursor must be dirty after mark_read");
5904        assert!(
5905            cursor.read_ids.contains(&e1.to_string()),
5906            "the read entry id must be in read_ids: {}",
5907            cursor.read_ids
5908        );
5909        let dirty = dirty_cursors(&pool, did).await?;
5910        assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
5911        assert_eq!(dirty[0].feed_url, feed_url);
5912
5913        // Marking it unread again moves the id to unread_ids and keeps it dirty.
5914        assert!(mark_read(&pool, did, e1, false).await?);
5915        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5916        assert!(cursor.dirty);
5917        assert!(
5918            cursor.unread_ids.contains(&e1.to_string()),
5919            "unread id must be in unread_ids: {}",
5920            cursor.unread_ids
5921        );
5922        assert!(
5923            !cursor.read_ids.contains(&e1.to_string()),
5924            "id must have left read_ids: {}",
5925            cursor.read_ids
5926        );
5927
5928        // mark_feed_read dirties the one per-feed cursor too (batched, not
5929        // per-article).
5930        assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
5931        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5932        assert!(cursor.dirty);
5933        assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
5934
5935        // A non-subscriber's mark_read is a no-op and dirties NO cursor.
5936        let outsider = "did:plc:outsider";
5937        assert!(!mark_read(&pool, outsider, e1, true).await?);
5938        assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
5939
5940        // The conditional clear only clears when updated_at matches the snapshot.
5941        let snap = dirty_cursors(&pool, did).await?[0].clone();
5942        // A stale updated_at must NOT clear (models a concurrent re-dirty).
5943        clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
5944        assert_eq!(
5945            dirty_cursors(&pool, did).await?.len(),
5946            1,
5947            "stale-snapshot clear must be a no-op"
5948        );
5949        // The matching updated_at clears it.
5950        clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
5951        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5952
5953        Ok(())
5954    }
5955
5956    #[test]
5957    fn json_id_set_toggle_is_set_like() {
5958        // Add is idempotent, remove drops, output is a JSON string array.
5959        let s = json_id_set_toggle("[]", 5, true);
5960        assert_eq!(s, r#"["5"]"#);
5961        assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); // no dup
5962        let s = json_id_set_toggle(&s, 7, true);
5963        assert_eq!(s, r#"["5","7"]"#);
5964        let s = json_id_set_toggle(&s, 5, false);
5965        assert_eq!(s, r#"["7"]"#);
5966        // Tolerates numeric-array input and malformed input.
5967        assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
5968        assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
5969    }
5970
5971    // -----------------------------------------------------------------------
5972    // Per-DID isolation: the shared cache is one row per URL, but the READ
5973    // SURFACE (entries/unread/starred) and the read/star MUTATIONS are scoped
5974    // to the caller's own subscriptions (`sub_ref`). User A must never see or
5975    // mutate user B's entries.
5976    // -----------------------------------------------------------------------
5977
5978    #[tokio::test]
5979    async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
5980        let pool = init_url("sqlite::memory:").await?;
5981
5982        // Two feeds in the SHARED cache; A subscribes to feed_a, B to feed_b.
5983        let feed_a = upsert_feed(
5984            &pool,
5985            &NewFeed {
5986                url: "https://a.example/feed.xml".to_string(),
5987                title: Some("A".to_string()),
5988                ..Default::default()
5989            },
5990        )
5991        .await?;
5992        let feed_b = upsert_feed(
5993            &pool,
5994            &NewFeed {
5995                url: "https://b.example/feed.xml".to_string(),
5996                title: Some("B".to_string()),
5997                ..Default::default()
5998            },
5999        )
6000        .await?;
6001
6002        insert_entries(
6003            &pool,
6004            feed_a,
6005            &[NewEntry {
6006                guid: "a-1".to_string(),
6007                url: Some("https://a.example/1".to_string()),
6008                title: Some("A one".to_string()),
6009                published: Some("2026-07-10T00:00:00Z".to_string()),
6010                content_html: Some("<p>secret A body</p>".to_string()),
6011                ..Default::default()
6012            }],
6013            0,
6014        )
6015        .await?;
6016        insert_entries(
6017            &pool,
6018            feed_b,
6019            &[NewEntry {
6020                guid: "b-1".to_string(),
6021                url: Some("https://b.example/1".to_string()),
6022                title: Some("B one".to_string()),
6023                published: Some("2026-07-11T00:00:00Z".to_string()),
6024                content_html: Some("<p>secret B body</p>".to_string()),
6025                ..Default::default()
6026            }],
6027            0,
6028        )
6029        .await?;
6030
6031        let did_a = "did:plc:aaaa";
6032        let did_b = "did:plc:bbbb";
6033        replace_sub_refs(&pool, did_a, &[feed_a]).await?;
6034        replace_sub_refs(&pool, did_b, &[feed_b]).await?;
6035
6036        // The id of B's only entry (the one A must not be able to touch).
6037        let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
6038
6039        // --- entries_for_feed is scoped: A sees A's feed, not B's ------------
6040        assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
6041        assert!(
6042            entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
6043            "A must not read entries of a feed it does not subscribe to"
6044        );
6045
6046        // --- unread list is scoped -------------------------------------------
6047        let unread_a = get_unread_for_did(&pool, did_a).await?;
6048        assert_eq!(unread_a.len(), 1);
6049        assert_eq!(unread_a[0].guid, "a-1");
6050        let unread_b = get_unread_for_did(&pool, did_b).await?;
6051        assert_eq!(unread_b.len(), 1);
6052        assert_eq!(unread_b[0].guid, "b-1");
6053
6054        // --- did_subscribes_to_entry authorizes correctly --------------------
6055        assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
6056        assert!(
6057            !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
6058            "A does not subscribe to B's feed"
6059        );
6060
6061        // --- mark_read is authorized: A CANNOT mark B's entry ----------------
6062        assert!(
6063            !mark_read(&pool, did_a, b_entry_id, true).await?,
6064            "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
6065        );
6066        // B's unread list is untouched by A's attempt.
6067        assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
6068        // A subscriber CAN mark it.
6069        assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
6070        assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
6071
6072        // --- toggle_star is authorized the same way --------------------------
6073        assert!(
6074            !mark_starred(&pool, did_a, b_entry_id, true).await?,
6075            "non-subscriber mark_starred must be a no-op (→ 404)"
6076        );
6077        assert!(
6078            get_starred_for_did(&pool, did_a).await?.is_empty(),
6079            "A's starred list stays empty after the rejected attempt"
6080        );
6081        assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
6082        assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
6083        // B's star never leaks into A's starred list.
6084        assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
6085
6086        // --- feeds_for_did is scoped to the DID's OWN sub_ref ----------------
6087        // This is the PDS-unreachable fallback's projection: it must NEVER
6088        // widen a DID's surface to feeds it does not subscribe to. A sees only
6089        // feed_a; B (still subscribed to feed_b here) sees only feed_b.
6090        let a_feeds = feeds_for_did(&pool, did_a).await?;
6091        assert_eq!(a_feeds.len(), 1);
6092        assert_eq!(a_feeds[0].id, feed_a);
6093        let b_feeds = feeds_for_did(&pool, did_b).await?;
6094        assert_eq!(b_feeds.len(), 1);
6095        assert_eq!(b_feeds[0].id, feed_b);
6096
6097        // --- resync drops a feed from the surface when the sub goes away ------
6098        replace_sub_refs(&pool, did_b, &[]).await?;
6099        assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
6100        assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
6101        assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
6102        // And the fallback projection is empty too — fail CLOSED, not open.
6103        assert!(feeds_for_did(&pool, did_b).await?.is_empty());
6104
6105        Ok(())
6106    }
6107
6108    // -----------------------------------------------------------------------
6109    // PDS-outage authorization (fail CLOSED). REGRESSION GUARD for the past
6110    // FAIL-OPEN bug (fixed in 2e53e0e): `resolve_subscriptions`' PDS/sidecar-
6111    // unreachable fallback used to synthesize a DID's `sub_ref` from EVERY
6112    // cached feed (`due_feeds(.., i64::MAX)`), granting cross-tenant read +
6113    // mutate during any outage. The fix serves the DID's OWN last-known
6114    // `sub_ref` via `feeds_for_did(did)` and NEVER widens it.
6115    //
6116    // This test replays that fixed fallback at the store layer — the seam the
6117    // web handler drives when `list_subscriptions_sorted(did) -> Err`. The
6118    // key adversarial shape is an ORPHAN cached feed (in the shared cache but
6119    // subscribed by NO ONE): the old fail-open code would have folded it into
6120    // the caller's surface. If the fail-open is reintroduced, `feeds_for_did`
6121    // would include that orphan and every assertion below flips — so this is a
6122    // real guard, not a tautology.
6123    // -----------------------------------------------------------------------
6124
6125    #[tokio::test]
6126    async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
6127        let pool = init_url("sqlite::memory:").await?;
6128
6129        let did_a = "did:plc:aaaa";
6130
6131        // feed_a: A's own subscription (its last-known `sub_ref`; the fallback
6132        // may serve this stale but must not widen past it).
6133        let feed_a = upsert_feed(
6134            &pool,
6135            &NewFeed {
6136                url: "https://a.example/feed.xml".to_string(),
6137                title: Some("A".to_string()),
6138                ..Default::default()
6139            },
6140        )
6141        .await?;
6142        // feed_orphan: present in the SHARED cache but subscribed by NO DID.
6143        // This is exactly what the fail-open path would have leaked to A.
6144        let feed_orphan = upsert_feed(
6145            &pool,
6146            &NewFeed {
6147                url: "https://orphan.example/feed.xml".to_string(),
6148                title: Some("Orphan".to_string()),
6149                ..Default::default()
6150            },
6151        )
6152        .await?;
6153
6154        insert_entries(
6155            &pool,
6156            feed_a,
6157            &[NewEntry {
6158                guid: "a-1".to_string(),
6159                url: Some("https://a.example/1".to_string()),
6160                title: Some("A one".to_string()),
6161                published: Some("2026-07-10T00:00:00Z".to_string()),
6162                content_html: Some("<p>A body</p>".to_string()),
6163                ..Default::default()
6164            }],
6165            0,
6166        )
6167        .await?;
6168        insert_entries(
6169            &pool,
6170            feed_orphan,
6171            &[NewEntry {
6172                guid: "orphan-1".to_string(),
6173                url: Some("https://orphan.example/1".to_string()),
6174                title: Some("Orphan one".to_string()),
6175                published: Some("2026-07-11T00:00:00Z".to_string()),
6176                content_html: Some("<p>secret orphan body</p>".to_string()),
6177                ..Default::default()
6178            }],
6179            0,
6180        )
6181        .await?;
6182
6183        // A's last-known subscription set is feed_a ONLY. No `sub_ref` row ever
6184        // points any DID at feed_orphan.
6185        replace_sub_refs(&pool, did_a, &[feed_a]).await?;
6186
6187        // Grab the orphan entry id via a transient sub so we can address it,
6188        // then drop the sub — nobody subscribes to feed_orphan afterwards.
6189        replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
6190        let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
6191        replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
6192
6193        // --- Replay the FIXED fallback projection ----------------------------
6194        // This is what `resolve_subscriptions` serves on the Err (outage) path:
6195        // the caller's OWN feeds, never widened. It must contain feed_a and
6196        // NEVER the orphan. (The old fail-open synthesized from every cached
6197        // feed → this vec would have held feed_orphan too.)
6198        let fallback = feeds_for_did(&pool, did_a).await?;
6199        let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
6200        assert_eq!(
6201            fallback_ids,
6202            vec![feed_a],
6203            "outage fallback must serve ONLY A's own last-known sub_ref, \
6204             never widen to the orphan cached feed"
6205        );
6206        assert!(
6207            !fallback_ids.contains(&feed_orphan),
6208            "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
6209             cached feed into A's surface"
6210        );
6211
6212        // --- With that projection in place, EVERY scoped read denies A -------
6213        assert!(
6214            !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
6215            "A must not be authorized for an orphan feed's entry during an outage"
6216        );
6217        assert!(
6218            entries_for_feed(&pool, did_a, feed_orphan)
6219                .await?
6220                .is_empty(),
6221            "entries_for_feed must not expose the orphan feed to A during an outage"
6222        );
6223        // Neither the unread nor the starred list may surface the orphan entry.
6224        let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
6225            .await?
6226            .into_iter()
6227            .map(|e| e.guid)
6228            .collect();
6229        assert!(
6230            !unread_guids.iter().any(|g| g == "orphan-1"),
6231            "orphan entry leaked into A's unread list during an outage"
6232        );
6233        assert!(
6234            get_starred_for_did(&pool, did_a).await?.is_empty(),
6235            "A has no starred entries; the orphan must not appear"
6236        );
6237
6238        // --- And EVERY scoped mutation is a no-op (→ 404 at the web layer) ---
6239        assert!(
6240            !mark_read(&pool, did_a, orphan_entry_id, true).await?,
6241            "A must not mark an orphan feed's entry read during an outage"
6242        );
6243        assert!(
6244            !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
6245            "A must not star an orphan feed's entry during an outage"
6246        );
6247        assert_eq!(
6248            mark_feed_read(&pool, did_a, feed_orphan, true).await?,
6249            0,
6250            "A must not mark-all-read the orphan feed during an outage"
6251        );
6252
6253        // Nothing was written for A against the orphan entry.
6254        let es_count: i64 =
6255            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6256                .bind(did_a)
6257                .bind(orphan_entry_id)
6258                .fetch_one(&pool)
6259                .await?;
6260        assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
6261
6262        Ok(())
6263    }
6264
6265    // -----------------------------------------------------------------------
6266    // Closed-beta invite gate
6267    // -----------------------------------------------------------------------
6268
6269    #[test]
6270    fn code_gen_shape_and_alphabet() {
6271        for _ in 0..200 {
6272            let code = generate_invite_code().unwrap();
6273            assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
6274            let body = &code["FEATHER-".len()..];
6275            assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
6276            // Every body char must be from the ambiguity-free alphabet — in
6277            // particular NEVER I/O/0/1.
6278            for c in body.chars() {
6279                assert!(
6280                    CODE_ALPHABET.contains(&(c as u8)),
6281                    "char {c:?} not in alphabet ({code})"
6282                );
6283                assert!(
6284                    !matches!(c, 'I' | 'O' | '0' | '1'),
6285                    "ambiguous char {c:?} leaked into {code}"
6286                );
6287            }
6288        }
6289        // Two codes in a row must differ (unguessable / random).
6290        assert_ne!(
6291            generate_invite_code().unwrap(),
6292            generate_invite_code().unwrap()
6293        );
6294    }
6295
6296    #[tokio::test]
6297    async fn busy_timeout_is_applied() -> Result<()> {
6298        // Opening an on-disk DB and reading back the PRAGMA proves the pool
6299        // carries busy_timeout = 5000 ms.
6300        let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
6301        std::fs::create_dir_all(&dir).ok();
6302        let path = dir.join("busy.db");
6303        let url = format!("sqlite://{}", path.display());
6304        let pool = init_url(&url).await?;
6305        let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
6306        let timeout: i64 = row.get(0);
6307        assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
6308        pool.close().await;
6309        std::fs::remove_dir_all(&dir).ok();
6310        Ok(())
6311    }
6312
6313    #[tokio::test]
6314    async fn redeem_valid_grants_seat() -> Result<()> {
6315        let pool = init_url("sqlite::memory:").await?;
6316        let code = mint_code(&pool, "did:plc:creator", 3600).await?;
6317        assert!(!has_beta_access(&pool, "did:plc:new").await?);
6318
6319        let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
6320        assert_eq!(out, Ok(()));
6321        assert!(has_beta_access(&pool, "did:plc:new").await?);
6322        assert_eq!(count_beta_access(&pool).await?, 1);
6323
6324        // The code is now spent — a second redeem is AlreadyRedeemed.
6325        let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
6326        assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
6327        Ok(())
6328    }
6329
6330    #[tokio::test]
6331    async fn redeem_not_found() -> Result<()> {
6332        let pool = init_url("sqlite::memory:").await?;
6333        let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
6334        assert_eq!(out, Err(RedeemError::NotFound));
6335        Ok(())
6336    }
6337
6338    /// Insert an already-expired `active` code directly (mint_code clamps a
6339    /// negative ttl to 0, so the past-expiry case is set up by hand).
6340    async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
6341        let now = now_unix();
6342        sqlx::query(
6343            r#"INSERT INTO invite_codes
6344               (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
6345               VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
6346        )
6347        .bind(code)
6348        .bind(creator)
6349        .bind(now - 100)
6350        .bind(now - 10) // expires_at in the past
6351        .execute(pool)
6352        .await?;
6353        Ok(())
6354    }
6355
6356    #[tokio::test]
6357    async fn redeem_expired() -> Result<()> {
6358        let pool = init_url("sqlite::memory:").await?;
6359        insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
6360        let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
6361        assert_eq!(out, Err(RedeemError::Expired));
6362        // No seat granted.
6363        assert_eq!(count_beta_access(&pool).await?, 0);
6364        Ok(())
6365    }
6366
6367    #[tokio::test]
6368    async fn redeem_capacity_full() -> Result<()> {
6369        let pool = init_url("sqlite::memory:").await?;
6370        // Cap of 1, one seat already taken by an admin seed.
6371        ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
6372        assert_eq!(count_beta_access(&pool).await?, 1);
6373
6374        let code = mint_code(&pool, "did:plc:admin", 3600).await?;
6375        let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
6376        assert_eq!(out, Err(RedeemError::CapacityFull));
6377        // Seat NOT granted and the code NOT consumed (tx rolled back).
6378        assert!(!has_beta_access(&pool, "did:plc:new").await?);
6379        // Raising the cap lets the same code redeem.
6380        let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
6381        assert_eq!(ok, Ok(()));
6382        Ok(())
6383    }
6384
6385    #[tokio::test]
6386    async fn count_active_codes_excludes_expired_and_redeemed() -> Result<()> {
6387        let pool = init_url("sqlite::memory:").await?;
6388        assert_eq!(count_active_codes(&pool).await?, 0);
6389
6390        // Two live codes.
6391        let a = mint_code(&pool, "did:plc:bot", 3600).await?;
6392        let _b = mint_code(&pool, "did:plc:bot", 3600).await?;
6393        assert_eq!(count_active_codes(&pool).await?, 2);
6394
6395        // An expired code doesn't count.
6396        insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:bot").await?;
6397        assert_eq!(count_active_codes(&pool).await?, 2);
6398
6399        // Redeeming one drops the active count.
6400        let out = redeem_code(&pool, &a, "did:plc:new", None, 100).await?;
6401        assert_eq!(out, Ok(()));
6402        assert_eq!(count_active_codes(&pool).await?, 1);
6403        Ok(())
6404    }
6405
6406    #[tokio::test]
6407    async fn expire_and_seed() -> Result<()> {
6408        let pool = init_url("sqlite::memory:").await?;
6409        // An already-expired code is swept to `expired`.
6410        insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
6411        let live = mint_code(&pool, "did:plc:creator", 3600).await?;
6412        let n = expire_old_codes(&pool).await?;
6413        assert_eq!(n, 1, "exactly the past-expiry code should flip");
6414        // The live code still redeems.
6415        assert_eq!(
6416            redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
6417            Ok(())
6418        );
6419
6420        // ensure_seed is idempotent.
6421        let created = ensure_seed(
6422            &pool,
6423            &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
6424        )
6425        .await?;
6426        assert_eq!(created, 2);
6427        let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
6428        assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
6429        assert!(has_beta_access(&pool, "did:plc:seed1").await?);
6430        Ok(())
6431    }
6432
6433    /// **The sweep spares a REDEEMED code that is past its TTL.** The
6434    /// existing sweep test seeds one active past-expiry code and one live
6435    /// one, so the `status = 'active'` guard never excludes anything — with
6436    /// it deleted the suite stayed green. Without it the hourly sweep rewrites
6437    /// redeemed codes to `expired`, destroying the redemption the invite audit
6438    /// trail depends on and inflating the logged sweep count.
6439    #[tokio::test]
6440    async fn the_expiry_sweep_spares_redeemed_codes() -> Result<()> {
6441        let pool = init_url("sqlite::memory:").await?;
6442        let code = mint_code(&pool, "did:plc:creator", 3600).await?;
6443        assert!(redeem_code(&pool, &code, "did:plc:new", None, 100)
6444            .await?
6445            .is_ok());
6446        // Time passes: the redeemed code is now past its TTL.
6447        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6448            .bind(now_unix() - 10)
6449            .bind(&code)
6450            .execute(&pool)
6451            .await?;
6452        insert_expired_code(&pool, "FEATHER-EXPIRED2", "did:plc:creator").await?;
6453
6454        let n = expire_old_codes(&pool).await?;
6455        assert_eq!(n, 1, "the sweep counted the redeemed code");
6456        let status: String = sqlx::query_scalar("SELECT status FROM invite_codes WHERE code = ?1")
6457            .bind(&code)
6458            .fetch_one(&pool)
6459            .await?;
6460        assert_eq!(status, "redeemed", "the sweep rewrote a redemption");
6461        Ok(())
6462    }
6463
6464    // -----------------------------------------------------------------------
6465    // Hardening caps: per-DID sub count, global feed count, per-feed entry trim.
6466    // -----------------------------------------------------------------------
6467
6468    #[tokio::test]
6469    async fn count_helpers_track_feeds_and_subs() -> Result<()> {
6470        let pool = init_url("sqlite::memory:").await?;
6471        assert_eq!(count_feeds(&pool).await?, 0);
6472
6473        let mut ids = Vec::new();
6474        for i in 0..3 {
6475            let id = upsert_feed(
6476                &pool,
6477                &NewFeed {
6478                    url: format!("https://f{i}.example/feed.xml"),
6479                    ..Default::default()
6480                },
6481            )
6482            .await?;
6483            ids.push(id);
6484        }
6485        assert_eq!(count_feeds(&pool).await?, 3);
6486
6487        let did = "did:plc:capcheck";
6488        assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
6489        replace_sub_refs(&pool, did, &ids).await?;
6490        assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
6491        Ok(())
6492    }
6493
6494    #[tokio::test]
6495    async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
6496        let pool = init_url("sqlite::memory:").await?;
6497        let feed_id = upsert_feed(
6498            &pool,
6499            &NewFeed {
6500                url: "https://firehose.example/feed.xml".to_string(),
6501                ..Default::default()
6502            },
6503        )
6504        .await?;
6505
6506        // Insert 5 entries with ascending published dates, cap retained to 2.
6507        let batch: Vec<NewEntry> = (0..5)
6508            .map(|i| NewEntry {
6509                guid: format!("g-{i}"),
6510                title: Some(format!("E{i}")),
6511                published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
6512                ..Default::default()
6513            })
6514            .collect();
6515        insert_entries(&pool, feed_id, &batch, 2).await?;
6516
6517        let did = "did:plc:trim";
6518        replace_sub_refs(&pool, did, &[feed_id]).await?;
6519        let kept = entries_for_feed(&pool, did, feed_id).await?;
6520        assert_eq!(
6521            kept.len(),
6522            2,
6523            "over-cap feed trimmed to the newest 2 entries"
6524        );
6525        // Newest first: g-4 (2026-07-05), g-3 (2026-07-04).
6526        assert_eq!(kept[0].guid, "g-4");
6527        assert_eq!(kept[1].guid, "g-3");
6528        Ok(())
6529    }
6530
6531    /// Regression: an UNDATED entry (NULL `published`) that was fetched most
6532    /// recently must NOT be evicted in favour of an older *dated* entry. The
6533    /// trim orders by `COALESCE(published, fetched_at) DESC`; under the old
6534    /// `ORDER BY published DESC` a NULL-published row sorts LAST and is dropped
6535    /// first even when it is the freshest thing in the feed.
6536    #[tokio::test]
6537    async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
6538        let pool = init_url("sqlite::memory:").await?;
6539        let feed_id = upsert_feed(
6540            &pool,
6541            &NewFeed {
6542                url: "https://undated.example/feed.xml".to_string(),
6543                ..Default::default()
6544            },
6545        )
6546        .await?;
6547
6548        // Two OLD dated entries (fetched long ago), plus one UNDATED entry
6549        // fetched most recently. Cap = 2, so exactly one row must be evicted.
6550        let batch = vec![
6551            NewEntry {
6552                guid: "old-dated-1".to_string(),
6553                title: Some("Old A".to_string()),
6554                published: Some("2026-07-01T00:00:00Z".to_string()),
6555                fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
6556                ..Default::default()
6557            },
6558            NewEntry {
6559                guid: "old-dated-2".to_string(),
6560                title: Some("Old B".to_string()),
6561                published: Some("2026-07-02T00:00:00Z".to_string()),
6562                fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
6563                ..Default::default()
6564            },
6565            NewEntry {
6566                guid: "fresh-undated".to_string(),
6567                title: Some("Fresh undated".to_string()),
6568                published: None,
6569                fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
6570                ..Default::default()
6571            },
6572        ];
6573        insert_entries(&pool, feed_id, &batch, 2).await?;
6574
6575        let did = "did:plc:undated";
6576        replace_sub_refs(&pool, did, &[feed_id]).await?;
6577        let kept = entries_for_feed(&pool, did, feed_id).await?;
6578        assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
6579        let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
6580        assert!(
6581            guids.contains(&"fresh-undated"),
6582            "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
6583        );
6584        assert!(
6585            guids.contains(&"old-dated-2"),
6586            "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
6587        );
6588        assert!(
6589            !guids.contains(&"old-dated-1"),
6590            "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
6591        );
6592        Ok(())
6593    }
6594
6595    /// **It must actually GROW — the name used to be a lie.**
6596    ///
6597    /// The earlier body was three lines asserting only `before > 0`. There was
6598    /// no second measurement, so `db_size_bytes` returning a constant `1` passed.
6599    /// That matters because this number is the poller's disk watermark: a size
6600    /// that never moves means the pause never trips and the volume fills
6601    /// instead.
6602    #[tokio::test]
6603    async fn db_size_is_positive_and_grows() -> Result<()> {
6604        let pool = init_url("sqlite::memory:").await?;
6605        let before = db_size_bytes(&pool).await?;
6606        assert!(before > 0, "a schema-initialised DB has a non-zero size");
6607
6608        // Enough rows that the file must gain pages, not just fill slack.
6609        seed_big_entries(&pool, "did:plc:growth", 400).await?;
6610
6611        let after = db_size_bytes(&pool).await?;
6612        assert!(
6613            after > before,
6614            "the database grew by {} bytes after 400 seeded entries; the size is \
6615             not tracking the data, so the disk watermark can never trip",
6616            after.saturating_sub(before),
6617        );
6618        Ok(())
6619    }
6620
6621    /// `purge_did_data` removes every per-DID row the caller owns (read/star
6622    /// state, cursors, sub_ref projection, beta seat, created invite codes) —
6623    /// and touches no other DID's rows nor the shared feeds/entries cache.
6624    #[tokio::test]
6625    async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
6626        let pool = init_url("sqlite::memory:").await?;
6627
6628        // A shared feed + entry both DIDs can subscribe to.
6629        let feed_id = upsert_feed(
6630            &pool,
6631            &NewFeed {
6632                url: "https://example.com/feed.xml".to_string(),
6633                title: Some("Example".to_string()),
6634                ..Default::default()
6635            },
6636        )
6637        .await?;
6638        insert_entries(
6639            &pool,
6640            feed_id,
6641            &[NewEntry {
6642                guid: "g-1".to_string(),
6643                url: Some("https://example.com/a".to_string()),
6644                title: Some("First".to_string()),
6645                published: Some("2026-07-10T08:00:00Z".to_string()),
6646                ..Default::default()
6647            }],
6648            0,
6649        )
6650        .await?;
6651        let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
6652            .fetch_one(&pool)
6653            .await?;
6654
6655        let victim = "did:plc:victim";
6656        let bystander = "did:plc:bystander";
6657
6658        // Seed BOTH DIDs with a full spread of per-DID rows.
6659        for did in [victim, bystander] {
6660            replace_sub_refs(&pool, did, &[feed_id]).await?;
6661            assert!(mark_read(&pool, did, entry_id, true).await?);
6662            assert!(mark_starred(&pool, did, entry_id, true).await?);
6663            upsert_cursor(
6664                &pool,
6665                &ReadCursor {
6666                    did: did.to_string(),
6667                    feed_url: "https://example.com/feed.xml".to_string(),
6668                    read_through: Some("2026-07-10T08:00:00Z".to_string()),
6669                    read_ids: "[]".to_string(),
6670                    unread_ids: "[]".to_string(),
6671                    dirty: false,
6672                    pds_created: false,
6673                    updated_at: now_rfc3339(),
6674                },
6675            )
6676            .await?;
6677            grant_access(&pool, did, Some("h.example"), "admin", None).await?;
6678            mint_code(&pool, did, 3600).await?;
6679        }
6680
6681        // Purge only the victim.
6682        let counts = purge_did_data(&pool, victim).await?;
6683        assert_eq!(
6684            counts.entry_state, 1,
6685            "one entry_state row (read+star merge)"
6686        );
6687        assert_eq!(counts.read_cursor, 1);
6688        assert_eq!(counts.sub_ref, 1);
6689        assert_eq!(counts.beta_access, 1);
6690        assert_eq!(counts.invite_codes, 1);
6691        assert_eq!(counts.total(), 5);
6692
6693        // The victim has zero rows left in every per-DID table.
6694        let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
6695            .bind(victim)
6696            .fetch_one(&pool)
6697            .await?;
6698        assert_eq!(es, 0, "victim still had entry_state rows");
6699        let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
6700            .bind(victim)
6701            .fetch_one(&pool)
6702            .await?;
6703        assert_eq!(rc, 0, "victim still had read_cursor rows");
6704        let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
6705            .bind(victim)
6706            .fetch_one(&pool)
6707            .await?;
6708        assert_eq!(sr, 0, "victim still had sub_ref rows");
6709        assert!(
6710            !has_beta_access(&pool, victim).await?,
6711            "victim still had a beta seat"
6712        );
6713        let victim_codes: i64 =
6714            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6715                .bind(victim)
6716                .fetch_one(&pool)
6717                .await?;
6718        assert_eq!(victim_codes, 0);
6719
6720        // The bystander is untouched.
6721        assert!(has_beta_access(&pool, bystander).await?);
6722        let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
6723        assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
6724        let bystander_codes: i64 =
6725            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6726                .bind(bystander)
6727                .fetch_one(&pool)
6728                .await?;
6729        assert_eq!(bystander_codes, 1);
6730
6731        // The shared cache is intact.
6732        assert_eq!(count_feeds(&pool).await?, 1);
6733
6734        // Idempotent: purging again removes nothing.
6735        let again = purge_did_data(&pool, victim).await?;
6736        assert_eq!(again.total(), 0);
6737
6738        Ok(())
6739    }
6740
6741    /// A departing DID leaves back-references on rows that belong to OTHER DIDs:
6742    ///   * the invite code it *redeemed* to join (inviter's row: `invitee_did`);
6743    ///   * seats it *granted* to others (`beta_access.granted_by`).
6744    /// `purge_did_data` must scrub both so no per-DID residue survives, while
6745    /// leaving those other DIDs' rows otherwise intact (their access is kept).
6746    #[tokio::test]
6747    async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
6748        let pool = init_url("sqlite::memory:").await?;
6749
6750        let inviter = "did:plc:inviter";
6751        let leaver = "did:plc:leaver";
6752        let friend = "did:plc:friend";
6753
6754        // inviter mints a code; leaver redeems it to join (stamps invitee_did).
6755        let inviter_code = mint_code(&pool, inviter, 3600).await?;
6756        grant_access(&pool, inviter, None, "admin", None).await?;
6757        assert_eq!(
6758            redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
6759            Ok(())
6760        );
6761
6762        // leaver mints a code; friend redeems it (stamps friend's granted_by).
6763        let leaver_code = mint_code(&pool, leaver, 3600).await?;
6764        assert_eq!(
6765            redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
6766            Ok(())
6767        );
6768
6769        // Precondition: the leaver DID is present in both back-reference columns.
6770        let invitee_before: i64 =
6771            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6772                .bind(leaver)
6773                .fetch_one(&pool)
6774                .await?;
6775        assert_eq!(
6776            invitee_before, 1,
6777            "leaver should be an invitee before purge"
6778        );
6779        let granted_before: i64 =
6780            sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6781                .bind(leaver)
6782                .fetch_one(&pool)
6783                .await?;
6784        assert_eq!(granted_before, 1, "leaver should be a granter before purge");
6785
6786        // Purge the leaver.
6787        let counts = purge_did_data(&pool, leaver).await?;
6788        assert_eq!(
6789            counts.invitee_scrubbed, 1,
6790            "the redeemed code's invitee_did"
6791        );
6792        assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
6793
6794        // No residue: the leaver DID appears in NEITHER back-reference column.
6795        let invitee_after: i64 =
6796            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6797                .bind(leaver)
6798                .fetch_one(&pool)
6799                .await?;
6800        assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
6801        let granted_after: i64 =
6802            sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6803                .bind(leaver)
6804                .fetch_one(&pool)
6805                .await?;
6806        assert_eq!(granted_after, 0, "leaver survived in granted_by");
6807
6808        // The other DIDs' rows are kept: the friend still has a seat (redacted
6809        // granter), and the inviter's code row still exists (invitee NULLed).
6810        assert!(
6811            has_beta_access(&pool, friend).await?,
6812            "friend's seat must survive the leaver's scrub"
6813        );
6814        let friend_granted_by: String =
6815            sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
6816                .bind(friend)
6817                .fetch_one(&pool)
6818                .await?;
6819        assert_eq!(friend_granted_by, REDACTED_DID);
6820        let inviter_code_rows: i64 =
6821            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6822                .bind(inviter)
6823                .fetch_one(&pool)
6824                .await?;
6825        assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
6826
6827        Ok(())
6828    }
6829
6830    // -- F2: consecutive-error count drives the poll backoff -----------------
6831
6832    /// **Rows that failed only because we could not poll them are cleared.**
6833    ///
6834    /// Excluding `at://` from `due_feeds` stops NEW failures; it does nothing
6835    /// about the ones already recorded. This instance carries 19 such rows at
6836    /// 35+ consecutive errors each — accumulated entirely by our own refusal to
6837    /// fetch a scheme we had not implemented. Left alone they keep counting
6838    /// toward `in_backoff` and `badly_broken`, so a public page would report
6839    /// unsupported feeds as broken publishers forever, with no poll that could
6840    /// ever clear them since they are no longer selected.
6841    ///
6842    /// Safe to re-run because of WHAT it clears, not because the count cannot
6843    /// grow: only rows never polled successfully (`last_polled IS NULL`) — see
6844    /// `the_at_uri_error_clearing_spares_a_row_that_has_been_polled`.
6845    #[tokio::test]
6846    async fn the_migration_clears_error_counts_on_unpollable_at_uri_rows() -> Result<()> {
6847        let pool = init_url("sqlite::memory:").await?;
6848        for url in [
6849            "https://real.example/feed.xml",
6850            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
6851        ] {
6852            upsert_feed(
6853                &pool,
6854                &NewFeed {
6855                    url: url.to_string(),
6856                    ..Default::default()
6857                },
6858            )
6859            .await?;
6860            sqlx::query(
6861                "UPDATE feeds SET consecutive_errors = 35, last_error_kind = 'fetch', \
6862                 last_error = 'unsupported scheme' WHERE url = ?1",
6863            )
6864            .bind(url)
6865            .execute(&pool)
6866            .await?;
6867        }
6868
6869        apply_migrations(&pool).await?;
6870
6871        let (at_errors, at_kind, at_detail): (i64, Option<String>, Option<String>) =
6872            sqlx::query_as(sqlx::AssertSqlSafe(format!(
6873                "SELECT consecutive_errors, last_error_kind, last_error FROM feeds \
6874                 WHERE kind = '{}'",
6875                crate::feed::FeedKind::Unsupported.as_str()
6876            )))
6877            .fetch_one(&pool)
6878            .await?;
6879        assert_eq!(at_errors, 0, "an unpollable row kept its failure count");
6880        // A row with no errors carries no reason — the invariant
6881        // `reset_feed_errors` upholds, and the migration must too.
6882        assert_eq!(at_kind, None, "an unpollable row kept its failure kind");
6883        assert_eq!(at_detail, None, "an unpollable row kept its failure detail");
6884
6885        // A real feed's failure history is NOT touched — it is still meaningful.
6886        let http_errors: i64 = sqlx::query_scalar(
6887            "SELECT consecutive_errors FROM feeds WHERE url = 'https://real.example/feed.xml'",
6888        )
6889        .fetch_one(&pool)
6890        .await?;
6891        assert_eq!(http_errors, 35, "a real feed's history was discarded");
6892        Ok(())
6893    }
6894
6895    /// **An `at://` feed is never selected for polling.**
6896    ///
6897    /// (Since 0.4.0 these are rows of kind `unsupported`: an at-URI that is not a
6898    /// well-formed publication, which no reader can fetch.) Selecting them does not
6899    /// leave the feature dormant — it manufactures a permanent failure per row,
6900    /// which since the cause histogram is *published* as an unreachable
6901    /// publisher. This instance already carries 19 such rows, subscribed before
6902    /// the scheme was refused.
6903    ///
6904    /// They are skipped rather than failed: unsupported is not broken, and the
6905    /// difference is the whole point of recording a cause at all.
6906    #[tokio::test]
6907    async fn an_at_uri_feed_is_never_due_for_polling() -> Result<()> {
6908        let pool = init_url("sqlite::memory:").await?;
6909        for url in [
6910            "https://example.com/feed.xml",
6911            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
6912            "at://alice.example.com/site.standard.publication/3lab",
6913        ] {
6914            upsert_feed(
6915                &pool,
6916                &NewFeed {
6917                    url: url.to_string(),
6918                    ..Default::default()
6919                },
6920            )
6921            .await?;
6922        }
6923        // All three have a NULL next_poll, which sorts FIRST — so if at:// were
6924        // selectable at all it would be selected before the http feed.
6925        let due = due_feeds(&pool, "2026-09-20T00:00:00Z", 50).await?;
6926        let urls: Vec<&str> = due.iter().map(|f| f.url.as_str()).collect();
6927        assert_eq!(
6928            urls,
6929            ["https://example.com/feed.xml"],
6930            "an at:// feed was handed to the poller"
6931        );
6932        Ok(())
6933    }
6934
6935    #[tokio::test]
6936    async fn feed_error_count_bumps_and_resets() -> Result<()> {
6937        let pool = init_url("sqlite::memory:").await?;
6938        let url = "https://broken.example/feed.xml";
6939        upsert_feed(
6940            &pool,
6941            &NewFeed {
6942                url: url.to_string(),
6943                ..Default::default()
6944            },
6945        )
6946        .await?;
6947
6948        // A fresh feed starts at 0 errors.
6949        let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
6950        assert_eq!(feed.consecutive_errors, 0);
6951
6952        // N consecutive failures grow the count 1,2,3, and — fed through
6953        // `backoff_for` — the backoff grows with it (never latched at the floor).
6954        let mut last = std::time::Duration::ZERO;
6955        for expected in 1..=3 {
6956            let count = bump_feed_errors(
6957                &pool,
6958                url,
6959                crate::feed::FailureKind::Fetch,
6960                "connection refused",
6961            )
6962            .await?;
6963            assert_eq!(count, expected, "bump returns the new count");
6964            let backoff = crate::feed::backoff_for(count as u32);
6965            assert!(
6966                backoff >= last,
6967                "backoff must not shrink as errors accumulate"
6968            );
6969            last = backoff;
6970        }
6971        // Growth actually happened (2 errors backs off longer than 1).
6972        assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
6973        assert_eq!(
6974            get_feed_by_url(&pool, url)
6975                .await?
6976                .unwrap()
6977                .consecutive_errors,
6978            3
6979        );
6980
6981        // A success resets the streak to 0 (back to the normal cadence).
6982        reset_feed_errors(&pool, url).await?;
6983        assert_eq!(
6984            get_feed_by_url(&pool, url)
6985                .await?
6986                .unwrap()
6987                .consecutive_errors,
6988            0
6989        );
6990        Ok(())
6991    }
6992
6993    /// **A recovered feed keeps no reason for having failed.**
6994    ///
6995    /// Added because a mutation found this untested: deleting the
6996    /// `last_error_kind = NULL, last_error = NULL` half of `reset_feed_errors`
6997    /// left the entire suite green. The histogram filters on
6998    /// `consecutive_errors > 0`, so a stale row would not inflate the public
6999    /// count — but anything reading the row directly would be handed a cause
7000    /// that stopped applying, which is the exact failure this column was added
7001    /// to end. A guarantee nothing checks is a comment.
7002    #[tokio::test]
7003    async fn a_successful_poll_clears_the_recorded_failure_reason() -> Result<()> {
7004        let pool = init_url("sqlite::memory:").await?;
7005        let url = "https://recovers.example/feed.xml";
7006        upsert_feed(
7007            &pool,
7008            &NewFeed {
7009                url: url.to_string(),
7010                ..Default::default()
7011            },
7012        )
7013        .await?;
7014
7015        bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "SENTINEL_WHY").await?;
7016        let failing: (Option<String>, Option<String>) =
7017            sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
7018                .bind(url)
7019                .fetch_one(&pool)
7020                .await?;
7021        assert_eq!(
7022            failing.0.as_deref(),
7023            Some("fetch"),
7024            "the kind was not stored"
7025        );
7026        assert_eq!(
7027            failing.1.as_deref(),
7028            Some("SENTINEL_WHY"),
7029            "the detail was not stored"
7030        );
7031
7032        reset_feed_errors(&pool, url).await?;
7033        let recovered: (Option<String>, Option<String>) =
7034            sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
7035                .bind(url)
7036                .fetch_one(&pool)
7037                .await?;
7038        assert_eq!(
7039            recovered.0, None,
7040            "a healthy feed still names a failure kind"
7041        );
7042        assert_eq!(
7043            recovered.1, None,
7044            "a healthy feed still carries error detail"
7045        );
7046        Ok(())
7047    }
7048
7049    /// **The closed vocabulary is closed where it is READ, not only written.**
7050    ///
7051    /// `FailureKind::parse` promises that a kind string from a newer build is
7052    /// not "silently attributed to a cause this one recognises" — and the
7053    /// histogram's comment leaned on it. But review found `parse` had zero
7054    /// production callers: `poll_health` handed the raw column to the public
7055    /// template, so an unrecognised string got its own bucket, rendered
7056    /// verbatim. The protection existed only as a doc comment.
7057    ///
7058    /// A row written by a future build must land in `unknown`.
7059    #[tokio::test]
7060    async fn an_unrecognised_failure_kind_folds_into_unknown() -> Result<()> {
7061        let pool = init_url("sqlite::memory:").await?;
7062        for (url, kind) in [
7063            ("https://a.example/f.xml", Some("fetch")),
7064            ("https://b.example/f.xml", Some("quota")), // a newer build's kind
7065            ("https://c.example/f.xml", None),          // a legacy row
7066        ] {
7067            upsert_feed(
7068                &pool,
7069                &NewFeed {
7070                    url: url.to_string(),
7071                    ..Default::default()
7072                },
7073            )
7074            .await?;
7075            sqlx::query(
7076                "UPDATE feeds SET consecutive_errors = 1, last_error_kind = ?2 WHERE url = ?1",
7077            )
7078            .bind(url)
7079            .bind(kind)
7080            .execute(&pool)
7081            .await?;
7082        }
7083        let now = chrono::Utc::now();
7084        let health = poll_health(
7085            &pool,
7086            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
7087            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
7088        )
7089        .await?;
7090        let mut kinds = health.failure_kinds.clone();
7091        kinds.sort();
7092        assert_eq!(
7093            kinds,
7094            vec![("fetch".to_string(), 1), ("unknown".to_string(), 2)],
7095            "an unrecognised kind reached the public histogram as its own bucket: {:?}",
7096            health.failure_kinds
7097        );
7098        Ok(())
7099    }
7100
7101    /// **The migration is exercised against a table that predates the columns.**
7102    ///
7103    /// Every other test here builds a fresh database, where `CREATE TABLE`
7104    /// already contains `last_error_kind` / `last_error` — so `ensure_column`,
7105    /// the code path that actually runs against the production volume, was
7106    /// never executed by any of them. A bad `ALTER` would have been found at
7107    /// boot, on the one machine, by crash-looping: `apply_migrations` runs
7108    /// inside `init`, and the entrypoint takes the container down when a child
7109    /// dies.
7110    ///
7111    /// Builds the OLD table shape by hand, puts a failing row in it, migrates,
7112    /// and asserts both that the columns arrive and that the pre-existing row
7113    /// survives with NULLs rather than being rewritten or dropped.
7114    #[tokio::test]
7115    async fn the_last_error_columns_migrate_onto_a_table_that_predates_them() -> Result<()> {
7116        let pool = init_url("sqlite::memory:").await?;
7117
7118        // Drop the current shape and rebuild the pre-migration one.
7119        sqlx::query("DROP TABLE feeds").execute(&pool).await?;
7120        sqlx::query(
7121            "CREATE TABLE feeds (
7122                id                 INTEGER PRIMARY KEY AUTOINCREMENT,
7123                url                TEXT NOT NULL UNIQUE,
7124                title              TEXT,
7125                site_url           TEXT,
7126                etag               TEXT,
7127                last_modified      TEXT,
7128                last_polled        TEXT,
7129                next_poll          TEXT,
7130                consecutive_errors INTEGER NOT NULL DEFAULT 0
7131            )",
7132        )
7133        .execute(&pool)
7134        .await?;
7135        sqlx::query("INSERT INTO feeds (url, consecutive_errors) VALUES (?1, 7)")
7136            .bind("https://legacy.example/feed.xml")
7137            .execute(&pool)
7138            .await?;
7139
7140        apply_migrations(&pool).await?;
7141
7142        // The columns exist...
7143        let cols: Vec<String> = sqlx::query("PRAGMA table_info(feeds)")
7144            .fetch_all(&pool)
7145            .await?
7146            .iter()
7147            .map(|r| r.get::<String, _>("name"))
7148            .collect();
7149        assert!(cols.iter().any(|c| c == "last_error_kind"), "{cols:?}");
7150        assert!(cols.iter().any(|c| c == "last_error"), "{cols:?}");
7151
7152        // ...and the pre-existing row is intact, with no invented cause.
7153        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
7154            "SELECT consecutive_errors, last_error_kind, last_error FROM feeds WHERE url = ?1",
7155        )
7156        .bind("https://legacy.example/feed.xml")
7157        .fetch_one(&pool)
7158        .await?;
7159        assert_eq!(row.0, 7, "the migration disturbed an existing error count");
7160        assert_eq!(row.1, None, "a legacy row was given a cause it never had");
7161        assert_eq!(row.2, None);
7162
7163        // And it is idempotent — `init` runs this on every boot.
7164        apply_migrations(&pool).await?;
7165        Ok(())
7166    }
7167
7168    /// The stored detail is bounded — it is a remote server's text on an
7169    /// unattended path.
7170    #[tokio::test]
7171    async fn the_stored_error_detail_is_truncated() -> Result<()> {
7172        let pool = init_url("sqlite::memory:").await?;
7173        let url = "https://verbose.example/feed.xml";
7174        upsert_feed(
7175            &pool,
7176            &NewFeed {
7177                url: url.to_string(),
7178                ..Default::default()
7179            },
7180        )
7181        .await?;
7182        bump_feed_errors(
7183            &pool,
7184            url,
7185            crate::feed::FailureKind::Body,
7186            &"x".repeat(10_000),
7187        )
7188        .await?;
7189        let stored: (Option<String>,) =
7190            sqlx::query_as("SELECT last_error FROM feeds WHERE url = ?1")
7191                .bind(url)
7192                .fetch_one(&pool)
7193                .await?;
7194        assert_eq!(stored.0.unwrap().chars().count(), MAX_ERROR_DETAIL_CHARS);
7195        Ok(())
7196    }
7197
7198    // -- F3: db_size_bytes ignores freed pages and drops after reclaim -------
7199
7200    /// A new on-disk database must be created in INCREMENTAL mode.
7201    ///
7202    /// This is the whole fix for new instances: `auto_vacuum` was read by
7203    /// `reclaim` and set nowhere, so every database ran in NONE and `reclaim`
7204    /// always took its full-`VACUUM` branch — the one that cannot complete on a
7205    /// volume under the pressure that triggered the sweep. The pragma only binds
7206    /// on a database with no tables yet, so "at creation" is the load-bearing
7207    /// part, not "somewhere in init".
7208    #[tokio::test]
7209    async fn a_new_database_is_created_in_incremental_vacuum_mode() -> Result<()> {
7210        let dir = std::env::temp_dir();
7211        let path = dir.join(format!("fr-autovac-{}.db", std::process::id()));
7212        for p in [
7213            path.display().to_string(),
7214            format!("{}-wal", path.display()),
7215            format!("{}-shm", path.display()),
7216        ] {
7217            std::fs::remove_file(&p).ok();
7218        }
7219        let pool = init_url(&format!("sqlite://{}", path.display())).await?;
7220
7221        assert_eq!(
7222            auto_vacuum_mode(&pool).await?,
7223            AutoVacuum::Incremental,
7224            "a fresh database is still in the mode where reclaim needs a full VACUUM"
7225        );
7226        // And the WAL is bounded rather than growing to its high-water mark
7227        // forever.
7228        let limit: i64 = sqlx::query_scalar("PRAGMA journal_size_limit")
7229            .fetch_one(&pool)
7230            .await?;
7231        assert_eq!(
7232            limit, WAL_SIZE_LIMIT_BYTES,
7233            "journal_size_limit not applied"
7234        );
7235
7236        // Being INCREMENTAL, the migration is a no-op — which is what makes the
7237        // flag safe for an operator to run without checking first.
7238        assert_eq!(
7239            migrate_to_incremental_vacuum(&pool, None).await?,
7240            VacuumMigration::NotNeeded(AutoVacuum::Incremental)
7241        );
7242
7243        pool.close().await;
7244        for p in [
7245            path.display().to_string(),
7246            format!("{}-wal", path.display()),
7247            format!("{}-shm", path.display()),
7248        ] {
7249            std::fs::remove_file(&p).ok();
7250        }
7251        Ok(())
7252    }
7253
7254    /// The migration refuses itself when the volume cannot hold the rebuild.
7255    ///
7256    /// A full `VACUUM` writes a complete second copy, so attempting one without
7257    /// headroom burns I/O on a box that has none and finishes nothing. Refusing
7258    /// is the entire reason this is an operator step rather than something
7259    /// `reclaim` does on its own.
7260    #[tokio::test]
7261    async fn the_vacuum_migration_refuses_without_headroom() -> Result<()> {
7262        let dir = std::env::temp_dir();
7263        let path = dir.join(format!("fr-autovac-none-{}.db", std::process::id()));
7264        for p in [
7265            path.display().to_string(),
7266            format!("{}-wal", path.display()),
7267            format!("{}-shm", path.display()),
7268        ] {
7269            std::fs::remove_file(&p).ok();
7270        }
7271        // Build a database the way one that predates this change looks: create
7272        // the file in NONE mode explicitly, then populate it.
7273        let url = format!("sqlite://{}", path.display());
7274        let opts = SqliteConnectOptions::from_str(&url)?
7275            .create_if_missing(true)
7276            .foreign_keys(true)
7277            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7278            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7279        let pool = SqlitePoolOptions::new()
7280            .min_connections(1)
7281            .max_connections(1)
7282            .connect_with(opts)
7283            .await?;
7284        init_schema(&pool).await?;
7285        assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::None);
7286
7287        // Zero free space: refused, and the mode is untouched.
7288        let refused = migrate_to_incremental_vacuum(&pool, Some(0)).await?;
7289        assert!(
7290            matches!(refused, VacuumMigration::RefusedNoHeadroom { .. }),
7291            "expected a refusal, got {refused:?}"
7292        );
7293        assert_eq!(
7294            auto_vacuum_mode(&pool).await?,
7295            AutoVacuum::None,
7296            "a refused migration must not have changed the mode"
7297        );
7298
7299        // With headroom it runs, and the database ends up INCREMENTAL — which is
7300        // what makes `reclaim` cheap from then on.
7301        let done = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
7302        let VacuumMigration::Migrated {
7303            bytes_after,
7304            file_after,
7305            ..
7306        } = done
7307        else {
7308            panic!("expected a migration, got {done:?}");
7309        };
7310        assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::Incremental);
7311        // The reported size must not include the WAL the VACUUM just filled. In
7312        // WAL mode a VACUUM writes the whole rebuilt database through the WAL,
7313        // so without the truncating checkpoint this reads as roughly double —
7314        // "the migration doubled my database", from the one line the command
7315        // prints.
7316        let file_after = file_after.expect("an on-disk database has a file size") as i64;
7317        assert!(
7318            bytes_after <= file_after * 2,
7319            "bytes_after ({bytes_after}) is inflated by an untruncated WAL against a \
7320             {file_after}-byte file"
7321        );
7322
7323        pool.close().await;
7324        for p in [
7325            path.display().to_string(),
7326            format!("{}-wal", path.display()),
7327            format!("{}-shm", path.display()),
7328        ] {
7329            std::fs::remove_file(&p).ok();
7330        }
7331        Ok(())
7332    }
7333
7334    /// **R6 benchmark: what the retention sweep actually costs, and what fixes it.**
7335    ///
7336    /// `#[ignore]` — builds a ~1M-row database once per shape per scale (ten
7337    /// times), so it is a measurement tool rather than a test. Run with:
7338    ///
7339    /// ```text
7340    /// cargo test --lib -- --ignored --nocapture r6_measure_retention_sweep
7341    /// ```
7342    ///
7343    /// It exists because R6 was "every delete batch re-scans `entry_state`" and
7344    /// the honest answer was "measure before changing an index". Kept so the next
7345    /// candidate index can be tried against the same fixture rather than a new
7346    /// one. Findings are recorded in `design/REVIEW-ROUND-2.md`.
7347    #[tokio::test]
7348    #[ignore]
7349    async fn r6_measure_retention_sweep() -> Result<()> {
7350        const FEEDS: i64 = 500;
7351        const PER_FEED: i64 = 2_000; // matches `max_entries_per_feed`
7352        const PINNED: i64 = 50_000; // entry_state rows a reader has touched
7353
7354        /// Build the fixture, apply `extra_indexes`, then plan and time a sweep.
7355        async fn run(
7356            label: &str,
7357            extra_indexes: &[&str],
7358            pinned: i64,
7359            old_list_form: bool,
7360        ) -> Result<()> {
7361            let dir = std::env::temp_dir();
7362            let path = dir.join(format!("fr-r6-{}-{label}.db", std::process::id()));
7363            // RAII, because every `?` between here and the end used to leak a
7364            // 1M-row fixture plus its -wal/-shm into the temp dir — six per run.
7365            struct Fixture(std::path::PathBuf);
7366            impl Fixture {
7367                fn wipe(&self) {
7368                    for p in [
7369                        self.0.display().to_string(),
7370                        format!("{}-wal", self.0.display()),
7371                        format!("{}-shm", self.0.display()),
7372                    ] {
7373                        std::fs::remove_file(&p).ok();
7374                    }
7375                }
7376            }
7377            impl Drop for Fixture {
7378                fn drop(&mut self) {
7379                    self.wipe();
7380                }
7381            }
7382            let fixture = Fixture(path.clone());
7383            fixture.wipe();
7384            let pool = init_url(&format!("sqlite://{}", path.display())).await?;
7385
7386            // Bulk-build with SQL: a million round trips would measure the
7387            // fixture, not the sweep. Recursive CTE because `generate_series` is
7388            // not compiled into the bundled SQLite.
7389            sqlx::query(
7390                "WITH RECURSIVE n(value) AS ( \
7391                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7392                 ) \
7393                 INSERT INTO feeds (url) \
7394                 SELECT 'https://f' || value || '.example/x.xml' FROM n",
7395            )
7396            .bind(FEEDS)
7397            .execute(&pool)
7398            .await
7399            .context("seeding feeds")?;
7400
7401            // Half the entries older than the window, half inside it.
7402            sqlx::query(
7403                "WITH RECURSIVE n(value) AS ( \
7404                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7405                 ) \
7406                 INSERT INTO entries (feed_id, guid, title, published, fetched_at) \
7407                 SELECT f.id, \
7408                        'g' || f.id || '-' || s.value, \
7409                        'Entry ' || s.value, \
7410                        CASE WHEN s.value % 2 = 0 THEN '2020-01-01T00:00:00Z' \
7411                             ELSE '2099-01-01T00:00:00Z' END, \
7412                        '2026-01-01T00:00:00Z' \
7413                 FROM feeds f, n s",
7414            )
7415            .bind(PER_FEED)
7416            .execute(&pool)
7417            .await?;
7418
7419            // **A REALISTIC pin distribution, which the first version did not
7420            // have.** It made every row `read=0,starred=0` or `read=1,starred=1`,
7421            // so 100% of `entry_state` matched `starred = 1 OR read = 0` — there
7422            // were no "read and not starred" rows at all, which is the commonest
7423            // state a reader leaves behind. That mattered: a PARTIAL index on the
7424            // pinned predicate then covers the whole table and cannot be
7425            // selective, so measuring one against that fixture measures nothing.
7426            //
7427            // 90% read-and-unstarred (evictable), 10% pinned, split between
7428            // starred and unread.
7429            sqlx::query(
7430                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
7431                 SELECT 'did:plc:reader', id, \
7432                        CASE WHEN id % 10 <> 0 THEN 1 \
7433                             WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7434                        CASE WHEN id % 10 <> 0 THEN 0 \
7435                             WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7436                        '2026-01-01T00:00:00Z' \
7437                 FROM entries LIMIT ?1",
7438            )
7439            .bind(pinned)
7440            .execute(&pool)
7441            .await?;
7442
7443            // Space is the other half of the trade: this is a 1 GB volume with a
7444            // 768 MiB watermark, so an index that buys time and costs disk can be
7445            // a net loss.
7446            let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7447                .fetch_one(&pool)
7448                .await?;
7449            let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
7450                .fetch_one(&pool)
7451                .await?;
7452            for idx in extra_indexes {
7453                sqlx::query(sqlx::AssertSqlSafe((*idx).to_string()))
7454                    .execute(&pool)
7455                    .await
7456                    .with_context(|| format!("creating {idx}"))?;
7457            }
7458            let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7459                .fetch_one(&pool)
7460                .await?;
7461            let index_bytes = (pages_after - pages_before) * page_size;
7462
7463            // What the index costs on the WRITE path — the poller inserts
7464            // constantly, the sweep runs once a day.
7465            let t_ins = std::time::Instant::now();
7466            sqlx::query(
7467                "WITH RECURSIVE n(value) AS ( \
7468                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 10000 \
7469                 ) \
7470                 INSERT INTO entries (feed_id, guid, published, fetched_at) \
7471                 SELECT 1, 'ins-' || value, '2099-06-01T00:00:00Z', '2026-01-01T00:00:00Z' \
7472                 FROM n",
7473            )
7474            .execute(&pool)
7475            .await?;
7476            let insert_10k = t_ins.elapsed();
7477
7478            // Give the planner statistics, as a long-lived instance would have.
7479            sqlx::query("ANALYZE").execute(&pool).await?;
7480
7481            // The plan must describe the query this run actually TIMES. It used
7482            // to be hardcoded to the `NOT IN` form regardless, so four of six
7483            // runs printed a plan for a different query than the one measured —
7484            // in the artifact kept precisely to be the evidence.
7485            let planned = if old_list_form {
7486                "EXPLAIN QUERY PLAN SELECT id FROM entries \
7487                 WHERE COALESCE(published, fetched_at) < '2026-06-01T00:00:00Z' \
7488                   AND id NOT IN (SELECT entry_id FROM entry_state \
7489                                  WHERE starred = 1 OR read = 0) \
7490                 LIMIT 1000"
7491            } else {
7492                "EXPLAIN QUERY PLAN SELECT e.id FROM entries e \
7493                 WHERE COALESCE(e.published, e.fetched_at) < '2026-06-01T00:00:00Z' \
7494                   AND NOT EXISTS (SELECT 1 FROM entry_state s \
7495                                   WHERE s.entry_id = e.id \
7496                                     AND (s.starred = 1 OR s.read = 0)) \
7497                 LIMIT 1000"
7498            };
7499            let plan: Vec<String> = sqlx::query(sqlx::AssertSqlSafe(planned))
7500                .fetch_all(&pool)
7501                .await?
7502                .into_iter()
7503                .map(|r| r.get::<String, _>("detail"))
7504                .collect();
7505
7506            // ONE variant per fixture — running both against the same database
7507            // measured the second against an already-emptied table, which
7508            // reported a 0-row "win" the first time this was written.
7509            let cutoff = (chrono::Utc::now() - chrono::Duration::days(30))
7510                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7511            let t = std::time::Instant::now();
7512            let deleted = if old_list_form {
7513                // The shape `prune_old_entries` used to have: the pinned set as
7514                // an `IN` list, re-materialised on every batch.
7515                let mut n = 0u64;
7516                loop {
7517                    let got = sqlx::query(
7518                        "DELETE FROM entries WHERE id IN ( \
7519                             SELECT id FROM entries \
7520                             WHERE COALESCE(published, fetched_at) < ?1 \
7521                               AND id NOT IN ( \
7522                                   SELECT entry_id FROM entry_state \
7523                                   WHERE starred = 1 OR read = 0 \
7524                               ) \
7525                             LIMIT 1000)",
7526                    )
7527                    .bind(&cutoff)
7528                    .execute(&pool)
7529                    .await?
7530                    .rows_affected();
7531                    n += got;
7532                    if got == 0 {
7533                        break;
7534                    }
7535                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7536                }
7537                n
7538            } else {
7539                // NOTE the arms are not identical work: this one goes through the
7540                // real `prune_old_entries`, which also runs the hard-ceiling pass
7541                // and the cursor scrub. The bias therefore runs AGAINST the
7542                // shipped form, so a win measured here is a lower bound — but the
7543                // two numbers are not a like-for-like microbenchmark.
7544                prune_old_entries(&pool, 30, 3650, 0).await?
7545            };
7546            let elapsed = t.elapsed();
7547
7548            // State the fixture's shape, so a future reader cannot mistake a
7549            // degenerate distribution for a representative one again.
7550            let matching: i64 = sqlx::query_scalar(
7551                "SELECT COUNT(*) FROM entry_state WHERE starred = 1 OR read = 0",
7552            )
7553            .fetch_one(&pool)
7554            .await?;
7555            println!("\n=== {label}  (entry_state = {pinned}, pinned = {matching}) ===");
7556            println!(
7557                "  index cost: {:.1} MiB on disk, 10k inserts in {insert_10k:?}",
7558                index_bytes as f64 / 1024.0 / 1024.0
7559            );
7560            for l in &plan {
7561                println!("  plan: {l}");
7562            }
7563            println!(
7564                "  deleted {deleted} in {elapsed:?}  ({:?}/batch)",
7565                elapsed / (deleted as u32 / PRUNE_BATCH as u32).max(1)
7566            );
7567
7568            pool.close().await;
7569            drop(fixture);
7570            Ok(())
7571        }
7572
7573        // R6's own hypothesis was that the per-batch `entry_state` scan is the
7574        // cost. Both scales are measured because that scan grows with TOTAL
7575        // users, not with the feed being swept — 50k is one active reader,
7576        // 600k is the figure the schema comment cites as realistic.
7577        const AGE_IDX: &str =
7578            "CREATE INDEX idx_entries_age ON entries(COALESCE(published, fetched_at))";
7579        // The index R6 actually asked for. Its row is the one the rejection
7580        // turns on — "changes the plan, changes the time by nothing" — and an
7581        // earlier version of this benchmark dropped it, leaving that claim
7582        // resting on prose while the artifact kept to prove it could not.
7583        const PINNED_IDX: &str = "CREATE INDEX idx_es_pinned ON entry_state(entry_id) \
7584                                  WHERE starred = 1 OR read = 0";
7585        for pinned in [PINNED, 600_000] {
7586            // `false` = the shipped `prune_old_entries`, whatever shape it
7587            // currently uses; `true` = the raw `NOT IN` list form it replaced,
7588            // kept so the regression stays measurable rather than remembered.
7589            run("as shipped (NOT EXISTS)", &[], pinned, false).await?;
7590            run("old NOT IN list form", &[], pinned, true).await?;
7591            run("old NOT IN + pinned index", &[PINNED_IDX], pinned, true).await?;
7592            // The row that was never measured: the pinned index against the
7593            // query that SHIPPED, rather than against the one being deleted.
7594            // Rejecting it on the strength of the latter was the error.
7595            run("as shipped + pinned index", &[PINNED_IDX], pinned, false).await?;
7596            run("as shipped + age index", &[AGE_IDX], pinned, false).await?;
7597        }
7598        Ok(())
7599    }
7600
7601    /// **The migration must not ask the pool for anything while holding a
7602    /// connection.** A single-connection pool is always saturated, so any such
7603    /// call stalls for the full acquire timeout.
7604    ///
7605    /// This has now been introduced twice — once by acquiring a connection for
7606    /// the pragma pair, and once by resolving the temp directory inside that
7607    /// block. The second was worse than a stall: `main_db_path` swallows errors
7608    /// into `None`, so it waited 30 s and then silently skipped the pragma it
7609    /// existed to set. A wall-clock assertion is crude, but it is the only thing
7610    /// that distinguishes "works" from "works after a 30-second timeout".
7611    #[tokio::test]
7612    async fn the_vacuum_migration_never_waits_on_its_own_pool() -> Result<()> {
7613        let dir = std::env::temp_dir();
7614        let path = dir.join(format!("fr-nodeadlock-{}.db", std::process::id()));
7615        for p in [
7616            path.display().to_string(),
7617            format!("{}-wal", path.display()),
7618            format!("{}-shm", path.display()),
7619        ] {
7620            std::fs::remove_file(&p).ok();
7621        }
7622        let url = format!("sqlite://{}", path.display());
7623        let opts = SqliteConnectOptions::from_str(&url)?
7624            .create_if_missing(true)
7625            .foreign_keys(true)
7626            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7627            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7628        // ONE connection: any pool call made while the migration holds it will
7629        // block until the acquire timeout rather than deadlocking forever.
7630        let pool = SqlitePoolOptions::new()
7631            .min_connections(1)
7632            .max_connections(1)
7633            .connect_with(opts)
7634            .await?;
7635        init_schema(&pool).await?;
7636
7637        let t0 = std::time::Instant::now();
7638        let outcome = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
7639        let elapsed = t0.elapsed();
7640
7641        assert!(
7642            matches!(outcome, VacuumMigration::Migrated { .. }),
7643            "expected a migration, got {outcome:?}"
7644        );
7645        assert!(
7646            elapsed < std::time::Duration::from_secs(5),
7647            "the migration took {elapsed:?} on an empty database — it is waiting on \
7648             its own pool while holding a connection"
7649        );
7650
7651        pool.close().await;
7652        for p in [
7653            path.display().to_string(),
7654            format!("{}-wal", path.display()),
7655            format!("{}-shm", path.display()),
7656        ] {
7657            std::fs::remove_file(&p).ok();
7658        }
7659        Ok(())
7660    }
7661
7662    /// `reclaim` must NOT run a full VACUUM in NONE mode — the branch that used
7663    /// to be the only one that ever executed, and the one that cannot finish on
7664    /// a volume under the pressure that triggers a sweep.
7665    ///
7666    /// Observable without timing a VACUUM: a full VACUUM returns freed pages to
7667    /// the OS, so `page_count` falls. Skipping it leaves the allocation in
7668    /// place — while `db_size_bytes`, which subtracts the freelist, still drops.
7669    /// That pairing is the actual claim: the watermark does not latch even
7670    /// though the file does not shrink.
7671    #[tokio::test]
7672    async fn reclaim_does_not_full_vacuum_in_none_mode() -> Result<()> {
7673        let dir = std::env::temp_dir();
7674        let path = dir.join(format!("fr-noneclaim-{}.db", std::process::id()));
7675        for p in [
7676            path.display().to_string(),
7677            format!("{}-wal", path.display()),
7678            format!("{}-shm", path.display()),
7679        ] {
7680            std::fs::remove_file(&p).ok();
7681        }
7682        let url = format!("sqlite://{}", path.display());
7683        let opts = SqliteConnectOptions::from_str(&url)?
7684            .create_if_missing(true)
7685            .foreign_keys(true)
7686            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7687            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7688        let pool = SqlitePoolOptions::new()
7689            .min_connections(1)
7690            .max_connections(1)
7691            .connect_with(opts)
7692            .await?;
7693        init_schema(&pool).await?;
7694
7695        let feed_id = upsert_feed(
7696            &pool,
7697            &NewFeed {
7698                url: "https://none.example/f.xml".to_string(),
7699                ..Default::default()
7700            },
7701        )
7702        .await?;
7703        let entries: Vec<NewEntry> = (0..1500)
7704            .map(|i| NewEntry {
7705                guid: format!("n-{i}"),
7706                content_html: Some("x".repeat(800)),
7707                ..Default::default()
7708            })
7709            .collect();
7710        insert_entries(&pool, feed_id, &entries, 0).await?;
7711        // Fold the WAL in so the "full" baseline is file pages, not WAL churn.
7712        sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
7713            .execute(&pool)
7714            .await?;
7715        let used_full = db_size_bytes(&pool).await?;
7716
7717        sqlx::query("DELETE FROM entries").execute(&pool).await?;
7718        let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7719            .fetch_one(&pool)
7720            .await?;
7721
7722        reclaim(&pool).await?;
7723
7724        let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7725            .fetch_one(&pool)
7726            .await?;
7727        assert_eq!(
7728            pages_after, pages_before,
7729            "reclaim shrank the file in NONE mode, so it ran the full VACUUM this \
7730             branch exists to avoid"
7731        );
7732        // …and the watermark still falls, which is what makes skipping safe.
7733        // `db_size_bytes` subtracts the freelist, so the delete alone lowers it
7734        // even though the file kept every page it had allocated.
7735        let used_after = db_size_bytes(&pool).await?;
7736        assert!(
7737            used_after < used_full,
7738            "used size did not fall after the delete ({used_after} !< {used_full}); \
7739             without a VACUUM the DB-size watermark would latch the poller off"
7740        );
7741
7742        pool.close().await;
7743        for p in [
7744            path.display().to_string(),
7745            format!("{}-wal", path.display()),
7746            format!("{}-shm", path.display()),
7747        ] {
7748            std::fs::remove_file(&p).ok();
7749        }
7750        Ok(())
7751    }
7752
7753    #[tokio::test]
7754    async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
7755        // On-disk DB so VACUUM has a file to shrink (in-memory has no freelist to
7756        // speak of the same way). Temp path, cleaned up at the end.
7757        let dir = std::env::temp_dir();
7758        let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
7759        let url = format!("sqlite://{}", path.display());
7760        let pool = init_url(&url).await?;
7761
7762        let feed_id = upsert_feed(
7763            &pool,
7764            &NewFeed {
7765                url: "https://bulk.example/feed.xml".to_string(),
7766                ..Default::default()
7767            },
7768        )
7769        .await?;
7770
7771        // Insert a large batch so the file allocates real pages.
7772        let entries: Vec<NewEntry> = (0..2000)
7773            .map(|i| NewEntry {
7774                guid: format!("guid-{i}"),
7775                title: Some(format!("Entry number {i} with some padding text")),
7776                content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
7777                published: Some("2026-01-01T00:00:00Z".to_string()),
7778                ..Default::default()
7779            })
7780            .collect();
7781        insert_entries(&pool, feed_id, &entries, 0).await?;
7782        let full = db_size_bytes(&pool).await?;
7783        assert!(full > 0);
7784
7785        // Prune: delete every entry (the retention sweep's effect). This frees
7786        // pages onto the freelist but does NOT shrink the file yet.
7787        sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
7788            .bind(feed_id)
7789            .execute(&pool)
7790            .await?;
7791
7792        // Because db_size_bytes subtracts freelist pages, the USED size already
7793        // reflects the delete even before the file shrinks.
7794        let after_delete = db_size_bytes(&pool).await?;
7795        assert!(
7796            after_delete < full,
7797            "used size must drop once rows are deleted (freed pages excluded): \
7798             {after_delete} !< {full}"
7799        );
7800
7801        // Reclaim returns the freed pages to the OS; used size stays low (and the
7802        // file itself shrinks). The key property F3 needs: the watermark can now
7803        // fall back below its threshold instead of latching polling off.
7804        reclaim(&pool).await?;
7805        let after_reclaim = db_size_bytes(&pool).await?;
7806        assert!(
7807            after_reclaim <= after_delete,
7808            "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
7809        );
7810        assert!(
7811            after_reclaim < full,
7812            "after prune+reclaim the DB is smaller than when full: \
7813             {after_reclaim} !< {full}"
7814        );
7815
7816        drop(pool);
7817        let _ = std::fs::remove_file(&path);
7818        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
7819        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
7820        Ok(())
7821    }
7822
7823    // -- F4 support: pds_created flag round-trips + flips ---------------------
7824
7825    #[tokio::test]
7826    async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
7827        let pool = init_url("sqlite::memory:").await?;
7828        let did = "did:plc:f4";
7829        let feed_url = "https://example.com/feed.xml";
7830        upsert_cursor(
7831            &pool,
7832            &ReadCursor {
7833                did: did.to_string(),
7834                feed_url: feed_url.to_string(),
7835                read_through: None,
7836                read_ids: r#"["1"]"#.to_string(),
7837                unread_ids: "[]".to_string(),
7838                dirty: true,
7839                pds_created: false,
7840                updated_at: now_rfc3339(),
7841            },
7842        )
7843        .await?;
7844
7845        // A brand-new cursor's PDS record does NOT yet exist.
7846        let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7847        assert!(!c.pds_created, "first flush must emit a create, not update");
7848
7849        // Two bystanders: the same DID on another feed, another DID on the same
7850        // feed. **The UPDATE must be scoped to exactly one row.** With its WHERE
7851        // clause deleted this test still passed — it seeded one cursor, so
7852        // "every row" and "this row" were the same row. Unscoped, every DID's
7853        // every cursor is flagged as created, their readState records are never
7854        // created, and every later flush emits `update` against nothing.
7855        for (d, f) in [
7856            (did, "https://other.example/feed.xml"),
7857            ("did:plc:other", feed_url),
7858        ] {
7859            upsert_cursor(
7860                &pool,
7861                &ReadCursor {
7862                    did: d.to_string(),
7863                    feed_url: f.to_string(),
7864                    read_through: None,
7865                    read_ids: "[]".to_string(),
7866                    unread_ids: "[]".to_string(),
7867                    dirty: false,
7868                    pds_created: false,
7869                    updated_at: now_rfc3339(),
7870                },
7871            )
7872            .await?;
7873        }
7874
7875        // After the create-flush lands, the flag flips so future flushes update.
7876        mark_cursor_pds_created(&pool, did, feed_url).await?;
7877        let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7878        assert!(c.pds_created);
7879        for (d, f) in [
7880            (did, "https://other.example/feed.xml"),
7881            ("did:plc:other", feed_url),
7882        ] {
7883            let bystander = get_cursor(&pool, d, f).await?.unwrap();
7884            assert!(
7885                !bystander.pds_created,
7886                "marking ({did}, {feed_url}) also flagged ({d}, {f})"
7887            );
7888        }
7889        Ok(())
7890    }
7891
7892    // -- STORAGE HYGIENE: retention prune + orphan-id scrub -------------------
7893
7894    /// Count entries currently in the cache.
7895    async fn count_entries(pool: &SqlitePool) -> Result<i64> {
7896        Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
7897            .fetch_one(pool)
7898            .await?)
7899    }
7900
7901    /// **The rolling window and the hard ceiling do not touch a publication, and
7902    /// this is the test that says the feature works at all.**
7903    ///
7904    /// Measured on 2026-09-27 against three real publications: the newest
7905    /// document Standard.site offered was 131 days old, Annotated's 109, minus
7906    /// listens' 241. Under the 14-day window every one of them stored **zero**
7907    /// rows — a successful poll and an empty feed. So age is not the policy here;
7908    /// COUNT is (`max_entries_per_feed`), and the ceiling below is only the
7909    /// not-immortal backstop.
7910    ///
7911    /// Both directions in one test on purpose: the RSS twin must still be
7912    /// deleted, or "nothing is ever swept" would pass.
7913    #[tokio::test]
7914    async fn the_window_and_the_ceiling_spare_a_publication_but_not_an_rss_entry() -> Result<()> {
7915        let pool = init_url("sqlite::memory:").await?;
7916        let rss = upsert_feed(
7917            &pool,
7918            &NewFeed {
7919                url: "https://aged.example/feed.xml".to_string(),
7920                ..Default::default()
7921            },
7922        )
7923        .await?;
7924        let publication = upsert_feed(
7925            &pool,
7926            &NewFeed {
7927                url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
7928                    .to_string(),
7929                ..Default::default()
7930            },
7931        )
7932        .await?;
7933        // The kind column is what the sweep filters on, so assert the fixture
7934        // really produced two different kinds rather than trusting `FeedKind::of`.
7935        let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM feeds ORDER BY id")
7936            .fetch_all(&pool)
7937            .await?;
7938        assert_eq!(kinds, vec!["rss".to_string(), "publication".to_string()]);
7939
7940        // A year old, and READ by somebody — so the window's own sparing rule
7941        // ("starred or unread survives") cannot be what keeps either row.
7942        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7943            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7944        for feed_id in [rss, publication] {
7945            insert_entries(
7946                &pool,
7947                feed_id,
7948                &[NewEntry {
7949                    guid: format!("ancient-{feed_id}"),
7950                    published: Some(ancient.clone()),
7951                    fetched_at: Some(ancient.clone()),
7952                    ..Default::default()
7953                }],
7954                0,
7955            )
7956            .await?;
7957        }
7958        replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
7959        for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
7960            .fetch_all(&pool)
7961            .await?
7962        {
7963            mark_read(&pool, "did:plc:reader", id, true).await?;
7964        }
7965        assert_eq!(count_entries(&pool).await?, 2);
7966
7967        // **The shipped configuration, all three knobs at their defaults.** An
7968        // earlier version of this test passed `0` for the archive ceiling, so the
7969        // combination under test was not the one any instance runs; at 3650 the
7970        // publication's year-old document is inside the ceiling and must still
7971        // survive.
7972        let deleted = prune_old_entries(&pool, 14, 180, 3_650).await?;
7973        assert_eq!(deleted, 1, "exactly one of the two should have gone");
7974        let surviving: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM entries")
7975            .fetch_all(&pool)
7976            .await?;
7977        assert_eq!(
7978            surviving,
7979            vec![publication],
7980            "the publication's year-old document was swept — under the 14-day \
7981             window that is every document a real publication has, so the feed a \
7982             reader subscribed to would be permanently empty",
7983        );
7984        Ok(())
7985    }
7986
7987    /// **"Not aged out" must not mean "immortal".**
7988    ///
7989    /// The per-feed trim is what bounds a publication, and it only runs when a
7990    /// poll stores something — so entries of a feed nobody polls any more have
7991    /// nothing else to reap them. This ceiling is that backstop, and it spares
7992    /// nothing, for the same reason the hard ceiling spares nothing: a saved
7993    /// record whose entry is gone still renders from the PDS record as a link.
7994    #[tokio::test]
7995    async fn the_archive_ceiling_reaps_a_publication_entry_past_it() -> Result<()> {
7996        let pool = init_url("sqlite::memory:").await?;
7997        // An RSS twin, to pin that this pass is SCOPED. Verified needed: dropping
7998        // the `kind NOT IN` clause from it left all 909 tests passing, and that
7999        // mutation quietly re-enables age-based eviction for RSS on an instance
8000        // whose operator set both RSS knobs to zero.
8001        let rss = upsert_feed(
8002            &pool,
8003            &NewFeed {
8004                url: "https://not-swept.example/feed.xml".to_string(),
8005                ..Default::default()
8006            },
8007        )
8008        .await?;
8009        let publication = upsert_feed(
8010            &pool,
8011            &NewFeed {
8012                url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
8013                    .to_string(),
8014                ..Default::default()
8015            },
8016        )
8017        .await?;
8018        let ancient = (chrono::Utc::now() - chrono::Duration::days(400))
8019            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8020        let recent = now_rfc3339();
8021        insert_entries(
8022            &pool,
8023            publication,
8024            &[
8025                NewEntry {
8026                    guid: "past-the-ceiling".into(),
8027                    published: Some(ancient.clone()),
8028                    fetched_at: Some(ancient),
8029                    ..Default::default()
8030                },
8031                NewEntry {
8032                    guid: "inside-the-ceiling".into(),
8033                    published: Some(recent.clone()),
8034                    fetched_at: Some(recent),
8035                    ..Default::default()
8036                },
8037            ],
8038            0,
8039        )
8040        .await?;
8041        let long_ago = (chrono::Utc::now() - chrono::Duration::days(400))
8042            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8043        insert_entries(
8044            &pool,
8045            rss,
8046            &[NewEntry {
8047                guid: "rss-past-the-archive-ceiling".into(),
8048                published: Some(long_ago.clone()),
8049                fetched_at: Some(long_ago),
8050                ..Default::default()
8051            }],
8052            0,
8053        )
8054        .await?;
8055
8056        // STARRED, so this also pins that the ceiling spares nothing.
8057        replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
8058        for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
8059            .fetch_all(&pool)
8060            .await?
8061        {
8062            mark_starred(&pool, "did:plc:reader", id, true).await?;
8063        }
8064
8065        // Rolling window and hard ceiling off: the archive ceiling is the only
8066        // thing that can delete here.
8067        let deleted = prune_old_entries(&pool, 0, 0, 365).await?;
8068        assert_eq!(
8069            deleted, 1,
8070            "the entry past the archive ceiling was not reaped"
8071        );
8072        let mut guids: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
8073            .fetch_all(&pool)
8074            .await?;
8075        guids.sort();
8076        assert_eq!(
8077            guids,
8078            vec![
8079                "inside-the-ceiling".to_string(),
8080                "rss-past-the-archive-ceiling".to_string(),
8081            ],
8082            "the archive ceiling must reap the publication's over-age entry and \
8083             ONLY that — an RSS entry on an instance with both RSS knobs at zero \
8084             is one the operator chose to keep",
8085        );
8086
8087        // And zero disables it, consistently with the other two knobs.
8088        assert_eq!(
8089            prune_old_entries(&pool, 0, 0, 0).await?,
8090            0,
8091            "publication_retention_days = 0 still deleted something",
8092        );
8093        Ok(())
8094    }
8095
8096    /// **A retention window too large to be a date must disable that pass, not
8097    /// kill the sweeper.**
8098    ///
8099    /// Every knob parses from a `u32` with no upper bound, and `Duration::days` /
8100    /// `DateTime - TimeDelta` both panic out of range — measured, anything past
8101    /// roughly 96 million days, and `u32::MAX` is. A unit slip (seconds or
8102    /// milliseconds typed into a days field) reaches it.
8103    ///
8104    /// The old failure was quiet: this runs in a spawned task, so tokio catches
8105    /// the panic and the sweeper stops for the life of the process, taking the
8106    /// release valve for `db_size_watermark_bytes` with it — the one thing that
8107    /// stops polling for every reader on the instance.
8108    ///
8109    /// `standard_site::ingest_floor` already answers the same input with "no
8110    /// floor", and `Config::retention_for` exists to keep the two agreeing, so
8111    /// this is also the end of a disagreement: unrepresentable meant "store
8112    /// everything" on one side and "panic" on the other.
8113    #[tokio::test]
8114    async fn an_unrepresentable_retention_window_disables_the_pass_it_belongs_to() -> Result<()> {
8115        let pool = init_url("sqlite::memory:").await?;
8116        let feed_id = upsert_feed(
8117            &pool,
8118            &NewFeed {
8119                url: "https://absurd.example/feed.xml".to_string(),
8120                ..Default::default()
8121            },
8122        )
8123        .await?;
8124        let ancient = (chrono::Utc::now() - chrono::Duration::days(1_000))
8125            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8126        insert_entries(
8127            &pool,
8128            feed_id,
8129            &[NewEntry {
8130                guid: "ancient".into(),
8131                published: Some(ancient.clone()),
8132                fetched_at: Some(ancient),
8133                ..Default::default()
8134            }],
8135            0,
8136        )
8137        .await?;
8138
8139        // Each knob in turn, since each computes its own cutoff.
8140        let absurd = u32::MAX as i64;
8141        assert_eq!(
8142            prune_old_entries(&pool, absurd, 0, 0).await?,
8143            0,
8144            "an absurd rolling window deleted something",
8145        );
8146        assert_eq!(
8147            prune_old_entries(&pool, 0, absurd, 0).await?,
8148            0,
8149            "an absurd hard ceiling deleted something",
8150        );
8151        assert_eq!(
8152            prune_old_entries(&pool, 0, 0, absurd).await?,
8153            0,
8154            "an absurd archive ceiling deleted something",
8155        );
8156        assert_eq!(
8157            count_entries(&pool).await?,
8158            1,
8159            "the entry went away under a window that cannot even be expressed",
8160        );
8161
8162        // And the sweep still works for the same knobs at a sane value — a
8163        // function that returned early on every input would satisfy the above.
8164        assert_eq!(
8165            prune_old_entries(&pool, 30, 0, 0).await?,
8166            1,
8167            "a 30-day window did not delete a 1000-day-old entry",
8168        );
8169        Ok(())
8170    }
8171
8172    /// The SQL list and the Rust slice are asserted equal, for the same reason
8173    /// [`POLLABLE_KINDS_SQL`] is: a literal here and a slice there is the drift
8174    /// the `kind` column was introduced to end.
8175    #[test]
8176    fn the_sql_aged_kind_list_matches_the_rust_one() {
8177        let expected = crate::feed::FeedKind::AGED
8178            .iter()
8179            .map(|k| format!("'{}'", k.as_str()))
8180            .collect::<Vec<_>>()
8181            .join(", ");
8182        assert_eq!(AGED_KINDS_SQL, expected);
8183    }
8184
8185    #[tokio::test]
8186    async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
8187        let pool = init_url("sqlite::memory:").await?;
8188        let feed_id = upsert_feed(
8189            &pool,
8190            &NewFeed {
8191                url: "https://ret.example/feed.xml".to_string(),
8192                ..Default::default()
8193            },
8194        )
8195        .await?;
8196
8197        let recent = now_rfc3339();
8198        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
8199            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8200
8201        // One fresh (published now), one ancient (published a year ago), and one
8202        // UNDATED-but-freshly-fetched (published NULL, fetched_at now) — the last
8203        // must survive because COALESCE falls back to fetched_at, not to "old".
8204        insert_entries(
8205            &pool,
8206            feed_id,
8207            &[
8208                NewEntry {
8209                    guid: "fresh".into(),
8210                    published: Some(recent.clone()),
8211                    fetched_at: Some(recent.clone()),
8212                    ..Default::default()
8213                },
8214                NewEntry {
8215                    guid: "ancient".into(),
8216                    published: Some(ancient.clone()),
8217                    fetched_at: Some(ancient.clone()),
8218                    ..Default::default()
8219                },
8220                NewEntry {
8221                    guid: "undated-fresh".into(),
8222                    published: None,
8223                    fetched_at: Some(recent.clone()),
8224                    ..Default::default()
8225                },
8226            ],
8227            0,
8228        )
8229        .await?;
8230        assert_eq!(count_entries(&pool).await?, 3);
8231        // Subscribe so mark_read is authorized to write an entry_state row.
8232        replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
8233
8234        // Give the ancient entry an entry_state row so we can prove the FK cascade.
8235        let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
8236            .fetch_one(&pool)
8237            .await?;
8238        let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
8239        assert!(wrote, "mark_read must write with a sub_ref in place");
8240        let state_before: i64 =
8241            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
8242                .bind(ancient_id)
8243                .fetch_one(&pool)
8244                .await?;
8245        assert_eq!(state_before, 1);
8246
8247        // Prune at a 90-day window: only the ancient entry is old.
8248        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
8249        assert_eq!(deleted, 1, "only the year-old entry should be pruned");
8250        assert_eq!(
8251            count_entries(&pool).await?,
8252            2,
8253            "fresh + undated-fresh survive"
8254        );
8255
8256        // The surviving guids are exactly the two fresh ones.
8257        let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
8258            .fetch_all(&pool)
8259            .await?;
8260        assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
8261
8262        // entry_state for the deleted entry cascaded away via the FK.
8263        let state_after: i64 =
8264            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
8265                .bind(ancient_id)
8266                .fetch_one(&pool)
8267                .await?;
8268        assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
8269
8270        // days == 0 disables the rolling WINDOW. The 3650-day ceiling still runs
8271        // (see `a_disabled_window_does_not_disable_the_ceiling`); it deletes
8272        // nothing here because both survivors are fresh.
8273        assert_eq!(prune_old_entries(&pool, 0, 3650, 0).await?, 0);
8274        assert_eq!(count_entries(&pool).await?, 2);
8275        Ok(())
8276    }
8277
8278    #[tokio::test]
8279    async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
8280        let pool = init_url("sqlite::memory:").await?;
8281        let did = "did:plc:reader";
8282        let feed_url = "https://orphan.example/feed.xml";
8283        let feed_id = upsert_feed(
8284            &pool,
8285            &NewFeed {
8286                url: feed_url.to_string(),
8287                ..Default::default()
8288            },
8289        )
8290        .await?;
8291        // Caller subscribes so mark-read is authorized to project into the cursor.
8292        replace_sub_refs(&pool, did, &[feed_id]).await?;
8293
8294        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
8295            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8296        let recent = now_rfc3339();
8297        insert_entries(
8298            &pool,
8299            feed_id,
8300            &[
8301                NewEntry {
8302                    guid: "old".into(),
8303                    published: Some(ancient.clone()),
8304                    fetched_at: Some(ancient.clone()),
8305                    ..Default::default()
8306                },
8307                NewEntry {
8308                    guid: "new".into(),
8309                    published: Some(recent.clone()),
8310                    fetched_at: Some(recent.clone()),
8311                    ..Default::default()
8312                },
8313            ],
8314            0,
8315        )
8316        .await?;
8317        let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
8318            .fetch_one(&pool)
8319            .await?;
8320        let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
8321            .fetch_one(&pool)
8322            .await?;
8323
8324        // Mark BOTH read — the cursor's read_ids now references both entry ids.
8325        mark_read(&pool, did, old_id, true).await?;
8326        mark_read(&pool, did, new_id, true).await?;
8327        let before = get_cursor(&pool, did, feed_url).await?.unwrap();
8328        let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
8329        assert!(ids_before.contains(&old_id.to_string()));
8330        assert!(ids_before.contains(&new_id.to_string()));
8331
8332        // Prune the old entry — its id must be scrubbed from the cursor's id-set.
8333        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
8334        assert_eq!(deleted, 1);
8335        let after = get_cursor(&pool, did, feed_url).await?.unwrap();
8336        let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
8337        assert_eq!(
8338            ids_after,
8339            vec![new_id.to_string()],
8340            "orphaned (deleted) entry id must be removed; live id kept"
8341        );
8342        // The scrub re-dirties the cursor so the flusher resyncs the PDS record.
8343        assert!(
8344            after.dirty,
8345            "cursor must be marked dirty after orphan scrub"
8346        );
8347        Ok(())
8348    }
8349
8350    #[tokio::test]
8351    async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
8352        // The per-feed max_entries trim path must ALSO scrub orphaned cursor ids.
8353        let pool = init_url("sqlite::memory:").await?;
8354        let did = "did:plc:reader";
8355        let feed_url = "https://trim.example/feed.xml";
8356        let feed_id = upsert_feed(
8357            &pool,
8358            &NewFeed {
8359                url: feed_url.to_string(),
8360                ..Default::default()
8361            },
8362        )
8363        .await?;
8364        replace_sub_refs(&pool, did, &[feed_id]).await?;
8365
8366        // Two entries, cap of 2 for now (no trim yet).
8367        insert_entries(
8368            &pool,
8369            feed_id,
8370            &[
8371                NewEntry {
8372                    guid: "a".into(),
8373                    published: Some("2026-01-01T00:00:00Z".into()),
8374                    ..Default::default()
8375                },
8376                NewEntry {
8377                    guid: "b".into(),
8378                    published: Some("2026-01-02T00:00:00Z".into()),
8379                    ..Default::default()
8380                },
8381            ],
8382            2,
8383        )
8384        .await?;
8385        let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
8386            .fetch_one(&pool)
8387            .await?;
8388        mark_read(&pool, did, a_id, true).await?;
8389
8390        // Insert a newer entry with cap=1 → the oldest ('a') is trimmed away.
8391        insert_entries(
8392            &pool,
8393            feed_id,
8394            &[NewEntry {
8395                guid: "c".into(),
8396                published: Some("2026-01-03T00:00:00Z".into()),
8397                ..Default::default()
8398            }],
8399            1,
8400        )
8401        .await?;
8402        // 'a' is gone.
8403        let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
8404            .fetch_one(&pool)
8405            .await?;
8406        assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
8407
8408        // The cursor no longer references the trimmed id.
8409        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
8410        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
8411        assert!(
8412            !ids.contains(&a_id.to_string()),
8413            "trimmed entry id must be scrubbed from the cursor"
8414        );
8415        Ok(())
8416    }
8417
8418    /// A sweep spanning several batches must still delete everything.
8419    ///
8420    /// The batching exists to make the write-lock hold interruptible, not to
8421    /// make the sweep partial — so the obvious way to get it wrong is an
8422    /// off-by-one that leaves a batch behind, or a loop that exits on the first
8423    /// short batch instead of the first empty one.
8424    #[tokio::test]
8425    async fn a_sweep_larger_than_one_batch_still_drains() -> Result<()> {
8426        let pool = init_url("sqlite::memory:").await?;
8427        let feed_id = upsert_feed(
8428            &pool,
8429            &NewFeed {
8430                url: "https://bulk.example/f.xml".to_string(),
8431                ..Default::default()
8432            },
8433        )
8434        .await?;
8435        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8436            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8437        // Deliberately not a multiple of PRUNE_BATCH, so the final batch is
8438        // short and the loop has to keep going to the empty one.
8439        let count = (PRUNE_BATCH * 2 + 137) as usize;
8440        let entries: Vec<NewEntry> = (0..count)
8441            .map(|i| NewEntry {
8442                guid: format!("bulk-{i}"),
8443                published: Some(old.clone()),
8444                ..Default::default()
8445            })
8446            .collect();
8447        insert_entries(&pool, feed_id, &entries, 0).await?;
8448        assert_eq!(count_entries(&pool).await? as usize, count);
8449
8450        let deleted = prune_old_entries(&pool, 30, 180, 0).await?;
8451        assert_eq!(deleted as usize, count, "the sweep left rows behind");
8452        assert_eq!(count_entries(&pool).await?, 0);
8453        Ok(())
8454    }
8455
8456    /// What a sweep driven by a lock test actually did.
8457    ///
8458    /// `Contended` is NOT a failure. `SQLITE_BUSY` on the pruner is an outcome
8459    /// production expects and handles — `scheduler.rs` logs it and the next tick
8460    /// retries — so a test that treats it as a regression is stricter than the
8461    /// system it guards, and fails for a reason its own assertions are not
8462    /// about. See #146.
8463    enum SweepOutcome {
8464        Completed(u64),
8465        Contended,
8466    }
8467
8468    /// True for the `SQLITE_BUSY` FAMILY anywhere in the chain.
8469    ///
8470    /// Matched on the DRIVER CODE, not on the message text: "database is
8471    /// locked" is a string another error could plausibly carry, and this
8472    /// decides whether a test failure is suppressed.
8473    ///
8474    /// **Masked to the primary code.** sqlx-sqlite's `code()` returns
8475    /// `sqlite3_extended_errcode` verbatim, so comparing it to `"5"` matches
8476    /// only bare `SQLITE_BUSY` and treats the WAL variants as hard failures:
8477    /// `BUSY_RECOVERY` (261), `BUSY_SNAPSHOT` (517), `BUSY_TIMEOUT` (773).
8478    /// This database runs in WAL mode and `store.rs` already documents hitting
8479    /// `SQLITE_BUSY_SNAPSHOT`, so that gap is not hypothetical — the narrowing
8480    /// would have rejected the very class this tolerance exists for.
8481    ///
8482    /// `& 0xFF` is how SQLite defines the relationship: the low byte of an
8483    /// extended code IS the primary code.
8484    fn is_sqlite_busy(err: &anyhow::Error) -> bool {
8485        err.chain().any(|e| {
8486            e.downcast_ref::<sqlx::Error>().is_some_and(|e| match e {
8487                sqlx::Error::Database(db) => db
8488                    .code()
8489                    .and_then(|c| c.parse::<i32>().ok())
8490                    .is_some_and(is_busy_code),
8491                _ => false,
8492            })
8493        })
8494    }
8495
8496    /// The classification, split out so the WAL variants are TESTABLE.
8497    ///
8498    /// A `BUSY_SNAPSHOT` cannot be produced on demand in a test, so without
8499    /// this the claim that 261/517/773 are tolerated would be a comment and
8500    /// nothing else. The wiring — that `is_sqlite_busy` consults this at all —
8501    /// is pinned separately by `a_busy_sweep_is_reported_as_contended_not_as_a_failure`,
8502    /// which drives a real `SQLITE_BUSY` end to end.
8503    fn is_busy_code(code: i32) -> bool {
8504        code & 0xFF == 5
8505    }
8506
8507    /// **The whole `SQLITE_BUSY` family, and nothing else.**
8508    #[test]
8509    fn busy_codes_cover_the_wal_variants() {
8510        for code in [
8511            5,   // SQLITE_BUSY
8512            261, // SQLITE_BUSY_RECOVERY
8513            517, // SQLITE_BUSY_SNAPSHOT
8514            773, // SQLITE_BUSY_TIMEOUT
8515        ] {
8516            assert!(
8517                is_busy_code(code),
8518                "{code} is in the BUSY family but would be treated as a hard failure"
8519            );
8520        }
8521        for code in [
8522            0,   // SQLITE_OK
8523            1,   // SQLITE_ERROR
8524            6,   // SQLITE_LOCKED — adjacent, and deliberately NOT tolerated
8525            262, // SQLITE_LOCKED_SHAREDCACHE
8526            11,  // SQLITE_CORRUPT
8527        ] {
8528            assert!(
8529                !is_busy_code(code),
8530                "{code} is not contention, but would be swallowed as though it were"
8531            );
8532        }
8533    }
8534
8535    /// Run the batched delete, separating "the write lock was contended" from
8536    /// "the loop misbehaved". Only the second is this test's subject.
8537    async fn sweep_tolerating_busy(
8538        pool: &SqlitePool,
8539        select_ids: &str,
8540        cutoff: &str,
8541        label: &str,
8542    ) -> Result<SweepOutcome> {
8543        match delete_in_batches(pool, select_ids, cutoff, label).await {
8544            Ok(n) => Ok(SweepOutcome::Completed(n)),
8545            // Contended, not broken. Narrowed to SQLITE_BUSY on purpose: every
8546            // other error still fails the caller, so this is not a blanket
8547            // `let _ =` that would delete the test while keeping its name.
8548            Err(err) if is_sqlite_busy(&err) => Ok(SweepOutcome::Contended),
8549            Err(err) => Err(err),
8550        }
8551    }
8552
8553    /// **A sweep that loses the write lock is inconclusive, not a failure.**
8554    ///
8555    /// CI hit this on `main` at `d05a716`: the sweeper took `SQLITE_BUSY` and
8556    /// the test reported a regression, on a tree whose only changes were two
8557    /// version strings and a changelog.
8558    ///
8559    /// Forced deterministically rather than waiting for a contended runner — it
8560    /// did not reproduce in 48 local runs — by holding a write transaction open
8561    /// and giving the sweep a `busy_timeout` short enough to give up at once.
8562    #[tokio::test]
8563    async fn a_busy_sweep_is_reported_as_contended_not_as_a_failure() -> Result<()> {
8564        struct TempDb(std::path::PathBuf);
8565        impl Drop for TempDb {
8566            fn drop(&mut self) {
8567                for suffix in ["", "-wal", "-shm"] {
8568                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8569                }
8570            }
8571        }
8572        let path = std::env::temp_dir().join(format!("fr-busysweep-{}.db", std::process::id()));
8573        drop(TempDb(path.clone()));
8574        let _tmp = TempDb(path.clone());
8575        let url = format!("sqlite://{}", path.display());
8576        let pool = init_url(&url).await?;
8577
8578        let feed_id = upsert_feed(
8579            &pool,
8580            &NewFeed {
8581                url: "https://busy.example/f.xml".to_string(),
8582                ..Default::default()
8583            },
8584        )
8585        .await?;
8586        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8587            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8588        let entries: Vec<NewEntry> = (0..4)
8589            .map(|i| NewEntry {
8590                guid: format!("busy-{i}"),
8591                url: Some(format!("https://busy.example/{i}")),
8592                title: Some(format!("e{i}")),
8593                published: Some(old.clone()),
8594                ..Default::default()
8595            })
8596            .collect();
8597        insert_entries(&pool, feed_id, &entries, 1_000).await?;
8598
8599        // A sweep pool that gives up on a contended write immediately.
8600        let sweep_pool = SqlitePoolOptions::new()
8601            .max_connections(1)
8602            .connect_with(
8603                url.parse::<sqlx::sqlite::SqliteConnectOptions>()?
8604                    .busy_timeout(std::time::Duration::from_millis(2)),
8605            )
8606            .await?;
8607
8608        // Hold the write lock for the duration of the sweep below.
8609        let mut blocker = pool.acquire().await?;
8610        sqlx::query("BEGIN IMMEDIATE")
8611            .execute(&mut *blocker)
8612            .await?;
8613
8614        let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8615            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8616        let outcome = sweep_tolerating_busy(
8617            &sweep_pool,
8618            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8619            &cutoff,
8620            "busy-sweep-test",
8621        )
8622        .await;
8623
8624        sqlx::query("ROLLBACK").execute(&mut *blocker).await.ok();
8625
8626        match outcome {
8627            Ok(SweepOutcome::Contended) => Ok(()),
8628            Ok(SweepOutcome::Completed(n)) => panic!(
8629                "the sweep completed ({n} rows) while the write lock was held — \
8630                 the fixture is not actually contending, so this test proves nothing"
8631            ),
8632            Err(err) => panic!(
8633                "a contended sweep was reported as a failure rather than as \
8634                 inconclusive; production logs this and retries on the next \
8635                 tick (scheduler.rs): {err:#}"
8636            ),
8637        }
8638    }
8639
8640    /// **A sweep error that is NOT `SQLITE_BUSY` must still fail.**
8641    ///
8642    /// `sweep_tolerating_busy` claims to narrow its tolerance to contention.
8643    /// Without this, that claim is unenforced: widening the arm to `Err(_) =>
8644    /// Contended` swallows every sweep error — a malformed query, a missing
8645    /// table, a corrupt file — and the whole suite stays green. Measured, not
8646    /// assumed: that mutation passed 733 tests before this test existed.
8647    #[tokio::test]
8648    async fn a_non_busy_sweep_error_still_fails() -> Result<()> {
8649        let pool = init_url("sqlite::memory:").await?;
8650        // A table that does not exist: SQLITE_ERROR (1), not SQLITE_BUSY (5).
8651        let outcome = sweep_tolerating_busy(
8652            &pool,
8653            "SELECT id FROM no_such_table WHERE created < ?1",
8654            "2026-01-01T00:00:00Z",
8655            "bad-query-test",
8656        )
8657        .await;
8658
8659        match outcome {
8660            Err(err) => {
8661                assert!(
8662                    !is_sqlite_busy(&err),
8663                    "fixture drifted: this must be a non-BUSY error, got {err:#}"
8664                );
8665                Ok(())
8666            }
8667            Ok(SweepOutcome::Contended) => panic!(
8668                "a malformed sweep was reported as lock contention — the \
8669                 tolerance is a blanket error swallow, not a narrowing"
8670            ),
8671            Ok(SweepOutcome::Completed(n)) => {
8672                panic!("a sweep over a missing table reported {n} rows deleted")
8673            }
8674        }
8675    }
8676
8677    /// **An UNCONTENDED sweep must report `Completed`.**
8678    ///
8679    /// This exists to stop the `Contended` arm above becoming a way to never
8680    /// run the hand-off assertions. Make `sweep_tolerating_busy` return
8681    /// `Contended` unconditionally and the sweep-lock test still passes — it
8682    /// just silently stops testing anything. This one fails instead.
8683    ///
8684    /// That is the difference between tolerating a real contention loss and
8685    /// deleting a test while keeping its name.
8686    #[tokio::test]
8687    async fn a_sweep_with_no_contention_completes() -> Result<()> {
8688        struct TempDb(std::path::PathBuf);
8689        impl Drop for TempDb {
8690            fn drop(&mut self) {
8691                for suffix in ["", "-wal", "-shm"] {
8692                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8693                }
8694            }
8695        }
8696        let path = std::env::temp_dir().join(format!("fr-calmsweep-{}.db", std::process::id()));
8697        drop(TempDb(path.clone()));
8698        let _tmp = TempDb(path.clone());
8699        let pool = init_url(&format!("sqlite://{}", path.display())).await?;
8700
8701        let feed_id = upsert_feed(
8702            &pool,
8703            &NewFeed {
8704                url: "https://calm.example/f.xml".to_string(),
8705                ..Default::default()
8706            },
8707        )
8708        .await?;
8709        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8710            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8711        let entries: Vec<NewEntry> = (0..3)
8712            .map(|i| NewEntry {
8713                guid: format!("calm-{i}"),
8714                published: Some(old.clone()),
8715                ..Default::default()
8716            })
8717            .collect();
8718        insert_entries(&pool, feed_id, &entries, 1_000).await?;
8719
8720        let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8721            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8722        match sweep_tolerating_busy(
8723            &pool,
8724            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8725            &cutoff,
8726            "calm-sweep-test",
8727        )
8728        .await?
8729        {
8730            SweepOutcome::Completed(n) => {
8731                assert_eq!(n, 3, "the uncontended sweep did not delete the fixture");
8732                Ok(())
8733            }
8734            SweepOutcome::Contended => panic!(
8735                "nothing was holding the write lock, yet the sweep reported \
8736                 contention — every test that skips on `Contended` is now \
8737                 skipping unconditionally"
8738            ),
8739        }
8740    }
8741
8742    /// **The sweep must not lock other writers out for its duration.**
8743    ///
8744    /// The whole sweep used to be one transaction — both deletes plus a global
8745    /// cursor scrub that loads every `read_cursor` row and then issues a
8746    /// per-cursor live-ids query. SQLite is single-writer with a 5 s
8747    /// `busy_timeout`, so every mark-read, login write and cursor flush failed
8748    /// for that whole span.
8749    ///
8750    /// On-disk (WAL) because the in-memory pool is deliberately
8751    /// single-connection, which would make a concurrency test meaningless.
8752    ///
8753    /// **The writer runs on its own pool with a short `busy_timeout`, and the
8754    /// runtime is multi-thread.** Both are load-bearing — a 5 s `busy_timeout`
8755    /// on a shared runtime is what made this test flake on CI. See the comment
8756    /// on the writer pool and the `attempts` assertion.
8757    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8758    async fn a_writer_gets_through_while_the_sweep_runs() -> Result<()> {
8759        // **Cleanup on EVERY exit, including a panicking assertion.**
8760        //
8761        // The three `remove_file` calls used to sit after the assertions, so any
8762        // failure leaked the database and its `-wal`/`-shm` — 2.7–12.8 MB a time,
8763        // and this test is deliberately the one most likely to fail. Worse, setup
8764        // removed only the `.db`, so a recycled PID paired a fresh database with a
8765        // stale WAL. A guard drops on the unwind path too and takes all three.
8766        struct TempDb(std::path::PathBuf);
8767        impl Drop for TempDb {
8768            fn drop(&mut self) {
8769                for suffix in ["", "-wal", "-shm"] {
8770                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8771                }
8772            }
8773        }
8774        let dir = std::env::temp_dir();
8775        let path = dir.join(format!("fr-sweeplock-{}.db", std::process::id()));
8776        // Drops the previous run's leftovers, WAL and all, before opening.
8777        drop(TempDb(path.clone()));
8778        let _tmp = TempDb(path.clone());
8779        let url = format!("sqlite://{}", path.display());
8780        let pool = init_url(&url).await?;
8781
8782        let feed_id = upsert_feed(
8783            &pool,
8784            &NewFeed {
8785                url: "https://lock.example/f.xml".to_string(),
8786                ..Default::default()
8787            },
8788        )
8789        .await?;
8790        // **The fixture is DERIVED from the batch count, not described by it.**
8791        //
8792        // Every assertion below reasons about "ten hand-off windows". That was
8793        // prose — a `const BATCHES: u32 = 10` sitting next to a `PRUNE_BATCH *
8794        // 10` fixture with nothing tying them together. Editing the fixture
8795        // alone to `PRUNE_BATCH * 4` left the floor still demanding ten
8796        // hand-offs' worth of time from a four-batch loop, and correct code was
8797        // accused of not handing the lock over at all (1 run in 6). Now the
8798        // compiler carries the coupling.
8799        const BATCHES: i64 = 10;
8800        // **The 50% ceiling below is only safe because BATCHES is large.**
8801        //
8802        // `max_refused_run / attempts` is bounded by roughly `1 / BATCHES` only
8803        // because the fixture opens that many hand-off windows. Shrink it and
8804        // correct code walks into the ceiling: measured with production code
8805        // untouched and the per-batch hold grown 10x, `BATCHES = 4` gives ratios
8806        // of 0.21–0.35 and `BATCHES = 2` gives 0.45–0.56, **failing 3 runs in
8807        // 5**. The comment above invites editing this fixture; this stops that
8808        // edit from silently turning the assertion against the code it guards.
8809        const _: () = assert!(
8810            BATCHES >= 5,
8811            "the 50% ceiling assumes ~1/BATCHES; below 5 batches correct code              false-fails",
8812        );
8813        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8814            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8815        let entries: Vec<NewEntry> = (0..(PRUNE_BATCH * BATCHES) as usize)
8816            .map(|i| NewEntry {
8817                guid: format!("lock-{i}"),
8818                published: Some(old.clone()),
8819                ..Default::default()
8820            })
8821            .collect();
8822        insert_entries(&pool, feed_id, &entries, 0).await?;
8823
8824        // **The writer gets its OWN pool, with a SHORT `busy_timeout`.**
8825        //
8826        // This is the fix for the CI flake described on the `attempts` assertion
8827        // below, and it is two separate changes.
8828        //
8829        // *Its own pool*, so the only thing that can block a write is SQLite's
8830        // write lock — the thing under test. Sharing the 5-connection pool with
8831        // the sweep meant a write could also stall waiting to ACQUIRE a pooled
8832        // connection the sweep was holding, which is a confounder that looks
8833        // identical from the outside.
8834        //
8835        // *A short `busy_timeout`*, so a contended write FAILS FAST and the loop
8836        // takes another shot. At the production 5 s, SQLite's busy handler backs
8837        // off internally — 1, 2, 5, 10, 25, 50, 100 ms and up — all inside a
8838        // single `execute()`. The writer therefore gets ONE attempt per blocked
8839        // write, and once the ladder reaches 100 ms it sleeps straight past the
8840        // `PRUNE_BATCH_HANDOFF` windows `delete_in_batches` opens. Failing fast
8841        // turns one low-probability attempt into hundreds of independent ones:
8842        // measured 54 attempts at 5 ms, 517 at 2 ms, over the same sweep.
8843        const WRITER_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2);
8844        let writer_pool = SqlitePoolOptions::new()
8845            .min_connections(1)
8846            .max_connections(1)
8847            .connect_with(
8848                SqliteConnectOptions::from_str(&url)?
8849                    .foreign_keys(true)
8850                    .busy_timeout(WRITER_BUSY_TIMEOUT)
8851                    .log_statements(tracing::log::LevelFilter::Debug),
8852            )
8853            .await?;
8854
8855        let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
8856        let writer_done = std::sync::Arc::clone(&done);
8857        let writer = tokio::spawn(async move {
8858            // `(when the attempt STARTED, whether it landed)`.
8859            //
8860            // The ORDER is what the assertions read, not the timestamps: they
8861            // count consecutive failures. The instants serve only to select the
8862            // attempts made inside the measured window — the writer is spawned
8863            // before `t0`, so a plain counter would fold in attempts that can
8864            // never appear in `during`.
8865            //
8866            // (An earlier version of this comment, left behind by the switch away
8867            // from elapsed time, said completions were recorded and that "the
8868            // timestamps are the load-bearing part". Neither is true now.)
8869            let mut outcomes: Vec<(std::time::Instant, bool)> = Vec::new();
8870            // Kept for the failure message: if the writes are failing for a
8871            // reason that is NOT lock contention, nothing lands and the test
8872            // fails — this is what says why. Timestamped so the test can drop it
8873            // when it describes an attempt OUTSIDE the measured window; the
8874            // writer starts before `t0`, so the very first error is usually from
8875            // an attempt the assertions never look at.
8876            let mut first_err: Option<(std::time::Instant, String)> = None;
8877            while !writer_done.load(std::sync::atomic::Ordering::Relaxed) {
8878                let started = std::time::Instant::now();
8879                match grant_access(
8880                    &writer_pool,
8881                    &format!("did:plc:writer{}", outcomes.len()),
8882                    None,
8883                    "sweep-test",
8884                    None,
8885                )
8886                .await
8887                {
8888                    Ok(()) => outcomes.push((started, true)),
8889                    // Expected: the sweep holds the write lock right now.
8890                    // Retrying is the entire point, so this is counted, not
8891                    // fatal. A `?` here would abort the writer on the first
8892                    // contended write and destroy the measurement.
8893                    Err(err) => {
8894                        outcomes.push((started, false));
8895                        if first_err.is_none() {
8896                            first_err = Some((started, format!("{err:#}")));
8897                        }
8898                    }
8899                }
8900                tokio::task::yield_now().await;
8901            }
8902            writer_pool.close().await;
8903            (outcomes, first_err)
8904        });
8905
8906        // **Drive `delete_in_batches` directly, not `prune_old_entries`.**
8907        //
8908        // The subject is the batched delete loop and whether it hands the write
8909        // lock over between batches. `prune_old_entries` wraps it in work that
8910        // is not that — two delete passes plus `prune_orphan_cursor_ids` — so
8911        // timing the whole call measures a window in which the lock was never
8912        // meant to be held throughout, and writes landing outside the loop
8913        // count as though the loop had handed the lock over.
8914        //
8915        // A correction to what this comment first claimed. It said the cursor
8916        // scrub was a tail that "grows with the number of rows deleted", and
8917        // that this explained a `42 of 358` measurement. **That is false, and
8918        // measured to be false**: this fixture creates no `read_cursor` rows at
8919        // all, so the scrub does one `SELECT` over an empty table and loops zero
8920        // times — 0.16–2 ms, 0.03–0.5% of the window, at any fixture size. It
8921        // cannot explain anything. Narrowing the window is still right, for the
8922        // reason above; the mechanism originally given for it was not real.
8923        //
8924        // The consequence worth stating: because the fixture has no cursors, the
8925        // old form never covered the scrub's locking either — it only appeared
8926        // to. Nothing here regressed. `prune_orphan_cursor_ids` holding the lock
8927        // across a whole pass is a real production invariant (see its own doc)
8928        // and remains untested; that needs a test with actual cursors, not this
8929        // one.
8930        let hard_cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8931            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8932        let t0 = std::time::Instant::now();
8933        let outcome = sweep_tolerating_busy(
8934            &pool,
8935            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8936            &hard_cutoff,
8937            "sweep-lock-test",
8938        )
8939        .await?;
8940        let sweep = t0.elapsed();
8941
8942        // **Teardown happens on BOTH paths, before the outcome is inspected.**
8943        //
8944        // The contended arm below used to carry its own copy of these two lines.
8945        // A probe proved that arm is never reached by the suite — a `panic!` in
8946        // it failed nothing — so it was five lines of unexercised teardown that
8947        // would run for the first time on a contended CI runner, which is
8948        // exactly when it has to work. Hoisting leaves the arm with nothing that
8949        // can be wrong.
8950        done.store(true, std::sync::atomic::Ordering::Relaxed);
8951        let (outcomes, first_err) = writer.await?;
8952
8953        let deleted = match outcome {
8954            SweepOutcome::Completed(n) => n,
8955            // **Inconclusive, not a regression.** The sweeper lost the write
8956            // lock, which says nothing about whether it hands the lock over
8957            // between batches — the property below. Production logs this and
8958            // retries on the next tick (`scheduler.rs`), so a test that failed
8959            // here would be stricter than the system it guards. Observed on CI
8960            // at `d05a716`, on a tree with no `.rs` change at all.
8961            //
8962            // `a_sweep_with_no_contention_completes` is what stops this arm
8963            // becoming a way to never run the assertions.
8964            SweepOutcome::Contended => {
8965                eprintln!(
8966                    "sweep-lock test INCONCLUSIVE: the sweeper took SQLITE_BUSY; \
8967                     the hand-off assertions did not run"
8968                );
8969                return Ok(());
8970            }
8971        };
8972        let sweep_end = t0 + sweep;
8973        // Attempts actually made inside the measured window, in order.
8974        let inside: Vec<bool> = outcomes
8975            .iter()
8976            .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8977            .map(|(_, ok)| *ok)
8978            .collect();
8979        let attempts = inside.len();
8980        let during = inside.iter().filter(|ok| **ok).count();
8981        // **The longest unbroken run of REFUSED attempts.**
8982        //
8983        // Counted in attempts, not elapsed time — see the note on the assertion
8984        // for why that distinction is the whole point.
8985        let max_refused_run = {
8986            let (mut worst, mut run) = (0usize, 0usize);
8987            for ok in &inside {
8988                run = if *ok { 0 } else { run + 1 };
8989                worst = worst.max(run);
8990            }
8991            worst
8992        };
8993        let why = first_err
8994            .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8995            .map(|(_, e)| format!(" (first in-window write error: {e})"))
8996            .unwrap_or_default();
8997
8998        assert_eq!(deleted as usize, entries.len());
8999        // **The sweep has to BE batched before anything downstream means
9000        // anything, and this floor is derived, not calibrated.**
9001        //
9002        // The fixture is `PRUNE_BATCH * 10` rows, all older than the hard
9003        // ceiling, so the hard-ceiling delete drains them in ten full batches
9004        // and stands down `PRUNE_BATCH_HANDOFF` after each. A genuinely batched
9005        // sweep therefore cannot finish in under `10 * PRUNE_BATCH_HANDOFF` on
9006        // any machine, however fast its disk — the sleeps are a floor the
9007        // hardware cannot undercut, and the deletes themselves only add to it.
9008        //
9009        // A loop that has LOST its batching is faster, not slower: measured at
9010        // 56 ms with the `LIMIT` dropped, against 305 ms batched. That is why
9011        // this fires before the two assertions below — without it, removing the
9012        // batching starves the writer of attempts and gets reported as "invalid
9013        // measurement", blaming the test for the defect it just detected.
9014        //
9015        // **Partial coverage, measured rather than asserted.** Two mutations
9016        // that keep the loop looking roughly batched are caught only sometimes:
9017        //
9018        //   `LIMIT` dropped (no batching at all)   3-4 runs in 5-6, MOSTLY by
9019        //                                          the refusal assertion below,
9020        //                                          not by this floor
9021        //   `PRUNE_BATCH_HANDOFF` sleep removed    1 run in 5-6, by this floor
9022        //
9023        // (An earlier version attributed both to this floor. Re-measured: of
9024        // four catches of the `LIMIT` mutation in six runs, three panicked at
9025        // the refusal assertion and one here.)
9026        //
9027        // Both were caught more often — 5/5 and 3/5 — by the wall-clock form
9028        // this replaced. That is a real coverage loss and it was taken on
9029        // purpose: the wall-clock form FALSE-FAILED correct code, which is a
9030        // worse defect than missing a deliberate deletion of a commented line.
9031        // See the note on the assertion below for the measurement.
9032        //
9033        // Nothing here is tuned to make those two reliable. Doing so means
9034        // thresholding a rate, which is what this test has now been wrong about
9035        // three separate times.
9036        let handoff_floor = PRUNE_BATCH_HANDOFF * BATCHES as u32;
9037        assert!(
9038            sweep > handoff_floor,
9039            "the delete loop finished in {sweep:?}, under the {handoff_floor:?} that \
9040             {BATCHES} batches of `PRUNE_BATCH_HANDOFF` alone would take — it is not \
9041             handing the write lock over between batches at all"
9042        );
9043        // **Assert a RATIO OF TWO DURATIONS THAT SCALE TOGETHER.**
9044        //
9045        // Three thresholds have now failed here, each for the same reason: they
9046        // compared something machine-scaled against something fixed.
9047        //
9048        //   `worst * 3 < sweep`  — broke when the sweep got FASTER (the
9049        //                          `NOT EXISTS` rewrite, 1.49x) and tightened a
9050        //                          threshold calibrated against the slow version.
9051        //   `wrote >= 10`        — a raw count is writes-per-unit-time, so it
9052        //                          measured the runner. Flaked on CI at 4 writes.
9053        //   `during * 2 >=`      — a success FRACTION, which I claimed was
9054        //   `attempts`             scale-free. It is not, and this is the
9055        //                          important one, because the argument sounds
9056        //                          right. Successes come from the FIXED
9057        //                          `BATCHES * PRUNE_BATCH_HANDOFF` of open
9058        //                          window divided by write latency; failures
9059        //                          come from the machine-scaled lock hold
9060        //                          divided by the FIXED `WRITER_BUSY_TIMEOUT`.
9061        //                          Slow the machine by k and the fraction decays
9062        //                          as roughly 1/(1 + k²c) — quadratically,
9063        //                          toward failure. Measured with production code
9064        //                          fully correct and only the per-batch hold
9065        //                          grown 10x: **188/949 (19.8%) and 383/1028
9066        //                          (37.3%), two false failures in three runs**,
9067        //                          at loop durations of 3.4 s. CPU saturation
9068        //                          cannot find this — it slows writer and
9069        //                          sweeper together, which is the wrong axis.
9070        //
9071        //   `max_gap * 2 <`     — the longest WALL-CLOCK stretch with no write
9072        //   `sweep`               landing, against the loop's duration. Both
9073        //                         sides scale with the machine, which fixed the
9074        //                         fraction's problem and introduced a new one:
9075        //                         a gap opens when the writer is DESCHEDULED
9076        //                         just as surely as when the lock is held.
9077        //                         Observed under 4x CPU saturation, full suite:
9078        //                         `went 319.95ms of 609.83ms` — while **622 of
9079        //                         626 attempts landed**. The lock was fine; the
9080        //                         writer task simply did not run for 320 ms.
9081        //
9082        // So count REFUSALS, not time. The longest unbroken run of `SQLITE_BUSY`
9083        // against the number of attempts made:
9084        //
9085        //   handed over : the lock is free for `PRUNE_BATCH_HANDOFF` after every
9086        //                 batch, so the longest refused run is bounded by about
9087        //                 one batch's worth of attempts.
9088        //   held across : every attempt in the window is refused — 100%.
9089        //
9090        // **The ~10% this comment used to quote for the handed-over case is not
9091        // what the shipped configuration produces.** Measured here: 0.001–0.05,
9092        // and in roughly a quarter of runs the writer is refused ZERO times
9093        // (`max_refused_run == 0`, every attempt landing), so the assertion is
9094        // vacuously true and certifies the hand-off by never observing one. That
9095        // is a weak test, not a wrong one — but it is worth knowing that the
9096        // enormous margin comes from the writer rarely colliding at all, not
9097        // from a measured 10%. 10% is what appears only once the per-batch hold
9098        // dominates the hand-off (`PRUNE_BATCH` x10 gives 0.115–0.143).
9099        //
9100        // This is immune to descheduling in a way no wall-clock measure can be:
9101        // a starved writer makes no attempts, so it contributes to neither side
9102        // of the ratio. Machine speed still cancels, because both sides are
9103        // counts of the same attempts. Re-checked against the failure above:
9104        // 622 of 626 landing means a refused run of at most 4, nowhere near the
9105        // 313 it would take to trip.
9106        //
9107        // **Detection is near all-or-nothing, and that is a known limit rather
9108        // than an oversight.** Holding one transaction across only the FIRST
9109        // HALF of the batches — production code otherwise correct — is not
9110        // caught at all:
9111        //
9112        //   batches held in one tx (of 10)   runs failing
9113        //   5                                0 of 6   (ratios 0.05-0.27)
9114        //   7                                2 of 6
9115        //   9                                5 of 5
9116        //   10                               22 of 22
9117        //
9118        // The ratio systematically UNDERSTATES the wall-clock fraction the lock
9119        // was held, because a refused attempt costs ~2 ms and leaves the sweeper
9120        // running uncontended, while a successful write actively blocks it and
9121        // stretches the loop. So attempts pile up during free time. The
9122        // "10% vs 100%" framing above describes the endpoints, not the curve.
9123        //
9124        // Closing that would mean measuring the wall-clock SPAN of a refusal run
9125        // rather than its length — which is most of the way back to `max_gap`,
9126        // the form that false-failed correct code on a descheduled writer. Given
9127        // this assertion has now been wrong four times in a row, and the current
9128        // one has zero false failures across 134 runs in six environments while
9129        // catching the real defect 22/22, a fifth redesign to catch a
9130        // half-transaction — a mutation no plausible edit produces — is not a
9131        // trade worth making. Stated here so the next reader knows the gap is
9132        // chosen, not missed.
9133        //
9134        // Measured, with the apparatus verified before each run:
9135        //
9136        //   correct, 1x / 10x per-batch hold   passes
9137        //   one tx across batches, 1x          CAUGHT — refused 128 of 129
9138        //   one tx across batches, 10x         CAUGHT — refused 1841 of 1843
9139        //   `LIMIT` dropped                    caught 3 runs in 5 (by the floor)
9140        //   hand-off sleep removed             caught 1 run  in 5 (by the floor)
9141        //
9142        // The last two were 5/5 and 3/5 under the wall-clock form. Losing that
9143        // is the price of not false-failing correct code, and it is the right
9144        // way round: the named defect is now caught by two orders of magnitude,
9145        // and the mutations that got weaker are deliberate deletions of lines
9146        // that carry their own explanation.
9147        assert!(
9148            attempts >= 20,
9149            "the writer only got {attempts} attempts inside a {sweep:?} delete loop \
9150             — too few for the ratio below to mean anything. That is USUALLY an \
9151             invalid measurement rather than a held lock, but note that a loop \
9152             holding the lock throughout is itself one cause of a starved writer, \
9153             so check {during} (landed) before concluding the test is at \
9154             fault{why}"
9155        );
9156        assert!(
9157            max_refused_run * 2 < attempts,
9158            "the delete loop refused {max_refused_run} consecutive write attempts out \
9159             of {attempts} ({during} landed) — a loop that hands the write lock over \
9160             between batches refuses at most about one batch's worth in a row; one \
9161             that holds the lock across them refuses nearly every attempt it sees{why}"
9162        );
9163
9164        pool.close().await;
9165        // `_tmp` removes the database, WAL and shm as it drops — on this path
9166        // and on the unwind from any assertion above.
9167        Ok(())
9168    }
9169
9170    /// **The sparing predicate must quantify over ALL DIDs, not just one.**
9171    ///
9172    /// `entry_state`'s primary key is `(did, entry_id)`, so several readers can
9173    /// hold rows on the same shared entry. The window spares an entry when ANY of
9174    /// them has starred it or left it unread — one person's star protects the
9175    /// cached copy everyone reads.
9176    ///
9177    /// This is the ONLY case where `id NOT IN (…)` and the correlated
9178    /// `NOT EXISTS` that replaced it could diverge, and it had no test. Every
9179    /// other retention test writes one `entry_state` row per entry under a single
9180    /// DID, where the two forms are trivially identical — so the claim that the
9181    /// suite made the equivalence executable was false when it was written. It is
9182    /// true now.
9183    #[tokio::test]
9184    async fn sparing_honours_every_did_not_just_one() -> Result<()> {
9185        let pool = init_url("sqlite::memory:").await?;
9186        let feed_id = upsert_feed(
9187            &pool,
9188            &NewFeed {
9189                url: "https://shared.example/f.xml".to_string(),
9190                ..Default::default()
9191            },
9192        )
9193        .await?;
9194        let old = (chrono::Utc::now() - chrono::Duration::days(400))
9195            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9196        let guids = [
9197            "nobody-touched",      // no state row at all -> evicted
9198            "both-read-unstarred", // two DIDs, both read+unstarred -> evicted
9199            "one-starred",         // A read+unstarred, B starred -> SPARED by B
9200            "one-unread",          // A read+unstarred, B unread   -> SPARED by B
9201        ];
9202        let entries: Vec<NewEntry> = guids
9203            .iter()
9204            .map(|g| NewEntry {
9205                guid: (*g).to_string(),
9206                published: Some(old.clone()),
9207                ..Default::default()
9208            })
9209            .collect();
9210        insert_entries(&pool, feed_id, &entries, 0).await?;
9211
9212        let id_of = |g: &'static str| {
9213            let pool = pool.clone();
9214            async move {
9215                sqlx::query_scalar::<_, i64>("SELECT id FROM entries WHERE guid = ?1")
9216                    .bind(g)
9217                    .fetch_one(&pool)
9218                    .await
9219                    .unwrap()
9220            }
9221        };
9222        // (did, entry, read, starred)
9223        let rows: [(&str, &'static str, i64, i64); 6] = [
9224            ("did:plc:a", "both-read-unstarred", 1, 0),
9225            ("did:plc:b", "both-read-unstarred", 1, 0),
9226            ("did:plc:a", "one-starred", 1, 0),
9227            ("did:plc:b", "one-starred", 1, 1),
9228            ("did:plc:a", "one-unread", 1, 0),
9229            ("did:plc:b", "one-unread", 0, 0),
9230        ];
9231        for (did, guid, read, starred) in rows {
9232            let id = id_of(guid).await;
9233            sqlx::query(
9234                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
9235                 VALUES (?1, ?2, ?3, ?4, '2026-01-01T00:00:00Z')",
9236            )
9237            .bind(did)
9238            .bind(id)
9239            .bind(read)
9240            .bind(starred)
9241            .execute(&pool)
9242            .await?;
9243        }
9244
9245        // Window only — no ceiling, so nothing is swept for age alone.
9246        let deleted = prune_old_entries(&pool, 30, 0, 0).await?;
9247        assert_eq!(
9248            deleted, 2,
9249            "expected the untouched and the all-read entries to go"
9250        );
9251
9252        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
9253            .fetch_all(&pool)
9254            .await?;
9255        assert_eq!(
9256            left,
9257            vec!["one-starred".to_string(), "one-unread".to_string()],
9258            "a second reader's star or unread mark must spare the SHARED entry"
9259        );
9260        Ok(())
9261    }
9262
9263    /// **A mark-read landing during the scrub must not be overwritten.**
9264    ///
9265    /// Moving the scrub out of the sweep's transaction removed a multi-minute
9266    /// write-lock hold and introduced a lost update in its place: the id-sets
9267    /// were read into a snapshot up front and written back unguarded, so a
9268    /// `mark_read` arriving mid-pass had its id silently dropped — and the
9269    /// rewrite set `dirty = 1`, so the flusher pushed the truncated set to the
9270    /// PDS as authoritative. Local `entry_state` still said read, so the loss was
9271    /// invisible here and visible only in every other atproto client.
9272    ///
9273    /// **⚠️ THIS TEST DOES NOT PROVE THAT, AND THE NAME NO LONGER CLAIMS IT.**
9274    ///
9275    /// The mark-read below lands BEFORE the scrub is called, not during it — so
9276    /// a snapshot-then-write implementation taking its snapshot at the top of
9277    /// `prune_orphan_cursor_ids` would see it too, and pass. The discriminator
9278    /// does not discriminate; what is actually pinned is the ordinary outcome:
9279    /// orphaned ids go, live ids stay.
9280    ///
9281    /// What the lost-update shape is really prevented by is a TYPE fact, not
9282    /// this test: `scrub_one_cursor(pool, did, feed_url)` is handed no id-sets,
9283    /// so it cannot write back anything but what it read itself, and
9284    /// re-introducing the bug means changing its signature.
9285    ///
9286    /// Proving it by test needs a real interleave — hold the write lock on a
9287    /// second connection, let the scrub block on it, commit a `mark_read`, then
9288    /// release — which needs a file-backed database and, without a hook inside
9289    /// the pass, a sleep to be sure the key snapshot has already run. A sleep is
9290    /// how this suite gets flaky in CI, and a flaky test is worse than an honest
9291    /// one, so it is left undone and written down instead.
9292    #[tokio::test]
9293    async fn the_cursor_scrub_drops_orphans_and_keeps_live_ids() -> Result<()> {
9294        let pool = init_url("sqlite::memory:").await?;
9295        let did = "did:plc:race";
9296        let feed_url = "https://race.example/f.xml";
9297        let feed_id = upsert_feed(
9298            &pool,
9299            &NewFeed {
9300                url: feed_url.to_string(),
9301                ..Default::default()
9302            },
9303        )
9304        .await?;
9305        insert_entries(
9306            &pool,
9307            feed_id,
9308            &[
9309                NewEntry {
9310                    guid: "live".to_string(),
9311                    ..Default::default()
9312                },
9313                NewEntry {
9314                    guid: "doomed".to_string(),
9315                    ..Default::default()
9316                },
9317            ],
9318            0,
9319        )
9320        .await?;
9321        replace_sub_refs(&pool, did, &[feed_id]).await?;
9322        let live_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'live'")
9323            .fetch_one(&pool)
9324            .await?;
9325        let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
9326            .fetch_one(&pool)
9327            .await?;
9328
9329        // A cursor holding only the id that is about to be deleted.
9330        upsert_cursor(
9331            &pool,
9332            &ReadCursor {
9333                did: did.to_string(),
9334                feed_url: feed_url.to_string(),
9335                read_through: None,
9336                read_ids: format!("[\"{doomed_id}\"]"),
9337                unread_ids: "[]".to_string(),
9338                dirty: false,
9339                pds_created: false,
9340                updated_at: now_rfc3339(),
9341            },
9342        )
9343        .await?;
9344        sqlx::query("DELETE FROM entries WHERE guid = 'doomed'")
9345            .execute(&pool)
9346            .await?;
9347
9348        // A reader marks the surviving entry read. NOTE this lands before the
9349        // scrub, not during it — see the caveat on this test. It is here because
9350        // the live id must survive the pass, not because it catches the race.
9351        mark_read(&pool, did, live_id, true).await?;
9352
9353        assert_eq!(prune_orphan_cursor_ids(&pool, None).await?, 1);
9354
9355        let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
9356        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
9357        assert_eq!(
9358            ids,
9359            vec![live_id.to_string()],
9360            "the scrub dropped a live id"
9361        );
9362        assert!(
9363            !ids.contains(&doomed_id.to_string()),
9364            "the orphaned id survived the scrub"
9365        );
9366        Ok(())
9367    }
9368
9369    /// The cursor scrub still happens — it just no longer rides inside the
9370    /// delete transaction. Moving it out is only safe because it is idempotent;
9371    /// this pins that it still runs at all, which is the thing a "move it out"
9372    /// refactor can silently drop.
9373    #[tokio::test]
9374    async fn the_sweep_still_scrubs_orphaned_cursor_ids() -> Result<()> {
9375        let pool = init_url("sqlite::memory:").await?;
9376        let did = "did:plc:scrub";
9377        let feed_url = "https://scrub.example/f.xml";
9378        let feed_id = upsert_feed(
9379            &pool,
9380            &NewFeed {
9381                url: feed_url.to_string(),
9382                ..Default::default()
9383            },
9384        )
9385        .await?;
9386        let old = (chrono::Utc::now() - chrono::Duration::days(400))
9387            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9388        insert_entries(
9389            &pool,
9390            feed_id,
9391            &[NewEntry {
9392                guid: "doomed".to_string(),
9393                published: Some(old),
9394                ..Default::default()
9395            }],
9396            0,
9397        )
9398        .await?;
9399        let doomed = entries_for_feed(&pool, did, feed_id).await;
9400        // `entries_for_feed` is sub_ref-scoped; read the id directly instead.
9401        drop(doomed);
9402        let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
9403            .fetch_one(&pool)
9404            .await?;
9405
9406        upsert_cursor(
9407            &pool,
9408            &ReadCursor {
9409                did: did.to_string(),
9410                feed_url: feed_url.to_string(),
9411                read_through: None,
9412                read_ids: format!("[\"{doomed_id}\"]"),
9413                unread_ids: "[]".to_string(),
9414                dirty: false,
9415                pds_created: false,
9416                updated_at: now_rfc3339(),
9417            },
9418        )
9419        .await?;
9420
9421        assert_eq!(prune_old_entries(&pool, 30, 180, 0).await?, 1);
9422
9423        let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
9424        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
9425        assert!(
9426            ids.is_empty(),
9427            "the deleted entry's id survived in the cursor: {ids:?}"
9428        );
9429        assert!(cursor.dirty, "a rewritten cursor must be re-flushed");
9430        Ok(())
9431    }
9432
9433    #[tokio::test]
9434    async fn prune_and_reclaim_drops_db_size() -> Result<()> {
9435        // On-disk DB so VACUUM has a file to shrink.
9436        let dir = std::env::temp_dir();
9437        let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
9438        let url = format!("sqlite://{}", path.display());
9439        let pool = init_url(&url).await?;
9440
9441        let feed_id = upsert_feed(
9442            &pool,
9443            &NewFeed {
9444                url: "https://bulk.example/feed.xml".to_string(),
9445                ..Default::default()
9446            },
9447        )
9448        .await?;
9449        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
9450            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9451        let entries: Vec<NewEntry> = (0..2000)
9452            .map(|i| NewEntry {
9453                guid: format!("guid-{i}"),
9454                content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
9455                published: Some(ancient.clone()),
9456                fetched_at: Some(ancient.clone()),
9457                ..Default::default()
9458            })
9459            .collect();
9460        insert_entries(&pool, feed_id, &entries, 0).await?;
9461        let full = db_size_bytes(&pool).await?;
9462        assert!(full > 0);
9463
9464        // A retention sweep prunes every (year-old) entry, then reclaim shrinks.
9465        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
9466        assert_eq!(deleted, 2000);
9467        reclaim(&pool).await?;
9468        let after = db_size_bytes(&pool).await?;
9469        assert!(
9470            after < full,
9471            "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
9472        );
9473
9474        drop(pool);
9475        let _ = std::fs::remove_file(&path);
9476        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9477        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9478        Ok(())
9479    }
9480
9481    // ---- B1: an existing PRE-0.2.2 invite_codes table (no intended_did) must
9482    // migrate cleanly, not crash-loop boot. ----------------------------------
9483
9484    #[tokio::test]
9485    async fn migrates_pre_intended_did_invite_codes_table() -> Result<()> {
9486        // Build an on-disk DB whose `invite_codes` table has the OLD 0.2.1 shape
9487        // (NO `intended_did` column, and therefore no `intended_did` index), then
9488        // run init_schema/migrations against it — this is exactly the existing-prod
9489        // volume that blocker B1 crash-looped (the SCHEMA's `CREATE INDEX ...
9490        // (intended_did, ...)` fired before the ALTER TABLE added the column).
9491        let dir = std::env::temp_dir();
9492        let path = dir.join(format!("fr-b1-{}.db", std::process::id()));
9493        let url = format!("sqlite://{}", path.display());
9494
9495        // Open a raw pool WITHOUT init_schema and hand-build the old table shape.
9496        let opts = SqliteConnectOptions::from_str(&url)?
9497            .create_if_missing(true)
9498            .foreign_keys(true);
9499        let pool = SqlitePoolOptions::new()
9500            .min_connections(1)
9501            .max_connections(1)
9502            .connect_with(opts)
9503            .await?;
9504        sqlx::query(
9505            r#"CREATE TABLE invite_codes (
9506                code         TEXT PRIMARY KEY,
9507                creator_did  TEXT NOT NULL,
9508                status       TEXT NOT NULL,
9509                invitee_did  TEXT,
9510                created_at   INTEGER NOT NULL,
9511                expires_at   INTEGER NOT NULL,
9512                redeemed_at  INTEGER
9513            );"#,
9514        )
9515        .execute(&pool)
9516        .await?;
9517        // Seed a legacy active code so the migration runs against real data.
9518        sqlx::query(
9519            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9520             VALUES ('FEATHER-LEGACY00', 'did:plc:old', 'active', 1, 9999999999)",
9521        )
9522        .execute(&pool)
9523        .await?;
9524
9525        // The column is genuinely absent to start with (pre-condition of B1).
9526        let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9527            .fetch_all(&pool)
9528            .await?
9529            .iter()
9530            .map(|r| r.get::<String, _>("name"))
9531            .collect();
9532        assert!(
9533            !cols.iter().any(|c| c == "intended_did"),
9534            "pre-condition: legacy table must lack intended_did"
9535        );
9536
9537        // THE FIX: init_schema must succeed (not error with "no such column").
9538        init_schema(&pool)
9539            .await
9540            .expect("init_schema on a pre-0.2.2 invite_codes table must not crash");
9541
9542        // Post-condition: the column now exists, both indexes were created, and the
9543        // legacy row is intact.
9544        let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9545            .fetch_all(&pool)
9546            .await?
9547            .iter()
9548            .map(|r| r.get::<String, _>("name"))
9549            .collect();
9550        assert!(cols.iter().any(|c| c == "intended_did"));
9551        let idx: Vec<String> = sqlx::query(
9552            "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='invite_codes'",
9553        )
9554        .fetch_all(&pool)
9555        .await?
9556        .iter()
9557        .map(|r| r.get::<String, _>("name"))
9558        .collect();
9559        assert!(idx.iter().any(|n| n == "idx_invite_codes_intended"));
9560        assert!(idx.iter().any(|n| n == "idx_invite_codes_intended_active"));
9561
9562        // Idempotent: running it again is a no-op, not an error.
9563        init_schema(&pool)
9564            .await
9565            .expect("re-running init_schema must be idempotent");
9566
9567        // **The OAuth tables must exist too.** They live in this database, and
9568        // creating them only when the Rust backend is selected would make the
9569        // first request after a cutover flip fail with "no such table" -- at the
9570        // one moment nobody wants to find out a migration was missed. They are
9571        // empty and harmless while the sidecar is serving.
9572        let tables: Vec<String> =
9573            sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table'")
9574                .fetch_all(&pool)
9575                .await
9576                .unwrap();
9577        for table in ["oauth_state", "oauth_session", "oauth_nonce"] {
9578            assert!(
9579                tables.iter().any(|t| t == table),
9580                "{table} is missing, so the rust backend would fail on its first request: {tables:?}"
9581            );
9582        }
9583
9584        // The legacy code still redeems (NULL intended_did → open, as before).
9585        let out = redeem_code(&pool, "FEATHER-LEGACY00", "did:plc:new", None, 100).await?;
9586        assert_eq!(out, Ok(()));
9587
9588        drop(pool);
9589        let _ = std::fs::remove_file(&path);
9590        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9591        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9592        Ok(())
9593    }
9594
9595    // ---- 0.3.9: the schema a RELEASED binary left behind must upgrade. ------
9596    //
9597    // B1 above hand-built the old shape of ONE table, so it could only catch the
9598    // mistake it was written for. 0.3.9 made the same mistake on `feeds` — an
9599    // index in the base SCHEMA on `kind`, a column only `apply_migrations` adds
9600    // — and crash-looped production on its first boot, while every test here
9601    // passed, because every other test starts from an empty file. These start
9602    // from the schema a released binary actually created (dumped, not
9603    // transcribed), so they cover every table at once: v0.3.8, the release
9604    // before the bug, and v0.2.0, the oldest and furthest-migrated shape.
9605
9606    /// A fresh in-memory pool on ONE connection that never expires. The bug
9607    /// class is DDL order, which does not depend on a file, and a file named by
9608    /// pid leaks on a failed run and then fails the next run whose pid matches,
9609    /// at the fixture's first CREATE TABLE, before it tests anything.
9610    async fn upgrade_test_pool() -> Result<SqlitePool> {
9611        let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true);
9612        Ok(SqlitePoolOptions::new()
9613            .min_connections(1)
9614            .max_connections(1)
9615            .idle_timeout(None)
9616            .max_lifetime(None)
9617            .connect_with(opts)
9618            .await?)
9619    }
9620
9621    /// Every table's columns (with type, NOT NULL, default and pk) and every
9622    /// index (with uniqueness, partiality and its columns in order), as one
9623    /// comparable set. Column ORDER is left out on purpose: `ALTER TABLE ADD
9624    /// COLUMN` appends, so a migrated table legitimately orders differently
9625    /// from a fresh one.
9626    async fn schema_shape(pool: &SqlitePool) -> Result<std::collections::BTreeSet<String>> {
9627        let mut shape = std::collections::BTreeSet::new();
9628        let tables: Vec<String> = sqlx::query_scalar(
9629            "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
9630        )
9631        .fetch_all(pool)
9632        .await?;
9633        for t in tables {
9634            for r in sqlx::query(
9635                r#"SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)"#,
9636            )
9637            .bind(&t)
9638            .fetch_all(pool)
9639            .await?
9640            {
9641                shape.insert(format!(
9642                    "column {t}.{} {} notnull={} default={:?} pk={}",
9643                    r.get::<String, _>("name"),
9644                    r.get::<String, _>("type"),
9645                    r.get::<i64, _>("notnull"),
9646                    r.get::<Option<String>, _>("dflt_value"),
9647                    r.get::<i64, _>("pk"),
9648                ));
9649            }
9650            for r in sqlx::query(r#"SELECT name, "unique", partial FROM pragma_index_list(?)"#)
9651                .bind(&t)
9652                .fetch_all(pool)
9653                .await?
9654            {
9655                let name: String = r.get("name");
9656                let cols: Vec<String> =
9657                    sqlx::query_scalar("SELECT name FROM pragma_index_info(?) ORDER BY seqno")
9658                        .bind(&name)
9659                        .fetch_all(pool)
9660                        .await?;
9661                shape.insert(format!(
9662                    "index {t}.{name} unique={} partial={} ({})",
9663                    r.get::<i64, _>("unique"),
9664                    r.get::<i64, _>("partial"),
9665                    cols.join(", "),
9666                ));
9667            }
9668        }
9669        Ok(shape)
9670    }
9671
9672    /// Load `fixture`, seed rows the way an old binary inserted them, run the
9673    /// current `init_schema`, and require the result to be indistinguishable
9674    /// in shape from a fresh database, with `kind` back-filled correctly.
9675    async fn assert_upgrades_from(version: &str, fixture: &'static str) -> Result<()> {
9676        let pool = upgrade_test_pool().await?;
9677        sqlx::raw_sql(fixture).execute(&pool).await?;
9678
9679        let has_kind = |pool: SqlitePool| async move {
9680            Ok::<_, anyhow::Error>(
9681                sqlx::query_scalar::<_, i64>(
9682                    "SELECT count(*) FROM pragma_table_info('feeds') WHERE name = 'kind'",
9683                )
9684                .fetch_one(&pool)
9685                .await?
9686                    == 1,
9687            )
9688        };
9689        assert!(
9690            !has_kind(pool.clone()).await?,
9691            "pre-condition: a {version} feeds table has no kind column"
9692        );
9693
9694        // One row of each kind, inserted the way the old binary did: without `kind`.
9695        let publication = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
9696        for u in ["https://example.com/feed.xml", publication] {
9697            sqlx::query("INSERT INTO feeds (url) VALUES (?)")
9698                .bind(u)
9699                .execute(&pool)
9700                .await?;
9701        }
9702
9703        init_schema(&pool)
9704            .await
9705            .unwrap_or_else(|e| panic!("init_schema must upgrade a {version} database: {e:#}"));
9706
9707        let kinds: Vec<(String, String)> =
9708            sqlx::query_as("SELECT url, kind FROM feeds ORDER BY id")
9709                .fetch_all(&pool)
9710                .await?;
9711        assert_eq!(
9712            kinds,
9713            vec![
9714                (
9715                    "https://example.com/feed.xml".to_string(),
9716                    "rss".to_string()
9717                ),
9718                (publication.to_string(), "publication".to_string()),
9719            ],
9720            "{version}: existing rows are back-filled from their URL"
9721        );
9722        // What it indexes, not only its name: an `idx_feeds_kind` on the wrong
9723        // column passed a name check. (The shape comparison below also covers
9724        // this; this one names the bug that shipped.)
9725        let indexed: Vec<String> = sqlx::query_scalar(
9726            "SELECT name FROM pragma_index_info('idx_feeds_kind') ORDER BY seqno",
9727        )
9728        .fetch_all(&pool)
9729        .await?;
9730        assert_eq!(
9731            indexed,
9732            vec!["kind".to_string()],
9733            "{version}: idx_feeds_kind exists, on feeds(kind), after the column"
9734        );
9735
9736        // The general check: anything a fresh database has that the upgraded
9737        // one lacks, or the reverse, is a migration gap.
9738        let fresh = upgrade_test_pool().await?;
9739        init_schema(&fresh).await?;
9740        let (want, got) = (schema_shape(&fresh).await?, schema_shape(&pool).await?);
9741        assert!(
9742            want == got,
9743            "{version}: upgraded schema differs from a fresh one\n  missing: {:#?}\n  extra: {:#?}",
9744            want.difference(&got).collect::<Vec<_>>(),
9745            got.difference(&want).collect::<Vec<_>>(),
9746        );
9747
9748        // And a second boot over the upgraded database is a no-op, not an error.
9749        init_schema(&pool)
9750            .await
9751            .unwrap_or_else(|e| panic!("{version}: re-running init_schema failed: {e:#}"));
9752        Ok(())
9753    }
9754
9755    #[tokio::test]
9756    async fn a_v0_3_8_database_upgrades_to_the_current_schema() -> Result<()> {
9757        assert_upgrades_from(
9758            "v0.3.8",
9759            include_str!("../tests/fixtures/schema-v0.3.8.sql"),
9760        )
9761        .await
9762    }
9763
9764    #[tokio::test]
9765    async fn a_v0_2_0_database_upgrades_to_the_current_schema() -> Result<()> {
9766        assert_upgrades_from(
9767            "v0.2.0",
9768            include_str!("../tests/fixtures/schema-v0.2.0.sql"),
9769        )
9770        .await
9771    }
9772
9773    // ---- B2: a code minted FOR a specific DID is redeemable ONLY by that DID. --
9774
9775    #[tokio::test]
9776    async fn redeem_enforces_intended_did_binding() -> Result<()> {
9777        let pool = init_url("sqlite::memory:").await?;
9778        // Mint a claim FOR did:plc:A (the follower the bot posted the link to).
9779        let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:A").await?;
9780
9781        // A DIFFERENT DID (a throwaway that stole the public link) is refused as if
9782        // the code didn't exist — no seat granted, code still active.
9783        let stolen = redeem_code(&pool, &code, "did:plc:B", Some("thief.bsky"), 100).await?;
9784        assert_eq!(stolen, Err(RedeemError::NotFound));
9785        assert!(!has_beta_access(&pool, "did:plc:B").await?);
9786        assert_eq!(count_active_codes(&pool).await?, 1, "code must stay active");
9787
9788        // The INTENDED DID redeems successfully.
9789        let ok = redeem_code(&pool, &code, "did:plc:A", Some("alice.bsky"), 100).await?;
9790        assert_eq!(ok, Ok(()));
9791        assert!(has_beta_access(&pool, "did:plc:A").await?);
9792
9793        // A NULL-intended (admin/browser) code stays open to anyone (unchanged).
9794        let open = mint_code(&pool, "did:plc:admin", 3600).await?;
9795        let anyone = redeem_code(&pool, &open, "did:plc:C", None, 100).await?;
9796        assert_eq!(anyone, Ok(()));
9797        assert!(has_beta_access(&pool, "did:plc:C").await?);
9798        Ok(())
9799    }
9800
9801    // ---- S4: at most one ACTIVE code per intended DID; a concurrent second mint
9802    // hits the partial-unique index, and is_intended_active_conflict recognises it.
9803
9804    #[tokio::test]
9805    async fn intended_active_partial_unique_index_blocks_double_mint() -> Result<()> {
9806        let pool = init_url("sqlite::memory:").await?;
9807        // First mint for the DID succeeds.
9808        mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup").await?;
9809        // A SECOND active mint for the SAME DID violates the partial unique index.
9810        let err = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9811            .await
9812            .expect_err("second active mint for the same DID must fail the unique index");
9813        assert!(
9814            is_intended_active_conflict(&err),
9815            "the conflict must be recognised so the web layer can recover: {err:?}"
9816        );
9817        // Still exactly one active code for the DID.
9818        assert!(find_active_code_for_did(&pool, "did:plc:dup")
9819            .await?
9820            .is_some());
9821
9822        // Once the first code is redeemed (no longer active), a fresh mint for the
9823        // DID is allowed again (partial index only constrains active rows).
9824        let existing = find_active_code_for_did(&pool, "did:plc:dup")
9825            .await?
9826            .unwrap();
9827        redeem_code(&pool, &existing, "did:plc:dup", None, 100).await??;
9828        mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9829            .await
9830            .expect("a new mint is allowed after the prior one is redeemed");
9831
9832        // And the conflict helper does NOT fire on an unrelated error (a PRIMARY KEY
9833        // clash on `code`, i.e. a different constraint).
9834        sqlx::query(
9835            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9836             VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9837        )
9838        .execute(&pool)
9839        .await?;
9840        let pk_err = sqlx::query(
9841            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9842             VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9843        )
9844        .execute(&pool)
9845        .await
9846        .expect_err("duplicate PRIMARY KEY must error");
9847        let as_anyhow = anyhow::Error::new(pk_err);
9848        assert!(
9849            !is_intended_active_conflict(&as_anyhow),
9850            "a non-intended-index conflict must NOT be mistaken for the recover-able one"
9851        );
9852        Ok(())
9853    }
9854
9855    #[tokio::test]
9856    async fn purge_expires_orphaned_active_intended_code() -> Result<()> {
9857        // Cheap nit: purging a DID that is the TARGET of an active claim must both
9858        // NULL intended_did AND expire the (now orphaned) active code, so it stops
9859        // counting against the mint cap for its full TTL.
9860        let pool = init_url("sqlite::memory:").await?;
9861        let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:leaver").await?;
9862        assert_eq!(count_active_codes(&pool).await?, 1);
9863
9864        purge_did_data(&pool, "did:plc:leaver").await?;
9865
9866        // The code is no longer active (expired), so it no longer counts.
9867        assert_eq!(
9868            count_active_codes(&pool).await?,
9869            0,
9870            "orphaned code must be expired by purge, not left active"
9871        );
9872        // And intended_did was scrubbed.
9873        let intended: Option<String> =
9874            sqlx::query("SELECT intended_did FROM invite_codes WHERE code = ?1")
9875                .bind(&code)
9876                .fetch_one(&pool)
9877                .await?
9878                .get("intended_did");
9879        assert!(intended.is_none(), "intended_did must be NULLed");
9880        Ok(())
9881    }
9882
9883    /// A `(key, source)` observation upserts in place: two writes for the same
9884    /// relay leave ONE row, carrying the newer value.
9885    #[tokio::test]
9886    async fn network_stat_upserts_per_source() -> Result<()> {
9887        let pool = init_url("sqlite::memory:").await?;
9888        let mut stat = NetworkStat {
9889            key: ADOPTION_STAT_KEY.to_string(),
9890            source: "https://relay1.us-west.bsky.network".to_string(),
9891            value: 1,
9892            truncated: false,
9893            observed_at: "2026-08-12T00:00:00Z".to_string(),
9894        };
9895        record_network_stat(&pool, &stat).await?;
9896        stat.value = 4;
9897        stat.observed_at = "2026-08-13T00:00:00Z".to_string();
9898        record_network_stat(&pool, &stat).await?;
9899
9900        let rows: i64 = sqlx::query("SELECT COUNT(*) AS n FROM network_stat")
9901            .fetch_one(&pool)
9902            .await?
9903            .get("n");
9904        assert_eq!(rows, 1, "the same relay must update, not duplicate");
9905        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9906            .await?
9907            .expect("a stat");
9908        assert_eq!(latest.value, 4);
9909        assert_eq!(latest.observed_at, "2026-08-13T00:00:00Z");
9910        Ok(())
9911    }
9912
9913    /// **Regression (v0.2.9 review).** Once a slow walk can return a PARTIAL
9914    /// count, a plain upsert lets it overwrite a complete, larger one — moving
9915    /// the published "at least N" DOWN because a relay was slow, not because
9916    /// adoption fell. A truncated observation may only ever raise the floor.
9917    #[tokio::test]
9918    async fn a_truncated_observation_never_lowers_a_stored_count() -> Result<()> {
9919        let pool = init_url("sqlite::memory:").await?;
9920        let mut stat = NetworkStat {
9921            key: ADOPTION_STAT_KEY.to_string(),
9922            source: "https://relay1.us-west.bsky.network".to_string(),
9923            value: 2000,
9924            truncated: false,
9925            observed_at: "2026-08-13T00:00:00Z".to_string(),
9926        };
9927        record_network_stat(&pool, &stat).await?;
9928
9929        // A budget-truncated walk that only got one page in.
9930        stat.value = 500;
9931        stat.truncated = true;
9932        stat.observed_at = "2026-08-14T00:00:00Z".to_string();
9933        record_network_stat(&pool, &stat).await?;
9934
9935        let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9936            .await?
9937            .expect("a stat");
9938        assert_eq!(kept.value, 2000, "a partial walk must not lower the count");
9939        assert!(!kept.truncated, "and must not mark the kept row truncated");
9940        assert_eq!(kept.observed_at, "2026-08-13T00:00:00Z");
9941
9942        // A truncated observation that RAISES the floor is still accepted...
9943        stat.value = 3000;
9944        record_network_stat(&pool, &stat).await?;
9945        assert_eq!(
9946            latest_network_stat(&pool, ADOPTION_STAT_KEY)
9947                .await?
9948                .expect("a stat")
9949                .value,
9950            3000
9951        );
9952
9953        // ...and a COMPLETE observation wins even when it is smaller, because
9954        // repos genuinely can go away and a full walk is authoritative.
9955        stat.value = 42;
9956        stat.truncated = false;
9957        record_network_stat(&pool, &stat).await?;
9958        assert_eq!(
9959            latest_network_stat(&pool, ADOPTION_STAT_KEY)
9960                .await?
9961                .expect("a stat")
9962                .value,
9963            42,
9964            "a complete walk is authoritative even when it shrinks"
9965        );
9966
9967        // An EQUAL-valued truncated observation must not downgrade the row
9968        // either: it proves nothing the stored complete count did not already
9969        // prove, but flipping `truncated` would silently degrade /about from
9970        // "42" to "at least 42" with no change in actual adoption. The strict
9971        // `<` in the guard let exactly this through — the equal case is the one
9972        // the two assertions above cannot reach, because both move the value.
9973        stat.truncated = true;
9974        stat.observed_at = "2026-08-15T00:00:00Z".to_string();
9975        record_network_stat(&pool, &stat).await?;
9976        let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9977            .await?
9978            .expect("a stat");
9979        assert_eq!(kept.value, 42);
9980        assert!(
9981            !kept.truncated,
9982            "an equal truncated observation must not mark the kept row truncated"
9983        );
9984        assert_eq!(
9985            kept.observed_at, "2026-08-14T00:00:00Z",
9986            "the rejected observation must not have rewritten the row at all"
9987        );
9988        Ok(())
9989    }
9990
9991    /// Relays disagree by design (non-archival indexes); the max is surfaced.
9992    #[tokio::test]
9993    async fn latest_network_stat_picks_the_max_across_sources() -> Result<()> {
9994        let pool = init_url("sqlite::memory:").await?;
9995        for (source, value, truncated) in [
9996            ("https://relay1.us-west.bsky.network", 2i64, false),
9997            ("https://relay1.us-east.bsky.network", 40i64, true),
9998        ] {
9999            record_network_stat(
10000                &pool,
10001                &NetworkStat {
10002                    key: ADOPTION_STAT_KEY.to_string(),
10003                    source: source.to_string(),
10004                    value,
10005                    truncated,
10006                    observed_at: "2026-08-13T00:00:00Z".to_string(),
10007                },
10008            )
10009            .await?;
10010        }
10011        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
10012            .await?
10013            .expect("a stat");
10014        assert_eq!(latest.value, 40);
10015        assert_eq!(latest.source, "https://relay1.us-east.bsky.network");
10016        // `truncated` round-trips as a bool.
10017        assert!(latest.truncated);
10018        Ok(())
10019    }
10020
10021    #[tokio::test]
10022    async fn latest_network_stat_is_none_on_an_empty_table() -> Result<()> {
10023        let pool = init_url("sqlite::memory:").await?;
10024        assert!(latest_network_stat(&pool, ADOPTION_STAT_KEY)
10025            .await?
10026            .is_none());
10027        Ok(())
10028    }
10029
10030    /// **The lookup is keyed.** Every existing network-stat test writes only
10031    /// `ADOPTION_STAT_KEY`, so the `WHERE key = ?1` never discriminated; with
10032    /// it widened to `OR 1=1` the suite stayed green. The public `/stats`
10033    /// page asks for the adoption count, and unkeyed it would render the
10034    /// largest value of ANY stat as the network size.
10035    #[tokio::test]
10036    async fn latest_network_stat_ignores_other_keys() -> Result<()> {
10037        let pool = init_url("sqlite::memory:").await?;
10038        for (key, source, value) in [
10039            (ADOPTION_STAT_KEY, "https://relay1.example", 40),
10040            ("some.other.metric", "https://relay1.example", 9_999),
10041        ] {
10042            record_network_stat(
10043                &pool,
10044                &NetworkStat {
10045                    key: key.to_string(),
10046                    source: source.to_string(),
10047                    value,
10048                    truncated: false,
10049                    observed_at: now_rfc3339(),
10050                },
10051            )
10052            .await?;
10053        }
10054        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
10055            .await?
10056            .expect("the adoption stat was recorded");
10057        assert_eq!(
10058            latest.value, 40,
10059            "another key's value was returned as the adoption count"
10060        );
10061        Ok(())
10062    }
10063
10064    // ── poll health (the public stats page) ─────────────────────────────────
10065
10066    /// Seed a feed row **through the real writer**, so its `kind` is whatever
10067    /// production would store.
10068    ///
10069    /// This used to be a raw `INSERT`, which took the `kind` column's
10070    /// `DEFAULT 'rss'`. That is correct for an http(s) URL and silently wrong
10071    /// for an `at://` one — the helper claimed to seed a row the poller skips
10072    /// while seeding one it selects.
10073    async fn feed_polled(
10074        pool: &SqlitePool,
10075        url: &str,
10076        last_polled: Option<&str>,
10077        next_poll: Option<&str>,
10078    ) {
10079        upsert_feed(
10080            pool,
10081            &NewFeed {
10082                url: url.to_string(),
10083                last_polled: last_polled.map(str::to_string),
10084                next_poll: next_poll.map(str::to_string),
10085                ..Default::default()
10086            },
10087        )
10088        .await
10089        .unwrap();
10090    }
10091
10092    /// The numbers on the public page must describe the poller's actual state.
10093    #[tokio::test]
10094    async fn poll_health_counts_tracked_recent_and_overdue() -> anyhow::Result<()> {
10095        let pool = init_url("sqlite::memory:").await?;
10096        let now = "2026-01-01T12:00:00Z";
10097        let hour_ago = "2026-01-01T11:00:00Z";
10098
10099        // Polled 10 minutes ago, due in 50 minutes: healthy.
10100        feed_polled(
10101            &pool,
10102            "https://a.example/f",
10103            Some("2026-01-01T11:50:00Z"),
10104            Some("2026-01-01T12:50:00Z"),
10105        )
10106        .await;
10107        // Polled 3 hours ago and overdue: the backlog case.
10108        feed_polled(
10109            &pool,
10110            "https://b.example/f",
10111            Some("2026-01-01T09:00:00Z"),
10112            Some("2026-01-01T10:00:00Z"),
10113        )
10114        .await;
10115        // Never polled: counts as overdue (next_poll IS NULL), and must not
10116        // corrupt the "oldest poll" figure with a NULL.
10117        feed_polled(&pool, "https://c.example/f", None, None).await;
10118
10119        let h = poll_health(&pool, now, hour_ago).await?;
10120        assert_eq!(h.feeds_tracked, 3);
10121        assert_eq!(
10122            h.polled_last_hour, 1,
10123            "only the 11:50 poll is within the hour"
10124        );
10125        assert_eq!(h.overdue, 2, "the stale feed and the never-polled one");
10126        assert_eq!(
10127            h.last_poll_secs_ago,
10128            Some(600),
10129            "most recent poll was 10 minutes ago"
10130        );
10131        // **A never-polled feed IS the worst staleness.**
10132        //
10133        // This originally asserted `Some(10_800)` — the oldest FINITE age — and
10134        // in doing so pinned a defect: `MIN` skips NULLs, so the page reported
10135        // "3h ago" while a quarter of the feeds had never been fetched at all.
10136        // The figure read healthiest in the most degraded state, which is the
10137        // opposite of what a health page is for.
10138        assert_eq!(
10139            h.oldest_poll_secs_ago, None,
10140            "a never-polled feed must outrank any finite age"
10141        );
10142        assert_eq!(h.never_polled, 1);
10143
10144        // With every feed polled, the finite worst case is reported again.
10145        sqlx::query("UPDATE feeds SET last_polled = ?1 WHERE last_polled IS NULL")
10146            .bind("2026-01-01T09:00:00Z")
10147            .execute(&pool)
10148            .await?;
10149        let h = poll_health(&pool, now, hour_ago).await?;
10150        assert_eq!(h.never_polled, 0);
10151        assert_eq!(h.oldest_poll_secs_ago, Some(10_800));
10152        Ok(())
10153    }
10154
10155    /// **`/stats` measures the poller, so it counts only what the poller sees.**
10156    ///
10157    /// `due_feeds` skips `at://` rows; nothing ever advances their `next_poll`
10158    /// or sets `last_polled`. Counted, they read as overdue and never-polled
10159    /// forever, and force "oldest poll" to `never` — the same "unsupported
10160    /// shown as broken" the exclusion exists to end, moved to different rows on
10161    /// a public page. The same predicate decides both queries so they cannot
10162    /// drift.
10163    #[tokio::test]
10164    async fn poll_health_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
10165        let pool = init_url("sqlite::memory:").await?;
10166        let now = "2026-01-01T12:00:00Z";
10167        let hour_ago = "2026-01-01T11:00:00Z";
10168        feed_polled(
10169            &pool,
10170            "https://a.example/f",
10171            Some("2026-01-01T11:50:00Z"),
10172            Some("2026-01-01T12:50:00Z"),
10173        )
10174        .await;
10175        // Never polled, never due: the shape every at:// row has.
10176        feed_polled(
10177            &pool,
10178            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10179            None,
10180            None,
10181        )
10182        .await;
10183
10184        let h = poll_health(&pool, now, hour_ago).await?;
10185        assert_eq!(
10186            h.feeds_tracked, 1,
10187            "an unpollable row was counted as tracked"
10188        );
10189        assert_eq!(h.overdue, 0, "an unpollable row was counted as overdue");
10190        assert_eq!(
10191            h.never_polled, 0,
10192            "an unpollable row was counted as never polled"
10193        );
10194        assert_eq!(
10195            h.oldest_poll_secs_ago,
10196            Some(600),
10197            "an unpollable row forced the oldest poll to `never`"
10198        );
10199        assert_eq!(h.polled_last_hour, 1);
10200        Ok(())
10201    }
10202
10203    /// **The admin's failing-feeds list is the poller's too.** `failing_feeds`
10204    /// feeds `/admin/metrics`; it was not given the exclusion both `/stats`
10205    /// queries got. An `at://` row that carries errors — from a rollback to a
10206    /// build that polled them, say — would then sit at the top of the one page
10207    /// an operator uses to diagnose "unsupported shown as broken", with no
10208    /// poll ever coming to clear it and the one-shot migration already spent.
10209    #[tokio::test]
10210    async fn failing_feeds_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
10211        let pool = init_url("sqlite::memory:").await?;
10212        for url in [
10213            "https://broken.example/feed.xml",
10214            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10215        ] {
10216            upsert_feed(
10217                &pool,
10218                &NewFeed {
10219                    url: url.to_string(),
10220                    ..Default::default()
10221                },
10222            )
10223            .await?;
10224            bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
10225        }
10226        let failing = failing_feeds(&pool, 10).await?;
10227        let urls: Vec<&str> = failing.iter().map(|f| f.url.as_str()).collect();
10228        assert_eq!(
10229            urls,
10230            vec!["https://broken.example/feed.xml"],
10231            "an unpollable row was listed as a failing feed"
10232        );
10233        Ok(())
10234    }
10235
10236    /// **The clearing is idempotent by predicate, not by stamp.** It touches
10237    /// only rows that have never been polled successfully: `bump_feed_errors`
10238    /// never sets `last_polled`, both success paths do. So a row a wired
10239    /// reader has fetched once keeps its later failures across restarts, and
10240    /// a row that only ever failed under our own refusal is cleared at every
10241    /// boot — including after a rollback to a build that polled it. No
10242    /// version stamp, nothing for a test to rewind.
10243    #[tokio::test]
10244    async fn the_at_uri_error_clearing_spares_a_row_that_has_been_polled() -> anyhow::Result<()> {
10245        let pool = init_url("sqlite::memory:").await?;
10246        let polled = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/polled";
10247        let never = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/never";
10248        for url in [polled, never] {
10249            upsert_feed(
10250                &pool,
10251                &NewFeed {
10252                    url: url.to_string(),
10253                    ..Default::default()
10254                },
10255            )
10256            .await?;
10257            bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
10258        }
10259        // A wired reader fetched this one once, then it started failing.
10260        sqlx::query("UPDATE feeds SET last_polled = '2026-01-01T00:00:00Z' WHERE url = ?1")
10261            .bind(polled)
10262            .execute(&pool)
10263            .await?;
10264
10265        for boot in 1..=2 {
10266            apply_migrations(&pool).await?;
10267            let mut errors = std::collections::HashMap::new();
10268            for url in [polled, never] {
10269                let n: i64 =
10270                    sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
10271                        .bind(url)
10272                        .fetch_one(&pool)
10273                        .await?;
10274                errors.insert(url, n);
10275            }
10276            assert_eq!(
10277                errors[polled], 1,
10278                "boot {boot} wiped a polled row's failure"
10279            );
10280            assert_eq!(
10281                errors[never], 0,
10282                "boot {boot} left a never-polled row failing"
10283            );
10284        }
10285        Ok(())
10286    }
10287
10288    /// **The SQL kind list and the Rust one are the same list.** A literal in
10289    /// SQL and a slice in Rust is the drift the column exists to end; wiring
10290    /// the standard.site reader changes both, and this is what makes
10291    /// forgetting one a failure rather than a silently dormant feature.
10292    #[test]
10293    fn the_sql_kind_list_matches_the_rust_one() {
10294        let expected = crate::feed::FeedKind::POLLABLE
10295            .iter()
10296            .map(|k| format!("'{}'", k.as_str()))
10297            .collect::<Vec<_>>()
10298            .join(", ");
10299        assert_eq!(POLLABLE_KINDS_SQL, expected);
10300    }
10301
10302    /// **A feed's kind is recorded at insert, not re-derived from its URL.**
10303    ///
10304    /// "Can the poller fetch this?" was a substring predicate spliced into
10305    /// four statements, and a review found a fifth reader that had drifted
10306    /// from it. A column the writers set cannot drift: the Rust side decides
10307    /// once, SQL reads a value.
10308    #[tokio::test]
10309    async fn a_feed_row_records_its_kind_at_insert() -> anyhow::Result<()> {
10310        let pool = init_url("sqlite::memory:").await?;
10311        for (url, want) in [
10312            ("https://real.example/feed.xml", crate::feed::FeedKind::Rss),
10313            ("http://real.example/feed.xml", crate::feed::FeedKind::Rss),
10314            (
10315                "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
10316                crate::feed::FeedKind::Publication,
10317            ),
10318        ] {
10319            upsert_feed(
10320                &pool,
10321                &NewFeed {
10322                    url: url.to_string(),
10323                    ..Default::default()
10324                },
10325            )
10326            .await?;
10327            let got: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10328                .bind(url)
10329                .fetch_one(&pool)
10330                .await?;
10331            assert_eq!(got, want.as_str(), "wrong kind recorded for {url}");
10332        }
10333        Ok(())
10334    }
10335
10336    /// **A row written before the column existed is back-filled from its URL.**
10337    /// That back-fill is the LAST use of the string predicate; every reader
10338    /// keys on `kind` afterwards.
10339    #[tokio::test]
10340    async fn the_migration_backfills_kind_from_the_url() -> anyhow::Result<()> {
10341        let pool = init_url("sqlite::memory:").await?;
10342        // A table that predates the column, with both shapes in it.
10343        sqlx::query("DROP TABLE feeds").execute(&pool).await?;
10344        sqlx::query(
10345            "CREATE TABLE feeds (
10346                 id INTEGER PRIMARY KEY AUTOINCREMENT,
10347                 url TEXT NOT NULL UNIQUE,
10348                 title TEXT, site_url TEXT, etag TEXT, last_modified TEXT,
10349                 last_polled TEXT, next_poll TEXT,
10350                 consecutive_errors INTEGER NOT NULL DEFAULT 0,
10351                 last_error_kind TEXT, last_error TEXT
10352             )",
10353        )
10354        .execute(&pool)
10355        .await?;
10356        for url in [
10357            "https://real.example/feed.xml",
10358            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
10359            "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
10360        ] {
10361            sqlx::query("INSERT INTO feeds (url) VALUES (?1)")
10362                .bind(url)
10363                .execute(&pool)
10364                .await?;
10365        }
10366
10367        apply_migrations(&pool).await?;
10368
10369        let kinds: Vec<(String, String)> =
10370            sqlx::query_as("SELECT url, kind FROM feeds ORDER BY url")
10371                .fetch_all(&pool)
10372                .await?;
10373        let by_url: std::collections::HashMap<_, _> = kinds.into_iter().collect();
10374        assert_eq!(by_url["https://real.example/feed.xml"], "rss");
10375        assert_eq!(
10376            by_url["at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"],
10377            "publication"
10378        );
10379        // Recognised as an at-URI (not `rss`), like every other guard does —
10380        // and, since 0.4.0 polls publications, classed `unsupported`: storage
10381        // refuses this spelling (#183), so polling it would fail every tick.
10382        assert_eq!(
10383            by_url["At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac"],
10384            "unsupported",
10385            "the back-fill must recognise a non-canonical spelling, and not poll it"
10386        );
10387        Ok(())
10388    }
10389
10390    /// **`feeds.kind` is derived from the URL, so it has to be re-derivable.**
10391    ///
10392    /// The back-fill translated one direction only — a row the Rust side would
10393    /// call `rss` was never touched — which is correct for a one-time migration
10394    /// and wrong for a column that has to survive the rule changing. A kind that
10395    /// disagrees with its own URL is currently permanent: nothing re-reads it.
10396    #[tokio::test]
10397    async fn the_back_fill_corrects_a_kind_that_disagrees_with_the_url() -> anyhow::Result<()> {
10398        let pool = init_url("sqlite::memory:").await?;
10399        let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10400        for (url, wrong) in [
10401            ("https://real.example/feed.xml", "publication"),
10402            (at, "rss"),
10403        ] {
10404            sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, ?2)")
10405                .bind(url)
10406                .bind(wrong)
10407                .execute(&pool)
10408                .await?;
10409        }
10410
10411        apply_migrations(&pool).await?;
10412
10413        let by_url: std::collections::HashMap<String, String> =
10414            sqlx::query_as("SELECT url, kind FROM feeds")
10415                .fetch_all(&pool)
10416                .await?
10417                .into_iter()
10418                .collect();
10419        assert_eq!(
10420            by_url["https://real.example/feed.xml"], "rss",
10421            "an http feed marked as a publication stayed one, and nothing polls it"
10422        );
10423        assert_eq!(by_url[at], "publication", "the at:// direction regressed");
10424        Ok(())
10425    }
10426
10427    /// **Taking a row out of the poller orphans its poll state, so clear it.**
10428    ///
10429    /// `last_polled` is set here on purpose: the migration's other cleanup step
10430    /// only clears rows we never polled, so a row that HAS been polled proves
10431    /// this reset is the one doing the work. An error count left on a row the
10432    /// scheduler will never select again is hidden from `/stats`, which filters
10433    /// on kind — and if a later rule change readmits the row, it resumes at a
10434    /// backoff earned under a classification that no longer applies.
10435    #[tokio::test]
10436    async fn a_row_taken_out_of_the_poller_loses_the_poll_state_it_cannot_use() -> anyhow::Result<()>
10437    {
10438        let pool = init_url("sqlite::memory:").await?;
10439        let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab";
10440        sqlx::query(
10441            "INSERT INTO feeds (url, kind, consecutive_errors, last_error_kind, last_error, \
10442             next_poll, last_polled) \
10443             VALUES (?1, 'rss', 7, 'fetch', 'connection refused', ?2, ?3)",
10444        )
10445        .bind(at)
10446        .bind("2026-09-10T00:00:00Z")
10447        .bind("2026-09-01T00:00:00Z")
10448        .execute(&pool)
10449        .await?;
10450
10451        apply_migrations(&pool).await?;
10452
10453        let (kind, errors, error_kind, error, next_poll): (
10454            String,
10455            i64,
10456            Option<String>,
10457            Option<String>,
10458            Option<String>,
10459        ) = sqlx::query_as(
10460            "SELECT kind, consecutive_errors, last_error_kind, last_error, next_poll \
10461             FROM feeds WHERE url = ?1",
10462        )
10463        .bind(at)
10464        .fetch_one(&pool)
10465        .await?;
10466        assert_eq!(kind, "unsupported", "the row was not reclassified at all");
10467        assert_eq!(
10468            (errors, error_kind, error, next_poll),
10469            (0, None, None, None),
10470            "a row the scheduler will never select again kept its backoff and failure history"
10471        );
10472        Ok(())
10473    }
10474
10475    /// **A row we cannot read must not stop the process from starting.**
10476    ///
10477    /// This runs on the boot path. Refusing to start is a strictly worse
10478    /// outcome than declining to have an opinion about one row, and it is a
10479    /// failure mode the SQL predicate this replaced did not have: it evaluated
10480    /// a non-text `url` happily and returned false.
10481    #[tokio::test]
10482    async fn an_unreadable_feeds_row_does_not_stop_the_boot() -> anyhow::Result<()> {
10483        let pool = init_url("sqlite::memory:").await?;
10484        sqlx::query("INSERT INTO feeds (url, kind) VALUES (X'ff41', 'rss')")
10485            .execute(&pool)
10486            .await?;
10487        sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, 'publication')")
10488            .bind("https://real.example/feed.xml")
10489            .execute(&pool)
10490            .await?;
10491
10492        apply_migrations(&pool).await?;
10493
10494        let corrected: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10495            .bind("https://real.example/feed.xml")
10496            .fetch_one(&pool)
10497            .await?;
10498        assert_eq!(
10499            corrected, "rss",
10500            "one unreadable row aborted the pass before the readable ones were corrected"
10501        );
10502        let untouched: String =
10503            sqlx::query_scalar("SELECT kind FROM feeds WHERE typeof(url) = 'blob'")
10504                .fetch_one(&pool)
10505                .await?;
10506        assert_eq!(
10507            untouched, "rss",
10508            "a row we declined to classify was classified anyway"
10509        );
10510        Ok(())
10511    }
10512
10513    /// Re-subscribing must re-derive the kind, not preserve whatever is there.
10514    ///
10515    /// `upsert_feed` binds `FeedKind::of` on the way in, but its conflict clause
10516    /// never carried `kind`, so the value a row was first written with is the
10517    /// value it keeps. Harmless while the rule is fixed; the rule is about to
10518    /// change.
10519    #[tokio::test]
10520    async fn a_re_upsert_re_derives_the_kind() -> anyhow::Result<()> {
10521        let pool = init_url("sqlite::memory:").await?;
10522        let url = "https://real.example/feed.xml";
10523        let feed = NewFeed {
10524            url: url.to_string(),
10525            ..Default::default()
10526        };
10527        upsert_feed(&pool, &feed).await?;
10528        sqlx::query("UPDATE feeds SET kind = 'publication' WHERE url = ?1")
10529            .bind(url)
10530            .execute(&pool)
10531            .await?;
10532
10533        upsert_feed(&pool, &feed).await?;
10534
10535        let kind: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10536            .bind(url)
10537            .fetch_one(&pool)
10538            .await?;
10539        assert_eq!(
10540            kind, "rss",
10541            "a second subscription to the same URL kept the stale classification"
10542        );
10543        Ok(())
10544    }
10545
10546    /// **The readers key on `kind`, not on the URL.** A row whose kind says
10547    /// publication is unpollable even if its URL looks ordinary — which is
10548    /// what makes the column, rather than the string, the source of truth.
10549    #[tokio::test]
10550    async fn the_poller_and_the_pages_key_on_kind() -> anyhow::Result<()> {
10551        let pool = init_url("sqlite::memory:").await?;
10552        upsert_feed(
10553            &pool,
10554            &NewFeed {
10555                url: "https://looks-ordinary.example/feed.xml".to_string(),
10556                ..Default::default()
10557            },
10558        )
10559        .await?;
10560        // Force the kind independently of the URL: only the column should matter.
10561        // `unsupported` because it is the kind no poller reads (publications
10562        // are pollable since 0.4.0).
10563        sqlx::query("UPDATE feeds SET kind = 'unsupported' WHERE url LIKE 'https://looks%'")
10564            .execute(&pool)
10565            .await?;
10566
10567        let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10568        assert!(due.is_empty(), "due_feeds read the URL, not the kind");
10569        assert_eq!(
10570            unpollable_feeds(&pool).await?,
10571            1,
10572            "unpollable_feeds read the URL"
10573        );
10574
10575        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10576        assert_eq!(h.feeds_tracked, 0, "poll_health read the URL, not the kind");
10577        Ok(())
10578    }
10579
10580    /// **SQL and Rust agree on what an at-URI is — case-insensitively.**
10581    ///
10582    /// This test used to pin the opposite, and pinned a bug. It asserted that a
10583    /// mixed-case `At://` row IS handed to the poller, reasoning that the Rust
10584    /// guards use a case-sensitive `strip_prefix` so "every other check treats
10585    /// it as a plain URL". They do not: URL schemes are case-insensitive, so
10586    /// `Url::parse` folds `At://` to scheme `at`, which `net::check_scheme`
10587    /// refuses — and the DID form does not parse at all. Such a row can only
10588    /// fail, every tick, forever, and be published in the `fetch` bucket as an
10589    /// unreachable publisher. That is the exact conflation the exclusion exists
10590    /// to end.
10591    ///
10592    /// Recognition is case-insensitive on both sides now. Storing one is still
10593    /// refused: `feeds.url` is UNIQUE, so two spellings of one publication are
10594    /// two rows — the same rule the canonical-handle check applies.
10595    #[tokio::test]
10596    async fn a_mixed_case_at_uri_is_unpollable_on_both_sides() -> anyhow::Result<()> {
10597        let pool = init_url("sqlite::memory:").await?;
10598        let odd = "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10599        assert!(
10600            !crate::feed::is_storable_feed_url(odd, true),
10601            "a non-canonical spelling must not be storable"
10602        );
10603        upsert_feed(
10604            &pool,
10605            &NewFeed {
10606                url: odd.to_string(),
10607                ..Default::default()
10608            },
10609        )
10610        .await?;
10611        let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10612        assert!(
10613            due.is_empty(),
10614            "a row nothing can fetch was handed to the poller: {:?}",
10615            due.iter().map(|f| &f.url).collect::<Vec<_>>()
10616        );
10617
10618        // And the boot-time clearing reaches it, so a legacy row that already
10619        // accrued errors stops counting as a broken publisher.
10620        bump_feed_errors(&pool, odd, crate::feed::FailureKind::Fetch, "refused").await?;
10621        apply_migrations(&pool).await?;
10622        let n: i64 = sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
10623            .bind(odd)
10624            .fetch_one(&pool)
10625            .await?;
10626        assert_eq!(n, 0, "the clearing skipped a mixed-case at-URI row");
10627        Ok(())
10628    }
10629
10630    /// **The global feeds ceiling counts every row, including unpollable ones
10631    /// — deliberately, and visibly.**
10632    ///
10633    /// `count_feeds` is a fifth reader of "is this an at-URI" that does NOT use
10634    /// the unpollable kinds, and that is the right call: the ceiling bounds
10635    /// STORAGE on a small box, and an unpollable row occupies a row. What was
10636    /// wrong is that the capacity it consumed appeared on no surface — `/stats`
10637    /// measures the poller and excludes them, so an operator could be at the
10638    /// cap while every page said otherwise. `unpollable_feeds` is what
10639    /// `/admin/metrics` renders to close that gap.
10640    #[tokio::test]
10641    async fn the_ceiling_counts_unpollable_rows_and_they_are_countable() -> anyhow::Result<()> {
10642        let pool = init_url("sqlite::memory:").await?;
10643        for url in [
10644            "https://real.example/feed.xml",
10645            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10646            "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
10647        ] {
10648            upsert_feed(
10649                &pool,
10650                &NewFeed {
10651                    url: url.to_string(),
10652                    ..Default::default()
10653                },
10654            )
10655            .await?;
10656        }
10657        assert_eq!(
10658            count_feeds(&pool).await?,
10659            3,
10660            "the ceiling must bound storage, so every row counts"
10661        );
10662        assert_eq!(
10663            unpollable_feeds(&pool).await?,
10664            2,
10665            "both at-URI spellings are unpollable and must be countable"
10666        );
10667        Ok(())
10668    }
10669
10670    /// A fresh instance has no polls yet. The page must say so rather than
10671    /// rendering a zero that reads as "polled just now".
10672    #[tokio::test]
10673    async fn poll_health_on_an_empty_instance_reports_no_polls() -> anyhow::Result<()> {
10674        let pool = init_url("sqlite::memory:").await?;
10675        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10676        assert_eq!(h.feeds_tracked, 0);
10677        assert_eq!(h.last_poll_secs_ago, None);
10678        assert_eq!(h.oldest_poll_secs_ago, None);
10679        Ok(())
10680    }
10681
10682    /// A poll timestamped in the future — clock skew, or a restored backup —
10683    /// reads as "just now", never as a negative age.
10684    #[tokio::test]
10685    async fn a_future_poll_timestamp_does_not_go_negative() -> anyhow::Result<()> {
10686        let pool = init_url("sqlite::memory:").await?;
10687        feed_polled(
10688            &pool,
10689            "https://a.example/f",
10690            Some("2026-01-01T13:00:00Z"),
10691            None,
10692        )
10693        .await;
10694        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10695        assert_eq!(h.last_poll_secs_ago, Some(0));
10696        Ok(())
10697    }
10698
10699    // ── retention is a CACHE policy, not a data-retention policy ────────────
10700
10701    async fn aged_entry(pool: &SqlitePool, url: &str, days_old: i64) -> i64 {
10702        let when = (chrono::Utc::now() - chrono::Duration::days(days_old))
10703            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
10704        sqlx::query("INSERT INTO feeds (url) VALUES (?1) ON CONFLICT(url) DO NOTHING")
10705            .bind("https://f.example/feed")
10706            .execute(pool)
10707            .await
10708            .unwrap();
10709        let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds WHERE url = ?1")
10710            .bind("https://f.example/feed")
10711            .fetch_one(pool)
10712            .await
10713            .unwrap();
10714        sqlx::query("INSERT INTO entries (feed_id, guid, url, title, published, fetched_at) VALUES (?1,?2,?3,'t',?4,?4)")
10715            .bind(feed_id).bind(url).bind(url).bind(&when)
10716            .execute(pool).await.unwrap();
10717        sqlx::query_scalar("SELECT id FROM entries WHERE guid = ?1")
10718            .bind(url)
10719            .fetch_one(pool)
10720            .await
10721            .unwrap()
10722    }
10723
10724    async fn mark(pool: &SqlitePool, entry_id: i64, read: i64, starred: i64) {
10725        sqlx::query("INSERT INTO entry_state (did, entry_id, read, starred, updated_at) VALUES ('did:plc:x',?1,?2,?3,'2026-01-01T00:00:00Z')")
10726            .bind(entry_id).bind(read).bind(starred)
10727            .execute(pool).await.unwrap();
10728    }
10729
10730    /// **A STARRED article is never evicted, however old.**
10731    ///
10732    /// The starred view joins `entries`, and `entry_state` cascades on delete,
10733    /// so pruning a starred entry removed it from the starred list entirely —
10734    /// and the content is not recoverable, because a feed serves only its last
10735    /// few dozen items. The PDS keeps the saved RECORD; it has never held the
10736    /// article.
10737    #[tokio::test]
10738    async fn retention_keeps_starred_and_unread_entries() -> anyhow::Result<()> {
10739        let pool = init_url("sqlite::memory:").await?;
10740        let old_read = aged_entry(&pool, "old-read", 30).await;
10741        let old_starred = aged_entry(&pool, "old-starred", 30).await;
10742        let old_unread = aged_entry(&pool, "old-unread", 30).await;
10743        let recent_read = aged_entry(&pool, "recent-read", 1).await;
10744        mark(&pool, old_read, 1, 0).await;
10745        mark(&pool, old_starred, 1, 1).await; // read AND starred
10746        mark(&pool, old_unread, 0, 0).await;
10747        mark(&pool, recent_read, 1, 0).await;
10748
10749        let deleted = prune_old_entries(&pool, 14, 3650, 0).await?;
10750        assert_eq!(deleted, 1, "only the old, read, unstarred entry should go");
10751
10752        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10753            .fetch_all(&pool)
10754            .await?;
10755        assert_eq!(left, vec!["old-starred", "old-unread", "recent-read"]);
10756        Ok(())
10757    }
10758
10759    /// An entry nobody has interacted with at all — no `entry_state` row — is
10760    /// still evicted once it ages out. Otherwise the cache never shrinks, since
10761    /// most entries are never opened.
10762    #[tokio::test]
10763    async fn retention_evicts_entries_with_no_reader_state() -> anyhow::Result<()> {
10764        let pool = init_url("sqlite::memory:").await?;
10765        aged_entry(&pool, "untouched-old", 30).await;
10766        aged_entry(&pool, "untouched-new", 1).await;
10767        assert_eq!(prune_old_entries(&pool, 14, 3650, 0).await?, 1);
10768        Ok(())
10769    }
10770
10771    /// **A recently-polled feed is NOT made due again.**
10772    ///
10773    /// `due_feeds` treats NULL as due immediately, so an unbounded nudge from a
10774    /// page handler turned every reload of the starred view into another poll of
10775    /// those feeds — outbound amplification against third-party origins, and one
10776    /// reader monopolising a poll budget that is shared and already the binding
10777    /// constraint on user count.
10778    #[tokio::test]
10779    async fn a_recently_polled_feed_is_not_nudged_again() -> anyhow::Result<()> {
10780        let pool = init_url("sqlite::memory:").await?;
10781        let recent = "2026-01-01T11:59:00Z";
10782        let stale_before = "2026-01-01T11:00:00Z"; // one hour before "now"
10783
10784        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10785            .bind("https://fresh.example/f")
10786            .bind(recent)
10787            .bind("2026-01-01T12:59:00Z")
10788            .execute(&pool)
10789            .await?;
10790        // Polled long ago: this one SHOULD be nudged.
10791        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10792            .bind("https://stale.example/f")
10793            .bind("2026-01-01T06:00:00Z")
10794            .bind("2026-01-01T07:00:00Z")
10795            .execute(&pool)
10796            .await?;
10797
10798        mark_feed_due(&pool, "https://fresh.example/f", stale_before).await?;
10799        mark_feed_due(&pool, "https://stale.example/f", stale_before).await?;
10800
10801        let fresh: Option<String> =
10802            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://fresh.example/f'")
10803                .fetch_one(&pool)
10804                .await?;
10805        let stale: Option<String> =
10806            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://stale.example/f'")
10807                .fetch_one(&pool)
10808                .await?;
10809
10810        assert!(
10811            fresh.is_some(),
10812            "a feed polled a minute ago was made due again — a reload loop is an \
10813             amplification vector"
10814        );
10815        assert!(stale.is_none(), "a long-unpolled feed should be nudged");
10816        Ok(())
10817    }
10818
10819    /// A feed that has never been polled is always nudgeable — there is no
10820    /// recent fetch to argue it would be wasted.
10821    #[tokio::test]
10822    async fn a_never_polled_feed_is_nudged() -> anyhow::Result<()> {
10823        let pool = init_url("sqlite::memory:").await?;
10824        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, NULL, ?2)")
10825            .bind("https://new.example/f")
10826            .bind("2026-01-01T12:59:00Z")
10827            .execute(&pool)
10828            .await?;
10829        mark_feed_due(&pool, "https://new.example/f", "2026-01-01T11:00:00Z").await?;
10830        let next: Option<String> =
10831            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://new.example/f'")
10832                .fetch_one(&pool)
10833                .await?;
10834        assert!(next.is_none());
10835        Ok(())
10836    }
10837
10838    /// **The hard ceiling is the bound that sparing would otherwise remove.**
10839    ///
10840    /// "Mark unread" is a one-click control and `entries` is shared across every
10841    /// reader, so an unbounded `read = 0` exception lets one person pin rows
10842    /// permanently — and since the poller stops entirely above
10843    /// `db_size_watermark_bytes` with this DELETE as its only release valve,
10844    /// those pins could stop polling for everyone.
10845    #[tokio::test]
10846    async fn the_hard_ceiling_evicts_even_starred_and_unread() -> anyhow::Result<()> {
10847        let pool = init_url("sqlite::memory:").await?;
10848        let ancient_starred = aged_entry(&pool, "ancient-starred", 400).await;
10849        let ancient_unread = aged_entry(&pool, "ancient-unread", 400).await;
10850        let recent_starred = aged_entry(&pool, "recent-starred", 30).await;
10851        mark(&pool, ancient_starred, 1, 1).await;
10852        mark(&pool, ancient_unread, 0, 0).await;
10853        mark(&pool, recent_starred, 1, 1).await;
10854
10855        // 14-day soft window, 180-day hard ceiling.
10856        prune_old_entries(&pool, 14, 180, 0).await?;
10857
10858        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10859            .fetch_all(&pool)
10860            .await?;
10861        assert_eq!(
10862            left,
10863            vec!["recent-starred"],
10864            "past the ceiling nothing is pinned — otherwise one reader can stall the poller \
10865             for every reader"
10866        );
10867        Ok(())
10868    }
10869
10870    /// The per-feed trim spares starred entries too. It was fixed in the
10871    /// retention sweep and NOT here, which left the documented guarantee false —
10872    /// and this path runs on every poll of every feed rather than daily.
10873    #[tokio::test]
10874    async fn the_per_feed_trim_spares_starred_entries() -> anyhow::Result<()> {
10875        let pool = init_url("sqlite::memory:").await?;
10876        let old_starred = aged_entry(&pool, "old-starred", 5).await;
10877        mark(&pool, old_starred, 1, 1).await;
10878        for i in 0..5 {
10879            aged_entry(&pool, &format!("filler-{i}"), 1).await;
10880        }
10881        let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds LIMIT 1")
10882            .fetch_one(&pool)
10883            .await?;
10884
10885        // Trim hard enough that the older starred entry would be cut. The trim
10886        // runs inside `insert_entries`, so drive it the way production does.
10887        insert_entries(&pool, feed_id, &[], 2).await?;
10888
10889        let left: Vec<String> =
10890            sqlx::query_scalar("SELECT guid FROM entries WHERE guid = 'old-starred'")
10891                .fetch_all(&pool)
10892                .await?;
10893        assert_eq!(
10894            left,
10895            vec!["old-starred"],
10896            "the per-feed trim evicted a starred entry"
10897        );
10898        Ok(())
10899    }
10900
10901    /// **When more entries are starred than the cap, the NEWEST starred ones
10902    /// are spared.** The sparing subquery orders by date and takes `cap`; the
10903    /// existing tests seed one starred row (fewer than the cap, so the order
10904    /// never chooses) or assert only a count. With `DESC` flipped to `ASC` the
10905    /// suite stayed green — and in production the trim would spare the OLDEST
10906    /// starred articles and evict the newest, on every poll of every feed.
10907    #[tokio::test]
10908    async fn the_trim_spares_the_newest_starred_entries_when_over_cap() -> anyhow::Result<()> {
10909        let pool = init_url("sqlite::memory:").await?;
10910        // Five starred entries, one per day, cap of two: only the two newest
10911        // may survive.
10912        let mut ids = Vec::new();
10913        for days_old in 1..=5 {
10914            let id = aged_entry(&pool, &format!("starred-{days_old}"), days_old).await;
10915            mark(&pool, id, 1, 1).await;
10916            ids.push((days_old, id));
10917        }
10918        let feed_id: i64 = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
10919            .bind(ids[0].1)
10920            .fetch_one(&pool)
10921            .await?;
10922        insert_entries(&pool, feed_id, &[], 2).await?;
10923
10924        let mut survivors: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
10925            .fetch_all(&pool)
10926            .await?;
10927        survivors.sort();
10928        assert_eq!(
10929            survivors,
10930            vec!["starred-1".to_string(), "starred-2".to_string()],
10931            "the trim spared the wrong starred entries"
10932        );
10933        Ok(())
10934    }
10935}