Skip to main content

feather_reader/
store.rs

1//! SQLite persistence layer (via `sqlx`, runtime queries).
2//!
3//! FeatherReader keeps the source of truth for *what a user follows* and *their
4//! read-position* in the user's own atproto PDS (as `community.lexicon.rss.*`
5//! records). This module is the **local per-DID cache + debounce
6//! buffer**: a single SQLite file that holds
7//!
8//! * `feeds` + `entries` — a shared cache of feed metadata and articles, keyed by
9//!   feed URL / feed-native GUID and **shared across every DID** that follows the
10//!   same feed (many users on one instance don't multiply fetch load), and
11//! * `entry_state` + `read_cursor` — per-DID read/star state and the per-feed
12//!   read cursor that the (v1.1) batched flusher syncs up to the PDS.
13//!
14//! All queries here are **runtime** queries (`sqlx::query` / `sqlx::query_as`),
15//! not the compile-time `query!` macros — so the crate builds with no
16//! `DATABASE_URL` and no offline metadata. Schema creation is idempotent
17//! (`CREATE TABLE IF NOT EXISTS`) and runs inside [`init`].
18//!
19//! Errors propagate as [`anyhow::Result`]; nothing in the non-test paths panics.
20
21use anyhow::{Context, Result};
22use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
23use sqlx::{ConnectOptions, FromRow, Row};
24use std::str::FromStr;
25
26use crate::config::Config;
27
28/// Typed failure modes for [`redeem_code`]. Distinct variants so the web layer
29/// can map each to the right user-facing message / HTTP status without string
30/// matching. Everything else (a real SQLite error) still propagates as
31/// [`anyhow::Error`] out of the `Result`.
32#[derive(Debug, thiserror::Error, PartialEq, Eq)]
33pub enum RedeemError {
34    /// No invite code with that value exists.
35    #[error("invite code not found")]
36    NotFound,
37    /// The code exists but is past its `expires_at` (or already flipped to
38    /// `expired`).
39    #[error("invite code expired")]
40    Expired,
41    /// The code has already been redeemed (or is otherwise not `active`).
42    #[error("invite code already redeemed")]
43    AlreadyRedeemed,
44    /// The closed-beta seat cap ([`Config`]'s `FEATHERREADER_BETA_CAP`) is full.
45    #[error("beta is at capacity")]
46    CapacityFull,
47}
48
49/// The SQLite connection pool type the rest of the crate refers to as
50/// [`Pool`]. A thin alias over `SqlitePool` so [`crate::AppState`] and the web
51/// layer name one stable type; if the backend ever changes, this is the single
52/// place to swap it.
53pub type Pool = SqlitePool;
54
55/// A cached syndication feed, shared across all DIDs that subscribe to its URL.
56///
57/// This mirrors the PDS-side `community.lexicon.rss.subscription.url`; the row is
58/// created/updated by the poller, never owned by a single user.
59#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
60pub struct Feed {
61    pub id: i64,
62    pub url: String,
63    pub title: Option<String>,
64    pub site_url: Option<String>,
65    /// HTTP `ETag` from the last successful fetch, for conditional GET.
66    pub etag: Option<String>,
67    /// HTTP `Last-Modified` from the last successful fetch, for conditional GET.
68    pub last_modified: Option<String>,
69    /// When we last polled this feed (RFC3339), or `None` if never.
70    pub last_polled: Option<String>,
71    /// When this feed is next due to be polled (RFC3339), or `None`.
72    pub next_poll: Option<String>,
73    /// Count of consecutive poll FAILURES since the last success/304. Drives the
74    /// exponential poll backoff (reset to 0 on any success or 304).
75    #[sqlx(default)]
76    pub consecutive_errors: i64,
77}
78
79/// A cached article/item belonging to a [`Feed`]. Shared cache (not per-DID).
80#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
81pub struct Entry {
82    pub id: i64,
83    pub feed_id: i64,
84    /// Feed-native GUID/id, unique within a feed (used for dedup on re-fetch).
85    pub guid: String,
86    pub url: Option<String>,
87    pub title: Option<String>,
88    pub author: Option<String>,
89    /// Publication time as reported by the feed (RFC3339), or `None`.
90    pub published: Option<String>,
91    /// Article body HTML, **already sanitized** (ammonia) before it reaches here.
92    pub content_html: Option<String>,
93    /// When FeatherReader first fetched/stored this entry (RFC3339).
94    pub fetched_at: String,
95}
96
97/// One row of a LIST view — deliberately **without** `content_html`.
98///
99/// The list queries used to be `SELECT e.*` into [`Entry`], which carries the
100/// sanitized article body. The body is essentially the whole of a cached entry
101/// (measured: 11.9 KB/entry), and no list surface has ever rendered it — the
102/// reader's `EntryRow` reads id, title, feed title, date, read, starred and
103/// link, and nothing else. So every article on every page load was read off
104/// disk, allocated, and dropped unexamined. On a 512 MB box with 250 concurrent
105/// requests permitted, one reader with a large backlog could ask for hundreds of
106/// megabytes in a single handler, and the resulting OOM/restart looked like a
107/// healthy machine that simply fell over.
108///
109/// `read` / `starred` come from the same `LEFT JOIN` that filters the view, so a
110/// caller does not have to fetch the whole unread or starred set a second time
111/// just to decorate the rows it is showing.
112///
113/// [`Entry`] is still the right type for the single-entry reader, which is the
114/// one surface that genuinely needs the body.
115#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
116pub struct EntryListRow {
117    pub id: i64,
118    pub feed_id: i64,
119    /// Feed-native GUID — used to match a cached entry against a PDS saved record.
120    pub guid: String,
121    pub url: Option<String>,
122    pub title: Option<String>,
123    pub published: Option<String>,
124    /// This DID's read bit. `false` when there is no `entry_state` row at all.
125    pub read: bool,
126    /// This DID's star bit. `false` when there is no `entry_state` row at all.
127    pub starred: bool,
128}
129
130/// Which list [`list_entries`] (and its siblings) is producing.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum ListView {
133    /// No `entry_state` row for this DID, or one with `read = 0`.
134    Unread,
135    /// An `entry_state` row with `starred = 1`.
136    Starred,
137    /// Every subscribed entry, read or not.
138    All,
139}
140
141impl ListView {
142    /// The `WHERE` fragment that selects this view, given `s` as the per-DID
143    /// `entry_state` LEFT JOIN alias.
144    fn predicate(self) -> &'static str {
145        match self {
146            // An entry with no state row is unread — hence LEFT JOIN + COALESCE
147            // rather than a join that would drop never-touched entries.
148            ListView::Unread => "COALESCE(s.read, 0) = 0",
149            ListView::Starred => "COALESCE(s.starred, 0) = 1",
150            ListView::All => "1 = 1",
151        }
152    }
153}
154
155/// Per-`(did, entry)` read/star state — the fast in-session working copy that the
156/// batched flusher later syncs to the PDS as a per-feed read cursor.
157#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
158pub struct EntryState {
159    pub did: String,
160    pub entry_id: i64,
161    pub read: bool,
162    pub starred: bool,
163    pub updated_at: String,
164}
165
166/// Per-`(did, feed_url)` read cursor — the local mirror of the PDS
167/// `community.lexicon.rss.readState` record plus flush bookkeeping.
168///
169/// `read_ids` / `unread_ids` are stored as JSON arrays of entry ids (the two
170/// bounded exception sets around the `read_through` high-water-mark); `dirty`
171/// marks that local `entry_state` has changed since the last PDS flush.
172#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
173pub struct ReadCursor {
174    pub did: String,
175    pub feed_url: String,
176    /// High-water-mark (RFC3339): every entry seen/published `<=` this is read.
177    pub read_through: Option<String>,
178    /// JSON array of entry ids newer than `read_through` that are also read.
179    pub read_ids: String,
180    /// JSON array of entry ids older than `read_through` explicitly kept unread.
181    pub unread_ids: String,
182    /// Set when `entry_state` changed since the last flush (debounce trigger).
183    pub dirty: bool,
184    /// Whether this cursor's `readState` record has been CREATED in the PDS yet.
185    /// The first flush of a feed must emit an `applyWrites#create` (an `#update`
186    /// errors on a record that does not pre-exist, and applyWrites is atomic
187    /// per-repo, so one not-yet-created cursor would drop the whole DID batch).
188    /// Flipped to `true` on the flush that creates it.
189    #[sqlx(default)]
190    pub pds_created: bool,
191    pub updated_at: String,
192}
193
194/// The `network_stat` key the relay adoption probe writes under.
195///
196/// Lives here, beside [`NetworkStat`], because **both** the writer (the
197/// scheduler's probe, compiled into the binary) and the reader (`web::about`,
198/// compiled into the library) name it — a literal in either place would be two
199/// strings free to drift apart.
200pub const ADOPTION_STAT_KEY: &str = "adoption.subscription";
201
202/// One relay's observation of how many repos hold a collection
203/// (`design/NETWORK-SPEC.md` §4.3). A projection: droppable, rebuildable from
204/// the network, and never read by anything on the reading path.
205#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
206pub struct NetworkStat {
207    /// The metric key, e.g. [`ADOPTION_STAT_KEY`].
208    pub key: String,
209    /// The relay base URL the number came from.
210    pub source: String,
211    /// The observed count.
212    pub value: i64,
213    /// Set when the probe hit its page cap: the value is a floor, not a count.
214    pub truncated: bool,
215    /// When the observation was taken (RFC3339, UTC).
216    pub observed_at: String,
217}
218
219/// New-feed payload for [`upsert_feed`] (id is assigned by SQLite).
220#[derive(Debug, Clone, Default)]
221pub struct NewFeed {
222    pub url: String,
223    pub title: Option<String>,
224    pub site_url: Option<String>,
225    pub etag: Option<String>,
226    pub last_modified: Option<String>,
227    pub last_polled: Option<String>,
228    pub next_poll: Option<String>,
229}
230
231/// New-entry payload for [`insert_entries`] (id is assigned by SQLite,
232/// `fetched_at` defaults to "now" when not supplied).
233#[derive(Debug, Clone, Default)]
234pub struct NewEntry {
235    pub guid: String,
236    pub url: Option<String>,
237    pub title: Option<String>,
238    pub author: Option<String>,
239    pub published: Option<String>,
240    /// Already-sanitized HTML.
241    pub content_html: Option<String>,
242    /// Optional explicit fetch time (RFC3339); defaults to now if `None`.
243    pub fetched_at: Option<String>,
244}
245
246/// The SQLite schema. Idempotent — safe to run on every startup.
247///
248/// `feeds`/`entries` are the shared cache; `entry_state`/`read_cursor` are
249/// per-DID. Indices cover the scheduler's due-feed query, the read/unread list
250/// query, and the flusher's dirty-cursor scan.
251const SCHEMA: &str = r#"
252PRAGMA foreign_keys = ON;
253
254CREATE TABLE IF NOT EXISTS feeds (
255    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
256    url                TEXT NOT NULL UNIQUE,
257    title              TEXT,
258    site_url           TEXT,
259    etag               TEXT,
260    last_modified      TEXT,
261    last_polled        TEXT,
262    next_poll          TEXT,
263    consecutive_errors INTEGER NOT NULL DEFAULT 0,
264    last_error_kind    TEXT,
265    last_error         TEXT,
266    -- What the poller does with this row; see `feed::FeedKind`. Written by the
267    -- Rust side at insert so SQL never re-derives it from the URL.
268    kind               TEXT NOT NULL DEFAULT 'rss'
269);
270CREATE INDEX IF NOT EXISTS idx_feeds_next_poll ON feeds (next_poll);
271-- NOTE: `idx_feeds_kind` is created in `apply_migrations`, AFTER `kind` is
272-- ensured, for the same reason as the `intended_did` indexes below. 0.3.9 put
273-- it here and crash-looped production on its first boot: on an existing volume
274-- the CREATE TABLE above is a no-op, so the column does not exist yet.
275
276CREATE TABLE IF NOT EXISTS entries (
277    id           INTEGER PRIMARY KEY AUTOINCREMENT,
278    feed_id      INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
279    guid         TEXT NOT NULL,
280    url          TEXT,
281    title        TEXT,
282    author       TEXT,
283    published    TEXT,
284    content_html TEXT,
285    fetched_at   TEXT NOT NULL,
286    UNIQUE (feed_id, guid)
287);
288CREATE INDEX IF NOT EXISTS idx_entries_feed_published ON entries (feed_id, published);
289
290CREATE TABLE IF NOT EXISTS entry_state (
291    did        TEXT NOT NULL,
292    entry_id   INTEGER NOT NULL REFERENCES entries (id) ON DELETE CASCADE,
293    read       INTEGER NOT NULL DEFAULT 0,
294    starred    INTEGER NOT NULL DEFAULT 0,
295    updated_at TEXT NOT NULL,
296    PRIMARY KEY (did, entry_id)
297);
298CREATE INDEX IF NOT EXISTS idx_entry_state_did_read ON entry_state (did, read);
299-- The FK child key. `entry_id` is the TRAILING column of the primary key, so
300-- without this index it is not the leading column of anything and SQLite must
301-- FULL SCAN entry_state for EVERY row deleted from `entries` to service
302-- ON DELETE CASCADE.
303--
304-- That is not theoretical. Measured on 600k entry_state rows: 500 deletes took
305-- 10.3s and 2,000 took 38.3s, against a busy_timeout of 5s — so any retention
306-- sweep removing more than roughly 260 entries made every concurrent writer
307-- (star, mark-read, OAuth session write) fail with SQLITE_BUSY. With this index
308-- the same 32,850-row delete goes from ~10 minutes to 0.7s.
309--
310-- It also fixes the per-feed trim, whose starred-sparing subquery scans
311-- entry_state on every poll of every feed and scales with TOTAL rows across all
312-- users rather than with the feed being trimmed (2ms -> 21ms at 1M rows).
313CREATE INDEX IF NOT EXISTS idx_entry_state_entry_id ON entry_state (entry_id);
314
315-- Per-DID subscription projection. The shared `feeds`/`entries` cache is
316-- deduped by URL and NOT owned by any single DID; `sub_ref` records which
317-- feeds a given DID actually subscribes to (mirrored from the caller's PDS
318-- subscription set on every resolve/sync). Every entry/feed READ and every
319-- read/star MUTATION is scoped through this table so one user can never read
320-- or mutate another user's cached articles. Rows are refreshed by
321-- `replace_sub_refs`.
322CREATE TABLE IF NOT EXISTS sub_ref (
323    did     TEXT NOT NULL,
324    feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
325    PRIMARY KEY (did, feed_id)
326);
327CREATE INDEX IF NOT EXISTS idx_sub_ref_feed ON sub_ref (feed_id);
328
329CREATE TABLE IF NOT EXISTS read_cursor (
330    did          TEXT NOT NULL,
331    feed_url     TEXT NOT NULL,
332    read_through TEXT,
333    read_ids     TEXT NOT NULL DEFAULT '[]',
334    unread_ids   TEXT NOT NULL DEFAULT '[]',
335    dirty        INTEGER NOT NULL DEFAULT 0,
336    pds_created  INTEGER NOT NULL DEFAULT 0,
337    updated_at   TEXT NOT NULL,
338    PRIMARY KEY (did, feed_url)
339);
340CREATE INDEX IF NOT EXISTS idx_read_cursor_dirty ON read_cursor (did, dirty);
341-- The (did, feed_url) PRIMARY KEY can't serve a feed_url-only lookup (did is the
342-- leading column). The retention path's orphan-cursor cleanup filters cursors by
343-- feed_url alone, so give it an index.
344CREATE INDEX IF NOT EXISTS idx_read_cursor_feed_url ON read_cursor (feed_url);
345
346CREATE TABLE IF NOT EXISTS beta_access (
347    did              TEXT PRIMARY KEY,
348    handle           TEXT,
349    granted_by       TEXT NOT NULL,
350    granted_at       INTEGER NOT NULL,
351    invite_code_used TEXT
352);
353
354CREATE TABLE IF NOT EXISTS invite_codes (
355    code         TEXT PRIMARY KEY,
356    creator_did  TEXT NOT NULL,
357    status       TEXT NOT NULL,
358    invitee_did  TEXT,
359    -- The follower DID a bot-minted claim was minted FOR (recorded at mint time,
360    -- distinct from `invitee_did` which is stamped at redeem). This is the
361    -- server-side idempotency key: a second `POST /bot/claims` for a DID that
362    -- already holds an outstanding active code returns the SAME code instead of
363    -- minting a duplicate, so a bot-host state loss cannot re-mint per follower.
364    intended_did TEXT,
365    created_at   INTEGER NOT NULL,
366    expires_at   INTEGER NOT NULL,
367    redeemed_at  INTEGER
368);
369CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
370-- NOTE: the `intended_did` indexes are created in `apply_migrations`, AFTER the
371-- `intended_did` column is ensured. They MUST NOT live in this base SCHEMA batch:
372-- on an existing pre-0.2.2 volume the `CREATE TABLE IF NOT EXISTS invite_codes`
373-- above is a no-op (the table already exists without `intended_did`), so a
374-- `CREATE INDEX ... (intended_did, ...)` here would fail with "no such column"
375-- and crash-loop the boot before migrations ever run.
376
377-- Network-observation counters (v0.2.8, design/NETWORK-SPEC.md §4.3). One row
378-- per (metric, relay): the adoption probe records how many repos a given relay
379-- has INDEXED as holding a collection. We store the COUNT, never the DID list —
380-- persisting the DIDs would build a durable register of "accounts that use an
381-- RSS reader" on our disk for a feature whose only output is an integer. This
382-- table is a PROJECTION, not a source of truth: `DROP TABLE` it and the next
383-- probe rebuilds it, and nothing in the reader path reads it. Bounded forever at
384-- (metrics × relays) rows, so it never interacts with the DB-size watermark.
385CREATE TABLE IF NOT EXISTS network_stat (
386    key         TEXT NOT NULL,   -- e.g. 'adoption.subscription'
387    source      TEXT NOT NULL,   -- the relay host the number came from
388    value       INTEGER NOT NULL,
389    truncated   INTEGER NOT NULL DEFAULT 0,
390    observed_at TEXT NOT NULL,
391    PRIMARY KEY (key, source)
392);
393-- Repo-operation timings, for comparing the two backends across a CUTOVER.
394--
395-- Persisted rather than held in memory because flipping the backend requires a
396-- restart, and an in-memory table would lose the outgoing backend's numbers at
397-- exactly the moment they became worth comparing against. These rows are the
398-- only reason a "side by side" table can show two backends at once.
399--
400-- `repo_timing` is a bounded window of recent samples (pruned per backend+op);
401-- `repo_timing_total` carries the all-time counts, which must survive that
402-- pruning or a long-running backend would appear to have served fewer calls
403-- than a fresh one.
404CREATE TABLE IF NOT EXISTS repo_timing (
405    id       INTEGER PRIMARY KEY AUTOINCREMENT,
406    backend  TEXT    NOT NULL,
407    op       TEXT    NOT NULL,
408    micros   INTEGER NOT NULL,
409    ok       INTEGER NOT NULL,
410    at       INTEGER NOT NULL
411);
412
413CREATE INDEX IF NOT EXISTS idx_repo_timing_key ON repo_timing(backend, op, id);
414
415CREATE TABLE IF NOT EXISTS repo_timing_total (
416    backend    TEXT    NOT NULL,
417    op         TEXT    NOT NULL,
418    ok_count   INTEGER NOT NULL DEFAULT 0,
419    err_count  INTEGER NOT NULL DEFAULT 0,
420    PRIMARY KEY (backend, op)
421);
422
423"#;
424
425/// RFC3339 timestamp for "now" (UTC, seconds precision), used as the default for
426/// `*_at` columns. Uses `chrono` to match the shape written by [`crate::feed`]
427/// and [`crate::web`] (one timestamp format across the whole crate).
428fn now_rfc3339() -> String {
429    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
430}
431
432/// Open the per-DID SQLite cache described by [`Config`] (its `db_path`), run
433/// schema creation, and return the pool.
434///
435/// This is the entrypoint `main` calls: it derives the sqlx SQLite URL from the
436/// configured filesystem path and delegates to [`init_url`]. Kept separate from
437/// [`init_url`] so tests can open an in-memory database directly.
438pub async fn init(config: &Config) -> Result<Pool> {
439    // sqlx wants a `sqlite://<path>` URL; build it from the configured path.
440    let db_url = format!("sqlite://{}", config.db_path.display());
441    init_url(&db_url).await
442}
443
444/// Open (creating if needed) the SQLite database at `db_url`, run schema
445/// creation, and return a connection pool.
446///
447/// `db_url` is a sqlx SQLite URL, e.g. `sqlite://featherreader.db` or
448/// `sqlite::memory:` for an ephemeral in-memory database. The file is created
449/// if it does not exist; WAL journaling is enabled for on-disk databases and
450/// foreign keys are enforced on every connection.
451/// Ceiling the WAL is truncated back to at each checkpoint.
452///
453/// The WAL lives on the same volume as the database and counts against the same
454/// 1 GB, but nothing bounded it: SQLite grows the WAL to fit the largest
455/// transaction it has ever seen and never shrinks it again without this limit.
456const WAL_SIZE_LIMIT_BYTES: i64 = 64 * 1024 * 1024;
457
458pub async fn init_url(db_url: &str) -> Result<Pool> {
459    // An in-memory DB must run on a SINGLE connection: each `:memory:` connection
460    // is a *separate* database, and a multi-connection in-memory pool can also
461    // deadlock a writer against an idle pooled connection's shared-cache table
462    // read-lock (SQLITE_LOCKED, code 262 — which `busy_timeout` does NOT retry;
463    // seen as a Linux-only flaky failure in redeem_code's UPDATE). On-disk uses
464    // WAL + a 5-connection pool as normal.
465    let is_memory = db_url.contains(":memory:");
466    let mut opts = SqliteConnectOptions::from_str(db_url)
467        .with_context(|| format!("invalid sqlite url: {db_url}"))?
468        .create_if_missing(true)
469        .foreign_keys(true);
470    // WAL is a no-op / unsupported for :memory:, so only request it on-disk.
471    if !is_memory {
472        opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
473        // **Incremental auto-vacuum, set at CREATION.**
474        //
475        // `auto_vacuum` was read by `reclaim` and never set anywhere, so every
476        // database ran in SQLite's default NONE mode and `reclaim` always took
477        // its full-`VACUUM` branch — daily, and again after every prune. A full
478        // VACUUM needs free disk roughly equal to the live database because it
479        // writes a whole new file, which is exactly what is scarce under the
480        // disk pressure that triggers a sweep; on a ~700 MiB database on a 1 GB
481        // volume it cannot complete at all.
482        //
483        // This pragma only takes effect on a database with no tables yet, so it
484        // fixes NEW instances permanently and does nothing to existing ones —
485        // deliberately. Changing it on a populated database requires running the
486        // very full VACUUM that is unsafe here, so that is a separate,
487        // operator-invoked step: see [`migrate_to_incremental_vacuum`].
488        opts = opts.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental);
489        // Truncate the WAL back down at checkpoints. Without a limit, a WAL
490        // grown once by a single large transaction stays that size for the life
491        // of the file — permanently occupying volume the watermark is trying to
492        // protect. The batched retention deletes keep transactions small now, so
493        // in practice the WAL should rarely approach this; the limit is what
494        // makes that a guarantee rather than a hope.
495        opts = opts.pragma("journal_size_limit", WAL_SIZE_LIMIT_BYTES.to_string());
496    }
497    // Under a concurrent write burst (the poller's insert_entries tx racing the
498    // web layer's mark_read / redeem_code tx) SQLite would otherwise return
499    // SQLITE_BUSY the instant a writer holds the lock. `busy_timeout` makes a
500    // blocked connection WAIT (retry) for up to this long before erroring, so
501    // short lock contention resolves transparently instead of surfacing a
502    // spurious failure. Mirrors the OAuth sidecar's `stores.ts`
503    // (`PRAGMA busy_timeout = 5000`). 5 s is comfortably above any single
504    // FeatherReader transaction.
505    opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
506    // Quiet sqlx's per-statement query logging.
507    opts = opts.log_statements(tracing::log::LevelFilter::Debug);
508
509    let pool = SqlitePoolOptions::new()
510        // Keep at least one connection alive so an in-memory DB isn't dropped
511        // (each `:memory:` connection is a *separate* database otherwise).
512        .min_connections(1)
513        .max_connections(if is_memory { 1 } else { 5 })
514        .connect_with(opts)
515        .await
516        .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
517
518    init_schema(&pool).await?;
519    Ok(pool)
520}
521
522/// Run the idempotent schema creation. Split out so callers/tests can (re)apply
523/// it against an already-open pool.
524pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
525    // `execute` runs the multi-statement batch (sqlite allows this).
526    sqlx::query(SCHEMA)
527        .execute(pool)
528        .await
529        .context("failed to create schema")?;
530    apply_migrations(pool).await?;
531    // The Rust OAuth client's tables live in the same database. Created
532    // UNCONDITIONALLY, not only when that backend is selected: the tables are
533    // empty and harmless under the sidecar, whereas creating them lazily would
534    // make the first request after a cutover flip fail with "no such table" --
535    // at the one moment nobody wants to discover a migration was missed.
536    crate::oauth::store::init_schema(pool)
537        .await
538        .context("failed to create the OAuth schema")?;
539    Ok(())
540}
541
542/// Apply additive, idempotent migrations to bring an EXISTING database up to the
543/// current [`SCHEMA`]. `CREATE TABLE IF NOT EXISTS` never alters a table that
544/// already exists, so a column added to a shipped table must be back-filled here
545/// (SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe `table_info` first).
546async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
547    // feeds.consecutive_errors — drives the exponential poll backoff. Older DBs
548    // predate the column; add it (defaulting to 0) if it is missing.
549    ensure_column(
550        pool,
551        "PRAGMA table_info(feeds)",
552        "consecutive_errors",
553        "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
554    )
555    .await?;
556    // feeds.last_error_kind / feeds.last_error — WHY a feed is failing, not just
557    // how often. `consecutive_errors` recorded a count and nothing else, which is
558    // how a systematic defect across sixty feeds stayed indistinguishable from
559    // sixty dead blogs until #159: every one of them was our own 304 handling,
560    // and the table could not say so. Nullable, and NULL once a poll succeeds.
561    ensure_column(
562        pool,
563        "PRAGMA table_info(feeds)",
564        "last_error_kind",
565        "ALTER TABLE feeds ADD COLUMN last_error_kind TEXT",
566    )
567    .await?;
568    ensure_column(
569        pool,
570        "PRAGMA table_info(feeds)",
571        "last_error",
572        "ALTER TABLE feeds ADD COLUMN last_error TEXT",
573    )
574    .await?;
575
576    // feeds.kind — what the poller does with a row. Older DBs predate it and
577    // get `'rss'` from the DEFAULT, which is wrong for the at:// rows, so it is
578    // back-filled below.
579    ensure_column(
580        pool,
581        "PRAGMA table_info(feeds)",
582        "kind",
583        "ALTER TABLE feeds ADD COLUMN kind TEXT NOT NULL DEFAULT 'rss'",
584    )
585    .await?;
586    // Here, not in the base SCHEMA batch: it names a column that only exists
587    // after the line above. See the note beside `idx_feeds_next_poll`.
588    sqlx::query("CREATE INDEX IF NOT EXISTS idx_feeds_kind ON feeds (kind)")
589        .execute(pool)
590        .await
591        .context("creating idx_feeds_kind")?;
592
593    // **Re-derived in Rust, every row, every start — not translated once.**
594    //
595    // `kind` is a pure function of `url`, so it is a cache, and a cache that is
596    // only ever written forward goes stale the moment the function changes.
597    // The first version of this was a one-directional SQL `UPDATE` carrying its
598    // own copy of the rule as a string predicate: it agreed with
599    // `FeedKind::of` on the day it was written, translated `rss` to
600    // `publication` and never the reverse, and had no way to notice either
601    // fact. Asking the Rust classifier about every row instead means the column
602    // cannot disagree with the one function that defines it, and a future kind
603    // — or a corrected rule — needs no migration of its own.
604    //
605    // Cheap by shape, not by assumption: it writes only rows that are actually
606    // wrong, so the steady state is a single scan of a table that holds one row
607    // per subscribed feed.
608    let rows = sqlx::query("SELECT id, url, kind FROM feeds")
609        .fetch_all(pool)
610        .await
611        .context("reading feeds to re-derive kind")?;
612    let mut tx = pool.begin().await.context("begin kind re-derivation")?;
613    let (mut to_pollable, mut to_unpollable, mut unreadable) = (0u64, 0u64, 0u64);
614    for row in rows {
615        // **A row we cannot read is skipped, not fatal.** This runs on the boot
616        // path, so anything that returns `Err` here is the difference between a
617        // wedged poller and a site that will not start. A `url` or `kind` that
618        // is not decodable as text takes no opinion from us and keeps whatever
619        // it has; every reader downstream already treats an unknown kind as
620        // unpollable. Nothing sqlx writes produces such a row — it binds `&str`
621        // as TEXT everywhere — so reaching this means the file was edited by
622        // hand, which is exactly when refusing to boot is the least helpful
623        // thing to do.
624        let (Ok(id), Ok(url), Ok(kind)) = (
625            row.try_get::<i64, _>("id"),
626            row.try_get::<String, _>("url"),
627            row.try_get::<String, _>("kind"),
628        ) else {
629            unreadable += 1;
630            continue;
631        };
632        let want = crate::feed::FeedKind::of(&url);
633        if kind == want.as_str() {
634            continue;
635        }
636        sqlx::query("UPDATE feeds SET kind = ?1 WHERE id = ?2")
637            .bind(want.as_str())
638            .bind(id)
639            .execute(&mut *tx)
640            .await
641            .with_context(|| format!("re-deriving kind for feed {id}"))?;
642        if crate::feed::FeedKind::POLLABLE.contains(&want) {
643            to_pollable += 1;
644        } else {
645            // **Declaring a row unpollable orphans its poll state, so clear
646            // it.** A backoff horizon and an error count belong to a feed the
647            // scheduler selects; on a row it will never select again they are
648            // dead, and not inert. They are hidden from `/stats` and the cause
649            // histogram, which filter on kind, so they rot unseen — and if a
650            // later rule change makes the row pollable again it resumes at
651            // `backoff_for(n)` on an `n` earned under a classification that no
652            // longer applies, which for seven prior errors is a first retry ten
653            // hours out instead of five minutes.
654            //
655            // Narrower than the step below, deliberately: that one clears only
656            // rows we never polled, on the grounds that a real feed's history
657            // still means something. This clears rows whose history can no
658            // longer mean anything, because nothing will add to it or act on
659            // it.
660            sqlx::query(
661                "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, \
662                 last_error = NULL, next_poll = NULL WHERE id = ?1",
663            )
664            .bind(id)
665            .execute(&mut *tx)
666            .await
667            .with_context(|| format!("clearing orphaned poll state for feed {id}"))?;
668            to_unpollable += 1;
669        }
670    }
671    tx.commit().await.context("commit kind re-derivation")?;
672    // Quiet in the steady state, which is every boot where nothing changed.
673    // Split by direction because the two mean opposite things to an operator:
674    // one puts feeds back in the poller's queue, the other takes them out of
675    // every figure `/stats` reports.
676    if to_pollable > 0 || to_unpollable > 0 {
677        tracing::info!(
678            to_pollable,
679            to_unpollable,
680            "feeds.kind re-derived from the URL"
681        );
682    }
683    if unreadable > 0 {
684        tracing::warn!(
685            unreadable,
686            "feeds rows are not readable as text; their kind was left alone"
687        );
688    }
689
690    // **Clear failure counts on rows we never actually polled.**
691    //
692    // `due_feeds` excludes them by kind (see `feed::FeedKind`) — but rows
693    // subscribed before the scheme check already carry the errors OUR refusal
694    // produced. Left alone they would count as failing forever, since no poll
695    // that could clear them will ever be scheduled.
696    //
697    // A real feed's history is untouched: it still means something. The
698    // recorded reason goes with the count: a row with no errors must carry no
699    // reason, which is what `reset_feed_errors` promises and a test asserts.
700    //
701    // **Idempotent by predicate.** `last_polled` is set only by a successful
702    // poll — `bump_feed_errors` never touches it — so `last_polled IS NULL`
703    // selects exactly the rows whose every error came from our own refusal.
704    // A row a wired standard.site reader has fetched once keeps its later
705    // failures across restarts; a row that only ever failed under the refusal
706    // is cleared at every boot, including after a rollback to a build that
707    // polled it. A version stamp was the first design and left that rollback
708    // case a permanent hole (re-accumulated errors hidden by the filters,
709    // never cleared). Trade-off accepted: a publication that has never once
710    // succeeded restarts its backoff at the floor on every boot.
711    sqlx::query(sqlx::AssertSqlSafe(format!(
712        "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
713         WHERE kind NOT IN ({POLLABLE_KINDS_SQL}) AND last_polled IS NULL \
714         AND consecutive_errors > 0"
715    )))
716    .execute(pool)
717    .await
718    .context("clearing error counts on unpollable at:// feeds")?;
719    // read_cursor.pds_created — tracks whether a feed's readState record has been
720    // created in the PDS, so the first flush emits a `create` (not a bare
721    // `update`, which errors on a not-yet-existing record). Older DBs predate it.
722    ensure_column(
723        pool,
724        "PRAGMA table_info(read_cursor)",
725        "pds_created",
726        "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
727    )
728    .await?;
729    // invite_codes.intended_did — the follower DID a bot claim was minted for, the
730    // server-side idempotency key for `POST /bot/claims`. Older DBs (before the
731    // follow→invite bot) predate it; it is nullable (browser/admin-minted codes
732    // leave it NULL).
733    ensure_column(
734        pool,
735        "PRAGMA table_info(invite_codes)",
736        "intended_did",
737        "ALTER TABLE invite_codes ADD COLUMN intended_did TEXT",
738    )
739    .await?;
740    // Indexes on `intended_did` are created HERE (not in the base SCHEMA batch)
741    // because they reference a column that only exists after the migration above.
742    // On an existing pre-0.2.2 DB the `invite_codes` CREATE TABLE is a no-op, so
743    // an index on `intended_did` in SCHEMA would fail before this migration ran
744    // (that was blocker B1). All are `IF NOT EXISTS`, so re-running is a no-op.
745    //
746    // Look up an outstanding active claim by the DID it was minted for (bot dedupe).
747    sqlx::query(
748        "CREATE INDEX IF NOT EXISTS idx_invite_codes_intended \
749         ON invite_codes (intended_did, status)",
750    )
751    .execute(pool)
752    .await
753    .context("creating idx_invite_codes_intended")?;
754    // Enforce at MOST one outstanding active claim per intended DID. This makes
755    // the bot's dedupe check-then-mint race-safe: two concurrent `POST /bot/claims`
756    // for the same follower can no longer both insert an active code (the second
757    // INSERT hits this unique constraint). Partial so it only constrains active
758    // bot-minted rows — redeemed/expired rows and NULL-intended (admin/browser)
759    // codes are unconstrained. (Blocker/should-fix S4.)
760    sqlx::query(
761        "CREATE UNIQUE INDEX IF NOT EXISTS idx_invite_codes_intended_active \
762         ON invite_codes (intended_did) \
763         WHERE intended_did IS NOT NULL AND status = 'active'",
764    )
765    .execute(pool)
766    .await
767    .context("creating idx_invite_codes_intended_active")?;
768    Ok(())
769}
770
771/// Add a column via `alter_sql` iff `info_sql` (a `PRAGMA table_info(<table>)`)
772/// does not already report `column`. All three SQL args are hard-coded internal
773/// literals (never user input), so they are safe `&'static str`s — the table name
774/// can't be a bind parameter in `PRAGMA`, which is why they're passed whole.
775async fn ensure_column(
776    pool: &SqlitePool,
777    info_sql: &'static str,
778    column: &str,
779    alter_sql: &'static str,
780) -> Result<()> {
781    let rows = sqlx::query(info_sql)
782        .fetch_all(pool)
783        .await
784        .with_context(|| format!("{info_sql} failed"))?;
785    let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
786    if !present {
787        sqlx::query(alter_sql)
788            .execute(pool)
789            .await
790            .with_context(|| format!("adding column {column} via {alter_sql}"))?;
791    }
792    Ok(())
793}
794
795/// Insert a feed by URL, or update its metadata if the URL already exists.
796/// Returns the feed's row id (existing or newly assigned).
797///
798/// EVERY updatable column is COALESCE'd, so `None` means "leave alone" for all
799/// of them and a partial upsert cannot clobber a field it never mentioned.
800///
801/// `etag`/`last_modified` were the exception until now, and the exception was
802/// silently disabling conditional GET for the entire instance. `set_next_poll`
803/// in the scheduler supplies only `url` + `next_poll` after every single poll,
804/// which wrote both validators back to NULL — so `304 Not Modified` was
805/// unreachable and every feed was re-downloaded, re-parsed, re-sanitised and
806/// re-inserted in full, hourly, forever. `feed::touch_polled` had discovered the
807/// same trap earlier and worked around it in its own caller by re-reading the
808/// row first; that local fix is what let the next caller walk into it.
809///
810/// A stale validator is not a hazard: if the origin no longer issues one it
811/// ignores our `If-None-Match` and returns `200`, and if it still matches then
812/// `304` was the correct answer anyway.
813pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
814    let row = sqlx::query(
815        r#"
816        INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll, kind)
817        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
818        ON CONFLICT (url) DO UPDATE SET
819            title         = COALESCE(excluded.title, feeds.title),
820            site_url      = COALESCE(excluded.site_url, feeds.site_url),
821            etag          = COALESCE(excluded.etag, feeds.etag),
822            last_modified = COALESCE(excluded.last_modified, feeds.last_modified),
823            last_polled   = COALESCE(excluded.last_polled, feeds.last_polled),
824            next_poll     = COALESCE(excluded.next_poll, feeds.next_poll),
825            -- Not COALESCE: `kind` is derived from the URL, and `excluded`
826            -- always carries the current answer. Preserving the stored value
827            -- would make a row's classification a function of when it was
828            -- first subscribed rather than of what it is.
829            kind          = excluded.kind
830        RETURNING id
831        "#,
832    )
833    .bind(&feed.url)
834    .bind(&feed.title)
835    .bind(&feed.site_url)
836    .bind(&feed.etag)
837    .bind(&feed.last_modified)
838    .bind(&feed.last_polled)
839    .bind(&feed.next_poll)
840    // Decided once, in Rust, and never re-derived from the URL by SQL.
841    .bind(crate::feed::FeedKind::of(&feed.url).as_str())
842    .fetch_one(pool)
843    .await
844    .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
845
846    Ok(row.get::<i64, _>("id"))
847}
848
849/// Fetch a feed by its URL, if present.
850pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
851    let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
852        .bind(url)
853        .fetch_optional(pool)
854        .await
855        .with_context(|| format!("get_feed_by_url failed for {url}"))?;
856    Ok(feed)
857}
858
859/// The `kind` values the scheduler may select, as a SQL list.
860///
861/// Pinned against [`crate::feed::FeedKind::POLLABLE`] by
862/// `the_sql_kind_list_matches_the_rust_one` — a literal here and a slice there
863/// is exactly the drift the column was introduced to end, so the two are
864/// asserted equal rather than trusted. Wiring the standard.site reader means
865/// changing both, and that test is what makes forgetting one a failure.
866pub(crate) const POLLABLE_KINDS_SQL: &str = "'rss'";
867
868/// The `kind` values the retention **window** applies to, as a SQL list.
869///
870/// Pinned against [`crate::feed::FeedKind::AGED`] by
871/// `the_sql_aged_kind_list_matches_the_rust_one`, for the same reason
872/// [`POLLABLE_KINDS_SQL`] is pinned against `POLLABLE`.
873///
874/// Why a publication is not in it: see `FeedKind::AGED`. Measured — a 14-day
875/// window stored zero rows from every real publication tried, because their
876/// newest documents were 109 to 241 days old.
877pub(crate) const AGED_KINDS_SQL: &str = "'rss'";
878
879/// How many rows the poller will never select — the capacity consumed by feeds
880/// that cannot be fetched.
881///
882/// Rendered on `/admin/metrics` because the global ceiling counts these rows
883/// (see [`count_feeds`]) while `/stats` does not, so without this the cap could
884/// be reached with every public number saying otherwise.
885pub async fn unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
886    sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
887        "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
888    )))
889    .fetch_one(pool)
890    .await
891    .context("counting unpollable feeds")
892}
893
894/// How many rows the poller will never select. Test-only: the assertion the
895/// at:// tests make, spelled once, against the predicate the code uses.
896#[cfg(test)]
897pub(crate) async fn count_unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
898    sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
899        "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
900    )))
901    .fetch_one(pool)
902    .await
903    .context("counting unpollable feeds")
904}
905
906/// The scheduler's hot query: feeds whose `next_poll` is due (`<= as_of`, or
907/// never polled), oldest-due first. `as_of` is an RFC3339 timestamp.
908pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
909    let sql = format!(
910        r#"
911        SELECT * FROM feeds
912        WHERE (next_poll IS NULL OR next_poll <= ?1)
913          -- `at://` is not pollable, so it is not due: skipped, not failed.
914          -- The why lives on `feed::FeedKind::POLLABLE`.
915          AND kind IN ({POLLABLE_KINDS_SQL})
916        ORDER BY next_poll IS NOT NULL, next_poll ASC
917        LIMIT ?2
918        "#
919    );
920    let feeds = sqlx::query_as::<_, Feed>(sqlx::AssertSqlSafe(sql))
921        .bind(as_of)
922        .bind(limit)
923        .fetch_all(pool)
924        .await
925        .context("due_feeds failed")?;
926    Ok(feeds)
927}
928
929/// One failing feed, named, for the ADMIN view only.
930///
931/// The public `/stats` histogram is counts by cause and nothing else, by that
932/// page's own stated promise. This is the other half: the coarse bucket
933/// `fetch` covers DNS failure, timeout, SSRF refusal and — as #159 proved —
934/// this reader's own bugs, so a count alone cannot separate "the publishers are
935/// gone" from "we are broken". The detail can, and it lives behind the
936/// `ALLOWED_DIDS` gate where per-feed data is already permitted.
937#[derive(Debug, Clone, PartialEq, Eq)]
938pub struct FailingFeed {
939    pub url: String,
940    pub consecutive_errors: i64,
941    /// `None` for a row that predates the column — see the `unknown` bucket.
942    pub kind: Option<String>,
943    pub detail: Option<String>,
944}
945
946/// Every currently-failing feed with its recorded cause, worst first.
947///
948/// **Admin-gated callers only.** Bounded because this renders into one response
949/// and a large instance should not be able to make that response unbounded.
950pub async fn failing_feeds(pool: &SqlitePool, limit: i64) -> Result<Vec<FailingFeed>> {
951    // The same exclusion as `poll_health`: a row the poller never selects
952    // can never have its errors cleared, so listing it here would pin it to
953    // the top of the operator's page for good.
954    let sql = format!(
955        r#"
956        SELECT url, consecutive_errors, last_error_kind, last_error
957        FROM feeds
958        WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
959        ORDER BY consecutive_errors DESC, url ASC
960        LIMIT ?1
961        "#
962    );
963    let rows: Vec<(String, i64, Option<String>, Option<String>)> =
964        sqlx::query_as(sqlx::AssertSqlSafe(sql))
965            .bind(limit)
966            .fetch_all(pool)
967            .await
968            .context("listing failing feeds")?;
969    Ok(rows
970        .into_iter()
971        .map(|(url, consecutive_errors, kind, detail)| FailingFeed {
972            url,
973            consecutive_errors,
974            kind,
975            detail,
976        })
977        .collect())
978}
979
980/// Cap on the stored `last_error` detail. Remote text on an unattended path.
981const MAX_ERROR_DETAIL_CHARS: usize = 300;
982
983/// Record a poll FAILURE for a feed: bump its `consecutive_errors` by one and
984/// return the NEW count. The count drives the exponential poll backoff, so a
985/// persistently-failing feed spaces its retries out toward the ceiling instead of
986/// hammering the 5-minute floor forever. Reset to 0 by [`reset_feed_errors`] on
987/// any success/304.
988pub async fn bump_feed_errors(
989    pool: &SqlitePool,
990    url: &str,
991    kind: crate::feed::FailureKind,
992    detail: &str,
993) -> Result<i64> {
994    let row = sqlx::query(
995        "UPDATE feeds SET consecutive_errors = consecutive_errors + 1, \
996         last_error_kind = ?2, last_error = ?3 \
997         WHERE url = ?1 RETURNING consecutive_errors",
998    )
999    .bind(url)
1000    .bind(kind.as_str())
1001    // **Truncated.** This is a remote server's error text on an unattended path;
1002    // an upstream that returns a megabyte of prose should cost a bounded row,
1003    // not an unbounded one.
1004    .bind(
1005        detail
1006            .chars()
1007            .take(MAX_ERROR_DETAIL_CHARS)
1008            .collect::<String>(),
1009    )
1010    .fetch_optional(pool)
1011    .await
1012    .with_context(|| format!("bump_feed_errors failed for {url}"))?;
1013    // If the feed row somehow vanished, treat it as the first error.
1014    Ok(row
1015        .map(|r| r.get::<i64, _>("consecutive_errors"))
1016        .unwrap_or(1))
1017}
1018
1019/// Schedule a feed's next poll `delay` from now.
1020///
1021/// Lived as a private fn in the scheduler until `web::add_subscription`
1022/// needed it too: a poll taken off the scheduler settled the error columns but
1023/// never rescheduled, so a re-subscribed working feed stayed parked on its stale
1024/// backoff horizon for up to 24h. One implementation, two callers.
1025///
1026/// `upsert_feed` COALESCEs unset fields, so supplying only url + next_poll bumps
1027/// the schedule without clobbering title/validators/last_polled.
1028pub async fn set_next_poll(pool: &SqlitePool, url: &str, delay: std::time::Duration) -> Result<()> {
1029    let next = chrono::Utc::now()
1030        + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::hours(1));
1031    let next_poll = next.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1032    let nf = NewFeed {
1033        url: url.to_string(),
1034        next_poll: Some(next_poll),
1035        ..Default::default()
1036    };
1037    upsert_feed(pool, &nf).await.map(|_| ())
1038}
1039
1040/// Reset a feed's `consecutive_errors` to 0 after a successful poll (or a 304).
1041/// A no-op UPDATE if the row is missing.
1042pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
1043    // **Clears the reason too.** A stale `last_error` on a feed that is now
1044    // succeeding is worse than none: it is the aggregate below reporting a cause
1045    // that stopped applying, which is the failure this column exists to end.
1046    sqlx::query(
1047        "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
1048         WHERE url = ?1",
1049    )
1050    .bind(url)
1051    .execute(pool)
1052    .await
1053    .with_context(|| format!("reset_feed_errors failed for {url}"))?;
1054    Ok(())
1055}
1056
1057/// The feeds a `did` currently subscribes to, per its `sub_ref` projection.
1058/// Used by the PDS-unreachable fallback in `resolve_subscriptions` to render
1059/// the sidebar from the caller's OWN last-known subscriptions (fail closed)
1060/// rather than every cached feed.
1061pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
1062    let feeds = sqlx::query_as::<_, Feed>(
1063        r#"
1064        SELECT f.* FROM feeds f
1065        JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
1066        ORDER BY f.title IS NULL, f.title, f.url
1067        "#,
1068    )
1069    .bind(did)
1070    .fetch_all(pool)
1071    .await
1072    .with_context(|| format!("feeds_for_did failed for {did}"))?;
1073    Ok(feeds)
1074}
1075
1076/// The feed ids a `did` currently subscribes to (its `sub_ref` rows).
1077///
1078/// **Not bounded by `max_subs_per_did`.** This comment used to claim it was, and
1079/// callers leaned on that: the cap is enforced on the ADD and OPML paths only,
1080/// never on read, and `sub_ref` is rebuilt from whatever the PDS returns — which
1081/// any client can write to, bounded only by the list-pages ceiling at 20,000
1082/// records. A claim in a comment is not a bound.
1083///
1084/// Callers must therefore not assume a small result. The one that cared — the
1085/// list views' scope filter — no longer does: it passes the whole set as a
1086/// single `json_each` bind rather than one SQL placeholder per feed.
1087pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
1088    let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
1089        .bind(did)
1090        .fetch_all(pool)
1091        .await
1092        .with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
1093    Ok(ids)
1094}
1095
1096/// The number of feeds a `did` currently subscribes to (its `sub_ref` rows).
1097/// Backs the per-DID subscription cap enforced at the add/import paths.
1098pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
1099    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
1100        .bind(did)
1101        .fetch_one(pool)
1102        .await
1103        .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
1104    Ok(n)
1105}
1106
1107/// The number of distinct feeds in the shared cache. Backs the global feeds
1108/// ceiling checked before a brand-new feed is inserted.
1109pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
1110    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
1111        .fetch_one(pool)
1112        .await
1113        .context("count_feeds failed")?;
1114    Ok(n)
1115}
1116
1117/// The **used** size of the SQLite database, in bytes, computed as
1118/// `(page_count - freelist_count) * page_size`. Backs the DB-size watermark that
1119/// disables new polling.
1120///
1121/// Subtracting the freelist is what keeps the watermark from latching the poller
1122/// off: `page_count` counts pages the file has *allocated*, including ones freed
1123/// by a `DELETE` but not yet returned to the OS (SQLite keeps them on a freelist
1124/// for reuse and never shrinks the file without a VACUUM). Counting only the
1125/// live pages means a retention prune (which frees pages, see [`reclaim`]) is
1126/// actually reflected here, so the watermark can drop back below its threshold
1127/// and polling resumes. Cheap (three `PRAGMA` reads); works for file + `:memory:`.
1128///
1129/// **The WAL counts too.** This is the number the DB-size watermark compares
1130/// against a VOLUME size, and in WAL mode the `-wal` sidecar sits on that same
1131/// volume — so leaving it out understated exactly the quantity the watermark
1132/// exists to bound. It is added back below, best-effort: a WAL that cannot be
1133/// stat'd contributes zero rather than failing the check, since a watermark that
1134/// errors is worse than one that is slightly optimistic.
1135pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
1136    let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
1137        .fetch_one(pool)
1138        .await
1139        .context("PRAGMA page_count failed")?;
1140    let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1141        .fetch_one(pool)
1142        .await
1143        .context("PRAGMA freelist_count failed")?;
1144    let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
1145        .fetch_one(pool)
1146        .await
1147        .context("PRAGMA page_size failed")?;
1148    let used_pages = page_count.saturating_sub(freelist_count).max(0);
1149    Ok(used_pages
1150        .saturating_mul(page_size)
1151        .saturating_add(wal_bytes(pool).await))
1152}
1153
1154/// Bytes the write-ahead log currently occupies on the database's volume, or 0
1155/// when there is no WAL (`:memory:`, non-WAL journal modes) or it cannot be
1156/// stat'd. Best-effort by design — see [`db_size_bytes`].
1157async fn wal_bytes(pool: &SqlitePool) -> i64 {
1158    let Some(path) = main_db_path(pool).await else {
1159        return 0;
1160    };
1161    std::fs::metadata(format!("{path}-wal"))
1162        .map(|m| i64::try_from(m.len()).unwrap_or(i64::MAX))
1163        .unwrap_or(0)
1164}
1165
1166/// The main database's file path, or `None` for `:memory:`.
1167async fn main_db_path(pool: &SqlitePool) -> Option<String> {
1168    sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main' AND file <> ''")
1169        .fetch_optional(pool)
1170        .await
1171        .ok()
1172        .flatten()
1173}
1174
1175/// Freelist pages returned to the OS per `incremental_vacuum` step. At a 4 KiB
1176/// page that is ~8 MiB per batch — a short lock hold, and few enough steps that
1177/// a large reclaim is tens of statements rather than thousands.
1178const RECLAIM_BATCH_PAGES: i64 = 2_000;
1179
1180/// Backstop on the reclaim loop. `freelist_count == 0` and the no-progress check
1181/// are the real terminators; at [`RECLAIM_BATCH_PAGES`] this is 2M pages (~8 GiB),
1182/// far past anything a 1 GB volume holds.
1183const RECLAIM_MAX_BATCHES: usize = 1_000;
1184
1185/// Reclaim freed pages so the database file (and its used-page accounting) can
1186/// actually shrink after a retention/prune sweep DELETEs rows.
1187///
1188/// Without this, a `DELETE` moves pages onto the freelist but never shrinks the
1189/// file — so once the DB-size watermark trips and retention deletes rows,
1190/// `page_count` stays put and [`db_size_bytes`] (well, its raw `page_count`
1191/// form) would never fall back below the watermark, latching the poller off
1192/// forever. Call this AFTER a prune. It uses incremental vacuum when the database
1193/// is in `auto_vacuum = INCREMENTAL` mode (cheap, no full rewrite), and otherwise
1194/// falls back to a full `VACUUM`.
1195pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
1196    match auto_vacuum_mode(pool).await? {
1197        AutoVacuum::Incremental => {
1198            // **Bounded, like the deletes that precede it.**
1199            //
1200            // With no page argument this reclaims the ENTIRE freelist in one
1201            // transaction — handing straight back the write-lock hold that
1202            // batching the retention deletes had just won, immediately after the
1203            // sweep that created the freelist in the first place. Same shape as
1204            // `delete_in_batches`: a bounded unit of work, then an explicit
1205            // hand-off so a waiting writer actually gets in.
1206            // **Both early exits are LOUD.** Failing to reclaim is the failure
1207            // this function exists to prevent: `db_size_bytes` stays high,
1208            // `poll_due_once` keeps polling paused, and `/stats` says "paused"
1209            // with nothing anywhere saying reclaim gave up. Exiting silently
1210            // makes that indistinguishable from a sweep that had nothing to do.
1211            let mut drained = true;
1212            for batch in 0..RECLAIM_MAX_BATCHES {
1213                let before: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1214                    .fetch_one(pool)
1215                    .await
1216                    .context("PRAGMA freelist_count failed")?;
1217                if before == 0 {
1218                    break;
1219                }
1220                // A PRAGMA argument cannot be a bind parameter, and this one is
1221                // a `const i64` declared in this file — nothing external reaches
1222                // it.
1223                sqlx::query(sqlx::AssertSqlSafe(format!(
1224                    "PRAGMA incremental_vacuum({RECLAIM_BATCH_PAGES})"
1225                )))
1226                .execute(pool)
1227                .await
1228                .context("PRAGMA incremental_vacuum failed")?;
1229                let after: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1230                    .fetch_one(pool)
1231                    .await
1232                    .context("PRAGMA freelist_count failed")?;
1233                // No progress: either nothing more can be freed, or a
1234                // concurrent retention delete pushed `after` back up. Both leave
1235                // pages allocated, which is what an operator needs to know.
1236                //
1237                // This comment previously also claimed "a long-lived WAL read
1238                // snapshot pins freelist pages". MEASURED AND FALSE: with a
1239                // reader holding a snapshot taken BEFORE the delete, the
1240                // freelist still drained 2000 → 0 and `page_count` halved. A
1241                // reader blocks the CHECKPOINT, not the incremental vacuum — so
1242                // that case exits this loop through the SUCCESS path and is
1243                // reported below, not here.
1244                if after >= before {
1245                    tracing::warn!(
1246                        freelist_pages = after,
1247                        batches_run = batch + 1,
1248                        "reclaim stopped making progress with pages still on the \
1249                         freelist; the file will not shrink and the DB-size watermark \
1250                         may stay engaged until the next sweep"
1251                    );
1252                    drained = false;
1253                    break;
1254                }
1255                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1256                // `after > 0` matters: the final batch can drain the freelist
1257                // completely, in which case the loop reaches here having
1258                // SUCCEEDED and would otherwise log "with pages still on the
1259                // freelist" for an empty one — and suppress the success line.
1260                // This is the same guard `delete_in_batches` carries, and the
1261                // same defect it already had; reproduced here verbatim by
1262                // copying the loop's shape without its condition.
1263                if batch + 1 == RECLAIM_MAX_BATCHES && after > 0 {
1264                    tracing::warn!(
1265                        batches_run = batch + 1,
1266                        freelist_pages = after,
1267                        "reclaim hit its batch backstop with pages still on the \
1268                         freelist; the rest waits for the next sweep"
1269                    );
1270                    drained = false;
1271                }
1272            }
1273            if drained {
1274                tracing::debug!("reclaim: freelist drained");
1275            }
1276        }
1277        // SQLite already returns freed pages at every commit in this mode.
1278        // Nothing to do, and a VACUUM would be pure cost.
1279        AutoVacuum::Full => {}
1280        // **Deliberately a no-op, where this used to run a full VACUUM.**
1281        //
1282        // Nothing ever set `auto_vacuum`, so NONE was the mode every database
1283        // actually ran in — which made the full-VACUUM branch the one that
1284        // always executed, daily and after every prune. A full VACUUM writes a
1285        // complete second copy of the database, so it needs free disk roughly
1286        // equal to the live file; that is precisely what is missing under the
1287        // disk pressure that triggers a retention sweep. `poll_due_once` already
1288        // carries a comment explaining this danger and removed VACUUM from the
1289        // poll path — while leaving it in the retention path that runs under the
1290        // same pressure.
1291        //
1292        // Skipping it does NOT latch the DB-size watermark, which is the failure
1293        // this branch was written to prevent: `db_size_bytes` subtracts the
1294        // freelist, so a DELETE lowers the measured size with no VACUUM at all.
1295        // What is lost is the FILE shrinking, and the fix for that is to get the
1296        // database into INCREMENTAL mode — see `migrate_to_incremental_vacuum`,
1297        // which is operator-invoked precisely because it needs the one operation
1298        // that is unsafe to attempt automatically.
1299        AutoVacuum::None => {
1300            tracing::warn!(
1301                "auto_vacuum=NONE: skipping reclaim. Freed pages stay allocated and \
1302                 the file will not shrink. Run `featherreader --migrate-auto-vacuum` \
1303                 once, while the volume has headroom, to move this database to \
1304                 INCREMENTAL mode."
1305            );
1306        }
1307    }
1308
1309    // Truncate the WAL as well. It lives on the same volume and is counted by
1310    // `db_size_bytes`, so reclaiming database pages while leaving a WAL grown by
1311    // the sweep that just ran would give back part of the space and hold the
1312    // rest. Worth doing even in the NONE branch above, where it is the only
1313    // space this function can return at all.
1314    //
1315    // **A blocked checkpoint is the real way the file stays big, so it warns.**
1316    //
1317    // Measured: with a reader holding an open snapshot, `incremental_vacuum`
1318    // still drains the freelist and `page_count` halves — but the main file
1319    // stayed at 16.4 MB until the reader released and the checkpoint could
1320    // truncate it to 8.2 MB. So a reader does not stop the reclaim; it stops the
1321    // SHRINK. That is the operator-visible outcome (`db_size_bytes` counts the
1322    // WAL, and the watermark is compared against a volume), and it used to be
1323    // reported at `debug!` — below any realistic filter — while the loop above
1324    // warned loudly about a mechanism that does not actually occur.
1325    //
1326    // Not an error: the next sweep checkpoints again once the reader is gone.
1327    match checkpoint_wal(pool).await {
1328        Ok(true) => {}
1329        Ok(false) => tracing::warn!(
1330            "the WAL could not be truncated after reclaim (busy: a concurrent reader \
1331             OR writer held it); the freed pages are gone but the file has not \
1332             shrunk yet, and the DB-size watermark may stay engaged until the next \
1333             sweep"
1334        ),
1335        Err(err) => tracing::warn!(%err, "wal checkpoint after reclaim failed"),
1336    }
1337    Ok(())
1338}
1339
1340/// Run a truncating WAL checkpoint. `Ok(false)` means SQLite declined because a
1341/// reader held the WAL.
1342///
1343/// **The busy case is a ROW, not an error.** `PRAGMA wal_checkpoint` returns
1344/// `(busy, log_frames, checkpointed_frames)` and sets `busy = 1` when it could
1345/// not run — measured: `(1, 3, 3)` with one open read transaction versus
1346/// `(0, 0, 0)` without. So `if let Err(..)` never fires on the case it was
1347/// written for, and a caller that depends on the WAL actually being truncated
1348/// (the migration's size report does) would silently get the untruncated one.
1349async fn checkpoint_wal<'e, E>(conn: E) -> Result<bool>
1350where
1351    E: sqlx::Executor<'e, Database = sqlx::Sqlite>,
1352{
1353    let row: (i64, i64, i64) = sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
1354        .fetch_one(conn)
1355        .await
1356        .context("PRAGMA wal_checkpoint(TRUNCATE) failed")?;
1357    Ok(row.0 == 0)
1358}
1359
1360/// A database's `auto_vacuum` mode.
1361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1362pub enum AutoVacuum {
1363    /// 0 — freed pages stay on the freelist; only a full `VACUUM` returns them.
1364    None,
1365    /// 1 — SQLite returns freed pages at every commit.
1366    Full,
1367    /// 2 — freed pages are returned on demand by `PRAGMA incremental_vacuum`.
1368    Incremental,
1369}
1370
1371/// Read the database's `auto_vacuum` mode.
1372pub async fn auto_vacuum_mode(pool: &SqlitePool) -> Result<AutoVacuum> {
1373    let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1374        .fetch_one(pool)
1375        .await
1376        .context("PRAGMA auto_vacuum failed")?;
1377    Ok(match mode {
1378        1 => AutoVacuum::Full,
1379        2 => AutoVacuum::Incremental,
1380        _ => AutoVacuum::None,
1381    })
1382}
1383
1384/// What [`migrate_to_incremental_vacuum`] did.
1385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1386pub enum VacuumMigration {
1387    /// Already in a mode that reclaims; nothing was run.
1388    NotNeeded(AutoVacuum),
1389    /// Refused: not enough free space on the volume to hold the rebuilt file.
1390    ///
1391    /// `file_bytes` is the on-disk size, reported alongside the live-page figure
1392    /// the requirement is computed from, because on exactly this population
1393    /// (`NONE` mode, large freelist) the two differ a lot and only one of them
1394    /// matches what `ls -l` says.
1395    RefusedNoHeadroom {
1396        needed: u64,
1397        available: u64,
1398        file_bytes: Option<u64>,
1399    },
1400    /// Ran the pragma + full VACUUM; the database is now INCREMENTAL.
1401    Migrated {
1402        bytes_before: i64,
1403        bytes_after: i64,
1404        file_before: Option<u64>,
1405        file_after: Option<u64>,
1406    },
1407}
1408
1409/// Move a populated database from `auto_vacuum = NONE` to `INCREMENTAL`.
1410///
1411/// **Why this cannot happen at boot.** SQLite ignores `PRAGMA auto_vacuum` on a
1412/// database that already has tables unless it is followed by a full `VACUUM`,
1413/// which rebuilds the file. So the migration off the dangerous mode requires the
1414/// exact operation that is dangerous — a genuine chicken-and-egg, and the reason
1415/// this is an explicit operator step run when the volume has headroom rather
1416/// than something attempted lazily on a machine that is already under pressure.
1417///
1418/// Doing it automatically would also reintroduce the failure shape T2.1 just
1419/// removed: a boot-time VACUUM that cannot complete on a full volume, on a
1420/// supervisor that restarts the machine whenever a child exits, is a crash loop.
1421///
1422/// `available_bytes` is the caller's measurement of free space on the database's
1423/// volume (`None` where the platform cannot report it). The check is a refusal,
1424/// not a warning: starting a VACUUM that cannot finish wastes I/O on a box that
1425/// has none to spare. `VACUUM` itself is atomic — an interrupted one leaves the
1426/// original database intact — so the risk being managed here is wasted work and
1427/// a long write-lock hold, not corruption.
1428pub async fn migrate_to_incremental_vacuum(
1429    pool: &SqlitePool,
1430    available_bytes: Option<u64>,
1431) -> Result<VacuumMigration> {
1432    let mode = auto_vacuum_mode(pool).await?;
1433    if mode != AutoVacuum::None {
1434        return Ok(VacuumMigration::NotNeeded(mode));
1435    }
1436
1437    // **The on-disk file, not the live-page count.** `db_size_bytes` subtracts
1438    // the freelist, and the population this migration exists for is precisely
1439    // `auto_vacuum = NONE` with a large freelist — so the live size can be far
1440    // smaller than the file, and an operator comparing the refusal message to
1441    // `ls -l` would not trust either number. The rebuild is sized by the LIVE
1442    // pages (that is what gets copied), but the report shows both.
1443    let bytes_before = db_size_bytes(pool).await?;
1444    let file_before = main_db_file_bytes(pool).await;
1445    // Resolved BEFORE a connection is acquired below. Asking the pool for
1446    // anything while holding one of its connections deadlocks a saturated pool —
1447    // and a single-connection pool is always saturated. The first version of the
1448    // temp-directory block did exactly that, and because `main_db_path` swallows
1449    // errors into `None` it did not even fail loudly: it stalled for the full
1450    // acquire timeout and then silently skipped setting the directory, which is
1451    // the one thing it exists to do.
1452    let temp_dir = main_db_path(pool).await.and_then(|p| {
1453        std::path::Path::new(&p)
1454            .parent()
1455            .map(std::path::Path::to_path_buf)
1456    });
1457    let needed = (bytes_before.max(0) as u64).saturating_mul(2);
1458    if let Some(available) = available_bytes {
1459        if available < needed {
1460            return Ok(VacuumMigration::RefusedNoHeadroom {
1461                needed,
1462                available,
1463                file_bytes: file_before,
1464            });
1465        }
1466    }
1467
1468    // **One connection for both statements.**
1469    //
1470    // `PRAGMA auto_vacuum` on a populated database is connection-scoped INTENT
1471    // that only takes effect when the SAME connection runs the VACUUM. Issued
1472    // against the pool they can land on different connections, and the rebuild
1473    // then happens in NONE mode — caught by the `ensure!` below, so loud rather
1474    // than silent, but the operator has paid a whole-file rewrite for nothing on
1475    // a box chosen for being short of disk.
1476    let mut conn = pool
1477        .acquire()
1478        .await
1479        .context("acquiring a connection for the auto_vacuum migration")?;
1480
1481    // **Put the temp copy on the DATABASE's volume.**
1482    //
1483    // A VACUUM rebuilds through a temporary database, and the headroom check
1484    // above measures the data volume. `temp_store = FILE` alone only chooses
1485    // file-over-memory; it does NOT choose which filesystem, so the temp copy
1486    // resolved via `SQLITE_TMPDIR`/`TMPDIR`/`/var/tmp`/`/tmp` — the container
1487    // rootfs. The check could pass on `/data` and the VACUUM still hit
1488    // `SQLITE_FULL`, or fill the rootfs out from under Caddy.
1489    //
1490    // `temp_store_directory` is the pragma that actually decides — measured:
1491    // setting it alone moves the file, setting `temp_store = FILE` alone does
1492    // not. It is deprecated but fully functional in the bundled SQLite (3.51.3,
1493    // built without `SQLITE_OMIT_DEPRECATED`), and there is no non-deprecated
1494    // equivalent reachable from a connection.
1495    //
1496    // `temp_store = FILE` is kept as belt-and-braces rather than because it is
1497    // needed: this build's compile-time default is already FILE, but a build
1498    // defaulting to MEMORY would silently ignore the directory entirely.
1499    //
1500    // Note it sets the PROCESS-GLOBAL `sqlite3_temp_directory`, not connection
1501    // state — visible on other connections and other pools. Harmless because
1502    // this function is only reachable from the one-shot `--migrate-auto-vacuum`
1503    // CLI path, which does nothing else.
1504    sqlx::query("PRAGMA temp_store = FILE")
1505        .execute(&mut *conn)
1506        .await
1507        .context("PRAGMA temp_store = FILE failed")?;
1508    if let Some(dir) = temp_dir.clone() {
1509        // The path comes from SQLite's own `database_list`, not from a caller.
1510        let quoted = dir.display().to_string().replace('\'', "''");
1511        if let Err(err) = sqlx::query(sqlx::AssertSqlSafe(format!(
1512            "PRAGMA temp_store_directory = '{quoted}'"
1513        )))
1514        .execute(&mut *conn)
1515        .await
1516        {
1517            // Not fatal: the VACUUM can still succeed if the default temp
1518            // location happens to have room. But the headroom check is then
1519            // measuring the wrong filesystem, so say so.
1520            tracing::warn!(
1521                %err, dir = %dir.display(),
1522                "could not point SQLite's temp storage at the database volume; the \
1523                 headroom check may not cover where the VACUUM actually writes"
1524            );
1525        }
1526    }
1527
1528    // Order matters: the pragma records the INTENT, and the VACUUM is what
1529    // actually rewrites the file in the new mode. Reversed, the VACUUM would
1530    // rebuild in NONE mode and the pragma would then be ignored again.
1531    sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
1532        .execute(&mut *conn)
1533        .await
1534        .context("PRAGMA auto_vacuum = INCREMENTAL failed")?;
1535    sqlx::query("VACUUM")
1536        .execute(&mut *conn)
1537        .await
1538        .context("VACUUM failed during the auto_vacuum migration")?;
1539
1540    // Fold the WAL back in BEFORE measuring. A VACUUM in WAL mode writes the
1541    // entire rebuilt database through the WAL, which keeps that high-water size
1542    // until a truncating checkpoint — and `db_size_bytes` now counts the WAL. So
1543    // the one number this command reports read as "the migration doubled my
1544    // database", which is the opposite of what it did.
1545    match checkpoint_wal(&mut *conn).await {
1546        Ok(true) => {}
1547        // Reported, because the size this function returns is computed straight
1548        // after and would otherwise read as "the migration doubled my database"
1549        // with nothing saying why.
1550        Ok(false) => tracing::warn!(
1551            "the WAL could not be truncated (a concurrent reader OR writer held it), \
1552             so the reported size below includes it"
1553        ),
1554        Err(err) => tracing::warn!(%err, "post-migration wal checkpoint failed"),
1555    }
1556
1557    // Verified on the HELD connection, then released before anything that goes
1558    // back to the pool. The test pool is single-connection, and so is a
1559    // production pool that happens to be saturated — reaching for a second one
1560    // while still holding the first is a deadlock waiting for a busy moment.
1561    let after_raw: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1562        .fetch_one(&mut *conn)
1563        .await
1564        .context("PRAGMA auto_vacuum failed after the migration")?;
1565    drop(conn);
1566    let after = match after_raw {
1567        1 => AutoVacuum::Full,
1568        2 => AutoVacuum::Incremental,
1569        _ => AutoVacuum::None,
1570    };
1571    anyhow::ensure!(
1572        after == AutoVacuum::Incremental,
1573        "the auto_vacuum migration ran but the database is still in {after:?} mode"
1574    );
1575    Ok(VacuumMigration::Migrated {
1576        bytes_before,
1577        bytes_after: db_size_bytes(pool).await?,
1578        file_before,
1579        file_after: main_db_file_bytes(pool).await,
1580    })
1581}
1582
1583/// Size of the main database FILE on disk, or `None` for `:memory:` / an
1584/// unstattable path. Distinct from [`db_size_bytes`], which reports live pages.
1585async fn main_db_file_bytes(pool: &SqlitePool) -> Option<u64> {
1586    let path = main_db_path(pool).await?;
1587    std::fs::metadata(path).ok().map(|m| m.len())
1588}
1589
1590/// Insert a batch of entries for `feed_id`, deduping on `(feed_id, guid)`, then
1591/// trim the feed to at most [`crate::config`]-configured `max_entries_per_feed`
1592/// rows (newest by published date) so one firehose feed can't fill the disk.
1593///
1594/// On a GUID collision the existing entry is updated in place (title/url/body
1595/// may have changed on re-fetch) rather than duplicated. Runs in one
1596/// transaction. Returns the number of rows processed.
1597///
1598/// `max_entries_per_feed <= 0` disables the per-feed trim.
1599pub async fn insert_entries(
1600    pool: &SqlitePool,
1601    feed_id: i64,
1602    entries: &[NewEntry],
1603    max_entries_per_feed: i64,
1604) -> Result<u64> {
1605    let mut tx = pool.begin().await.context("begin insert_entries tx")?;
1606    let mut count: u64 = 0;
1607    for e in entries {
1608        let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
1609        let res = sqlx::query(
1610            r#"
1611            INSERT INTO entries
1612                (feed_id, guid, url, title, author, published, content_html, fetched_at)
1613            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1614            ON CONFLICT (feed_id, guid) DO UPDATE SET
1615                url          = excluded.url,
1616                title        = excluded.title,
1617                author       = excluded.author,
1618                published    = excluded.published,
1619                content_html = excluded.content_html
1620            "#,
1621        )
1622        .bind(feed_id)
1623        .bind(&e.guid)
1624        .bind(&e.url)
1625        .bind(&e.title)
1626        .bind(&e.author)
1627        .bind(&e.published)
1628        .bind(&e.content_html)
1629        .bind(&fetched_at)
1630        .execute(&mut *tx)
1631        .await
1632        .with_context(|| format!("insert entry {} failed", e.guid))?;
1633        count += res.rows_affected();
1634    }
1635
1636    // Entries-per-feed cap: keep only the newest `max_entries_per_feed` rows for
1637    // this feed, deleting the overflow in the same transaction. "Newest" is
1638    // COALESCE(published, fetched_at) so an UNDATED entry (NULL published) sorts
1639    // by when we fetched it (NOT NULL) rather than always sorting LAST and being
1640    // evicted first — otherwise a feed of undated items would trim its freshest
1641    // rows. This bounds a single firehose/misbehaving feed's storage footprint
1642    // independent of the global retention sweep. `<= 0` disables it.
1643    //
1644    // The bound is `2 * max_entries_per_feed`, not `max_entries_per_feed`: the
1645    // newest N by date, plus up to N starred. See the sparing subquery below.
1646    if max_entries_per_feed > 0 {
1647        sqlx::query(
1648            r#"
1649            DELETE FROM entries
1650            WHERE feed_id = ?1
1651              AND id NOT IN (
1652                  SELECT id FROM entries
1653                  WHERE feed_id = ?1
1654                  ORDER BY COALESCE(published, fetched_at) DESC, id DESC
1655                  LIMIT ?2
1656              )
1657              -- Starred entries survive the per-feed trim, exactly as they
1658              -- survive the retention sweep. This predicate was added to the
1659              -- sweep and NOT here, which left the documented guarantee
1660              -- ("starred entries are never evicted") false — and made this
1661              -- path, which runs on every poll of every feed rather than daily,
1662              -- the main producer of the very "starred but not cached" case the
1663              -- saved-record rendering exists to paper over.
1664              --
1665              -- The sparing is BOUNDED and SCOPED, and both matter:
1666              --
1667              -- Bounded, because the first version spared every starred row
1668              -- without limit, which did not weaken the cap so much as remove
1669              -- it — measured at cap=5 with 50 starred rows, 55 survived, 11x
1670              -- the cap. That is the same unbounded-sparing mistake the
1671              -- retention hard ceiling was added to fix, reintroduced in the
1672              -- other sweep. Worst case is now cap + cap.
1673              --
1674              -- Scoped, because `SELECT entry_id FROM entry_state WHERE
1675              -- starred = 1` reads EVERY starred row on the instance, for every
1676              -- poll of every feed — cost scaling with total users rather than
1677              -- with the feed being trimmed.
1678              AND id NOT IN (
1679                  SELECT e2.id FROM entries e2
1680                  WHERE e2.feed_id = ?1
1681                    AND EXISTS (
1682                        SELECT 1 FROM entry_state s
1683                        WHERE s.entry_id = e2.id AND s.starred = 1
1684                    )
1685                  ORDER BY COALESCE(e2.published, e2.fetched_at) DESC, e2.id DESC
1686                  LIMIT ?2
1687              )
1688            "#,
1689        )
1690        .bind(feed_id)
1691        .bind(max_entries_per_feed)
1692        .execute(&mut *tx)
1693        .await
1694        .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
1695    }
1696
1697    // Per-feed trim above may have DELETEd entries; their ids can linger in the
1698    // read_cursor exception sets (read_ids/unread_ids have no FK to entries), so
1699    // scrub the orphaned ids out of THIS feed's cursors in the same transaction.
1700    // Bounds id-set growth and keeps the flushed PDS record from referencing
1701    // entries that no longer exist. Scoped to the one feed for cheapness.
1702    if max_entries_per_feed > 0 {
1703        prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
1704    }
1705
1706    tx.commit().await.context("commit insert_entries tx")?;
1707    Ok(count)
1708}
1709
1710/// Make a feed due for polling on the next tick.
1711///
1712/// Used when a saved article is missing from the cache: if the reader still
1713/// subscribes to the feed, the poller may be able to bring the article back on
1714/// its own. Clearing `next_poll` is the whole mechanism — `due_feeds` treats
1715/// NULL as due — so this adds no synthetic rows and no special-case fetch path.
1716///
1717/// **Rate-limited by `not_polled_since`**, and that is not a nicety.
1718///
1719/// `due_feeds` treats a NULL `next_poll` as due immediately, so clearing it
1720/// unconditionally from a page handler meant every reload of the starred view
1721/// made those feeds due again — bypassing the poll interval entirely. That is
1722/// outbound amplification against third-party feed origins, and it lets one
1723/// reader's feeds monopolise a poll budget that is shared and already the
1724/// binding constraint on how many readers an instance can serve.
1725///
1726/// A feed polled within the window is left alone: if the article was not in the
1727/// feed a minute ago, another fetch now will not find it either. The nudge is
1728/// therefore worth at most one extra poll per feed per interval, which is the
1729/// cadence the poller already targets.
1730///
1731/// A no-op if the URL is not a known feed.
1732pub async fn mark_feed_due(
1733    pool: &SqlitePool,
1734    feed_url: &str,
1735    not_polled_since: &str,
1736) -> Result<()> {
1737    sqlx::query(
1738        "UPDATE feeds SET next_poll = NULL \
1739         WHERE url = ?1 AND (last_polled IS NULL OR last_polled < ?2)",
1740    )
1741    .bind(feed_url)
1742    .bind(not_polled_since)
1743    .execute(pool)
1744    .await
1745    .context("marking a feed due")?;
1746    Ok(())
1747}
1748
1749/// Delete entries whose age exceeds the retention window — the shared cache's
1750/// **rolling window** — except those a reader has starred or not yet read. "Age" is `COALESCE(published, fetched_at)` so an UNDATED
1751/// entry falls back to when it was fetched (never NULL) rather than being treated
1752/// as infinitely old. `entry_state` cascades via its `ON DELETE CASCADE` FK.
1753///
1754/// After the delete, orphaned entry ids are scrubbed out of every affected feed's
1755/// `read_cursor` exception sets (which have no FK to `entries`) so the id-sets do
1756/// not grow without bound and the flushed PDS record never references a vanished
1757/// entry. The caller (the retention sweep) should follow a non-zero return with
1758/// [`reclaim`] so freed pages return to the OS.
1759///
1760/// The two knobs are **independent**. `days == 0` disables the rolling window and
1761/// nothing else; `hard_days == 0` disables the ceiling and nothing else. Only
1762/// when both are off is this a no-op. Returns the number of entry rows deleted.
1763pub async fn prune_old_entries(
1764    pool: &SqlitePool,
1765    days: i64,
1766    hard_days: i64,
1767    publication_days: i64,
1768) -> Result<u64> {
1769    let now = chrono::Utc::now();
1770    // **A window too large to be a date disables that pass; it must not panic.**
1771    //
1772    // `chrono::Duration::days` and `DateTime - TimeDelta` both panic out of
1773    // range, and every knob here parses from a `u32` with no upper bound — so
1774    // `FEATHERREADER_RETENTION_DAYS=1000000000` (a plausible unit slip: seconds or
1775    // milliseconds typed into a days field) panicked this function. Measured:
1776    // anything past roughly 96 million days overflows, and `u32::MAX` does.
1777    //
1778    // The consequence was not a crash an operator would notice. This runs in a
1779    // spawned task, so tokio catches the panic and the retention sweeper simply
1780    // stops for the life of the process — silently, permanently, and taking the
1781    // release valve for `db_size_watermark_bytes` with it, which is the one thing
1782    // that stops polling for every reader.
1783    //
1784    // Disabled-not-panicking is also the answer `standard_site::ingest_floor`
1785    // already gives for the same input, and the two are supposed to mirror each
1786    // other — `Config::retention_for` exists to keep them agreeing. An
1787    // unrepresentable window meant "store everything" there and "panic" here.
1788    let at = |d: i64, knob: &str| -> Option<String> {
1789        let cutoff = chrono::Duration::try_days(d).and_then(|w| now.checked_sub_signed(w));
1790        if cutoff.is_none() {
1791            tracing::warn!(
1792                days = d,
1793                knob,
1794                "retention window is too large to express as a date; treating it as \
1795                 disabled for this sweep rather than failing the sweeper"
1796            );
1797        }
1798        cutoff.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
1799    };
1800
1801    let cutoff = (days > 0).then(|| at(days, "retention_days")).flatten();
1802    // **The third window, for the kinds age does not bound.** See
1803    // [`AGED_KINDS_SQL`] and `FeedKind::AGED`: a publication's entries are
1804    // bounded by COUNT (the per-feed trim), because a 14-day window stored zero
1805    // rows from every real publication measured. This is the backstop that keeps
1806    // "not aged out" from meaning "immortal" — the per-feed trim only runs when a
1807    // poll stores something, so rows belonging to a feed nobody polls any more
1808    // have nothing else to reap them.
1809    let publication_cutoff = (publication_days > 0)
1810        .then(|| at(publication_days, "publication_retention_days"))
1811        .flatten();
1812    // The ceiling only means anything if it is STRICTLY OLDER than the window.
1813    // At `0 < hard_days <= days` the two cutoffs coincide, and since the hard
1814    // delete spares nothing, it would delete exactly the rows the soft delete
1815    // exists to spare — turning the whole starred/unread exception into a no-op.
1816    // With no window at all (`days <= 0`) there is nothing to be inside of, so a
1817    // positive ceiling stands on its own.
1818    //
1819    // This used to be `hard_days.max(days)`, which clamps the wrong way: it made
1820    // `0` — the value an operator reaches for to turn a ceiling OFF, and the
1821    // documented "disabled" value for `RETENTION_DAYS` one line above it in the
1822    // same table — the single most destructive setting available, silently
1823    // purging starred and unread entries at the soft window. Measured: with
1824    // `days=14`, `hard=0` deleted a 30-day starred entry and a 30-day unread one.
1825    //
1826    // `<= 0` now means disabled, consistently with `days`. A contradictory
1827    // positive value is refused rather than reinterpreted downward.
1828    //
1829    // The ceiling is deliberately NOT gated on the window being enabled. It used
1830    // to be — this function returned on `days <= 0` before the ceiling was even
1831    // computed — which made `RETENTION_DAYS=0` mean "no window AND no ceiling":
1832    // the one configuration with no bound on the shared cache whatsoever. That
1833    // became load-bearing when the per-feed trim started sparing starred entries.
1834    // Before, the trim was a backstop for them; now nothing was. "I don't want a
1835    // rolling window" and "I don't want any ceiling at all" are different
1836    // statements, and are now configured separately.
1837    let hard_cutoff = if hard_days > 0 && (days <= 0 || hard_days > days) {
1838        at(hard_days, "retention_hard_days")
1839    } else {
1840        if hard_days > 0 {
1841            tracing::warn!(
1842                hard_days,
1843                days,
1844                "retention hard ceiling is not older than the retention window; \
1845                 ignoring it — set it above the window or to 0 to disable"
1846            );
1847        }
1848        None
1849    };
1850
1851    if cutoff.is_none() && hard_cutoff.is_none() && publication_cutoff.is_none() {
1852        return Ok(0);
1853    }
1854
1855    // **The hard ceiling — the bound that sparing would otherwise remove.**
1856    //
1857    // Sparing `read = 0` is not a small exception: "mark unread" is a one-click
1858    // UI control, and `entries` is SHARED across every reader on the instance.
1859    // Without a ceiling, one person can pin unbounded rows, and the pins are
1860    // permanent.
1861    //
1862    // That matters beyond disk. `poll_due_once` stops ALL polling once the
1863    // database crosses `db_size_watermark_bytes`, and the retention DELETE is
1864    // the documented release valve. Pinned rows can hold the valve shut
1865    // forever, so the failure mode is: one reader pins enough content, the DB
1866    // latches above the watermark, and polling stops for EVERY reader with no
1867    // self-healing path. The window used to be an unconditional bound; sparing
1868    // removed it, and this restores it.
1869    //
1870    // Starred entries go too at this age, and that is now safe: a saved record
1871    // whose entry is gone renders from the PDS record as a link card, so the
1872    // reader keeps the article's identity even when the cache does not keep its
1873    // text.
1874    let hard_deleted = match &hard_cutoff {
1875        Some(cutoff) => {
1876            delete_in_batches(
1877                pool,
1878                // Scoped to the kinds the window applies to. A publication's
1879                // entries answer to `publication_cutoff` below instead, which is
1880                // generous where this is tight — an archive read is not a cache
1881                // of the last few days.
1882                &format!(
1883                    "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
1884                     AND feed_id IN (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL}))"
1885                ),
1886                cutoff,
1887                "hard ceiling",
1888            )
1889            .await?
1890        }
1891        None => 0,
1892    };
1893    // **Entries a reader has DELIBERATELY marked are kept, whatever their age.**
1894    //
1895    // Precisely: an entry is spared when some DID has an `entry_state` row for
1896    // it with `starred = 1` or `read = 0`. An entry nobody has ever touched has
1897    // no `entry_state` row at all and is NOT spared, even though every read path
1898    // treats "no row" as unread.
1899    //
1900    // That asymmetry is deliberate and load-bearing. Sparing every never-touched
1901    // entry would spare essentially the whole table — almost no entry is ever
1902    // interacted with — which would make the window a no-op and leave the hard
1903    // ceiling as the only bound. The window is for evicting cache nobody claimed;
1904    // the exception is for the things a reader acted on.
1905    //
1906    // This comment used to read "starred and unread entries are kept", which is
1907    // the reading that would motivate exactly that change.
1908    //
1909    // The window is a cache eviction policy, not a data-retention policy. The
1910    // PDS is the source of truth for what a reader CHOSE — subscriptions,
1911    // folders, stars, read-state — but the entry CONTENT was never there. It
1912    // exists here and at the origin feed, and a feed typically serves only its
1913    // last few dozen items, so a pruned article is usually unrecoverable.
1914    //
1915    // Deleting indiscriminately therefore lost two things a reader would notice:
1916    // a starred article vanished from the starred view entirely (the view joins
1917    // `entries`, and `entry_state` cascades on the delete, so the star went with
1918    // it), and anything still unread disappeared before it was ever read. Both
1919    // are the opposite of a cache.
1920    //
1921    // This is what the documentation has always described; the query did not
1922    // implement it.
1923    let soft_deleted = match &cutoff {
1924        Some(cutoff) => {
1925            delete_in_batches(
1926                pool,
1927                // **`NOT EXISTS`, not `id NOT IN (…)`.**
1928                //
1929                // The list form materialises the ENTIRE pinned set on every
1930                // batch, and that set scales with total users rather than with
1931                // the feed being swept; this probes `idx_entry_state_entry_id`
1932                // per candidate row instead. Measured on 1M entries with 600k
1933                // `entry_state` rows of which 10% are pinned: **64.8 s as a list,
1934                // 43.6 s as a correlated exists — 1.49x, for no disk and no write
1935                // amplification.**
1936                //
1937                // **An earlier version of this comment claimed 2.4x, and that a
1938                // partial index on the pinned predicate "changed the time by
1939                // nothing at all". Both were artifacts of a bad fixture.** It
1940                // made every `entry_state` row match `starred = 1 OR read = 0` —
1941                // no "read and not starred" rows at all, which is the commonest
1942                // state a reader leaves behind. That inflated the list form's
1943                // cost (the materialised set was the whole table) and made a
1944                // PARTIAL index on that predicate cover 100% of rows, so it could
1945                // not be selective and duly did nothing.
1946                //
1947                // On a realistic distribution the review's proposed index is NOT
1948                // useless: it takes the list form from 64.8 s to 44.0 s, most of
1949                // the way to the rewrite. The rewrite is still the better change
1950                // because it costs no disk and no insert throughput — but it wins
1951                // by less than claimed, against an alternative that was dismissed
1952                // on a measurement of the wrong thing.
1953                //
1954                // Indexes are still declined, now on honest numbers: the pinned
1955                // index buys 12% (43.6 → 38.5 s) for 6.9 MiB, the age index 22%
1956                // (→ 33.9 s) for 27.9 MiB, both with write amplification on a
1957                // poller that inserts constantly, against a daily sweep that is
1958                // already batched and interruptible. See
1959                // `store::tests::r6_measure_retention_sweep`.
1960                //
1961                // Also strictly safer. `NOT IN` against a subquery containing a
1962                // NULL evaluates to NULL for every row, which would silently
1963                // delete nothing. `entry_state.entry_id` is `NOT NULL` today, so
1964                // the two are equivalent — but the equivalence depends on a
1965                // column constraint somewhere else, and `NOT EXISTS` does not.
1966                // `sparing_honours_every_did_not_just_one` pins the multi-DID
1967                // case, which is the only one where the forms could diverge.
1968                &format!(
1969                    "SELECT e.id FROM entries e \
1970                     WHERE COALESCE(e.published, e.fetched_at) < ?1 \
1971                       AND e.feed_id IN \
1972                           (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL})) \
1973                       AND NOT EXISTS ( \
1974                           SELECT 1 FROM entry_state s \
1975                           WHERE s.entry_id = e.id \
1976                             AND (s.starred = 1 OR s.read = 0) \
1977                       )"
1978                ),
1979                cutoff,
1980                "window",
1981            )
1982            .await?
1983        }
1984        None => 0,
1985    };
1986    // **The archive ceiling, for every kind the window does not cover.**
1987    //
1988    // `kind NOT IN` rather than `kind = 'publication'` deliberately: a kind added
1989    // later and left out of `FeedKind::AGED` inherits a bound here rather than
1990    // inheriting immortality. Spares nothing, for the reason the hard ceiling
1991    // spares nothing — a saved record whose entry is gone still renders from the
1992    // PDS record as a link card, so the reader keeps the article's identity.
1993    let publication_deleted = match &publication_cutoff {
1994        Some(cutoff) => {
1995            delete_in_batches(
1996                pool,
1997                &format!(
1998                    "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
1999                     AND feed_id IN (SELECT id FROM feeds WHERE kind NOT IN ({AGED_KINDS_SQL}))"
2000                ),
2001                cutoff,
2002                "archive ceiling",
2003            )
2004            .await?
2005        }
2006        None => 0,
2007    };
2008    let deleted = soft_deleted + hard_deleted + publication_deleted;
2009
2010    // Only touch cursors when rows actually went away — and OUTSIDE the deletes.
2011    //
2012    // This used to run inside the one transaction that wrapped both deletes,
2013    // which made the whole sweep a single write-lock hold: load every
2014    // `read_cursor` row, then issue a fresh per-cursor `SELECT … JOIN … WHERE
2015    // f.url = ?` returning up to `max_entries_per_feed` ids, all before the
2016    // commit. SQLite is single-writer and `busy_timeout` is 5 s, so for that
2017    // whole span every mark-read, every login write and every cursor flush
2018    // failed.
2019    //
2020    // Correctness survives the move because the scrub is idempotent — it
2021    // computes each cursor's surviving ids from what is in `entries` NOW, and
2022    // rewrites only cursors that actually change. If the process dies between
2023    // the deletes and the scrub, the next sweep finishes the job, and in the
2024    // meantime a stale id in an exception set is inert: the flusher sends it,
2025    // and it names an entry nobody can reach.
2026    if deleted > 0 {
2027        if let Err(err) = prune_orphan_cursor_ids(pool, None).await {
2028            // The deletes already committed and are the point of this call.
2029            // A failed scrub leaves stale ids to be cleaned up next sweep.
2030            tracing::warn!(%err, "retention sweep: cursor id scrub failed after the deletes");
2031        }
2032    }
2033
2034    Ok(deleted)
2035}
2036
2037/// Rows deleted per statement by [`delete_in_batches`].
2038///
2039/// Small enough that one batch — including its `entry_state` FK cascade — is a
2040/// short lock hold, large enough that a big sweep is tens of statements rather
2041/// than thousands.
2042const PRUNE_BATCH: i64 = 1_000;
2043
2044/// Backstop against a delete loop that never drains. `rows_affected == 0` is the
2045/// real terminator; this only bounds the damage if a future predicate change
2046/// makes that untrue. At [`PRUNE_BATCH`] this is 10M rows, far past anything a
2047/// 1 GB volume holds.
2048const PRUNE_MAX_BATCHES: usize = 10_000;
2049
2050/// How long [`delete_in_batches`] stands down between batches, so a writer
2051/// waiting on the SQLite write lock actually gets it rather than losing the race
2052/// to the loop's next statement.
2053///
2054/// Named because it is the one thing that makes batching a fix rather than
2055/// bookkeeping, and because `a_writer_gets_through_while_the_sweep_runs` derives
2056/// its "was this sweep long enough to measure" floor from it. A sweep that is
2057/// genuinely batched cannot finish faster than one hand-off per batch; that is a
2058/// structural lower bound, not a number calibrated against a particular machine.
2059const PRUNE_BATCH_HANDOFF: std::time::Duration = std::time::Duration::from_millis(10);
2060
2061/// Delete every entry matched by `select_ids` (a `SELECT id FROM entries …`
2062/// bound to one `?1` cutoff), in bounded batches, **one implicit transaction per
2063/// batch**.
2064///
2065/// The retention sweep used to be a single `DELETE` inside one explicit
2066/// transaction. On a populated instance that is one unbroken write-lock hold
2067/// covering tens of thousands of row deletes plus their `entry_state` cascades —
2068/// measured at ~10 minutes before `idx_entry_state_entry_id` existed, and still
2069/// a single indivisible span after it. Everything else that writes (mark-read,
2070/// login, cursor flush) has a 5 s `busy_timeout` and simply fails for the
2071/// duration.
2072///
2073/// Batching does not make the total work smaller; it makes it INTERRUPTIBLE. A
2074/// writer waiting on the lock gets in between batches instead of timing out, and
2075/// the short sleep below guarantees that window actually exists rather than
2076/// leaving it to chance against a tight loop.
2077///
2078/// A partial sweep is safe: each batch commits on its own, and the predicate is
2079/// a fixed cutoff, so a crash mid-sweep leaves fewer rows deleted and the next
2080/// run finishes the job.
2081async fn delete_in_batches(
2082    pool: &SqlitePool,
2083    select_ids: &str,
2084    cutoff: &str,
2085    label: &str,
2086) -> Result<u64> {
2087    let sql = format!("DELETE FROM entries WHERE id IN ({select_ids} LIMIT {PRUNE_BATCH})");
2088    let mut total: u64 = 0;
2089    for batch in 0..PRUNE_MAX_BATCHES {
2090        let n = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
2091            .bind(cutoff)
2092            .execute(pool)
2093            .await
2094            .with_context(|| format!("prune_old_entries {label} (cutoff {cutoff})"))?
2095            .rows_affected();
2096        total += n;
2097        if n == 0 {
2098            return Ok(total);
2099        }
2100        // Hand the write lock over, so the loop cannot re-acquire it the instant
2101        // it commits and leave a waiting writer to fight for the gap between two
2102        // statements. At `PRUNE_BATCH` rows per batch this adds one
2103        // `PRUNE_BATCH_HANDOFF` per 1,000 deleted rows to a sweep that runs once
2104        // a day.
2105        //
2106        // This comment has twice carried a number it could not support. It first
2107        // said a writer "still starves" without the hand-off; that was replaced
2108        // with "roughly 3x writer throughput", quoting one sample from each of
2109        // two runs. Repeated, the two distributions overlap heavily (medians
2110        // ~1.4 writes/ms with the sleep against ~1.0 without, and several
2111        // sleep-less runs beat the median with it), so 3x is not a figure this
2112        // comment can assert.
2113        //
2114        // What is defensible without a benchmark: removing it lets the loop
2115        // re-acquire immediately, so a waiting writer is left racing the gap
2116        // between two statements instead of being handed a window. Writers do
2117        // still get through either way. `a_writer_gets_through_while_the_sweep_runs`
2118        // catches the removal about three runs in five — see the note there; the
2119        // rest of the time the loop still looks batched, because it is.
2120        tokio::time::sleep(PRUNE_BATCH_HANDOFF).await;
2121        // Only warn if the backstop actually cut the sweep short. A final batch
2122        // that happened to drain the last rows would otherwise log "the rest
2123        // waits for the next run" with nothing left — and an operator who reads
2124        // that during an incident would go looking for a backlog that is not
2125        // there. `n < PRUNE_BATCH` means this batch found fewer rows than it
2126        // asked for, so there are none behind it.
2127        // Still a 1-in-`PRUNE_BATCH` false positive when the final batch drains
2128        // exactly a full batch with nothing behind it — distinguishing that
2129        // needs another COUNT per sweep, which is not worth paying to make a
2130        // backstop message that has never fired slightly more precise.
2131        if batch + 1 == PRUNE_MAX_BATCHES && n == PRUNE_BATCH as u64 {
2132            tracing::warn!(
2133                label,
2134                total,
2135                "retention sweep hit its batch backstop; the rest waits for the next run"
2136            );
2137        }
2138    }
2139    Ok(total)
2140}
2141
2142/// Scrub entry ids that no longer exist out of `read_cursor.read_ids` /
2143/// `unread_ids`. `read_cursor` is keyed by `(did, feed_url)` and its id-sets have
2144/// NO foreign key to `entries`, so a prune/trim that deletes entries would
2145/// otherwise leave dangling ids that (a) grow the sets without bound and (b) get
2146/// flushed to the PDS as references to vanished entries.
2147///
2148/// When `feed_id` is `Some`, only that feed's cursors are examined (the cheap
2149/// path used right after a per-feed trim); `None` scans every cursor (the
2150/// retention sweep, which can delete across many feeds at once). A cursor whose
2151/// sets actually change is rewritten and marked `dirty` so the flusher resyncs
2152/// it; unchanged cursors are left untouched (no spurious dirtying / PDS writes).
2153/// Returns the number of cursor rows modified.
2154///
2155/// This is the TRANSACTIONAL variant, used by the per-feed trim inside
2156/// `insert_entries`: it is scoped to one feed, examines that feed's cursors
2157/// only, and genuinely wants to land atomically with the trim that created the
2158/// orphans. The retention sweep uses [`prune_orphan_cursor_ids`] instead —
2159/// global scope inside one transaction is what made the sweep a multi-minute
2160/// write-lock hold.
2161async fn prune_orphan_cursor_ids_tx(
2162    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2163    feed_id: Option<i64>,
2164) -> Result<u64> {
2165    // The set of live entry ids we prune against. Scope to the feed's URL when a
2166    // feed_id is given so we filter only that feed's cursors against that feed's
2167    // entries; otherwise consider all cursors / all entries.
2168    let feed_url = match feed_id {
2169        Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
2170            Some(u) => Some(u),
2171            None => return Ok(0), // feed vanished mid-tx; nothing to prune
2172        },
2173        None => None,
2174    };
2175
2176    // Load the (did, feed_url, read_ids, unread_ids) of the candidate cursors.
2177    let cursors: Vec<(String, String, String, String)> = match &feed_url {
2178        Some(url) => sqlx::query(
2179            "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
2180        )
2181        .bind(url)
2182        .fetch_all(&mut **tx)
2183        .await
2184        .context("prune_orphan_cursor_ids: load feed cursors")?,
2185        None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
2186            .fetch_all(&mut **tx)
2187            .await
2188            .context("prune_orphan_cursor_ids: load all cursors")?,
2189    }
2190    .into_iter()
2191    .map(|r| {
2192        (
2193            r.get::<String, _>("did"),
2194            r.get::<String, _>("feed_url"),
2195            r.get::<String, _>("read_ids"),
2196            r.get::<String, _>("unread_ids"),
2197        )
2198    })
2199    .collect();
2200
2201    if cursors.is_empty() {
2202        return Ok(0);
2203    }
2204
2205    let now = now_rfc3339();
2206    let mut changed: u64 = 0;
2207    for (did, curl, read_ids, unread_ids) in cursors {
2208        // The live entry ids for THIS cursor's feed (join by URL — the cursor key).
2209        let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2210            "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2211        )
2212        .bind(&curl)
2213        .fetch_all(&mut **tx)
2214        .await
2215        .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
2216        .into_iter()
2217        .collect();
2218
2219        let new_read = filter_id_set_to_live(&read_ids, &live);
2220        let new_unread = filter_id_set_to_live(&unread_ids, &live);
2221        if new_read == read_ids && new_unread == unread_ids {
2222            continue; // nothing orphaned — leave the cursor (and its dirty flag) alone
2223        }
2224        sqlx::query(
2225            "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2226             WHERE did = ?1 AND feed_url = ?2",
2227        )
2228        .bind(&did)
2229        .bind(&curl)
2230        .bind(&new_read)
2231        .bind(&new_unread)
2232        .bind(&now)
2233        .execute(&mut **tx)
2234        .await
2235        .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
2236        changed += 1;
2237    }
2238    Ok(changed)
2239}
2240
2241/// [`prune_orphan_cursor_ids_tx`] over the pool — **no enclosing transaction**.
2242///
2243/// Same result, different locking. Each statement commits on its own, so the
2244/// single write lock is taken for one cursor rewrite at a time and released
2245/// between them, and the reads in between block nothing at all in WAL mode.
2246/// That matters because this is the global pass: the retention sweep's version
2247/// loads EVERY `read_cursor` row and then issues one live-ids query per cursor,
2248/// and holding all of that inside a transaction is what made a daily sweep look
2249/// like an outage to every writer on the instance.
2250///
2251/// **Each cursor's read-modify-write is one short transaction**, and that is not
2252/// optional. The first version of this loaded every cursor into a snapshot, then
2253/// walked them issuing an unguarded `UPDATE` per cursor from that snapshot. A
2254/// `mark_read` landing during the walk — seconds, on a global pass — had its new
2255/// id silently overwritten by the stale set, and the rewrite set `dirty = 1`, so
2256/// the flusher then pushed the truncated set to the PDS as authoritative. Local
2257/// `entry_state` still said read, so the loss was invisible here and visible
2258/// only in every OTHER atproto client. The transactional predecessor did not
2259/// have that bug: it held the write lock across the whole pass, so a concurrent
2260/// `mark_read` blocked and applied on top.
2261///
2262/// So the lock is not eliminated, it is SCOPED: one cursor's live-ids query plus
2263/// its update, rather than every cursor's. That keeps what T2.2 was for (a daily
2264/// sweep must not look like an outage) without trading it for lost writes.
2265///
2266/// Re-running is still safe — surviving ids are recomputed from the current
2267/// contents of `entries` — so dying partway just means the next sweep finishes.
2268///
2269/// `feed_id = Some(..)` scopes to one feed; `None` scans every cursor. Returns
2270/// the number of cursor rows modified.
2271async fn prune_orphan_cursor_ids(pool: &SqlitePool, feed_id: Option<i64>) -> Result<u64> {
2272    let feed_url = match feed_id {
2273        Some(fid) => match sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2274            .bind(fid)
2275            .fetch_optional(pool)
2276            .await
2277            .context("prune_orphan_cursor_ids: feed url")?
2278        {
2279            Some(u) => Some(u),
2280            None => return Ok(0),
2281        },
2282        None => None,
2283    };
2284
2285    // Only the KEYS come from this snapshot. The id-sets are deliberately not
2286    // read here — they are re-read inside each cursor's own transaction below,
2287    // because anything read out here is stale by the time it is written back.
2288    let keys: Vec<(String, String)> = match &feed_url {
2289        Some(url) => sqlx::query_as("SELECT did, feed_url FROM read_cursor WHERE feed_url = ?1")
2290            .bind(url)
2291            .fetch_all(pool)
2292            .await
2293            .context("prune_orphan_cursor_ids: load feed cursors")?,
2294        None => sqlx::query_as("SELECT did, feed_url FROM read_cursor")
2295            .fetch_all(pool)
2296            .await
2297            .context("prune_orphan_cursor_ids: load all cursors")?,
2298    };
2299
2300    let mut changed: u64 = 0;
2301    for (did, curl) in keys {
2302        // A cursor that vanished between the key snapshot and now is simply
2303        // skipped; a cursor that APPEARED is missed until the next sweep. Both
2304        // are fine — the scrub is housekeeping, not a correctness barrier.
2305        match scrub_one_cursor(pool, &did, &curl).await {
2306            Ok(true) => changed += 1,
2307            Ok(false) => {}
2308            // One bad cursor must not abandon the rest of the pass.
2309            Err(err) => tracing::warn!(%err, %did, feed = %curl, "cursor id scrub failed"),
2310        }
2311    }
2312    Ok(changed)
2313}
2314
2315/// Scrub one cursor's id-sets inside its own transaction. Returns whether the
2316/// row changed.
2317///
2318/// The read of the id-sets, the live-ids query and the write all happen under
2319/// one transaction, so a `mark_read` that lands mid-sweep either goes first (and
2320/// is included) or waits (and applies on top). Reading the sets outside and
2321/// writing them back later is the lost-update shape this function exists to
2322/// avoid — see [`prune_orphan_cursor_ids`].
2323async fn scrub_one_cursor(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<bool> {
2324    let mut tx = pool.begin().await.context("begin scrub_one_cursor tx")?;
2325
2326    let (_, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2327    // An empty exception set has nothing to orphan, and skipping it avoids the
2328    // live-ids query entirely — the dominant cost of this pass, and the common
2329    // case for a cursor sitting at its high-water mark.
2330    if is_empty_id_set(&read_ids) && is_empty_id_set(&unread_ids) {
2331        return Ok(false);
2332    }
2333
2334    let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2335        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2336    )
2337    .bind(feed_url)
2338    .fetch_all(&mut *tx)
2339    .await
2340    .with_context(|| format!("prune_orphan_cursor_ids: live ids for {feed_url}"))?
2341    .into_iter()
2342    .collect();
2343
2344    let new_read = filter_id_set_to_live(&read_ids, &live);
2345    let new_unread = filter_id_set_to_live(&unread_ids, &live);
2346    if new_read == read_ids && new_unread == unread_ids {
2347        return Ok(false); // nothing orphaned — leave the cursor (and its dirty flag) alone
2348    }
2349    sqlx::query(
2350        "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2351         WHERE did = ?1 AND feed_url = ?2",
2352    )
2353    .bind(did)
2354    .bind(feed_url)
2355    .bind(&new_read)
2356    .bind(&new_unread)
2357    .bind(now_rfc3339())
2358    .execute(&mut *tx)
2359    .await
2360    .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{feed_url}"))?;
2361    tx.commit().await.context("commit scrub_one_cursor tx")?;
2362    Ok(true)
2363}
2364
2365/// Whether a stored id-set is *textually* empty — `[]` or blank.
2366///
2367/// Deliberately NOT a parse: this is a fast pre-filter, and
2368/// [`filter_id_set_to_live`] remains the authority on what a set contains. An
2369/// unparseable value returns `false` here, so it goes through the full path and
2370/// gets canonicalised to `[]` rather than being skipped — the pre-filter fails
2371/// toward doing the work, which is the safe direction.
2372fn is_empty_id_set(raw: &str) -> bool {
2373    let t = raw.trim();
2374    t.is_empty() || t == "[]"
2375}
2376
2377/// Filter a JSON id-array string down to only ids present in `live`, returning
2378/// the canonical JSON-array-of-strings form (matching [`json_id_set_toggle`]). A
2379/// malformed input yields `[]`.
2380fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
2381    let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
2382        .ok()
2383        .map(|vals| {
2384            vals.into_iter()
2385                .filter_map(|v| match v {
2386                    serde_json::Value::Number(n) => n.as_i64(),
2387                    serde_json::Value::String(s) => s.parse::<i64>().ok(),
2388                    _ => None,
2389                })
2390                .filter(|id| live.contains(id))
2391                .collect()
2392        })
2393        .unwrap_or_default();
2394    let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
2395    serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
2396}
2397
2398/// Replace the per-DID subscription projection (`sub_ref`) for `did` with
2399/// exactly `feed_ids`, in one transaction.
2400///
2401/// Called from the web layer's subscription-resolve/sync path so `sub_ref`
2402/// always mirrors the caller's *current* PDS subscription set. This is the
2403/// authority every scoped read/mutation checks against — a feed the caller no
2404/// longer subscribes to drops out of their read surface immediately.
2405pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
2406    let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
2407    sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
2408        .bind(did)
2409        .execute(&mut *tx)
2410        .await
2411        .with_context(|| format!("clear sub_ref for {did}"))?;
2412    for &feed_id in feed_ids {
2413        sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
2414            .bind(did)
2415            .bind(feed_id)
2416            .execute(&mut *tx)
2417            .await
2418            .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
2419    }
2420    tx.commit().await.context("commit replace_sub_refs tx")?;
2421    Ok(())
2422}
2423
2424/// Whether `did` currently subscribes to the feed `feed_id` owns
2425/// (i.e. a `sub_ref` row exists). The authorization primitive behind every
2426/// per-DID scoped read/mutation.
2427pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
2428    let found: Option<i64> = sqlx::query_scalar(
2429        r#"
2430        SELECT 1
2431        FROM entries e
2432        JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
2433        WHERE e.id = ?2
2434        "#,
2435    )
2436    .bind(did)
2437    .bind(entry_id)
2438    .fetch_optional(pool)
2439    .await
2440    .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
2441    Ok(found.is_some())
2442}
2443
2444/// The exact `(sql, bind_count)` `list_entries` runs, for a view and scope.
2445///
2446/// **One path, so a test cannot assert on something the query is free to
2447/// ignore.** A named `LIST_PROJECTION` constant was not enough: the test read
2448/// the constant while `list_entries` passed `list_query_sql` whatever it liked,
2449/// so swapping in an inline literal containing `e.content_html` still shipped
2450/// green. The test now calls this.
2451fn list_entries_sql(view: ListView, feed_ids: Option<&[i64]>) -> (String, usize) {
2452    list_query_sql(Projection::EntryList, view, feed_ids)
2453}
2454
2455/// Which columns a list query may select.
2456///
2457/// **A closed type, not a `&str`.** A named constant was not enough and neither
2458/// was a helper function: both left `list_query_sql` taking an arbitrary string,
2459/// so a call site could pass an inline literal containing `e.content_html` and
2460/// ship green — twice over, which is how this ended up as an enum. The article
2461/// body is up to 20 KB per row and the list renders 50 at a time, so reading it
2462/// is the difference between a bounded response and a megabyte per page.
2463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2464enum Projection {
2465    /// The list view. Deliberately omits `content_html`.
2466    EntryList,
2467    Count,
2468    Ids,
2469    FeedCounts,
2470    StarredUrls,
2471}
2472
2473impl Projection {
2474    const fn columns(self) -> &'static str {
2475        match self {
2476            Projection::EntryList => {
2477                "e.id, e.feed_id, e.guid, e.url, e.title, e.published, \
2478                 COALESCE(s.read, 0) AS read, COALESCE(s.starred, 0) AS starred"
2479            }
2480            Projection::Count => "COUNT(*)",
2481            Projection::Ids => "e.id",
2482            Projection::FeedCounts => "e.feed_id, COUNT(*)",
2483            Projection::StarredUrls => "e.url, e.guid",
2484        }
2485    }
2486}
2487
2488/// The shared body of every list query: the per-DID `entry_state` LEFT JOIN, the
2489/// `sub_ref` authorization predicate, the view predicate and the optional
2490/// feed-id restriction. `projection` is spliced in as the `SELECT` list.
2491///
2492/// Returns the SQL plus the number of feed-id placeholders emitted, so the
2493/// caller knows where its own `LIMIT`/`OFFSET` placeholders start. `?1` is
2494/// always the DID; feed ids are `?2..`.
2495///
2496/// **Why the callers may assert this is SQL-safe.** Only three things vary, and
2497/// none is caller data: `projection` and [`ListView::predicate`] are `&'static
2498/// str` written in this file, and the feed-id restriction contributes only a
2499/// COUNT — the ids themselves are bound, never formatted in. Every runtime value
2500/// (the DID, the ids, the limit, the offset) reaches SQLite as a bind parameter.
2501fn list_query_sql(
2502    projection: Projection,
2503    view: ListView,
2504    feed_ids: Option<&[i64]>,
2505) -> (String, usize) {
2506    let cols = projection.columns();
2507    let scoped = feed_ids.is_some();
2508    let mut sql = format!(
2509        "SELECT {cols} \
2510         FROM entries e \
2511         LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1 \
2512         WHERE {} \
2513           AND EXISTS ( \
2514               SELECT 1 FROM sub_ref sr \
2515               WHERE sr.did = ?1 AND sr.feed_id = e.feed_id \
2516           )",
2517        view.predicate()
2518    );
2519    if scoped {
2520        // **ONE bind parameter for any scope size.**
2521        //
2522        // This used to emit one placeholder per feed id, so the SQL string and
2523        // the bind list both grew with the reader's subscription count — which
2524        // is PDS-supplied and bounded only by the 20,000-record list ceiling.
2525        //
2526        // That was reachable-broken, not merely ugly: `SQLITE_LIMIT_VARIABLE_NUMBER`
2527        // is 32766 on the bundled build, and the ids were bound TWICE per render
2528        // (the count query and the page query), so the effective ceiling was
2529        // ~16,383 feeds — below the list ceiling. Past it, `prepare` fails with
2530        // "too many SQL variables" and the reader's page 500s. Measured: 20,000
2531        // ids through `json_each` is a 108 KB bind that runs in 9.9 ms; 32,767
2532        // placeholders does not prepare at all.
2533        // The first attempt at bounding it truncated the subscription list
2534        // instead, which traded a query-shape problem for an access problem:
2535        // `sync_sub_refs` writes `sub_ref` from that list, so dropped feeds
2536        // became unreadable AND unmutatable. `json_each` removes the need to
2537        // choose — the whole set rides in as one JSON text bind.
2538        sql.push_str(" AND e.feed_id IN (SELECT value FROM json_each(?2))");
2539    }
2540    (sql, usize::from(scoped))
2541}
2542
2543/// Bind the DID and the optional feed-id restriction, in the order
2544/// [`list_query_sql`] emits them — `?1` the DID, `?2` the scope JSON when there
2545/// is one.
2546fn bind_list_scope<'q, O>(
2547    q: sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments>,
2548    did: &'q str,
2549    feed_ids: Option<&[i64]>,
2550) -> sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments> {
2551    let q = q.bind(did);
2552    match feed_ids {
2553        // Serialising i64s cannot fail; the fallback is an empty array, which
2554        // matches nothing — the fail-closed direction for a scope filter.
2555        Some(ids) => q.bind(serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string())),
2556        None => q,
2557    }
2558}
2559
2560/// One page of a list view, newest-published first, scoped to `did`'s
2561/// subscriptions (`sub_ref`) and optionally narrowed to `feed_ids`.
2562///
2563/// **`limit` is a required parameter, not a convenience.** This function
2564/// replaced three `SELECT e.*` queries that had no `LIMIT` at all and pulled the
2565/// article body they never used; leaving an unbounded variant next to the
2566/// bounded one would just be the same trap with a longer name. If a caller wants
2567/// "everything", it has to say how much everything is allowed to be. See
2568/// [`EntryListRow`] for what the projection deliberately omits and why.
2569///
2570/// `feed_ids = Some(&[])` means "no feeds in scope" and returns empty without
2571/// touching the database — distinct from `None`, which means "every feed this
2572/// DID subscribes to".
2573pub async fn list_entries(
2574    pool: &SqlitePool,
2575    did: &str,
2576    view: ListView,
2577    feed_ids: Option<&[i64]>,
2578    limit: i64,
2579    offset: i64,
2580) -> Result<Vec<EntryListRow>> {
2581    if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2582        return Ok(Vec::new());
2583    }
2584    let (mut sql, n) = list_entries_sql(view, feed_ids);
2585    sql.push_str(&format!(
2586        " ORDER BY e.published DESC, e.id DESC LIMIT ?{} OFFSET ?{}",
2587        n + 2,
2588        n + 3
2589    ));
2590    let q = sqlx::query_as::<_, EntryListRow>(sqlx::AssertSqlSafe(sql));
2591    let rows = bind_list_scope(q, did, feed_ids)
2592        .bind(limit)
2593        .bind(offset.max(0))
2594        .fetch_all(pool)
2595        .await
2596        .with_context(|| format!("list_entries({view:?}) failed for {did}"))?;
2597    Ok(rows)
2598}
2599
2600/// How many entries the same scope + view would return, unpaged. Used for the
2601/// "N entries" heading and to decide whether a next-page link is warranted —
2602/// both of which used to read `entries.len()` off a fully materialized list.
2603pub async fn count_entries_for_view(
2604    pool: &SqlitePool,
2605    did: &str,
2606    view: ListView,
2607    feed_ids: Option<&[i64]>,
2608) -> Result<i64> {
2609    if feed_ids.is_some_and(<[i64]>::is_empty) {
2610        return Ok(0);
2611    }
2612    let (sql, _) = list_query_sql(Projection::Count, view, feed_ids);
2613    // `query_as` over a 1-tuple keeps one binding helper for both shapes.
2614    let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2615    let (n,) = bind_list_scope(q, did, feed_ids)
2616        .fetch_one(pool)
2617        .await
2618        .with_context(|| format!("count_entries_for_view({view:?}) failed for {did}"))?;
2619    Ok(n)
2620}
2621
2622/// The ordered entry ids for a scope + view — the same ordering [`list_entries`]
2623/// renders, used for the reader's prev/next links.
2624///
2625/// Ids only: this one genuinely spans the whole list rather than a page (prev/next
2626/// needs the reader's position in it), so it is the one query where row COUNT can
2627/// still be large. An id is 8 bytes against the 11.9 KB row this used to fetch,
2628/// and `limit` bounds it regardless. Past the limit, prev/next simply stops
2629/// finding neighbours — the article still opens.
2630pub async fn list_entry_ids(
2631    pool: &SqlitePool,
2632    did: &str,
2633    view: ListView,
2634    feed_ids: Option<&[i64]>,
2635    limit: i64,
2636) -> Result<Vec<i64>> {
2637    if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2638        return Ok(Vec::new());
2639    }
2640    let (mut sql, n) = list_query_sql(Projection::Ids, view, feed_ids);
2641    sql.push_str(&format!(
2642        " ORDER BY e.published DESC, e.id DESC LIMIT ?{}",
2643        n + 2
2644    ));
2645    let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2646    let rows = bind_list_scope(q, did, feed_ids)
2647        .bind(limit)
2648        .fetch_all(pool)
2649        .await
2650        .with_context(|| format!("list_entry_ids({view:?}) failed for {did}"))?;
2651    Ok(rows.into_iter().map(|(id,)| id).collect())
2652}
2653
2654/// Unread counts per `feed_id` for a DID — the sidebar's per-feed badges.
2655///
2656/// Counted in SQL. The sidebar used to fetch every unread entry (bodies and all)
2657/// and count them in Rust, on every page with chrome, which is the single most
2658/// frequent instance of the projection problem [`EntryListRow`] describes.
2659pub async fn unread_counts_by_feed(
2660    pool: &SqlitePool,
2661    did: &str,
2662) -> Result<std::collections::HashMap<i64, i64>> {
2663    let (sql, _) = list_query_sql(Projection::FeedCounts, ListView::Unread, None);
2664    let rows =
2665        sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!("{sql} GROUP BY e.feed_id")))
2666            .bind(did)
2667            .fetch_all(pool)
2668            .await
2669            .with_context(|| format!("unread_counts_by_feed failed for {did}"))?;
2670    Ok(rows.into_iter().collect())
2671}
2672
2673/// The `(url, guid)` identity pairs of every cached starred entry for a DID.
2674///
2675/// The starred view matches PDS saved records against these to decide which
2676/// records the cache can render itself. It must span the whole starred set, not
2677/// the visible page: a record that looks uncached gets an un-save button that
2678/// deletes the PDS RECORD rather than un-starring the entry, so narrowing this
2679/// set changes what a click destroys. Identity strings only — no bodies.
2680///
2681/// **Truncation is reported, not absorbed.** The `limit` is a memory backstop,
2682/// but hitting it violates the invariant above — and the first version had no
2683/// way to say so and no `ORDER BY`, so it silently returned an ARBITRARY subset
2684/// and every starred article outside it rendered with a record-destroying
2685/// button. `Truncated` lets the caller fail closed instead, and the ordering
2686/// makes the subset at least deterministic across renders rather than
2687/// whatever the query planner felt like returning.
2688pub enum StarredIdentities {
2689    /// The complete set for this DID.
2690    All(Vec<(Option<String>, String)>),
2691    /// `limit` was reached, so this is a partial set and MUST NOT be used to
2692    /// decide that a record is uncached.
2693    Truncated,
2694}
2695
2696pub async fn starred_identities(
2697    pool: &SqlitePool,
2698    did: &str,
2699    limit: i64,
2700) -> Result<StarredIdentities> {
2701    let (mut sql, n) = list_query_sql(Projection::StarredUrls, ListView::Starred, None);
2702    // One past the limit, so reaching it is distinguishable from landing on it
2703    // exactly. Ordered by id so the rows are stable; `url`/`guid` are not
2704    // guaranteed unique or non-NULL, and the id is both.
2705    //
2706    // The placeholder index comes from `list_query_sql` rather than being
2707    // hardcoded: it was `?2` only because this call passes `None` for the scope,
2708    // which is the kind of coupling that breaks silently when the shared builder
2709    // changes shape — as it just did.
2710    sql.push_str(&format!(" ORDER BY e.id LIMIT ?{}", n + 2));
2711    let rows = sqlx::query_as::<_, (Option<String>, String)>(sqlx::AssertSqlSafe(sql))
2712        .bind(did)
2713        .bind(limit.saturating_add(1))
2714        .fetch_all(pool)
2715        .await
2716        .with_context(|| format!("starred_identities failed for {did}"))?;
2717    if rows.len() as i64 > limit {
2718        return Ok(StarredIdentities::Truncated);
2719    }
2720    Ok(StarredIdentities::All(rows))
2721}
2722
2723/// Mark a single entry read/unread for a DID, upserting the per-DID state row
2724/// and stamping `updated_at`. Preserves any existing `starred` bit. Also
2725/// projects the change into the per-`(did, feed_url)` [`ReadCursor`] and marks
2726/// it `dirty` so the batched flusher pushes it to the PDS (see
2727/// `project_entry_into_cursor`).
2728///
2729/// AUTHORIZED per-DID: the upsert only touches an entry the caller subscribes
2730/// to (`sub_ref`). Returns `true` if a row was written, `false` if `did` does
2731/// not subscribe to the entry's feed (the web layer maps that to a 404 —
2732/// a non-subscriber can never mutate another user's state).
2733pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
2734    let now = now_rfc3339();
2735    let mut tx = pool.begin().await.context("begin mark_read tx")?;
2736    let res = sqlx::query(
2737        r#"
2738        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2739        SELECT ?1, e.id, ?3, 0, ?4
2740        FROM entries e
2741        WHERE e.id = ?2
2742          AND EXISTS (
2743              SELECT 1 FROM sub_ref sr
2744              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2745          )
2746        ON CONFLICT (did, entry_id) DO UPDATE SET
2747            read       = excluded.read,
2748            updated_at = excluded.updated_at
2749        "#,
2750    )
2751    .bind(did)
2752    .bind(entry_id)
2753    .bind(read)
2754    .bind(&now)
2755    .execute(&mut *tx)
2756    .await
2757    .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
2758
2759    if res.rows_affected() == 0 {
2760        // Not authorized (no `sub_ref`) — nothing written, no cursor to dirty.
2761        tx.rollback().await.ok();
2762        return Ok(false);
2763    }
2764
2765    // Project the read/unread into this feed's read cursor (dirty=1) so the
2766    // flusher syncs it to the PDS. Same tx as the state write so a crash can't
2767    // leave the two out of step.
2768    project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
2769
2770    tx.commit().await.context("commit mark_read tx")?;
2771    Ok(true)
2772}
2773
2774/// Star/unstar a single entry for a DID (upsert, preserving `read`).
2775///
2776/// AUTHORIZED per-DID like [`mark_read`]: only touches an entry the caller
2777/// subscribes to. Returns `true` if a row was written, `false` if `did` does
2778/// not subscribe (→ 404 at the web layer).
2779pub async fn mark_starred(
2780    pool: &SqlitePool,
2781    did: &str,
2782    entry_id: i64,
2783    starred: bool,
2784) -> Result<bool> {
2785    let res = sqlx::query(
2786        r#"
2787        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2788        SELECT ?1, e.id, 0, ?3, ?4
2789        FROM entries e
2790        WHERE e.id = ?2
2791          AND EXISTS (
2792              SELECT 1 FROM sub_ref sr
2793              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2794          )
2795        ON CONFLICT (did, entry_id) DO UPDATE SET
2796            starred    = excluded.starred,
2797            updated_at = excluded.updated_at
2798        "#,
2799    )
2800    .bind(did)
2801    .bind(entry_id)
2802    .bind(starred)
2803    .bind(now_rfc3339())
2804    .execute(pool)
2805    .await
2806    .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
2807    Ok(res.rows_affected() > 0)
2808}
2809
2810/// Fold ids already covered by a high-water-mark into `read_through`, so the
2811/// exception set stops growing. Returns the new `read_through` when it advanced.
2812///
2813/// **What was wrong.** `read_through` was never COMPUTED — `project_entry_into_cursor`
2814/// only carried an existing value through, and it starts NULL, so in practice it
2815/// was always NULL. That left `read_ids` as the sole mechanism, growing one id
2816/// per article read, bounded only by `max_entries_per_feed` (2000) — while the
2817/// flusher caps the record at `ReadState::MAX_IDS` (1000) keeping the TAIL, with
2818/// no log line. Past 1000 read articles in one feed, the oldest read-state
2819/// silently stopped syncing, and those articles came back UNREAD in any other
2820/// atproto reader. The `cap` helper's own comment assumed "the exception sets
2821/// are expected to stay well under the cap in normal use"; against a 2000-entry
2822/// per-feed ceiling that does not hold.
2823///
2824/// **The rule.** `read_through` means "every entry at or before this time is
2825/// read". So it may advance only to a point with no unread entry at or before
2826/// it. That point is computed here as the newest entry timestamp STRICTLY OLDER
2827/// than the oldest unread entry — strictly, because entries can share a
2828/// timestamp, and a watermark equal to an unread entry's time would assert that
2829/// entry is read.
2830///
2831/// Once the watermark moves, every `read_ids` entry at or before it is
2832/// redundant and is dropped — that is the compaction. `unread_ids` is filtered
2833/// the same way; by construction nothing unread sits at or below the new
2834/// watermark, so it empties, but the filter is written rather than assumed so it
2835/// stays correct if that invariant ever shifts.
2836///
2837/// Timestamps compare lexicographically because every writer normalises to UTC
2838/// `...Z` at seconds precision (`feed::fmt_time`, `now_rfc3339`) — the same
2839/// assumption `poll_health` and the retention window already make.
2840pub async fn compact_cursor(
2841    pool: &SqlitePool,
2842    did: &str,
2843    feed_url: &str,
2844) -> Result<Option<String>> {
2845    let mut tx = pool.begin().await.context("begin compact_cursor tx")?;
2846    let (read_through, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2847
2848    // The oldest entry on this feed that `did` has NOT read. `NULL` = nothing
2849    // unread, in which case the watermark can cover the whole feed.
2850    let oldest_unread: Option<String> = sqlx::query_scalar(
2851        r#"
2852        SELECT MIN(COALESCE(e.published, e.fetched_at))
2853        FROM entries e
2854        JOIN feeds f ON f.id = e.feed_id
2855        LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
2856        WHERE f.url = ?2 AND COALESCE(s.read, 0) = 0
2857        "#,
2858    )
2859    .bind(did)
2860    .bind(feed_url)
2861    .fetch_one(&mut *tx)
2862    .await
2863    .with_context(|| format!("compact_cursor: oldest unread for {did}/{feed_url}"))?;
2864
2865    let watermark: Option<String> = match &oldest_unread {
2866        Some(oldest) => sqlx::query_scalar(
2867            r#"
2868            SELECT MAX(COALESCE(e.published, e.fetched_at))
2869            FROM entries e JOIN feeds f ON f.id = e.feed_id
2870            WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) < ?2
2871            "#,
2872        )
2873        .bind(feed_url)
2874        .bind(oldest)
2875        .fetch_one(&mut *tx)
2876        .await
2877        .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2878        None => sqlx::query_scalar(
2879            r#"
2880            SELECT MAX(COALESCE(e.published, e.fetched_at))
2881            FROM entries e JOIN feeds f ON f.id = e.feed_id
2882            WHERE f.url = ?1
2883            "#,
2884        )
2885        .bind(feed_url)
2886        .fetch_one(&mut *tx)
2887        .await
2888        .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2889    };
2890
2891    // Nothing to cover, or the watermark is already at least this far along.
2892    // Never move it BACKWARDS: that would re-assert articles as unread.
2893    let Some(watermark) = watermark else {
2894        return Ok(None);
2895    };
2896    if read_through
2897        .as_deref()
2898        .is_some_and(|rt| rt >= &watermark[..])
2899    {
2900        return Ok(None);
2901    }
2902
2903    let keep_above = ids_published_after(&mut tx, feed_url, &read_ids, &watermark).await?;
2904    let keep_unread =
2905        ids_published_at_or_before(&mut tx, feed_url, &unread_ids, &watermark).await?;
2906
2907    write_cursor_sets(
2908        &mut tx,
2909        did,
2910        feed_url,
2911        Some(&watermark),
2912        &keep_above,
2913        &keep_unread,
2914        &now_rfc3339(),
2915    )
2916    .await?;
2917    tx.commit().await.context("commit compact_cursor tx")?;
2918    Ok(Some(watermark))
2919}
2920
2921/// The subset of `ids` whose entries are published strictly AFTER `watermark`,
2922/// as the canonical JSON array-of-strings the cursor stores.
2923async fn ids_published_after(
2924    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2925    feed_url: &str,
2926    ids: &str,
2927    watermark: &str,
2928) -> Result<String> {
2929    let live = ids_matching_watermark(tx, feed_url, watermark, true).await?;
2930    Ok(filter_id_set_to_live(ids, &live))
2931}
2932
2933/// The subset of `ids` whose entries are published at or BEFORE `watermark`.
2934async fn ids_published_at_or_before(
2935    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2936    feed_url: &str,
2937    ids: &str,
2938    watermark: &str,
2939) -> Result<String> {
2940    let live = ids_matching_watermark(tx, feed_url, watermark, false).await?;
2941    Ok(filter_id_set_to_live(ids, &live))
2942}
2943
2944/// Entry ids on `feed_url` on one side of `watermark`. `after = true` selects
2945/// strictly newer; `false` selects at-or-older.
2946async fn ids_matching_watermark(
2947    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2948    feed_url: &str,
2949    watermark: &str,
2950    after: bool,
2951) -> Result<std::collections::HashSet<i64>> {
2952    let sql = if after {
2953        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
2954         WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) > ?2"
2955    } else {
2956        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
2957         WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) <= ?2"
2958    };
2959    Ok(sqlx::query_scalar::<_, i64>(sql)
2960        .bind(feed_url)
2961        .bind(watermark)
2962        .fetch_all(&mut **tx)
2963        .await
2964        .context("compact_cursor: ids on one side of the watermark")?
2965        .into_iter()
2966        .collect())
2967}
2968
2969/// Clear `did`'s star on any cached entry matching `url` or `guid`, **ignoring
2970/// the subscription projection**. Returns the number of `entry_state` rows
2971/// changed.
2972///
2973/// This closes a desync between the two places a star lives. The starred view
2974/// matches PDS saved records against cached entries through `sub_ref`, so an
2975/// entry that is cached AND starred in a feed the reader has since UNSUBSCRIBED
2976/// from does not match: it renders as an uncached row whose button is
2977/// `POST /saved/{rkey}/delete`. That deletes the PDS record and used to leave
2978/// `entry_state.starred = 1` behind — invisible, because the starred list is
2979/// `sub_ref`-scoped too, until the reader resubscribes and the star reappears
2980/// with no record backing it.
2981///
2982/// **Why omitting `sub_ref` is safe here, when it is the per-DID isolation hook
2983/// everywhere else.** Every row this can touch is keyed by `did` and this writes
2984/// only `starred = 0`. The worst a caller can do with it is clear one of their
2985/// OWN stars — which is what they just asked for. The predicate that matters for
2986/// isolation is the `did` in the `WHERE`, and it is not optional.
2987///
2988/// Matching on `url` OR `guid` mirrors how the view decides a record is already
2989/// cached, so the removal path and the render path agree on what "the same
2990/// article" means.
2991pub async fn clear_star_by_identity(
2992    pool: &SqlitePool,
2993    did: &str,
2994    url: Option<&str>,
2995    guid: Option<&str>,
2996) -> Result<u64> {
2997    // Neither identifier present: nothing to match on. Running the statement
2998    // would compare NULL to NULL and match nothing, but returning early says so.
2999    if url.is_none_or(str::is_empty) && guid.is_none_or(str::is_empty) {
3000        return Ok(0);
3001    }
3002    let res = sqlx::query(
3003        r#"
3004        UPDATE entry_state
3005        SET starred = 0, updated_at = ?4
3006        WHERE did = ?1
3007          AND starred = 1
3008          AND entry_id IN (
3009              SELECT id FROM entries
3010              WHERE (?2 IS NOT NULL AND url = ?2)
3011                 OR (?3 IS NOT NULL AND guid = ?3)
3012          )
3013        "#,
3014    )
3015    .bind(did)
3016    .bind(url.filter(|u| !u.is_empty()))
3017    .bind(guid.filter(|g| !g.is_empty()))
3018    .bind(now_rfc3339())
3019    .execute(pool)
3020    .await
3021    .with_context(|| format!("clear_star_by_identity failed for {did}"))?;
3022    Ok(res.rows_affected())
3023}
3024
3025/// Mark every entry of a feed read (or unread) for a DID in one statement —
3026/// backs the "mark-all-read (per feed)" action. Also projects the change into
3027/// the feed's per-DID [`ReadCursor`] (dirty=1) so the batched flusher syncs the
3028/// new read-state to the PDS.
3029pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
3030    let now = now_rfc3339();
3031    let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
3032    let res = sqlx::query(
3033        r#"
3034        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
3035        SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
3036        WHERE e.feed_id = ?4
3037          AND EXISTS (
3038              SELECT 1 FROM sub_ref sr
3039              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3040          )
3041        ON CONFLICT (did, entry_id) DO UPDATE SET
3042            read       = excluded.read,
3043            updated_at = excluded.updated_at
3044        "#,
3045    )
3046    .bind(did)
3047    .bind(read)
3048    .bind(&now)
3049    .bind(feed_id)
3050    .execute(&mut *tx)
3051    .await
3052    .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
3053
3054    if res.rows_affected() > 0 {
3055        // Project every affected entry into this feed's read cursor. `feed_id`
3056        // maps to exactly one feed URL, so this is a single per-feed cursor —
3057        // batched, not per-article. Only runs when the caller was authorized
3058        // (some rows changed), so an unsubscribed feed leaves no cursor behind.
3059        project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
3060    }
3061
3062    tx.commit().await.context("commit mark_feed_read tx")?;
3063    Ok(res.rows_affected())
3064}
3065
3066// ---------------------------------------------------------------------------
3067// Read-cursor projection (wires the local read/unread mutation into the
3068// PDS-bound `read_cursor`, so the batched flusher actually pushes read-state)
3069// ---------------------------------------------------------------------------
3070
3071/// Add or remove an entry id from a JSON id-array string, returning the new JSON.
3072/// Membership is set-like (no duplicates) and order-stable (append on add). A
3073/// malformed input is treated as empty so a cosmetic parse issue never blocks a
3074/// projection.
3075fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
3076    let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
3077        .ok()
3078        .map(|vals| {
3079            vals.into_iter()
3080                .filter_map(|v| match v {
3081                    serde_json::Value::Number(n) => n.as_i64(),
3082                    serde_json::Value::String(s) => s.parse::<i64>().ok(),
3083                    _ => None,
3084                })
3085                .collect()
3086        })
3087        .unwrap_or_default();
3088    if present {
3089        if !ids.contains(&id) {
3090            ids.push(id);
3091        }
3092    } else {
3093        ids.retain(|&x| x != id);
3094    }
3095    // Serialize as a JSON array of strings (the shape the flusher / lexicon
3096    // expect — `community.lexicon.rss.readState.readIds` is a string array).
3097    let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
3098    serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
3099}
3100
3101/// The feed URL owning `feed_id`, if the row exists (cursors are keyed by URL,
3102/// not feed id — they mirror the PDS-side `readState.feedUrl`).
3103async fn feed_url_for_id_tx(
3104    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3105    feed_id: i64,
3106) -> Result<Option<String>> {
3107    let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
3108        .bind(feed_id)
3109        .fetch_optional(&mut **tx)
3110        .await
3111        .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
3112    Ok(url)
3113}
3114
3115/// Fetch the (read_through, read_ids, unread_ids) of an existing cursor, or the
3116/// empty defaults if there is none yet.
3117async fn cursor_sets(
3118    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3119    did: &str,
3120    feed_url: &str,
3121) -> Result<(Option<String>, String, String)> {
3122    let row = sqlx::query(
3123        "SELECT read_through, read_ids, unread_ids FROM read_cursor \
3124         WHERE did = ?1 AND feed_url = ?2",
3125    )
3126    .bind(did)
3127    .bind(feed_url)
3128    .fetch_optional(&mut **tx)
3129    .await
3130    .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
3131    Ok(match row {
3132        Some(r) => (
3133            r.get::<Option<String>, _>("read_through"),
3134            r.get::<String, _>("read_ids"),
3135            r.get::<String, _>("unread_ids"),
3136        ),
3137        None => (None, "[]".to_string(), "[]".to_string()),
3138    })
3139}
3140
3141/// Upsert the cursor row for `(did, feed_url)` with the given exception sets,
3142/// stamping `updated_at` and marking it `dirty` so `dirty_cursors` returns it.
3143async fn write_cursor_sets(
3144    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3145    did: &str,
3146    feed_url: &str,
3147    read_through: Option<&str>,
3148    read_ids: &str,
3149    unread_ids: &str,
3150    now: &str,
3151) -> Result<()> {
3152    sqlx::query(
3153        r#"
3154        INSERT INTO read_cursor
3155            (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3156        VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
3157        ON CONFLICT (did, feed_url) DO UPDATE SET
3158            read_through = excluded.read_through,
3159            read_ids     = excluded.read_ids,
3160            unread_ids   = excluded.unread_ids,
3161            dirty        = 1,
3162            updated_at   = excluded.updated_at
3163        "#,
3164    )
3165    .bind(did)
3166    .bind(feed_url)
3167    .bind(read_through)
3168    .bind(read_ids)
3169    .bind(unread_ids)
3170    .bind(now)
3171    .execute(&mut **tx)
3172    .await
3173    .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
3174    Ok(())
3175}
3176
3177/// Project a single entry's read/unread flip into its feed's read cursor.
3178///
3179/// The cursor mirrors `community.lexicon.rss.readState`: a `read_through`
3180/// high-water-mark plus two bounded exception sets. A per-article flip is
3181/// recorded in those sets (`read_ids` when read, `unread_ids` when unread), the
3182/// opposite set is cleared of the id, and the cursor is stamped + marked dirty.
3183/// This keeps the write batched by touching only the ONE per-feed cursor. (Note:
3184/// there is no compaction step yet that folds covered ids back into
3185/// `read_through`; the exception sets are expected to stay well under the cap.)
3186async fn project_entry_into_cursor(
3187    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3188    did: &str,
3189    entry_id: i64,
3190    read: bool,
3191    now: &str,
3192) -> Result<()> {
3193    // The entry's feed id → feed URL (the cursor key).
3194    let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
3195        .bind(entry_id)
3196        .fetch_optional(&mut **tx)
3197        .await
3198        .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
3199    let feed_id = match feed_id {
3200        Some(f) => f,
3201        None => return Ok(()), // entry vanished mid-tx; nothing to project
3202    };
3203    let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3204        Some(u) => u,
3205        None => return Ok(()),
3206    };
3207
3208    let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3209    // read=true: id joins read_ids, leaves unread_ids. read=false: the inverse.
3210    let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
3211    let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
3212    write_cursor_sets(
3213        tx,
3214        did,
3215        &feed_url,
3216        read_through.as_deref(),
3217        &read_ids,
3218        &unread_ids,
3219        now,
3220    )
3221    .await
3222}
3223
3224/// Project a mark-all-feed-read/unread into that feed's single read cursor.
3225///
3226/// Every entry the caller subscribes to on `feed_id` is folded into the cursor
3227/// in one write: on mark-all-READ each id joins `read_ids` (and leaves
3228/// `unread_ids`); on mark-all-UNREAD the inverse. Still ONE per-feed cursor row
3229/// (batched), stamped + dirtied for the flusher.
3230async fn project_feed_into_cursor(
3231    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3232    did: &str,
3233    feed_id: i64,
3234    read: bool,
3235    now: &str,
3236) -> Result<()> {
3237    let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3238        Some(u) => u,
3239        None => return Ok(()),
3240    };
3241
3242    // The entry ids on this feed the caller is authorized for (subscribes to).
3243    let ids: Vec<i64> = sqlx::query_scalar(
3244        r#"
3245        SELECT e.id FROM entries e
3246        WHERE e.feed_id = ?2
3247          AND EXISTS (
3248              SELECT 1 FROM sub_ref sr
3249              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3250          )
3251        "#,
3252    )
3253    .bind(did)
3254    .bind(feed_id)
3255    .fetch_all(&mut **tx)
3256    .await
3257    .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
3258
3259    let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3260    for id in ids {
3261        read_ids = json_id_set_toggle(&read_ids, id, read);
3262        unread_ids = json_id_set_toggle(&unread_ids, id, !read);
3263    }
3264    write_cursor_sets(
3265        tx,
3266        did,
3267        &feed_url,
3268        read_through.as_deref(),
3269        &read_ids,
3270        &unread_ids,
3271        now,
3272    )
3273    .await
3274}
3275
3276/// Test-only unbounded convenience wrappers over [`list_entries`].
3277///
3278/// Production code passes an explicit `limit`, because that is the whole point
3279/// of the change these replaced. Fixtures hold a handful of rows and asserting
3280/// on "the whole list" is what the tests actually mean, so they get a helper
3281/// with a stated ceiling instead of each spelling one out — and the ceiling is
3282/// high enough that a test hitting it is a broken fixture, not a truncation.
3283#[cfg(test)]
3284mod test_helpers {
3285    use super::*;
3286
3287    /// Far above any fixture; a test that reaches it has a bug of its own.
3288    const FIXTURE_MAX: i64 = 10_000;
3289
3290    pub(crate) async fn entries_for_feed(
3291        pool: &SqlitePool,
3292        did: &str,
3293        feed_id: i64,
3294    ) -> Result<Vec<EntryListRow>> {
3295        list_entries(pool, did, ListView::All, Some(&[feed_id]), FIXTURE_MAX, 0).await
3296    }
3297
3298    pub(crate) async fn get_unread_for_did(
3299        pool: &SqlitePool,
3300        did: &str,
3301    ) -> Result<Vec<EntryListRow>> {
3302        list_entries(pool, did, ListView::Unread, None, FIXTURE_MAX, 0).await
3303    }
3304
3305    pub(crate) async fn get_starred_for_did(
3306        pool: &SqlitePool,
3307        did: &str,
3308    ) -> Result<Vec<EntryListRow>> {
3309        list_entries(pool, did, ListView::Starred, None, FIXTURE_MAX, 0).await
3310    }
3311}
3312
3313#[cfg(test)]
3314pub(crate) use test_helpers::{entries_for_feed, get_starred_for_did, get_unread_for_did};
3315
3316/// Insert or update a per-`(did, feed_url)` read cursor, stamping `updated_at`.
3317/// The write path for local mark-read updates (and the seam a login-time PDS
3318/// merge would use, once that is wired).
3319pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
3320    sqlx::query(
3321        r#"
3322        INSERT INTO read_cursor
3323            (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3324        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
3325        ON CONFLICT (did, feed_url) DO UPDATE SET
3326            read_through = excluded.read_through,
3327            read_ids     = excluded.read_ids,
3328            unread_ids   = excluded.unread_ids,
3329            dirty        = excluded.dirty,
3330            updated_at   = excluded.updated_at
3331        "#,
3332    )
3333    .bind(&cursor.did)
3334    .bind(&cursor.feed_url)
3335    .bind(&cursor.read_through)
3336    .bind(&cursor.read_ids)
3337    .bind(&cursor.unread_ids)
3338    .bind(cursor.dirty)
3339    .bind(&cursor.updated_at)
3340    .execute(pool)
3341    .await
3342    .with_context(|| {
3343        format!(
3344            "upsert_cursor failed for {}/{}",
3345            cursor.did, cursor.feed_url
3346        )
3347    })?;
3348    Ok(())
3349}
3350
3351/// Fetch a single read cursor, if present.
3352pub async fn get_cursor(
3353    pool: &SqlitePool,
3354    did: &str,
3355    feed_url: &str,
3356) -> Result<Option<ReadCursor>> {
3357    let cursor = sqlx::query_as::<_, ReadCursor>(
3358        "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
3359    )
3360    .bind(did)
3361    .bind(feed_url)
3362    .fetch_optional(pool)
3363    .await
3364    .context("get_cursor failed")?;
3365    Ok(cursor)
3366}
3367
3368/// The flusher's hot query: every cursor with `dirty = 1` for a DID — the ones
3369/// whose read-state changed since the last batched PDS flush.
3370/// How many DIDs hold read-state that cannot currently be flushed: dirty
3371/// cursors with no OAuth session to send them with.
3372///
3373/// **The visible form of the parked state (#117).** The flusher deliberately
3374/// stops warning about these every round, and quiet-and-invisible would be a
3375/// worse bug than the noisy loop it replaces — so the count is surfaced on
3376/// `/admin/metrics`. A non-zero number is not itself an alarm: it is the normal
3377/// state of anyone signed out with unsynced reads. A number that only ever
3378/// grows is the thing to look at.
3379///
3380/// Rust-backend shaped: it asks about `oauth_session`, which is the Rust
3381/// backend's store. On the sidecar backend it over-reports, since those
3382/// sessions live in the sidecar's own database. Prod runs `rust` and the
3383/// sidecar is removed by #18.
3384pub async fn parked_readstate_dids(pool: &SqlitePool) -> Result<i64> {
3385    let row: (i64,) = sqlx::query_as(
3386        r#"
3387        SELECT COUNT(DISTINCT rc.did)
3388          FROM read_cursor rc
3389         WHERE rc.dirty = 1
3390           AND NOT EXISTS (SELECT 1 FROM oauth_session s WHERE s.sub = rc.did)
3391        "#,
3392    )
3393    .fetch_one(pool)
3394    .await
3395    .context("counting parked read-state DIDs")?;
3396    Ok(row.0)
3397}
3398
3399pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
3400    let cursors =
3401        sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
3402            .bind(did)
3403            .fetch_all(pool)
3404            .await
3405            .with_context(|| format!("dirty_cursors failed for {did}"))?;
3406    Ok(cursors)
3407}
3408
3409// ---------------------------------------------------------------------------
3410// Network observations (the adoption probe's projection)
3411// ---------------------------------------------------------------------------
3412
3413/// Record one relay's observation, keyed by `(key, source)` so each relay's
3414/// number is kept separately (non-archival relays legitimately disagree).
3415///
3416/// An upsert: the table is bounded forever at (metrics × relays) rows — two
3417/// today — so this can never grow the DB. It must stay an upsert and never
3418/// become a per-DID insert.
3419///
3420/// **A truncated observation never lowers a stored count.** A truncated walk
3421/// saw only part of the network, so a smaller number is evidence about the
3422/// *walk*, not about adoption. Without the guard, one slow run that managed a
3423/// single 500-repo page would overwrite a complete 2 000 and drag the published
3424/// "at least N" down — and because `latest_network_stat` takes the max across
3425/// sources, two relays behind the same operator degrade together, so `/about`
3426/// would sit at the lower figure until a full walk succeeded again. A COMPLETE
3427/// observation always wins, even when smaller (repos genuinely can disappear);
3428/// a truncated one may only ever raise the floor — and an EQUAL count raises
3429/// nothing, so it is rejected too. That is why the guard reads `<=` and not
3430/// `<`: the strict form let a truncated walk that merely matched the stored
3431/// number rewrite the row and flip `truncated` on, degrading "2 000" to "at
3432/// least 2 000" with no change in adoption.
3433pub async fn record_network_stat(pool: &SqlitePool, stat: &NetworkStat) -> Result<()> {
3434    sqlx::query(
3435        r#"
3436        INSERT INTO network_stat (key, source, value, truncated, observed_at)
3437        VALUES (?1, ?2, ?3, ?4, ?5)
3438        ON CONFLICT (key, source) DO UPDATE SET
3439            value       = excluded.value,
3440            truncated   = excluded.truncated,
3441            observed_at = excluded.observed_at
3442        WHERE NOT (excluded.truncated = 1 AND excluded.value <= network_stat.value)
3443        "#,
3444    )
3445    .bind(&stat.key)
3446    .bind(&stat.source)
3447    .bind(stat.value)
3448    .bind(stat.truncated)
3449    .bind(&stat.observed_at)
3450    .execute(pool)
3451    .await
3452    .with_context(|| {
3453        format!(
3454            "record_network_stat failed for {}/{}",
3455            stat.key, stat.source
3456        )
3457    })?;
3458    Ok(())
3459}
3460
3461/// The highest observation for `key` across every relay — the number to surface
3462/// (`design/NETWORK-SPEC.md` §4.1: relays disagree; show the max). `None` when no
3463/// probe has ever succeeded.
3464pub async fn latest_network_stat(pool: &SqlitePool, key: &str) -> Result<Option<NetworkStat>> {
3465    let stat = sqlx::query_as::<_, NetworkStat>(
3466        "SELECT key, source, value, truncated, observed_at FROM network_stat \
3467         WHERE key = ?1 ORDER BY value DESC, observed_at DESC LIMIT 1",
3468    )
3469    .bind(key)
3470    .fetch_optional(pool)
3471    .await
3472    .with_context(|| format!("latest_network_stat failed for {key}"))?;
3473    Ok(stat)
3474}
3475
3476/// Mark a cursor's PDS `readState` record as CREATED after the flush that first
3477/// created it, so subsequent flushes emit an `update` instead of another
3478/// `create`. Idempotent; a no-op if the row is gone.
3479pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
3480    sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
3481        .bind(did)
3482        .bind(feed_url)
3483        .execute(pool)
3484        .await
3485        .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
3486    Ok(())
3487}
3488
3489/// Clear the `dirty` flag on a cursor after a successful PDS flush — but ONLY if
3490/// the row still carries the exact `flushed_updated_at` snapshot we flushed.
3491///
3492/// The flusher reads a cursor, sends it to the PDS (a network round-trip), then
3493/// clears `dirty`. A concurrent [`upsert_cursor`] (a fresh mark-read) can land
3494/// DURING that in-flight write, bumping `updated_at` and re-setting `dirty = 1`
3495/// for reads that were NOT in the flushed snapshot. An unconditional
3496/// `SET dirty = 0` would silently drop those reads. Guarding on the snapshot's
3497/// `updated_at` makes this a compare-and-swap: if `updated_at` changed under us,
3498/// zero rows update, the row stays dirty, and it re-flushes next round.
3499pub async fn clear_cursor_dirty(
3500    pool: &SqlitePool,
3501    did: &str,
3502    feed_url: &str,
3503    flushed_updated_at: &str,
3504) -> Result<()> {
3505    sqlx::query(
3506        "UPDATE read_cursor SET dirty = 0 \
3507         WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
3508    )
3509    .bind(did)
3510    .bind(feed_url)
3511    .bind(flushed_updated_at)
3512    .execute(pool)
3513    .await
3514    .context("clear_cursor_dirty failed")?;
3515    Ok(())
3516}
3517
3518// ---------------------------------------------------------------------------
3519// Closed-beta invite gate (beta_access + invite_codes)
3520// ---------------------------------------------------------------------------
3521//
3522// Ported in SHAPE from a prior Go beta-gate (RedeemCode / CreateInviteCode /
3523// code_gen) but deliberately trimmed for FeatherReader's before-public
3524// experiment: NO viral invite-budget tree, NO generation cap, NO waitlist /
3525// invite-request table, and SQLite instead of Mongo. A code is minted by an
3526// existing member (or admin), and redeeming it grants a seat while seats remain
3527// under the configured cap.
3528
3529/// Unix-epoch seconds for "now" — the integer time base for the beta tables.
3530pub(crate) fn now_unix() -> i64 {
3531    chrono::Utc::now().timestamp()
3532}
3533
3534/// The invite-code alphabet: uppercase letters + digits with the
3535/// visually-ambiguous glyphs removed (`I`, `O`, `0`, `1`) so a code read aloud
3536/// or copied by hand is unambiguous.
3537const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
3538
3539/// Human-facing prefix so a FeatherReader invite code is recognisable at a
3540/// glance.
3541const CODE_PREFIX: &str = "FEATHER-";
3542
3543/// Number of random characters after the prefix.
3544const CODE_BODY_LEN: usize = 8;
3545
3546/// Generate a random, unguessable invite code of the form `FEATHER-XXXXXXXX`.
3547///
3548/// Draws from the OS CSPRNG (`getrandom`) and maps each byte onto
3549/// `CODE_ALPHABET` via rejection sampling so the alphabet distribution is
3550/// uniform (no modulo bias). Infallible in practice; a `getrandom` failure
3551/// (no entropy source) propagates as an error rather than a weak code.
3552pub fn generate_invite_code() -> Result<String> {
3553    let n = CODE_ALPHABET.len() as u16; // 31
3554                                        // Largest multiple of `n` that fits in a byte; bytes at or above it are
3555                                        // rejected so every accepted byte maps uniformly onto the alphabet.
3556    let limit = 256 / n * n; // 256 - (256 % n)
3557    let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
3558    out.push_str(CODE_PREFIX);
3559    let mut got = 0;
3560    let mut buf = [0u8; 1];
3561    while got < CODE_BODY_LEN {
3562        getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
3563        let b = buf[0] as u16;
3564        if b < limit {
3565            out.push(CODE_ALPHABET[(b % n) as usize] as char);
3566            got += 1;
3567        }
3568    }
3569    Ok(out)
3570}
3571
3572/// Whether a DID currently holds a beta seat.
3573pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
3574    let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
3575        .bind(did)
3576        .fetch_optional(pool)
3577        .await
3578        .with_context(|| format!("has_beta_access failed for {did}"))?;
3579    Ok(row.is_some())
3580}
3581
3582/// Count the beta seats currently granted — the numerator checked against the
3583/// configured cap on redeem.
3584pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
3585    let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3586        .fetch_one(pool)
3587        .await
3588        .context("count_beta_access failed")?;
3589    Ok(row.get::<i64, _>("n"))
3590}
3591
3592/// Count `active`, unexpired invite codes — the outstanding-but-unredeemed seats
3593/// a bot has already promised. Added to [`count_beta_access`] this is the "seats
3594/// committed" figure the bot mint path (`POST /bot/claims`) checks against the
3595/// cap, so it doesn't over-promise more claims than seats remain (the redeem-time
3596/// cap in [`redeem_code`] is the hard backstop; this avoids telling a follower
3597/// "you're in" for a seat that will be full by the time they claim it).
3598pub async fn count_active_codes(pool: &SqlitePool) -> Result<i64> {
3599    let now = now_unix();
3600    let row = sqlx::query(
3601        "SELECT COUNT(*) AS n FROM invite_codes WHERE status = 'active' AND expires_at >= ?1",
3602    )
3603    .bind(now)
3604    .fetch_one(pool)
3605    .await
3606    .context("count_active_codes failed")?;
3607    Ok(row.get::<i64, _>("n"))
3608}
3609
3610/// Grant a beta seat directly (admin / seed path — no code consumed). Idempotent
3611/// on `did` (re-granting updates the row rather than erroring).
3612pub async fn grant_access(
3613    pool: &SqlitePool,
3614    did: &str,
3615    handle: Option<&str>,
3616    granted_by: &str,
3617    invite_code_used: Option<&str>,
3618) -> Result<()> {
3619    sqlx::query(
3620        r#"
3621        INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3622        VALUES (?1, ?2, ?3, ?4, ?5)
3623        ON CONFLICT (did) DO UPDATE SET
3624            handle           = COALESCE(excluded.handle, beta_access.handle),
3625            granted_by       = excluded.granted_by,
3626            invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
3627        "#,
3628    )
3629    .bind(did)
3630    .bind(handle)
3631    .bind(granted_by)
3632    .bind(now_unix())
3633    .bind(invite_code_used)
3634    .execute(pool)
3635    .await
3636    .with_context(|| format!("grant_access failed for {did}"))?;
3637    Ok(())
3638}
3639
3640/// Mint a new `active` invite code owned by `creator_did`, expiring `ttl_secs`
3641/// from now. Returns the generated code string. The browser/admin path leaves the
3642/// bot idempotency key (`intended_did`) NULL; see [`mint_code_for_did`] for the
3643/// bot path that records the target follower.
3644pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
3645    mint_code_inner(pool, creator_did, ttl_secs, None).await
3646}
3647
3648/// Like [`mint_code`] but records the follower `intended_did` the code is minted
3649/// FOR, so a later `POST /bot/claims` for the same DID can return the SAME code
3650/// (see [`find_active_code_for_did`]) rather than minting a duplicate. This is the
3651/// app-side idempotency backstop that survives a bot-host state loss.
3652pub async fn mint_code_for_did(
3653    pool: &SqlitePool,
3654    creator_did: &str,
3655    ttl_secs: i64,
3656    intended_did: &str,
3657) -> Result<String> {
3658    mint_code_inner(pool, creator_did, ttl_secs, Some(intended_did)).await
3659}
3660
3661async fn mint_code_inner(
3662    pool: &SqlitePool,
3663    creator_did: &str,
3664    ttl_secs: i64,
3665    intended_did: Option<&str>,
3666) -> Result<String> {
3667    let code = generate_invite_code()?;
3668    let now = now_unix();
3669    let expires_at = now.saturating_add(ttl_secs.max(0));
3670    sqlx::query(
3671        r#"
3672        INSERT INTO invite_codes
3673            (code, creator_did, status, invitee_did, intended_did, created_at, expires_at, redeemed_at)
3674        VALUES (?1, ?2, 'active', NULL, ?3, ?4, ?5, NULL)
3675        "#,
3676    )
3677    .bind(&code)
3678    .bind(creator_did)
3679    .bind(intended_did)
3680    .bind(now)
3681    .bind(expires_at)
3682    .execute(pool)
3683    .await
3684    .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
3685    Ok(code)
3686}
3687
3688/// Does this error chain represent the partial-unique-index conflict raised when
3689/// a SECOND active claim is minted for a DID that already has one
3690/// (`idx_invite_codes_intended_active`)? The web layer uses this to recover from a
3691/// lost mint race (S4): on a conflict it re-reads the winner's code instead of
3692/// 500-ing. Matches on the sqlx `Database` error's UNIQUE-constraint code (SQLite
3693/// 2067 / primary 19) AND the offending COLUMN in the message
3694/// (`invite_codes.intended_did` — SQLite names the column(s), not the index), so an
3695/// unrelated constraint violation (e.g. the `code` PRIMARY KEY) is NOT swallowed.
3696pub fn is_intended_active_conflict(err: &anyhow::Error) -> bool {
3697    for cause in err.chain() {
3698        if let Some(sqlx::Error::Database(db)) = cause.downcast_ref::<sqlx::Error>() {
3699            let msg = db.message();
3700            // SQLite reports UNIQUE violations with (primary) code 19 /
3701            // (extended) 2067; the message names the offending column(s), e.g.
3702            // "UNIQUE constraint failed: invite_codes.intended_did".
3703            let is_unique = db.code().as_deref() == Some("2067")
3704                || db.code().as_deref() == Some("19")
3705                || msg.contains("UNIQUE constraint failed");
3706            // Scope to the intended_did index specifically. Only that index and the
3707            // `code` PRIMARY KEY can raise a UNIQUE error here; the partial unique
3708            // index is the only one over `intended_did`, so the column reference
3709            // uniquely identifies it.
3710            if is_unique && msg.contains("invite_codes.intended_did") {
3711                return true;
3712            }
3713        }
3714    }
3715    false
3716}
3717
3718/// The `code` of an outstanding (`active`, unexpired) invite minted FOR the
3719/// follower `intended_did`, if one exists — the app-side idempotency lookup for
3720/// `POST /bot/claims`. `Some(code)` means "return this existing code, do NOT mint
3721/// a second"; `None` means "no live code for this DID — mint one".
3722///
3723/// S3 — this lookup ONLY returns `active`, UNEXPIRED codes; once a code passes
3724/// `expires_at` (or `expire_old_codes` flips it to `expired`) this returns `None`,
3725/// so the next `POST /bot/claims` MINTS A FRESH code for the DID. There is no
3726/// in-place "refresh" of an expired code (the partial-unique index only constrains
3727/// `active` rows, so a fresh mint after expiry is allowed). The bot then re-posts:
3728/// its record rkey is deterministic per DID, so the existing skeet is UPDATED in
3729/// place with the new claim URL (see the bot's `reconcile_stale_record`, S1) rather
3730/// than a second skeet being posted. NOTE: a bot-`delivered` follower whose link
3731/// expired UNCLAIMED is only re-minted if the bot re-processes that DID (a re-seen
3732/// follow, a `waitlisted` retry, or a bot-store reset); manual recovery is to clear
3733/// the bot's `handled` row for that DID so the next cycle re-mints + re-posts.
3734/// If several live codes somehow exist (a race), the soonest-expiring is returned.
3735pub async fn find_active_code_for_did(
3736    pool: &SqlitePool,
3737    intended_did: &str,
3738) -> Result<Option<String>> {
3739    let now = now_unix();
3740    let row = sqlx::query(
3741        "SELECT code FROM invite_codes
3742         WHERE intended_did = ?1 AND status = 'active' AND expires_at >= ?2
3743         ORDER BY expires_at ASC
3744         LIMIT 1",
3745    )
3746    .bind(intended_did)
3747    .bind(now)
3748    .fetch_optional(pool)
3749    .await
3750    .with_context(|| format!("find_active_code_for_did failed for {intended_did}"))?;
3751    Ok(row.map(|r| r.get::<String, _>("code")))
3752}
3753
3754/// Atomically redeem an invite code for `did`, granting a beta seat.
3755///
3756/// Runs entirely in one transaction so the capacity check and the seat grant
3757/// cannot race (two redeems can't both slip past a `cap - 1` count). Steps:
3758/// 1. verify the code exists, is `active`, and is not past `expires_at`;
3759/// 2. verify the current seat count is `< cap`;
3760/// 3. flip the code `active`→`redeemed` (stamping `invitee_did` + `redeemed_at`);
3761/// 4. insert the `beta_access` row.
3762///
3763/// On a policy failure returns the matching [`RedeemError`] (the tx rolls back);
3764/// a real SQLite error propagates as the outer [`anyhow::Error`].
3765pub async fn redeem_code(
3766    pool: &SqlitePool,
3767    code: &str,
3768    did: &str,
3769    handle: Option<&str>,
3770    cap: i64,
3771) -> Result<std::result::Result<(), RedeemError>> {
3772    let now = now_unix();
3773    let mut tx = pool.begin().await.context("begin redeem_code tx")?;
3774
3775    // Take the write lock at the START of the transaction. sqlx issues a plain
3776    // deferred BEGIN, so without this the capacity SELECT below runs under a read
3777    // snapshot: two concurrent redeems could both pass the gate, and the loser's
3778    // later UPDATE would fail with SQLITE_BUSY_SNAPSHOT (which busy_timeout does
3779    // NOT retry) — an opaque error instead of a clean CapacityFull. A leading
3780    // no-op write against the target row acquires the RESERVED lock immediately
3781    // (SQLite locks on any write statement, even one matching zero rows), so the
3782    // second redeem blocks on the first, then reads the post-commit seat count
3783    // and returns CapacityFull. (The cap already held via snapshot isolation;
3784    // this upgrades the failure mode from a hard error to the right one.)
3785    sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
3786        .bind(code)
3787        .execute(&mut *tx)
3788        .await
3789        .context("redeem_code: acquire write lock")?;
3790
3791    // 1. Look the code up.
3792    let row =
3793        sqlx::query("SELECT status, expires_at, intended_did FROM invite_codes WHERE code = ?1")
3794            .bind(code)
3795            .fetch_optional(&mut *tx)
3796            .await
3797            .context("redeem_code: lookup")?;
3798    let row = match row {
3799        Some(r) => r,
3800        None => return Ok(Err(RedeemError::NotFound)),
3801    };
3802    let status: String = row.get("status");
3803    let expires_at: i64 = row.get("expires_at");
3804    let intended_did: Option<String> = row.get("intended_did");
3805
3806    // DID-binding gate (blocker B2). A bot-minted claim link is posted PUBLICLY
3807    // with a non-confidential token, so anyone who sees a follower's reply could
3808    // redeem it with a throwaway account — defeating the follow-gate, the daily
3809    // sybil budget, and the rate limit. When the code was minted FOR a specific
3810    // follower (`intended_did IS NOT NULL`), only that DID may redeem it; anyone
3811    // else gets a `NotFound` (indistinguishable from a bad code — no oracle).
3812    // Codes with a NULL `intended_did` (admin/browser-minted) stay open, as
3813    // before — those are meant to be sharable.
3814    if let Some(bound) = intended_did.as_deref() {
3815        if bound != did {
3816            return Ok(Err(RedeemError::NotFound));
3817        }
3818    }
3819
3820    // Status gate: only an `active` code is redeemable. Anything already
3821    // redeemed/revoked is "already redeemed" from the redeemer's view; an
3822    // `expired` status (or a past expiry) is "expired".
3823    if status == "expired" || now > expires_at {
3824        return Ok(Err(RedeemError::Expired));
3825    }
3826    if status != "active" {
3827        return Ok(Err(RedeemError::AlreadyRedeemed));
3828    }
3829
3830    // 2. Capacity gate (inside the tx so it can't race a concurrent redeem).
3831    let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3832        .fetch_one(&mut *tx)
3833        .await
3834        .context("redeem_code: count")?
3835        .get("n");
3836    if count >= cap {
3837        return Ok(Err(RedeemError::CapacityFull));
3838    }
3839
3840    // 3. Flip the code active→redeemed. The `status = 'active'` guard in the
3841    // WHERE makes this a compare-and-swap: if a concurrent tx already flipped it
3842    // (despite the read above), zero rows change and we treat it as redeemed.
3843    let flipped = sqlx::query(
3844        r#"
3845        UPDATE invite_codes
3846        SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
3847        WHERE code = ?1 AND status = 'active'
3848        "#,
3849    )
3850    .bind(code)
3851    .bind(did)
3852    .bind(now)
3853    .execute(&mut *tx)
3854    .await
3855    .context("redeem_code: flip")?;
3856    if flipped.rows_affected() == 0 {
3857        return Ok(Err(RedeemError::AlreadyRedeemed));
3858    }
3859
3860    // 4. Grant the seat.
3861    sqlx::query(
3862        r#"
3863        INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3864        VALUES (?1, ?2, ?3, ?4, ?5)
3865        ON CONFLICT (did) DO UPDATE SET
3866            handle           = COALESCE(excluded.handle, beta_access.handle),
3867            invite_code_used = excluded.invite_code_used
3868        "#,
3869    )
3870    .bind(did)
3871    .bind(handle)
3872    // granted_by is the code's creator; look it up in-tx to keep provenance.
3873    .bind(
3874        sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
3875            .bind(code)
3876            .fetch_one(&mut *tx)
3877            .await
3878            .context("redeem_code: creator lookup")?
3879            .get::<String, _>("creator_did"),
3880    )
3881    .bind(now)
3882    .bind(code)
3883    .execute(&mut *tx)
3884    .await
3885    .context("redeem_code: grant")?;
3886
3887    tx.commit().await.context("commit redeem_code tx")?;
3888    Ok(Ok(()))
3889}
3890
3891/// Sweep: flip every `active` code whose `expires_at` is in the past to
3892/// `expired`. Returns the number of codes expired. Called periodically by the
3893/// scheduler.
3894pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
3895    let now = now_unix();
3896    let res = sqlx::query(
3897        "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
3898    )
3899    .bind(now)
3900    .execute(pool)
3901    .await
3902    .context("expire_old_codes failed")?;
3903    Ok(res.rows_affected())
3904}
3905
3906/// Seed the admin-bootstrap DIDs: for each, insert a `beta_access` row
3907/// (`granted_by = 'admin'`) if one does not already exist. Idempotent — an
3908/// existing seat is left untouched. Returns how many new seats were created.
3909pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
3910    let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
3911    let now = now_unix();
3912    let mut created = 0u64;
3913    for did in dids {
3914        let res = sqlx::query(
3915            r#"
3916            INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3917            VALUES (?1, NULL, 'admin', ?2, NULL)
3918            ON CONFLICT (did) DO NOTHING
3919            "#,
3920        )
3921        .bind(did)
3922        .bind(now)
3923        .execute(&mut *tx)
3924        .await
3925        .with_context(|| format!("ensure_seed insert failed for {did}"))?;
3926        created += res.rows_affected();
3927    }
3928    tx.commit().await.context("commit ensure_seed tx")?;
3929    Ok(created)
3930}
3931
3932/// The row counts purged by [`purge_did_data`], for a confirmable success
3933/// message and for assertions in tests.
3934#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
3935pub struct PurgeCounts {
3936    /// `entry_state` rows removed (per-DID read/star flags).
3937    pub entry_state: u64,
3938    /// `read_cursor` rows removed (per-DID per-feed read cursors).
3939    pub read_cursor: u64,
3940    /// `sub_ref` rows removed (the DID's subscription projection).
3941    pub sub_ref: u64,
3942    /// `beta_access` rows removed (the DID's closed-beta seat: 0 or 1).
3943    pub beta_access: u64,
3944    /// `invite_codes` rows removed (codes this DID *created*).
3945    pub invite_codes: u64,
3946    /// `invite_codes` rows *scrubbed* (the code this DID *redeemed* to join —
3947    /// its `invitee_did` back-reference cleared to NULL, row kept).
3948    pub invitee_scrubbed: u64,
3949    /// `beta_access` rows *scrubbed* (seats this DID *granted* to others — the
3950    /// `granted_by` back-reference redacted to a sentinel, row kept).
3951    pub granted_by_scrubbed: u64,
3952}
3953
3954impl PurgeCounts {
3955    /// Total rows removed across every per-DID table. (Scrub counts are tracked
3956    /// separately — those rows belong to *other* DIDs and are redacted, not
3957    /// deleted — so they are excluded from the delete total.)
3958    pub fn total(&self) -> u64 {
3959        self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
3960    }
3961}
3962
3963/// Sentinel written into `beta_access.granted_by` when the granting DID deletes
3964/// its data: the column is `NOT NULL`, so we redact rather than NULL it. Keeps
3965/// the grantee's seat valid while removing the departed DID's back-reference.
3966pub const REDACTED_DID: &str = "__redacted__";
3967
3968/// Delete **all** local rows owned by `did` in a single transaction: the
3969/// per-DID read/star state (`entry_state`), per-feed read cursors
3970/// (`read_cursor`), the subscription projection (`sub_ref`), the closed-beta
3971/// seat (`beta_access`), and any invite codes this DID *created*
3972/// (`invite_codes`). The shared `feeds`/`entries` cache is intentionally left
3973/// intact — it is deduped and not owned by any single DID.
3974///
3975/// This is the local half of "delete my data": the caller pairs it with a
3976/// sidecar `POST /internal/revoke` so the OAuth tokens + sidecar session rows
3977/// are dropped too. Idempotent — deleting a DID with no rows returns all-zero
3978/// counts.
3979pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
3980    let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
3981
3982    let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
3983        .bind(did)
3984        .execute(&mut *tx)
3985        .await
3986        .with_context(|| format!("purge entry_state for {did}"))?
3987        .rows_affected();
3988
3989    let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
3990        .bind(did)
3991        .execute(&mut *tx)
3992        .await
3993        .with_context(|| format!("purge read_cursor for {did}"))?
3994        .rows_affected();
3995
3996    let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
3997        .bind(did)
3998        .execute(&mut *tx)
3999        .await
4000        .with_context(|| format!("purge sub_ref for {did}"))?
4001        .rows_affected();
4002
4003    let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
4004        .bind(did)
4005        .execute(&mut *tx)
4006        .await
4007        .with_context(|| format!("purge beta_access for {did}"))?
4008        .rows_affected();
4009
4010    let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
4011        .bind(did)
4012        .execute(&mut *tx)
4013        .await
4014        .with_context(|| format!("purge invite_codes for {did}"))?
4015        .rows_affected();
4016
4017    // Scrub the DID's back-references from rows that belong to OTHER DIDs so no
4018    // per-DID residue survives the delete:
4019    //   * the invite code this DID *redeemed* to join lives on the inviter's
4020    //     row (`invitee_did`) — NULL it out (column is nullable).
4021    //   * seats this DID *granted* to others carry `granted_by = <this did>` —
4022    //     redact to a sentinel (column is NOT NULL) so the grantee keeps access
4023    //     without retaining the departed DID.
4024    let invitee_scrubbed =
4025        sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
4026            .bind(did)
4027            .execute(&mut *tx)
4028            .await
4029            .with_context(|| format!("scrub invitee_did for {did}"))?
4030            .rows_affected();
4031
4032    // A departing DID may also be the TARGET of an outstanding bot claim
4033    // (`intended_did`, minted for them before they joined/left) — NULL it so no
4034    // per-DID residue survives. We ALSO expire the orphaned code in the same tx:
4035    // once `intended_did` is NULLed, an `active` row would otherwise keep counting
4036    // against the daily mint cap for its full 14-day TTL (and a re-follow would
4037    // double-count it), so `expired` it now. `redeemed`/already-`expired` rows are
4038    // untouched (the WHERE only matches `active`). (Cheap nit — purge orphan.)
4039    sqlx::query(
4040        "UPDATE invite_codes \
4041         SET intended_did = NULL, \
4042             status = CASE WHEN status = 'active' THEN 'expired' ELSE status END \
4043         WHERE intended_did = ?1",
4044    )
4045    .bind(did)
4046    .execute(&mut *tx)
4047    .await
4048    .with_context(|| format!("scrub intended_did for {did}"))?;
4049
4050    let granted_by_scrubbed =
4051        sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
4052            .bind(did)
4053            .bind(REDACTED_DID)
4054            .execute(&mut *tx)
4055            .await
4056            .with_context(|| format!("scrub granted_by for {did}"))?
4057            .rows_affected();
4058
4059    tx.commit().await.context("commit purge_did_data tx")?;
4060
4061    Ok(PurgeCounts {
4062        entry_state,
4063        read_cursor,
4064        sub_ref,
4065        beta_access,
4066        invite_codes,
4067        invitee_scrubbed,
4068        granted_by_scrubbed,
4069    })
4070}
4071
4072/// Aggregate poll health, for the public stats page.
4073///
4074/// **Deliberately aggregate-only.** No user counts, no error rates, no per-feed
4075/// detail: this is published to anyone, and a reader does not need to know how
4076/// many people use an instance or which feeds are failing. What it does answer
4077/// is the only question the page exists for — is the poller keeping up?
4078#[derive(Debug, Clone, PartialEq, Eq)]
4079pub struct PollHealth {
4080    /// Distinct feeds the poller is responsible for.
4081    pub feeds_tracked: i64,
4082    /// How many were polled within the last hour.
4083    pub polled_last_hour: i64,
4084    /// Feeds whose `next_poll` has passed — the backlog. A healthy instance
4085    /// clears this every tick; a growing number is the signal that the poller
4086    /// cannot keep up with the feed count.
4087    pub overdue: i64,
4088    /// Seconds since the most recent poll of any feed. `None` before the first.
4089    pub last_poll_secs_ago: Option<i64>,
4090    /// Seconds since the LEAST recently polled feed was polled — the worst
4091    /// staleness any reader is currently seeing.
4092    ///
4093    /// `None` when any feed has NEVER been polled, because that is a worse
4094    /// staleness than any finite age and reporting the finite one would make
4095    /// the page read healthiest exactly when it is least healthy.
4096    pub oldest_poll_secs_ago: Option<i64>,
4097    /// How many feeds have never been polled at all.
4098    pub never_polled: i64,
4099    /// Feeds currently in error backoff (`consecutive_errors > 0`).
4100    ///
4101    /// One of the two states that stop feeds updating, and previously visible
4102    /// nowhere: `consecutive_errors` was written by `bump_feed_errors` and read
4103    /// by nothing outside the backoff calculation — no page, no endpoint. Worse,
4104    /// a feed in backoff is NOT counted in `overdue`, because backoff is applied
4105    /// by pushing `next_poll` forward. So the one number a reader might have
4106    /// checked moved the wrong way: a feed failing every fetch made `overdue`
4107    /// look BETTER.
4108    pub in_backoff: i64,
4109    /// Of those, how many have reached `BADLY_BROKEN_ERRORS` consecutive
4110    /// failures — retried 2h40m apart rather than every 5 minutes.
4111    ///
4112    /// Not "will not recover on their own": the backoff ceiling is 24h at ten
4113    /// errors, and any of these recovers on its next successful poll. See
4114    /// `BADLY_BROKEN_ERRORS`.
4115    pub badly_broken: i64,
4116    /// Failing feeds grouped by **cause**, descending, as
4117    /// `(kind, count)` — `fetch`, `status`, `body`, `parse`.
4118    ///
4119    /// **Counts, never identities.** `/stats` is public and states that it
4120    /// reports machines rather than people: no per-feed detail, never which feed
4121    /// and never whose. A cause histogram keeps that promise and still answers
4122    /// the question `badly_broken` could not — whether sixty feeds are failing
4123    /// for sixty reasons or for one. Had this existed, #159 would have read
4124    /// `fetch: 60` on a page anyone could load, instead of costing a production
4125    /// investigation.
4126    pub failure_kinds: Vec<(String, i64)>,
4127}
4128
4129/// `consecutive_errors` at or above which a feed counts as `badly_broken`.
4130///
4131/// Chosen to mean "this is not a transient blip": `feed::backoff_for` climbs
4132/// exponentially, so by this many consecutive failures a feed is being retried
4133/// **2h40m apart** — `backoff_for(6)`.
4134///
4135/// **Not "at or near the ceiling", and not "effectively dead".** `BACKOFF_MAX`
4136/// is 24h and is first reached at *ten* errors, so a feed at this threshold is
4137/// still retried around nine times a day and recovers on its own the moment the
4138/// cause clears. Three doc comments claimed otherwise, and the claim was
4139/// load-bearing in the wrong direction.
4140///
4141/// **It says nothing about whose fault the failure is, and used to claim it
4142/// did.** This comment and the matching `/stats` copy read "almost certainly
4143/// gone rather than flaky" until 2026-09-20, when #159 found that 60-odd feeds
4144/// sat here because `guarded_get` was reading every `304 Not Modified` as a
4145/// malformed redirect. The publishers were live; the reader was broken. That
4146/// assertion is what stopped anyone looking, which is why `last_error_kind`
4147/// now exists — the row can answer the question the count never could.
4148const BADLY_BROKEN_ERRORS: i64 = 6;
4149
4150/// Compute [`PollHealth`] as of `now` (RFC3339, seconds precision — the same
4151/// format the scheduler writes, so the comparisons are lexicographic).
4152pub async fn poll_health(pool: &SqlitePool, now: &str, hour_ago: &str) -> Result<PollHealth> {
4153    // **Only what the poller sees.** `due_feeds` skips `at://` rows, so nothing
4154    // ever advances their `next_poll` or sets `last_polled`; counted here they
4155    // read as overdue and never-polled forever and force "oldest poll" to
4156    // `never` — unsupported shown as broken, on a public page, permanently.
4157    // The same predicate as the scheduler's, so the two cannot disagree.
4158    let aggregate = format!(
4159        r#"
4160        SELECT
4161            COUNT(*),
4162            COALESCE(SUM(CASE WHEN last_polled IS NOT NULL AND last_polled >= ?2 THEN 1 ELSE 0 END), 0),
4163            COALESCE(SUM(CASE WHEN next_poll IS NULL OR next_poll <= ?1 THEN 1 ELSE 0 END), 0),
4164            MAX(last_polled),
4165            -- NULL-AWARE. `MIN` skips NULLs, so an instance where most feeds
4166            -- had NEVER been polled reported the freshest of the few that had —
4167            -- the figure read healthiest in the most degraded state, which is
4168            -- the opposite of what a health page is for. A never-polled feed IS
4169            -- the worst staleness, so it wins outright.
4170            CASE WHEN SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END) > 0
4171                 THEN NULL ELSE MIN(last_polled) END,
4172            SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END),
4173            COALESCE(SUM(CASE WHEN consecutive_errors > 0 THEN 1 ELSE 0 END), 0),
4174            COALESCE(SUM(CASE WHEN consecutive_errors >= ?3 THEN 1 ELSE 0 END), 0)
4175        FROM feeds
4176        WHERE kind IN ({POLLABLE_KINDS_SQL})
4177        "#
4178    );
4179    #[allow(clippy::type_complexity)]
4180    let row: (i64, i64, i64, Option<String>, Option<String>, i64, i64, i64) =
4181        sqlx::query_as(sqlx::AssertSqlSafe(aggregate))
4182            .bind(now)
4183            .bind(hour_ago)
4184            .bind(BADLY_BROKEN_ERRORS)
4185            .fetch_one(pool)
4186            .await
4187            .context("computing poll health")?;
4188
4189    // A second, tiny query rather than a join: the histogram groups rows the
4190    // aggregate above collapses, and one statement doing both would make the
4191    // counts above harder to read than the extra round trip is worth.
4192    //
4193    // **Every failing feed lands in a bucket, so this sums to `in_backoff`.**
4194    //
4195    // A row that predates the column is failing with no recorded cause, and it
4196    // must not be attributed to some other feed's reason — but it must not
4197    // vanish either. Filtering them out made the breakdown silently disagree
4198    // with the `Failing` figure beside it: on a migrated database that is EVERY
4199    // currently-failing feed, so the page would have read "70 failing" next to
4200    // "3 fetch" with 67 unexplained and no indication a remainder existed.
4201    //
4202    // `unknown` is a deliberate bucket rather than an omission. It cannot
4203    // collide with a real kind — `FailureKind::as_str` never returns it, and
4204    // `FailureKind::parse("unknown")` is `None`.
4205    let histogram = format!(
4206        r#"
4207        -- **`failure_kind`, not `kind`.** Aliasing this `kind` collided with
4208        -- the `feeds.kind` column added for the poller: SQLite resolved
4209        -- `GROUP BY kind` to the table column, so every failing feed collapsed
4210        -- into ONE bucket labelled from an arbitrary row — a public page
4211        -- reporting "10 fetch" for ten unrelated causes. Caught by
4212        -- `an_unrecognised_failure_kind_folds_into_unknown`.
4213        SELECT COALESCE(last_error_kind, 'unknown') AS failure_kind, COUNT(*) AS n
4214        FROM feeds
4215        WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
4216        GROUP BY failure_kind
4217        ORDER BY n DESC, failure_kind ASC
4218        "#
4219    );
4220    let kinds: Vec<(String, i64)> = sqlx::query_as(sqlx::AssertSqlSafe(histogram))
4221        .fetch_all(pool)
4222        .await
4223        .context("computing the failure-cause histogram")?;
4224
4225    // **Close the vocabulary where it is READ.** `FailureKind::parse` promised
4226    // that a kind from a newer build would not be attributed to a cause this
4227    // one recognises — but nothing called it, so the raw column reached the
4228    // public template and an unrecognised string rendered as its own bucket.
4229    // Fold anything `parse` rejects into `unknown`, then re-aggregate and
4230    // re-order, so the histogram only ever shows the four kinds this build
4231    // knows plus the one honest bucket for what it does not.
4232    let mut folded: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
4233    for (kind, n) in kinds {
4234        let key = if kind == "unknown" || crate::feed::FailureKind::parse(&kind).is_some() {
4235            kind
4236        } else {
4237            "unknown".to_string()
4238        };
4239        *folded.entry(key).or_insert(0) += n;
4240    }
4241    let mut kinds: Vec<(String, i64)> = folded.into_iter().collect();
4242    kinds.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4243
4244    Ok(PollHealth {
4245        feeds_tracked: row.0,
4246        polled_last_hour: row.1,
4247        overdue: row.2,
4248        last_poll_secs_ago: secs_between(row.3.as_deref(), now),
4249        oldest_poll_secs_ago: secs_between(row.4.as_deref(), now),
4250        never_polled: row.5,
4251        in_backoff: row.6,
4252        badly_broken: row.7,
4253        failure_kinds: kinds,
4254    })
4255}
4256
4257/// Whole seconds from `then` to `now`, or `None` if `then` is absent or
4258/// unparseable. Never negative: a clock skew that puts a poll in the future
4259/// reads as "just now" rather than as a negative age.
4260fn secs_between(then: Option<&str>, now: &str) -> Option<i64> {
4261    let then = chrono::DateTime::parse_from_rfc3339(then?).ok()?;
4262    let now = chrono::DateTime::parse_from_rfc3339(now).ok()?;
4263    Some((now - then).num_seconds().max(0))
4264}
4265
4266#[cfg(test)]
4267mod tests {
4268    use super::*;
4269
4270    /// **A re-poll refreshes `published`; it never refreshes `fetched_at`.**
4271    ///
4272    /// The asymmetry is the whole reason a date must be stable. `published`
4273    /// comes back from the publisher on every poll, so a value the mapper
4274    /// recomputes — "now", say — is rewritten every hour and the row can never
4275    /// age. `fetched_at` is written once, at first insert, so an entry stored
4276    /// with no date is effectively dated when we first saw it, and that date
4277    /// does hold still. Both the per-feed cap and the retention sweep order on
4278    /// `COALESCE(published, fetched_at)`, so which of the two a row lands in
4279    /// decides whether it can ever be evicted or swept.
4280    #[tokio::test]
4281    async fn a_repoll_refreshes_published_but_never_fetched_at() -> Result<()> {
4282        let pool = init_url("sqlite::memory:").await?;
4283        let feed_id = upsert_feed(
4284            &pool,
4285            &NewFeed {
4286                url: "https://example.com/f.xml".to_string(),
4287                ..Default::default()
4288            },
4289        )
4290        .await?;
4291        let seen = |at: &str| {
4292            vec![NewEntry {
4293                guid: "g".to_string(),
4294                published: Some(at.to_string()),
4295                fetched_at: Some(at.to_string()),
4296                ..Default::default()
4297            }]
4298        };
4299        insert_entries(&pool, feed_id, &seen("2026-01-01T00:00:00Z"), 0).await?;
4300        insert_entries(&pool, feed_id, &seen("2026-09-20T00:00:00Z"), 0).await?;
4301
4302        let (published, fetched_at): (Option<String>, String) =
4303            sqlx::query_as("SELECT published, fetched_at FROM entries WHERE guid = 'g'")
4304                .fetch_one(&pool)
4305                .await?;
4306        assert_eq!(
4307            published.as_deref(),
4308            Some("2026-09-20T00:00:00Z"),
4309            "the second poll's date did not replace the first"
4310        );
4311        assert_eq!(
4312            fetched_at, "2026-01-01T00:00:00Z",
4313            "fetched_at moved, so an undated entry would never age either"
4314        );
4315        Ok(())
4316    }
4317
4318    /// A partial upsert must not erase the conditional-GET validators.
4319    ///
4320    /// `set_next_poll` supplies only `url` + `next_poll` and runs after EVERY
4321    /// poll of EVERY feed. While `upsert_feed` assigned etag/last_modified
4322    /// unconditionally, that call wrote both back to NULL, so `If-None-Match`
4323    /// was never sent, `304` was unreachable, and every feed was re-downloaded
4324    /// and re-parsed in full on every cycle. Nothing failed; it was invisible.
4325    #[tokio::test]
4326    async fn validators_survive_a_partial_upsert() -> Result<()> {
4327        let pool = init_url("sqlite::memory:").await?;
4328        let url = "https://example.com/feed.xml";
4329
4330        upsert_feed(
4331            &pool,
4332            &NewFeed {
4333                url: url.to_string(),
4334                etag: Some("\"abc123\"".to_string()),
4335                last_modified: Some("Wed, 01 Jan 2026 00:00:00 GMT".to_string()),
4336                ..Default::default()
4337            },
4338        )
4339        .await?;
4340
4341        // Exactly what `scheduler::set_next_poll` sends.
4342        upsert_feed(
4343            &pool,
4344            &NewFeed {
4345                url: url.to_string(),
4346                next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4347                ..Default::default()
4348            },
4349        )
4350        .await?;
4351
4352        let feed = get_feed_by_url(&pool, url).await?.expect("feed");
4353        assert_eq!(
4354            feed.etag.as_deref(),
4355            Some("\"abc123\""),
4356            "a partial upsert erased the ETag, disabling conditional GET"
4357        );
4358        assert_eq!(
4359            feed.last_modified.as_deref(),
4360            Some("Wed, 01 Jan 2026 00:00:00 GMT"),
4361            "a partial upsert erased Last-Modified"
4362        );
4363        assert_eq!(feed.next_poll.as_deref(), Some("2026-07-12T00:00:00Z"));
4364        Ok(())
4365    }
4366
4367    /// A hard ceiling that is not strictly older than the window is IGNORED.
4368    ///
4369    /// `hard_days.max(days)` made `0` — the obvious "off" value, and the
4370    /// documented disable value for `RETENTION_DAYS` — collapse the ceiling onto
4371    /// the soft window, where the delete spares nothing. The starred and unread
4372    /// rows the window exists to protect were purged at `retention_days`.
4373    #[tokio::test]
4374    async fn a_ceiling_inside_the_window_is_ignored_not_applied() -> Result<()> {
4375        for hard in [0_i64, 1, 7, 14] {
4376            let pool = init_url("sqlite::memory:").await?;
4377            let feed_id = upsert_feed(
4378                &pool,
4379                &NewFeed {
4380                    url: "https://example.com/f.xml".to_string(),
4381                    ..Default::default()
4382                },
4383            )
4384            .await?;
4385            let old = (chrono::Utc::now() - chrono::Duration::days(30))
4386                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4387            insert_entries(
4388                &pool,
4389                feed_id,
4390                &[
4391                    NewEntry {
4392                        guid: "starred-30d".to_string(),
4393                        published: Some(old.clone()),
4394                        ..Default::default()
4395                    },
4396                    NewEntry {
4397                        guid: "unread-30d".to_string(),
4398                        published: Some(old.clone()),
4399                        ..Default::default()
4400                    },
4401                ],
4402                0,
4403            )
4404            .await?;
4405            // Both need an explicit `entry_state` row: sparing keys off a
4406            // DELIBERATE mark, and an entry with no row at all is unclaimed
4407            // cache that the window is supposed to evict.
4408            sqlx::query(
4409                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4410                 SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z'
4411                 FROM entries WHERE guid = 'starred-30d'",
4412            )
4413            .execute(&pool)
4414            .await?;
4415            sqlx::query(
4416                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4417                 SELECT 'did:plc:x', id, 0, 0, '2026-01-01T00:00:00Z'
4418                 FROM entries WHERE guid = 'unread-30d'",
4419            )
4420            .execute(&pool)
4421            .await?;
4422
4423            prune_old_entries(&pool, 14, hard, 0).await?;
4424
4425            let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4426                .fetch_one(&pool)
4427                .await?;
4428            assert_eq!(
4429                left, 2,
4430                "hard_days={hard} destroyed starred/unread rows at the soft window"
4431            );
4432        }
4433        Ok(())
4434    }
4435
4436    /// Turning the rolling window off must NOT also turn the ceiling off.
4437    ///
4438    /// `prune_old_entries` used to return on `days <= 0` before the ceiling was
4439    /// even computed, so `RETENTION_DAYS=0` — advertised as "disables eviction" —
4440    /// meant no window AND no ceiling. That is the one configuration with no
4441    /// bound on the shared cache at all, and it stopped being survivable when the
4442    /// per-feed trim started sparing starred entries: nothing was left to catch
4443    /// them. The two knobs are independent now.
4444    #[tokio::test]
4445    async fn a_disabled_window_does_not_disable_the_ceiling() -> Result<()> {
4446        let pool = init_url("sqlite::memory:").await?;
4447        let feed_id = upsert_feed(
4448            &pool,
4449            &NewFeed {
4450                url: "https://example.com/f.xml".to_string(),
4451                ..Default::default()
4452            },
4453        )
4454        .await?;
4455        let age = |d: i64| {
4456            (chrono::Utc::now() - chrono::Duration::days(d))
4457                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4458        };
4459        insert_entries(
4460            &pool,
4461            feed_id,
4462            &[
4463                NewEntry {
4464                    guid: "starred-400d".to_string(),
4465                    published: Some(age(400)),
4466                    ..Default::default()
4467                },
4468                NewEntry {
4469                    guid: "starred-30d".to_string(),
4470                    published: Some(age(30)),
4471                    ..Default::default()
4472                },
4473            ],
4474            0,
4475        )
4476        .await?;
4477        // Star both, so only the ceiling can remove either one — the soft
4478        // window's exception would spare them both even if it did run.
4479        sqlx::query(
4480            "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4481             SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z' FROM entries",
4482        )
4483        .execute(&pool)
4484        .await?;
4485
4486        // No rolling window; a 180-day ceiling.
4487        let deleted = prune_old_entries(&pool, 0, 180, 0).await?;
4488
4489        assert_eq!(
4490            deleted, 1,
4491            "retention_days=0 skipped the hard ceiling, leaving the cache unbounded"
4492        );
4493        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
4494            .fetch_all(&pool)
4495            .await?;
4496        assert_eq!(
4497            left,
4498            vec!["starred-30d".to_string()],
4499            "the ceiling removed the wrong rows with the window disabled"
4500        );
4501        Ok(())
4502    }
4503
4504    /// With BOTH knobs off, nothing is deleted — that is the documented
4505    /// "no eviction at all" configuration, and it must stay a true no-op rather
4506    /// than falling through to one of the two deletes with a degenerate cutoff.
4507    #[tokio::test]
4508    async fn both_knobs_off_deletes_nothing() -> Result<()> {
4509        let pool = init_url("sqlite::memory:").await?;
4510        let feed_id = upsert_feed(
4511            &pool,
4512            &NewFeed {
4513                url: "https://example.com/f.xml".to_string(),
4514                ..Default::default()
4515            },
4516        )
4517        .await?;
4518        insert_entries(
4519            &pool,
4520            feed_id,
4521            &[NewEntry {
4522                guid: "ancient".to_string(),
4523                published: Some(
4524                    (chrono::Utc::now() - chrono::Duration::days(9999))
4525                        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4526                ),
4527                ..Default::default()
4528            }],
4529            0,
4530        )
4531        .await?;
4532
4533        assert_eq!(prune_old_entries(&pool, 0, 0, 0).await?, 0);
4534        let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4535            .fetch_one(&pool)
4536            .await?;
4537        assert_eq!(left, 1);
4538        Ok(())
4539    }
4540
4541    /// Starred sparing must not remove the per-feed cap.
4542    ///
4543    /// The first version spared every starred row without limit: at cap=5 with
4544    /// 50 starred entries, 55 survived — 11x the cap, i.e. no cap at all.
4545    #[tokio::test]
4546    async fn per_feed_trim_stays_bounded_when_everything_is_starred() -> Result<()> {
4547        let pool = init_url("sqlite::memory:").await?;
4548        let feed_id = upsert_feed(
4549            &pool,
4550            &NewFeed {
4551                url: "https://example.com/f.xml".to_string(),
4552                ..Default::default()
4553            },
4554        )
4555        .await?;
4556        let entries: Vec<NewEntry> = (0..100)
4557            .map(|i| NewEntry {
4558                guid: format!("g-{i}"),
4559                published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
4560                ..Default::default()
4561            })
4562            .collect();
4563        insert_entries(&pool, feed_id, &entries, 0).await?;
4564        sqlx::query(
4565            "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4566             SELECT 'did:plc:x', id, 0, 1, '2026-01-01T00:00:00Z'
4567             FROM entries LIMIT 50",
4568        )
4569        .execute(&pool)
4570        .await?;
4571
4572        // Re-run the trim with cap = 5.
4573        insert_entries(&pool, feed_id, &[], 5).await?;
4574
4575        let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4576            .fetch_one(&pool)
4577            .await?;
4578        assert!(
4579            left <= 10,
4580            "per-feed trim kept {left} rows for a cap of 5; sparing removed the bound"
4581        );
4582        Ok(())
4583    }
4584
4585    /// Init an in-memory SQLite, insert a feed + entries, read them back.
4586    #[tokio::test]
4587    async fn init_insert_readback() -> Result<()> {
4588        let pool = init_url("sqlite::memory:").await?;
4589
4590        // Insert a feed.
4591        let feed_id = upsert_feed(
4592            &pool,
4593            &NewFeed {
4594                url: "https://example.com/feed.xml".to_string(),
4595                title: Some("Example".to_string()),
4596                site_url: Some("https://example.com".to_string()),
4597                next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4598                ..Default::default()
4599            },
4600        )
4601        .await?;
4602        assert!(feed_id > 0);
4603
4604        // Read the feed back by URL.
4605        let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
4606            .await?
4607            .expect("feed should exist");
4608        assert_eq!(feed.id, feed_id);
4609        assert_eq!(feed.title.as_deref(), Some("Example"));
4610        assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
4611
4612        // Upsert on the same URL updates rather than duplicating.
4613        let feed_id2 = upsert_feed(
4614            &pool,
4615            &NewFeed {
4616                url: "https://example.com/feed.xml".to_string(),
4617                title: Some("Example (renamed)".to_string()),
4618                ..Default::default()
4619            },
4620        )
4621        .await?;
4622        assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
4623
4624        // Insert two entries.
4625        let n = insert_entries(
4626            &pool,
4627            feed_id,
4628            &[
4629                NewEntry {
4630                    guid: "guid-1".to_string(),
4631                    url: Some("https://example.com/a".to_string()),
4632                    title: Some("First".to_string()),
4633                    published: Some("2026-07-10T08:00:00Z".to_string()),
4634                    content_html: Some("<p>hello</p>".to_string()),
4635                    ..Default::default()
4636                },
4637                NewEntry {
4638                    guid: "guid-2".to_string(),
4639                    url: Some("https://example.com/b".to_string()),
4640                    title: Some("Second".to_string()),
4641                    published: Some("2026-07-11T08:00:00Z".to_string()),
4642                    ..Default::default()
4643                },
4644            ],
4645            0, // per-feed trim disabled for this test
4646        )
4647        .await?;
4648        assert_eq!(n, 2);
4649
4650        // The reader must subscribe to the feed for the scoped reads to return
4651        // its entries (per-DID isolation projection).
4652        let did = "did:plc:abc123";
4653        replace_sub_refs(&pool, did, &[feed_id]).await?;
4654
4655        // Read entries back (newest-published first).
4656        let entries = entries_for_feed(&pool, did, feed_id).await?;
4657        assert_eq!(entries.len(), 2);
4658        assert_eq!(entries[0].guid, "guid-2");
4659        assert_eq!(entries[1].guid, "guid-1");
4660        // The body is stored, but it is NOT in the list projection — that is the
4661        // point of `EntryListRow`. Read it the way the single-entry reader does.
4662        let body: Option<String> =
4663            sqlx::query_scalar("SELECT content_html FROM entries WHERE guid = 'guid-1'")
4664                .fetch_one(&pool)
4665                .await?;
4666        assert_eq!(body.as_deref(), Some("<p>hello</p>"));
4667
4668        // Re-inserting the same GUID dedups (updates in place, no new row).
4669        let n2 = insert_entries(
4670            &pool,
4671            feed_id,
4672            &[NewEntry {
4673                guid: "guid-1".to_string(),
4674                title: Some("First (edited)".to_string()),
4675                ..Default::default()
4676            }],
4677            0,
4678        )
4679        .await?;
4680        assert_eq!(n2, 1);
4681        assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
4682
4683        // --- per-DID read state ---
4684        let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
4685
4686        // Both entries start unread.
4687        assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
4688
4689        // Mark one read; unread count drops to 1.
4690        mark_read(&pool, did, e1, true).await?;
4691        let unread = get_unread_for_did(&pool, did).await?;
4692        assert_eq!(unread.len(), 1);
4693        assert_eq!(unread[0].guid, "guid-2");
4694
4695        // Star it; it shows in the starred list.
4696        mark_starred(&pool, did, e1, true).await?;
4697        let starred = get_starred_for_did(&pool, did).await?;
4698        assert_eq!(starred.len(), 1);
4699        assert_eq!(starred[0].id, e1);
4700
4701        // Mark-all-read clears the remaining unread.
4702        mark_feed_read(&pool, did, feed_id, true).await?;
4703        assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
4704
4705        // --- read cursor (batched-sync bookkeeping) ---
4706        let cursor = ReadCursor {
4707            did: did.to_string(),
4708            feed_url: "https://example.com/feed.xml".to_string(),
4709            read_through: Some("2026-07-11T08:00:00Z".to_string()),
4710            read_ids: "[]".to_string(),
4711            unread_ids: "[]".to_string(),
4712            dirty: true,
4713            pds_created: false,
4714            updated_at: now_rfc3339(),
4715        };
4716        upsert_cursor(&pool, &cursor).await?;
4717
4718        let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
4719            .await?
4720            .expect("cursor should exist");
4721        assert_eq!(
4722            fetched.read_through.as_deref(),
4723            Some("2026-07-11T08:00:00Z")
4724        );
4725        assert!(fetched.dirty);
4726
4727        // The flusher sees exactly one dirty cursor.
4728        let dirty = dirty_cursors(&pool, did).await?;
4729        assert_eq!(dirty.len(), 1);
4730        let flushed_at = dirty[0].updated_at.clone();
4731
4732        // After a flush, clearing dirty (with the flushed snapshot's updated_at)
4733        // removes it from the flusher's view.
4734        clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
4735        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
4736
4737        Ok(())
4738    }
4739
4740    // -----------------------------------------------------------------------
4741    // The bounded, body-free list projection.
4742    //
4743    // The three queries these replaced were `SELECT e.*` with no `LIMIT`. Both
4744    // halves of that are load-bearing on a 512 MB box: the projection dragged
4745    // an ~11.9 KB article body per row that no list surface reads, and the
4746    // missing bound let one reader's backlog decide how much a handler
4747    // allocates.
4748    // -----------------------------------------------------------------------
4749
4750    /// Seed `count` entries in one feed, each with a large body, subscribed by
4751    /// `did`. Returns the feed id.
4752    async fn seed_big_entries(pool: &SqlitePool, did: &str, count: usize) -> Result<i64> {
4753        let feed_id = upsert_feed(
4754            pool,
4755            &NewFeed {
4756                url: "https://example.com/big.xml".to_string(),
4757                ..Default::default()
4758            },
4759        )
4760        .await?;
4761        let body = "x".repeat(20_000);
4762        let entries: Vec<NewEntry> = (0..count)
4763            .map(|i| NewEntry {
4764                guid: format!("guid-{i:04}"),
4765                url: Some(format!("https://example.com/a/{i}")),
4766                title: Some(format!("Article {i}")),
4767                // Descending guid order matches descending published order, so
4768                // assertions can name the rows they expect.
4769                published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
4770                content_html: Some(body.clone()),
4771                ..Default::default()
4772            })
4773            .collect();
4774        insert_entries(pool, feed_id, &entries, 0).await?;
4775        replace_sub_refs(pool, did, &[feed_id]).await?;
4776        Ok(feed_id)
4777    }
4778
4779    /// `limit` is honoured, and `offset` walks the same ordering without gaps or
4780    /// repeats. Against the unbounded originals the first assertion returned all
4781    /// 250 rows.
4782    #[tokio::test]
4783    async fn list_entries_is_bounded_and_pages_without_overlap() -> Result<()> {
4784        let pool = init_url("sqlite::memory:").await?;
4785        let did = "did:plc:pager";
4786        seed_big_entries(&pool, did, 250).await?;
4787
4788        let page1 = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
4789        assert_eq!(page1.len(), 100, "limit was not applied");
4790        let page2 = list_entries(&pool, did, ListView::All, None, 100, 100).await?;
4791        let page3 = list_entries(&pool, did, ListView::All, None, 100, 200).await?;
4792        assert_eq!(page3.len(), 50, "the last page should be the remainder");
4793
4794        let walked: Vec<i64> = page1
4795            .iter()
4796            .chain(&page2)
4797            .chain(&page3)
4798            .map(|e| e.id)
4799            .collect();
4800        let unique: std::collections::HashSet<i64> = walked.iter().copied().collect();
4801        assert_eq!(unique.len(), 250, "paging repeated or skipped rows");
4802
4803        // And the walk is the same order an unpaged read would produce.
4804        let whole = list_entries(&pool, did, ListView::All, None, 1_000, 0).await?;
4805        assert_eq!(
4806            walked,
4807            whole.iter().map(|e| e.id).collect::<Vec<_>>(),
4808            "paging changed the ordering"
4809        );
4810
4811        // **The tie-break is pinned, not left to the engine.** The seed gives
4812        // 250 rows only 28 distinct dates, so the order is mostly ties; with
4813        // the `id DESC` tie-break deleted, SQLite happened to return ties in a
4814        // stable order and both assertions above still held. The expected
4815        // order is computed from the seed pattern here — newest date first,
4816        // then newest id — and must match exactly.
4817        let mut expected: Vec<(i64, i64)> = whole
4818            .iter()
4819            .map(|e| {
4820                let day: i64 = e.published.as_deref().unwrap()[8..10].parse().unwrap();
4821                (day, e.id)
4822            })
4823            .collect();
4824        expected.sort_by(|a, b| b.cmp(a));
4825        assert_eq!(
4826            walked,
4827            expected.iter().map(|(_, id)| *id).collect::<Vec<_>>(),
4828            "ties are not broken by newest id"
4829        );
4830
4831        assert_eq!(
4832            count_entries_for_view(&pool, did, ListView::All, None).await?,
4833            250,
4834            "the unpaged count must survive paging"
4835        );
4836        Ok(())
4837    }
4838
4839    /// The list projection must not read `content_html`.
4840    ///
4841    /// A type-level fact — `EntryListRow` has no body field — so the test proves
4842    /// it the only way that survives a refactor: by asking SQLite what the query
4843    /// it runs actually names. `SELECT e.*` would list every column.
4844    #[tokio::test]
4845    async fn the_list_projection_does_not_name_the_body_column() -> Result<()> {
4846        let pool = init_url("sqlite::memory:").await?;
4847        let did = "did:plc:projection";
4848        seed_big_entries(&pool, did, 3).await?;
4849
4850        // **The projection the query actually runs**, not a copy re-typed here.
4851        // The earlier version passed its own literal to `list_query_sql` and
4852        // asserted on that, so adding `e.content_html` to `list_entries` left
4853        // this green.
4854        let (sql, _) = list_entries_sql(ListView::All, None);
4855        assert!(
4856            !sql.contains("content_html") && !sql.contains("e.*"),
4857            "the list query reads the article body: {sql}"
4858        );
4859
4860        // And the rows really do come back without it, which is what bounds the
4861        // per-request allocation.
4862        let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
4863        assert_eq!(rows.len(), 3);
4864        let widest = rows
4865            .iter()
4866            .map(|r| {
4867                r.guid.len()
4868                    + r.url.as_deref().map_or(0, str::len)
4869                    + r.title.as_deref().map_or(0, str::len)
4870            })
4871            .max()
4872            .unwrap_or(0);
4873        assert!(
4874            widest < 1_000,
4875            "a list row carries {widest} bytes of text; the 20,000-byte body leaked in"
4876        );
4877        Ok(())
4878    }
4879
4880    /// **A large scope must not become a large SQL statement.**
4881    ///
4882    /// The scope filter used to emit one placeholder per feed id, so the SQL
4883    /// string and the bind list both grew with a reader's subscription count —
4884    /// which comes from the PDS and is bounded only by a 20,000-record list
4885    /// ceiling. The first attempt at fixing that truncated the subscription
4886    /// list, which silently removed the reader's access to the dropped feeds
4887    /// (`sub_ref` is written from the same list). `json_each` takes the whole
4888    /// set as ONE bind, so neither trade-off is needed.
4889    #[tokio::test]
4890    async fn a_large_scope_is_one_bind_and_still_filters() -> Result<()> {
4891        let pool = init_url("sqlite::memory:").await?;
4892        let did = "did:plc:widescope";
4893
4894        // 300 feeds, one entry each; the scope names 200 of them.
4895        let mut all_ids = Vec::new();
4896        for i in 0..300 {
4897            let feed_id = upsert_feed(
4898                &pool,
4899                &NewFeed {
4900                    url: format!("https://wide{i}.example/f.xml"),
4901                    ..Default::default()
4902                },
4903            )
4904            .await?;
4905            insert_entries(
4906                &pool,
4907                feed_id,
4908                &[NewEntry {
4909                    guid: format!("w-{i}"),
4910                    ..Default::default()
4911                }],
4912                0,
4913            )
4914            .await?;
4915            all_ids.push(feed_id);
4916        }
4917        replace_sub_refs(&pool, did, &all_ids).await?;
4918
4919        let scope: Vec<i64> = all_ids.iter().copied().take(200).collect();
4920        let rows = list_entries(&pool, did, ListView::All, Some(&scope), 1_000, 0).await?;
4921        assert_eq!(rows.len(), 200, "the scope filter did not narrow correctly");
4922        let in_scope: std::collections::HashSet<i64> = scope.iter().copied().collect();
4923        assert!(
4924            rows.iter().all(|r| in_scope.contains(&r.feed_id)),
4925            "a feed outside the scope came back"
4926        );
4927        assert_eq!(
4928            count_entries_for_view(&pool, did, ListView::All, Some(&scope)).await?,
4929            200
4930        );
4931
4932        // The statement itself carries no per-id placeholders — that is the
4933        // property, and it is what stops the SQL growing with the reader.
4934        let (sql, n) = list_query_sql(Projection::Ids, ListView::All, Some(&scope));
4935        assert_eq!(n, 1, "the scope must contribute exactly one placeholder");
4936        assert!(
4937            sql.contains("json_each(?2)") && !sql.contains("?3"),
4938            "the scope is still expanded into per-id placeholders: {sql}"
4939        );
4940        Ok(())
4941    }
4942
4943    /// Scope is applied INSIDE the query, so a page is a page of rows the reader
4944    /// will see. Filtering after the `LIMIT` (what the handler used to do) made
4945    /// pages arbitrarily short for any narrowed scope.
4946    #[tokio::test]
4947    async fn a_feed_scope_narrows_the_query_not_the_page() -> Result<()> {
4948        let pool = init_url("sqlite::memory:").await?;
4949        let did = "did:plc:scope";
4950        let wanted = seed_big_entries(&pool, did, 10).await?;
4951
4952        let other = upsert_feed(
4953            &pool,
4954            &NewFeed {
4955                url: "https://other.example/f.xml".to_string(),
4956                ..Default::default()
4957            },
4958        )
4959        .await?;
4960        let noise: Vec<NewEntry> = (0..40)
4961            .map(|i| NewEntry {
4962                guid: format!("noise-{i}"),
4963                // Newer than everything in `wanted`, so an unscoped query would
4964                // fill the whole page with these.
4965                published: Some("2027-01-01T00:00:00Z".to_string()),
4966                ..Default::default()
4967            })
4968            .collect();
4969        insert_entries(&pool, other, &noise, 0).await?;
4970        replace_sub_refs(&pool, did, &[wanted, other]).await?;
4971
4972        let scoped = list_entries(&pool, did, ListView::All, Some(&[wanted]), 10, 0).await?;
4973        assert_eq!(
4974            scoped.len(),
4975            10,
4976            "the scoped page came back short — the filter ran after the LIMIT"
4977        );
4978        assert!(scoped.iter().all(|e| e.feed_id == wanted));
4979
4980        // An EMPTY scope means "no feeds in scope", not "every feed".
4981        assert!(list_entries(&pool, did, ListView::All, Some(&[]), 10, 0)
4982            .await?
4983            .is_empty());
4984        assert_eq!(
4985            count_entries_for_view(&pool, did, ListView::All, Some(&[])).await?,
4986            0
4987        );
4988        Ok(())
4989    }
4990
4991    /// The per-row `read` / `starred` bits come off the row's own join, matching
4992    /// what the separate full-set queries used to compute — including the
4993    /// "no `entry_state` row means unread" rule the views depend on.
4994    #[tokio::test]
4995    async fn list_rows_carry_their_own_read_and_star_bits() -> Result<()> {
4996        let pool = init_url("sqlite::memory:").await?;
4997        let did = "did:plc:bits";
4998        seed_big_entries(&pool, did, 3).await?;
4999        let ids: Vec<i64> = list_entries(&pool, did, ListView::All, None, 10, 0)
5000            .await?
5001            .iter()
5002            .map(|e| e.id)
5003            .collect();
5004
5005        mark_read(&pool, did, ids[0], true).await?;
5006        mark_starred(&pool, did, ids[1], true).await?;
5007
5008        let all = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5009        let by_id = |id: i64| all.iter().find(|e| e.id == id).expect("row present");
5010        assert!(by_id(ids[0]).read && !by_id(ids[0]).starred);
5011        assert!(!by_id(ids[1]).read && by_id(ids[1]).starred);
5012        // Never touched: no state row at all, which must read as unread.
5013        assert!(!by_id(ids[2]).read && !by_id(ids[2]).starred);
5014
5015        // And the view predicates agree with the bits.
5016        let unread = list_entries(&pool, did, ListView::Unread, None, 10, 0).await?;
5017        assert_eq!(unread.len(), 2);
5018        assert!(unread.iter().all(|e| !e.read));
5019        let starred = list_entries(&pool, did, ListView::Starred, None, 10, 0).await?;
5020        assert_eq!(starred.len(), 1);
5021        assert_eq!(starred[0].id, ids[1]);
5022        Ok(())
5023    }
5024
5025    /// The sidebar's per-feed unread badges, counted in SQL rather than by
5026    /// materializing every unread entry and filtering in Rust.
5027    #[tokio::test]
5028    async fn unread_counts_are_per_feed_and_exclude_read_rows() -> Result<()> {
5029        let pool = init_url("sqlite::memory:").await?;
5030        let did = "did:plc:counts";
5031        let a = seed_big_entries(&pool, did, 5).await?;
5032        let b = upsert_feed(
5033            &pool,
5034            &NewFeed {
5035                url: "https://b.example/f.xml".to_string(),
5036                ..Default::default()
5037            },
5038        )
5039        .await?;
5040        insert_entries(
5041            &pool,
5042            b,
5043            &[
5044                NewEntry {
5045                    guid: "b-1".to_string(),
5046                    ..Default::default()
5047                },
5048                NewEntry {
5049                    guid: "b-2".to_string(),
5050                    ..Default::default()
5051                },
5052            ],
5053            0,
5054        )
5055        .await?;
5056        replace_sub_refs(&pool, did, &[a, b]).await?;
5057
5058        let first_a = list_entries(&pool, did, ListView::All, Some(&[a]), 1, 0).await?[0].id;
5059        mark_read(&pool, did, first_a, true).await?;
5060
5061        let counts = unread_counts_by_feed(&pool, did).await?;
5062        assert_eq!(counts.get(&a).copied(), Some(4));
5063        assert_eq!(counts.get(&b).copied(), Some(2));
5064
5065        // A feed the DID does not subscribe to contributes nothing.
5066        replace_sub_refs(&pool, did, &[b]).await?;
5067        let counts = unread_counts_by_feed(&pool, did).await?;
5068        assert_eq!(counts.get(&a), None);
5069        assert_eq!(counts.get(&b).copied(), Some(2));
5070        Ok(())
5071    }
5072
5073    /// **Read-state compaction: the water-mark must absorb the id set.**
5074    ///
5075    /// `read_through` was never computed, so `read_ids` was the only mechanism
5076    /// and grew one id per article read against a 2000-entry per-feed ceiling —
5077    /// while the flusher truncates the record at 1000, keeping the tail. Past
5078    /// 1000 read articles in a feed, the oldest read-state stopped syncing and
5079    /// those articles came back UNREAD in every other atproto reader.
5080    #[tokio::test]
5081    async fn compaction_folds_read_ids_into_the_water_mark() -> Result<()> {
5082        let pool = init_url("sqlite::memory:").await?;
5083        let did = "did:plc:compact";
5084        let feed_url = "https://compact.example/f.xml";
5085        let feed_id = upsert_feed(
5086            &pool,
5087            &NewFeed {
5088                url: feed_url.to_string(),
5089                ..Default::default()
5090            },
5091        )
5092        .await?;
5093        // 40 entries, oldest first by published date.
5094        let entries: Vec<NewEntry> = (0..40)
5095            .map(|i| NewEntry {
5096                guid: format!("c-{i:03}"),
5097                published: Some(format!("2026-01-{:02}T00:00:00Z", i + 1)),
5098                ..Default::default()
5099            })
5100            .collect();
5101        insert_entries(&pool, feed_id, &entries, 0).await?;
5102        replace_sub_refs(&pool, did, &[feed_id]).await?;
5103
5104        let all = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5105        // Oldest first, so the read prefix is contiguous from the start.
5106        let mut oldest_first = all.clone();
5107        oldest_first.reverse();
5108        for row in oldest_first.iter().take(30) {
5109            mark_read(&pool, did, row.id, true).await?;
5110        }
5111
5112        let before = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5113        assert!(before.read_through.is_none(), "read_through starts unset");
5114        let before_ids: Vec<String> = serde_json::from_str(&before.read_ids)?;
5115        assert_eq!(before_ids.len(), 30, "every read is its own exception");
5116
5117        let watermark = compact_cursor(&pool, did, feed_url)
5118            .await?
5119            .expect("the water-mark must advance");
5120
5121        let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5122        assert_eq!(after.read_through.as_deref(), Some(watermark.as_str()));
5123        let after_ids: Vec<String> = serde_json::from_str(&after.read_ids)?;
5124        assert!(
5125            after_ids.is_empty(),
5126            "a contiguous read prefix must fold entirely into the water-mark, left {after_ids:?}"
5127        );
5128        // The 30th entry is read and the 31st is not, so the mark sits on the
5129        // 30th — STRICTLY below the oldest unread, never equal to it.
5130        assert_eq!(watermark, "2026-01-30T00:00:00Z");
5131        assert!(after.dirty, "a rewritten cursor must be re-flushed");
5132        Ok(())
5133    }
5134
5135    /// The water-mark may never cover an unread entry, and may never move
5136    /// backwards. Both would re-assert articles as read that are not.
5137    #[tokio::test]
5138    async fn compaction_stops_below_the_oldest_unread_entry() -> Result<()> {
5139        let pool = init_url("sqlite::memory:").await?;
5140        let did = "did:plc:gap";
5141        let feed_url = "https://gap.example/f.xml";
5142        let feed_id = upsert_feed(
5143            &pool,
5144            &NewFeed {
5145                url: feed_url.to_string(),
5146                ..Default::default()
5147            },
5148        )
5149        .await?;
5150        let entries: Vec<NewEntry> = (0..10)
5151            .map(|i| NewEntry {
5152                guid: format!("g-{i:02}"),
5153                published: Some(format!("2026-02-{:02}T00:00:00Z", i + 1)),
5154                ..Default::default()
5155            })
5156            .collect();
5157        insert_entries(&pool, feed_id, &entries, 0).await?;
5158        replace_sub_refs(&pool, did, &[feed_id]).await?;
5159
5160        let mut oldest_first = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5161        oldest_first.reverse();
5162        // Read everything EXCEPT the third-oldest: a hole at 2026-02-03.
5163        for (i, row) in oldest_first.iter().enumerate() {
5164            if i != 2 {
5165                mark_read(&pool, did, row.id, true).await?;
5166            }
5167        }
5168
5169        let watermark = compact_cursor(&pool, did, feed_url)
5170            .await?
5171            .expect("advances");
5172        assert_eq!(
5173            watermark, "2026-02-02T00:00:00Z",
5174            "the water-mark jumped the unread hole"
5175        );
5176        let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5177        let kept: Vec<String> = serde_json::from_str(&after.read_ids)?;
5178        assert_eq!(
5179            kept.len(),
5180            7,
5181            "the 7 reads ABOVE the hole must stay as explicit exceptions"
5182        );
5183        // The unread hole is above the water-mark, so it needs no unread
5184        // exception — everything above the mark is unread by default.
5185        let unread: Vec<String> = serde_json::from_str(&after.unread_ids)?;
5186        assert!(
5187            unread.is_empty(),
5188            "redundant unread exceptions survived: {unread:?}"
5189        );
5190
5191        // Idempotent, and never backwards: re-running changes nothing.
5192        assert_eq!(
5193            compact_cursor(&pool, did, feed_url).await?,
5194            None,
5195            "a second compaction moved a water-mark that was already correct"
5196        );
5197        Ok(())
5198    }
5199
5200    /// Nothing read yet, or nothing in the feed: compaction must be a no-op
5201    /// rather than inventing a water-mark that asserts the backlog is read.
5202    #[tokio::test]
5203    async fn compaction_never_invents_a_water_mark() -> Result<()> {
5204        let pool = init_url("sqlite::memory:").await?;
5205        let did = "did:plc:none";
5206        let feed_url = "https://none.example/f.xml";
5207        let feed_id = upsert_feed(
5208            &pool,
5209            &NewFeed {
5210                url: feed_url.to_string(),
5211                ..Default::default()
5212            },
5213        )
5214        .await?;
5215        replace_sub_refs(&pool, did, &[feed_id]).await?;
5216
5217        // Empty feed: no entries at all.
5218        assert_eq!(compact_cursor(&pool, did, feed_url).await?, None);
5219
5220        insert_entries(
5221            &pool,
5222            feed_id,
5223            &[
5224                NewEntry {
5225                    guid: "n-1".to_string(),
5226                    published: Some("2026-03-01T00:00:00Z".to_string()),
5227                    ..Default::default()
5228                },
5229                NewEntry {
5230                    guid: "n-2".to_string(),
5231                    published: Some("2026-03-02T00:00:00Z".to_string()),
5232                    ..Default::default()
5233                },
5234            ],
5235            0,
5236        )
5237        .await?;
5238
5239        // Nothing read: the OLDEST entry is unread, so there is no timestamp
5240        // strictly below it and the mark cannot move at all.
5241        assert_eq!(
5242            compact_cursor(&pool, did, feed_url).await?,
5243            None,
5244            "a water-mark appeared with nothing read — that asserts the backlog is read"
5245        );
5246        Ok(())
5247    }
5248
5249    /// **The unsave desync: clearing a star must work for an UNSUBSCRIBED feed.**
5250    ///
5251    /// That is the whole case. Every other starred path is `sub_ref`-scoped, so
5252    /// an entry that is cached AND starred in a feed the reader has since
5253    /// unsubscribed from is invisible to all of them — including the starred
5254    /// list itself. Its PDS record therefore renders as "not cached", and the
5255    /// button on that row deletes the record. If clearing the local star were
5256    /// `sub_ref`-scoped too, it would silently do nothing, and the star would
5257    /// reappear with no record behind it the moment the reader resubscribed.
5258    #[tokio::test]
5259    async fn a_star_can_be_cleared_after_unsubscribing_from_its_feed() -> Result<()> {
5260        let pool = init_url("sqlite::memory:").await?;
5261        let did = "did:plc:unsub";
5262        let feed_id = seed_big_entries(&pool, did, 3).await?;
5263        let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5264        let target = rows[0].clone();
5265        mark_starred(&pool, did, target.id, true).await?;
5266        assert_eq!(get_starred_for_did(&pool, did).await?.len(), 1);
5267
5268        // Unsubscribe. The entry stays cached and stays starred, but every
5269        // sub_ref-scoped read now skips it.
5270        replace_sub_refs(&pool, did, &[]).await?;
5271        assert!(
5272            get_starred_for_did(&pool, did).await?.is_empty(),
5273            "fixture precondition: the star must be invisible to the scoped read"
5274        );
5275        assert!(
5276            matches!(
5277                starred_identities(&pool, did, 1_000).await?,
5278                StarredIdentities::All(ref v) if v.is_empty()
5279            ),
5280            "fixture precondition: the identity lookup must miss it too"
5281        );
5282        let still_starred: i64 =
5283            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5284                .bind(did)
5285                .fetch_one(&pool)
5286                .await?;
5287        assert_eq!(
5288            still_starred, 1,
5289            "the star is still there, just unreachable"
5290        );
5291
5292        // The removal path must reach it anyway.
5293        let cleared =
5294            clear_star_by_identity(&pool, did, target.url.as_deref(), Some(&target.guid)).await?;
5295        assert_eq!(cleared, 1, "the star survived the unsave");
5296        let after: i64 =
5297            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5298                .bind(did)
5299                .fetch_one(&pool)
5300                .await?;
5301        assert_eq!(after, 0);
5302
5303        // Resubscribing must NOT bring it back.
5304        replace_sub_refs(&pool, did, &[feed_id]).await?;
5305        assert!(
5306            get_starred_for_did(&pool, did).await?.is_empty(),
5307            "the star came back after resubscribing — the desync is still there"
5308        );
5309        Ok(())
5310    }
5311
5312    /// It clears only the CALLER's star, and only for the matching article.
5313    ///
5314    /// Omitting `sub_ref` is safe precisely because `did` is not optional; this
5315    /// pins that, and that a non-matching identity is a no-op rather than a
5316    /// wildcard.
5317    #[tokio::test]
5318    async fn clearing_a_star_touches_only_that_did_and_that_article() -> Result<()> {
5319        let pool = init_url("sqlite::memory:").await?;
5320        let mine = "did:plc:mine";
5321        let theirs = "did:plc:theirs";
5322        let feed_id = seed_big_entries(&pool, mine, 3).await?;
5323        replace_sub_refs(&pool, theirs, &[feed_id]).await?;
5324        let rows = list_entries(&pool, mine, ListView::All, None, 10, 0).await?;
5325
5326        for r in &rows {
5327            mark_starred(&pool, mine, r.id, true).await?;
5328            mark_starred(&pool, theirs, r.id, true).await?;
5329        }
5330
5331        let target = &rows[1];
5332        assert_eq!(
5333            clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5334            1
5335        );
5336
5337        let count = |did: &'static str| {
5338            let pool = pool.clone();
5339            async move {
5340                sqlx::query_scalar::<_, i64>(
5341                    "SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1",
5342                )
5343                .bind(did)
5344                .fetch_one(&pool)
5345                .await
5346                .unwrap()
5347            }
5348        };
5349        assert_eq!(count(mine).await, 2, "it cleared more than the one article");
5350        assert_eq!(count(theirs).await, 3, "it cleared another DID's stars");
5351
5352        // An identity that matches nothing is a no-op, not a wildcard.
5353        assert_eq!(
5354            clear_star_by_identity(&pool, mine, Some("https://nope.example/x"), Some("nope"))
5355                .await?,
5356            0
5357        );
5358        assert_eq!(count(mine).await, 2);
5359        // **Clearing an already-cleared star is a no-op**, reported as one:
5360        // `web::unsave` branches on `Ok(0)` vs `Ok(n)` to decide whether a
5361        // local star was actually cleared. This used to be untested — every
5362        // article here was starred first — so `starred = 1` in the WHERE clause
5363        // could be widened to `IN (0, 1)` with the suite green, rewriting
5364        // `updated_at` on rows that changed nothing and logging clears that
5365        // never happened.
5366        let before: String = sqlx::query_scalar(
5367            "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5368        )
5369        .bind(mine)
5370        .bind(target.id)
5371        .fetch_one(&pool)
5372        .await?;
5373        assert_eq!(
5374            clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5375            0,
5376            "a second clear reported rows it did not change"
5377        );
5378        let after: String = sqlx::query_scalar(
5379            "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5380        )
5381        .bind(mine)
5382        .bind(target.id)
5383        .fetch_one(&pool)
5384        .await?;
5385        assert_eq!(before, after, "a no-op clear rewrote updated_at");
5386
5387        // And neither identifier present does nothing at all.
5388        assert_eq!(clear_star_by_identity(&pool, mine, None, None).await?, 0);
5389        assert_eq!(
5390            clear_star_by_identity(&pool, mine, Some(""), Some("")).await?,
5391            0
5392        );
5393        assert_eq!(count(mine).await, 2);
5394        Ok(())
5395    }
5396
5397    /// `starred_identities` must span the WHOLE starred set, not a page.
5398    ///
5399    /// The starred view matches PDS saved records against it; a cached article
5400    /// missing from the set renders as "not cached", and that row's button
5401    /// deletes the PDS RECORD instead of un-starring the entry. Narrowing this
5402    /// set changes what a click destroys.
5403    #[tokio::test]
5404    async fn starred_identities_span_the_whole_set() -> Result<()> {
5405        let pool = init_url("sqlite::memory:").await?;
5406        let did = "did:plc:ident";
5407        seed_big_entries(&pool, did, 150).await?;
5408        for row in list_entries(&pool, did, ListView::All, None, 1_000, 0).await? {
5409            mark_starred(&pool, did, row.id, true).await?;
5410        }
5411
5412        let identities = match starred_identities(&pool, did, 20_000).await? {
5413            StarredIdentities::All(v) => v,
5414            StarredIdentities::Truncated => panic!("150 rows must not read as truncated"),
5415        };
5416        assert_eq!(
5417            identities.len(),
5418            150,
5419            "the identity set was truncated to a page"
5420        );
5421        assert!(identities
5422            .iter()
5423            .all(|(url, guid)| url.is_some() && !guid.is_empty()));
5424
5425        // **Hitting the cap must be REPORTED, not absorbed.** It used to return
5426        // an arbitrary subset with no way to tell, and every starred article
5427        // outside that subset then rendered an un-save button that deletes the
5428        // PDS record rather than un-starring the entry.
5429        assert!(
5430            matches!(
5431                starred_identities(&pool, did, 10).await?,
5432                StarredIdentities::Truncated
5433            ),
5434            "a truncated identity set reported itself as complete"
5435        );
5436        // Landing EXACTLY on the cap is complete, not truncated — the query asks
5437        // for one extra row precisely so the two are distinguishable.
5438        assert!(
5439            matches!(
5440                starred_identities(&pool, did, 150).await?,
5441                StarredIdentities::All(ref v) if v.len() == 150
5442            ),
5443            "a set exactly at the cap was misreported as truncated"
5444        );
5445        Ok(())
5446    }
5447
5448    /// Prev/next ids are bounded too, and keep the list's ordering.
5449    #[tokio::test]
5450    async fn entry_ids_are_ordered_and_capped() -> Result<()> {
5451        let pool = init_url("sqlite::memory:").await?;
5452        let did = "did:plc:ids";
5453        seed_big_entries(&pool, did, 60).await?;
5454
5455        let capped = list_entry_ids(&pool, did, ListView::All, None, 25).await?;
5456        assert_eq!(capped.len(), 25);
5457
5458        let rows = list_entries(&pool, did, ListView::All, None, 25, 0).await?;
5459        assert_eq!(
5460            capped,
5461            rows.iter().map(|e| e.id).collect::<Vec<_>>(),
5462            "the id list and the row list disagree on ordering"
5463        );
5464        Ok(())
5465    }
5466
5467    // -----------------------------------------------------------------------
5468    // Read-state PDS sync wiring: marking read/unread must project into the
5469    // per-feed `read_cursor` and mark it dirty so the batched flusher pushes it.
5470    // Before this wiring `mark_read` touched only `entry_state`; nothing dirtied
5471    // a cursor, so the flusher never synced read-state to the PDS.
5472    // -----------------------------------------------------------------------
5473
5474    #[tokio::test]
5475    async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
5476        let pool = init_url("sqlite::memory:").await?;
5477        let feed_url = "https://example.com/feed.xml";
5478        let feed_id = upsert_feed(
5479            &pool,
5480            &NewFeed {
5481                url: feed_url.to_string(),
5482                title: Some("Example".to_string()),
5483                ..Default::default()
5484            },
5485        )
5486        .await?;
5487        insert_entries(
5488            &pool,
5489            feed_id,
5490            &[
5491                NewEntry {
5492                    guid: "g1".to_string(),
5493                    published: Some("2026-07-10T00:00:00Z".to_string()),
5494                    ..Default::default()
5495                },
5496                NewEntry {
5497                    guid: "g2".to_string(),
5498                    published: Some("2026-07-11T00:00:00Z".to_string()),
5499                    ..Default::default()
5500                },
5501            ],
5502            0,
5503        )
5504        .await?;
5505        let did = "did:plc:reader";
5506        replace_sub_refs(&pool, did, &[feed_id]).await?;
5507
5508        // No cursor exists yet.
5509        assert!(get_cursor(&pool, did, feed_url).await?.is_none());
5510        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5511
5512        // Mark one entry read → the feed's read_cursor row now exists, dirty=1,
5513        // and dirty_cursors returns it (the exact assertion the fix requires).
5514        let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
5515        assert!(mark_read(&pool, did, e1, true).await?);
5516
5517        let cursor = get_cursor(&pool, did, feed_url)
5518            .await?
5519            .expect("mark_read must create the feed's read_cursor");
5520        assert!(cursor.dirty, "cursor must be dirty after mark_read");
5521        assert!(
5522            cursor.read_ids.contains(&e1.to_string()),
5523            "the read entry id must be in read_ids: {}",
5524            cursor.read_ids
5525        );
5526        let dirty = dirty_cursors(&pool, did).await?;
5527        assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
5528        assert_eq!(dirty[0].feed_url, feed_url);
5529
5530        // Marking it unread again moves the id to unread_ids and keeps it dirty.
5531        assert!(mark_read(&pool, did, e1, false).await?);
5532        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5533        assert!(cursor.dirty);
5534        assert!(
5535            cursor.unread_ids.contains(&e1.to_string()),
5536            "unread id must be in unread_ids: {}",
5537            cursor.unread_ids
5538        );
5539        assert!(
5540            !cursor.read_ids.contains(&e1.to_string()),
5541            "id must have left read_ids: {}",
5542            cursor.read_ids
5543        );
5544
5545        // mark_feed_read dirties the one per-feed cursor too (batched, not
5546        // per-article).
5547        assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
5548        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5549        assert!(cursor.dirty);
5550        assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
5551
5552        // A non-subscriber's mark_read is a no-op and dirties NO cursor.
5553        let outsider = "did:plc:outsider";
5554        assert!(!mark_read(&pool, outsider, e1, true).await?);
5555        assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
5556
5557        // The conditional clear only clears when updated_at matches the snapshot.
5558        let snap = dirty_cursors(&pool, did).await?[0].clone();
5559        // A stale updated_at must NOT clear (models a concurrent re-dirty).
5560        clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
5561        assert_eq!(
5562            dirty_cursors(&pool, did).await?.len(),
5563            1,
5564            "stale-snapshot clear must be a no-op"
5565        );
5566        // The matching updated_at clears it.
5567        clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
5568        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5569
5570        Ok(())
5571    }
5572
5573    #[test]
5574    fn json_id_set_toggle_is_set_like() {
5575        // Add is idempotent, remove drops, output is a JSON string array.
5576        let s = json_id_set_toggle("[]", 5, true);
5577        assert_eq!(s, r#"["5"]"#);
5578        assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); // no dup
5579        let s = json_id_set_toggle(&s, 7, true);
5580        assert_eq!(s, r#"["5","7"]"#);
5581        let s = json_id_set_toggle(&s, 5, false);
5582        assert_eq!(s, r#"["7"]"#);
5583        // Tolerates numeric-array input and malformed input.
5584        assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
5585        assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
5586    }
5587
5588    // -----------------------------------------------------------------------
5589    // Per-DID isolation: the shared cache is one row per URL, but the READ
5590    // SURFACE (entries/unread/starred) and the read/star MUTATIONS are scoped
5591    // to the caller's own subscriptions (`sub_ref`). User A must never see or
5592    // mutate user B's entries.
5593    // -----------------------------------------------------------------------
5594
5595    #[tokio::test]
5596    async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
5597        let pool = init_url("sqlite::memory:").await?;
5598
5599        // Two feeds in the SHARED cache; A subscribes to feed_a, B to feed_b.
5600        let feed_a = upsert_feed(
5601            &pool,
5602            &NewFeed {
5603                url: "https://a.example/feed.xml".to_string(),
5604                title: Some("A".to_string()),
5605                ..Default::default()
5606            },
5607        )
5608        .await?;
5609        let feed_b = upsert_feed(
5610            &pool,
5611            &NewFeed {
5612                url: "https://b.example/feed.xml".to_string(),
5613                title: Some("B".to_string()),
5614                ..Default::default()
5615            },
5616        )
5617        .await?;
5618
5619        insert_entries(
5620            &pool,
5621            feed_a,
5622            &[NewEntry {
5623                guid: "a-1".to_string(),
5624                url: Some("https://a.example/1".to_string()),
5625                title: Some("A one".to_string()),
5626                published: Some("2026-07-10T00:00:00Z".to_string()),
5627                content_html: Some("<p>secret A body</p>".to_string()),
5628                ..Default::default()
5629            }],
5630            0,
5631        )
5632        .await?;
5633        insert_entries(
5634            &pool,
5635            feed_b,
5636            &[NewEntry {
5637                guid: "b-1".to_string(),
5638                url: Some("https://b.example/1".to_string()),
5639                title: Some("B one".to_string()),
5640                published: Some("2026-07-11T00:00:00Z".to_string()),
5641                content_html: Some("<p>secret B body</p>".to_string()),
5642                ..Default::default()
5643            }],
5644            0,
5645        )
5646        .await?;
5647
5648        let did_a = "did:plc:aaaa";
5649        let did_b = "did:plc:bbbb";
5650        replace_sub_refs(&pool, did_a, &[feed_a]).await?;
5651        replace_sub_refs(&pool, did_b, &[feed_b]).await?;
5652
5653        // The id of B's only entry (the one A must not be able to touch).
5654        let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
5655
5656        // --- entries_for_feed is scoped: A sees A's feed, not B's ------------
5657        assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
5658        assert!(
5659            entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
5660            "A must not read entries of a feed it does not subscribe to"
5661        );
5662
5663        // --- unread list is scoped -------------------------------------------
5664        let unread_a = get_unread_for_did(&pool, did_a).await?;
5665        assert_eq!(unread_a.len(), 1);
5666        assert_eq!(unread_a[0].guid, "a-1");
5667        let unread_b = get_unread_for_did(&pool, did_b).await?;
5668        assert_eq!(unread_b.len(), 1);
5669        assert_eq!(unread_b[0].guid, "b-1");
5670
5671        // --- did_subscribes_to_entry authorizes correctly --------------------
5672        assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
5673        assert!(
5674            !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
5675            "A does not subscribe to B's feed"
5676        );
5677
5678        // --- mark_read is authorized: A CANNOT mark B's entry ----------------
5679        assert!(
5680            !mark_read(&pool, did_a, b_entry_id, true).await?,
5681            "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
5682        );
5683        // B's unread list is untouched by A's attempt.
5684        assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
5685        // A subscriber CAN mark it.
5686        assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
5687        assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
5688
5689        // --- toggle_star is authorized the same way --------------------------
5690        assert!(
5691            !mark_starred(&pool, did_a, b_entry_id, true).await?,
5692            "non-subscriber mark_starred must be a no-op (→ 404)"
5693        );
5694        assert!(
5695            get_starred_for_did(&pool, did_a).await?.is_empty(),
5696            "A's starred list stays empty after the rejected attempt"
5697        );
5698        assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
5699        assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
5700        // B's star never leaks into A's starred list.
5701        assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
5702
5703        // --- feeds_for_did is scoped to the DID's OWN sub_ref ----------------
5704        // This is the PDS-unreachable fallback's projection: it must NEVER
5705        // widen a DID's surface to feeds it does not subscribe to. A sees only
5706        // feed_a; B (still subscribed to feed_b here) sees only feed_b.
5707        let a_feeds = feeds_for_did(&pool, did_a).await?;
5708        assert_eq!(a_feeds.len(), 1);
5709        assert_eq!(a_feeds[0].id, feed_a);
5710        let b_feeds = feeds_for_did(&pool, did_b).await?;
5711        assert_eq!(b_feeds.len(), 1);
5712        assert_eq!(b_feeds[0].id, feed_b);
5713
5714        // --- resync drops a feed from the surface when the sub goes away ------
5715        replace_sub_refs(&pool, did_b, &[]).await?;
5716        assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
5717        assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
5718        assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
5719        // And the fallback projection is empty too — fail CLOSED, not open.
5720        assert!(feeds_for_did(&pool, did_b).await?.is_empty());
5721
5722        Ok(())
5723    }
5724
5725    // -----------------------------------------------------------------------
5726    // PDS-outage authorization (fail CLOSED). REGRESSION GUARD for the past
5727    // FAIL-OPEN bug (fixed in 2e53e0e): `resolve_subscriptions`' PDS/sidecar-
5728    // unreachable fallback used to synthesize a DID's `sub_ref` from EVERY
5729    // cached feed (`due_feeds(.., i64::MAX)`), granting cross-tenant read +
5730    // mutate during any outage. The fix serves the DID's OWN last-known
5731    // `sub_ref` via `feeds_for_did(did)` and NEVER widens it.
5732    //
5733    // This test replays that fixed fallback at the store layer — the seam the
5734    // web handler drives when `list_subscriptions_sorted(did) -> Err`. The
5735    // key adversarial shape is an ORPHAN cached feed (in the shared cache but
5736    // subscribed by NO ONE): the old fail-open code would have folded it into
5737    // the caller's surface. If the fail-open is reintroduced, `feeds_for_did`
5738    // would include that orphan and every assertion below flips — so this is a
5739    // real guard, not a tautology.
5740    // -----------------------------------------------------------------------
5741
5742    #[tokio::test]
5743    async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
5744        let pool = init_url("sqlite::memory:").await?;
5745
5746        let did_a = "did:plc:aaaa";
5747
5748        // feed_a: A's own subscription (its last-known `sub_ref`; the fallback
5749        // may serve this stale but must not widen past it).
5750        let feed_a = upsert_feed(
5751            &pool,
5752            &NewFeed {
5753                url: "https://a.example/feed.xml".to_string(),
5754                title: Some("A".to_string()),
5755                ..Default::default()
5756            },
5757        )
5758        .await?;
5759        // feed_orphan: present in the SHARED cache but subscribed by NO DID.
5760        // This is exactly what the fail-open path would have leaked to A.
5761        let feed_orphan = upsert_feed(
5762            &pool,
5763            &NewFeed {
5764                url: "https://orphan.example/feed.xml".to_string(),
5765                title: Some("Orphan".to_string()),
5766                ..Default::default()
5767            },
5768        )
5769        .await?;
5770
5771        insert_entries(
5772            &pool,
5773            feed_a,
5774            &[NewEntry {
5775                guid: "a-1".to_string(),
5776                url: Some("https://a.example/1".to_string()),
5777                title: Some("A one".to_string()),
5778                published: Some("2026-07-10T00:00:00Z".to_string()),
5779                content_html: Some("<p>A body</p>".to_string()),
5780                ..Default::default()
5781            }],
5782            0,
5783        )
5784        .await?;
5785        insert_entries(
5786            &pool,
5787            feed_orphan,
5788            &[NewEntry {
5789                guid: "orphan-1".to_string(),
5790                url: Some("https://orphan.example/1".to_string()),
5791                title: Some("Orphan one".to_string()),
5792                published: Some("2026-07-11T00:00:00Z".to_string()),
5793                content_html: Some("<p>secret orphan body</p>".to_string()),
5794                ..Default::default()
5795            }],
5796            0,
5797        )
5798        .await?;
5799
5800        // A's last-known subscription set is feed_a ONLY. No `sub_ref` row ever
5801        // points any DID at feed_orphan.
5802        replace_sub_refs(&pool, did_a, &[feed_a]).await?;
5803
5804        // Grab the orphan entry id via a transient sub so we can address it,
5805        // then drop the sub — nobody subscribes to feed_orphan afterwards.
5806        replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
5807        let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
5808        replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
5809
5810        // --- Replay the FIXED fallback projection ----------------------------
5811        // This is what `resolve_subscriptions` serves on the Err (outage) path:
5812        // the caller's OWN feeds, never widened. It must contain feed_a and
5813        // NEVER the orphan. (The old fail-open synthesized from every cached
5814        // feed → this vec would have held feed_orphan too.)
5815        let fallback = feeds_for_did(&pool, did_a).await?;
5816        let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
5817        assert_eq!(
5818            fallback_ids,
5819            vec![feed_a],
5820            "outage fallback must serve ONLY A's own last-known sub_ref, \
5821             never widen to the orphan cached feed"
5822        );
5823        assert!(
5824            !fallback_ids.contains(&feed_orphan),
5825            "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
5826             cached feed into A's surface"
5827        );
5828
5829        // --- With that projection in place, EVERY scoped read denies A -------
5830        assert!(
5831            !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
5832            "A must not be authorized for an orphan feed's entry during an outage"
5833        );
5834        assert!(
5835            entries_for_feed(&pool, did_a, feed_orphan)
5836                .await?
5837                .is_empty(),
5838            "entries_for_feed must not expose the orphan feed to A during an outage"
5839        );
5840        // Neither the unread nor the starred list may surface the orphan entry.
5841        let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
5842            .await?
5843            .into_iter()
5844            .map(|e| e.guid)
5845            .collect();
5846        assert!(
5847            !unread_guids.iter().any(|g| g == "orphan-1"),
5848            "orphan entry leaked into A's unread list during an outage"
5849        );
5850        assert!(
5851            get_starred_for_did(&pool, did_a).await?.is_empty(),
5852            "A has no starred entries; the orphan must not appear"
5853        );
5854
5855        // --- And EVERY scoped mutation is a no-op (→ 404 at the web layer) ---
5856        assert!(
5857            !mark_read(&pool, did_a, orphan_entry_id, true).await?,
5858            "A must not mark an orphan feed's entry read during an outage"
5859        );
5860        assert!(
5861            !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
5862            "A must not star an orphan feed's entry during an outage"
5863        );
5864        assert_eq!(
5865            mark_feed_read(&pool, did_a, feed_orphan, true).await?,
5866            0,
5867            "A must not mark-all-read the orphan feed during an outage"
5868        );
5869
5870        // Nothing was written for A against the orphan entry.
5871        let es_count: i64 =
5872            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5873                .bind(did_a)
5874                .bind(orphan_entry_id)
5875                .fetch_one(&pool)
5876                .await?;
5877        assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
5878
5879        Ok(())
5880    }
5881
5882    // -----------------------------------------------------------------------
5883    // Closed-beta invite gate
5884    // -----------------------------------------------------------------------
5885
5886    #[test]
5887    fn code_gen_shape_and_alphabet() {
5888        for _ in 0..200 {
5889            let code = generate_invite_code().unwrap();
5890            assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
5891            let body = &code["FEATHER-".len()..];
5892            assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
5893            // Every body char must be from the ambiguity-free alphabet — in
5894            // particular NEVER I/O/0/1.
5895            for c in body.chars() {
5896                assert!(
5897                    CODE_ALPHABET.contains(&(c as u8)),
5898                    "char {c:?} not in alphabet ({code})"
5899                );
5900                assert!(
5901                    !matches!(c, 'I' | 'O' | '0' | '1'),
5902                    "ambiguous char {c:?} leaked into {code}"
5903                );
5904            }
5905        }
5906        // Two codes in a row must differ (unguessable / random).
5907        assert_ne!(
5908            generate_invite_code().unwrap(),
5909            generate_invite_code().unwrap()
5910        );
5911    }
5912
5913    #[tokio::test]
5914    async fn busy_timeout_is_applied() -> Result<()> {
5915        // Opening an on-disk DB and reading back the PRAGMA proves the pool
5916        // carries busy_timeout = 5000 ms.
5917        let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
5918        std::fs::create_dir_all(&dir).ok();
5919        let path = dir.join("busy.db");
5920        let url = format!("sqlite://{}", path.display());
5921        let pool = init_url(&url).await?;
5922        let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
5923        let timeout: i64 = row.get(0);
5924        assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
5925        pool.close().await;
5926        std::fs::remove_dir_all(&dir).ok();
5927        Ok(())
5928    }
5929
5930    #[tokio::test]
5931    async fn redeem_valid_grants_seat() -> Result<()> {
5932        let pool = init_url("sqlite::memory:").await?;
5933        let code = mint_code(&pool, "did:plc:creator", 3600).await?;
5934        assert!(!has_beta_access(&pool, "did:plc:new").await?);
5935
5936        let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
5937        assert_eq!(out, Ok(()));
5938        assert!(has_beta_access(&pool, "did:plc:new").await?);
5939        assert_eq!(count_beta_access(&pool).await?, 1);
5940
5941        // The code is now spent — a second redeem is AlreadyRedeemed.
5942        let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
5943        assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
5944        Ok(())
5945    }
5946
5947    #[tokio::test]
5948    async fn redeem_not_found() -> Result<()> {
5949        let pool = init_url("sqlite::memory:").await?;
5950        let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
5951        assert_eq!(out, Err(RedeemError::NotFound));
5952        Ok(())
5953    }
5954
5955    /// Insert an already-expired `active` code directly (mint_code clamps a
5956    /// negative ttl to 0, so the past-expiry case is set up by hand).
5957    async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
5958        let now = now_unix();
5959        sqlx::query(
5960            r#"INSERT INTO invite_codes
5961               (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
5962               VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
5963        )
5964        .bind(code)
5965        .bind(creator)
5966        .bind(now - 100)
5967        .bind(now - 10) // expires_at in the past
5968        .execute(pool)
5969        .await?;
5970        Ok(())
5971    }
5972
5973    #[tokio::test]
5974    async fn redeem_expired() -> Result<()> {
5975        let pool = init_url("sqlite::memory:").await?;
5976        insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
5977        let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
5978        assert_eq!(out, Err(RedeemError::Expired));
5979        // No seat granted.
5980        assert_eq!(count_beta_access(&pool).await?, 0);
5981        Ok(())
5982    }
5983
5984    #[tokio::test]
5985    async fn redeem_capacity_full() -> Result<()> {
5986        let pool = init_url("sqlite::memory:").await?;
5987        // Cap of 1, one seat already taken by an admin seed.
5988        ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
5989        assert_eq!(count_beta_access(&pool).await?, 1);
5990
5991        let code = mint_code(&pool, "did:plc:admin", 3600).await?;
5992        let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
5993        assert_eq!(out, Err(RedeemError::CapacityFull));
5994        // Seat NOT granted and the code NOT consumed (tx rolled back).
5995        assert!(!has_beta_access(&pool, "did:plc:new").await?);
5996        // Raising the cap lets the same code redeem.
5997        let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
5998        assert_eq!(ok, Ok(()));
5999        Ok(())
6000    }
6001
6002    #[tokio::test]
6003    async fn count_active_codes_excludes_expired_and_redeemed() -> Result<()> {
6004        let pool = init_url("sqlite::memory:").await?;
6005        assert_eq!(count_active_codes(&pool).await?, 0);
6006
6007        // Two live codes.
6008        let a = mint_code(&pool, "did:plc:bot", 3600).await?;
6009        let _b = mint_code(&pool, "did:plc:bot", 3600).await?;
6010        assert_eq!(count_active_codes(&pool).await?, 2);
6011
6012        // An expired code doesn't count.
6013        insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:bot").await?;
6014        assert_eq!(count_active_codes(&pool).await?, 2);
6015
6016        // Redeeming one drops the active count.
6017        let out = redeem_code(&pool, &a, "did:plc:new", None, 100).await?;
6018        assert_eq!(out, Ok(()));
6019        assert_eq!(count_active_codes(&pool).await?, 1);
6020        Ok(())
6021    }
6022
6023    #[tokio::test]
6024    async fn expire_and_seed() -> Result<()> {
6025        let pool = init_url("sqlite::memory:").await?;
6026        // An already-expired code is swept to `expired`.
6027        insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
6028        let live = mint_code(&pool, "did:plc:creator", 3600).await?;
6029        let n = expire_old_codes(&pool).await?;
6030        assert_eq!(n, 1, "exactly the past-expiry code should flip");
6031        // The live code still redeems.
6032        assert_eq!(
6033            redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
6034            Ok(())
6035        );
6036
6037        // ensure_seed is idempotent.
6038        let created = ensure_seed(
6039            &pool,
6040            &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
6041        )
6042        .await?;
6043        assert_eq!(created, 2);
6044        let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
6045        assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
6046        assert!(has_beta_access(&pool, "did:plc:seed1").await?);
6047        Ok(())
6048    }
6049
6050    /// **The sweep spares a REDEEMED code that is past its TTL.** The
6051    /// existing sweep test seeds one active past-expiry code and one live
6052    /// one, so the `status = 'active'` guard never excludes anything — with
6053    /// it deleted the suite stayed green. Without it the hourly sweep rewrites
6054    /// redeemed codes to `expired`, destroying the redemption the invite audit
6055    /// trail depends on and inflating the logged sweep count.
6056    #[tokio::test]
6057    async fn the_expiry_sweep_spares_redeemed_codes() -> Result<()> {
6058        let pool = init_url("sqlite::memory:").await?;
6059        let code = mint_code(&pool, "did:plc:creator", 3600).await?;
6060        assert!(redeem_code(&pool, &code, "did:plc:new", None, 100)
6061            .await?
6062            .is_ok());
6063        // Time passes: the redeemed code is now past its TTL.
6064        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6065            .bind(now_unix() - 10)
6066            .bind(&code)
6067            .execute(&pool)
6068            .await?;
6069        insert_expired_code(&pool, "FEATHER-EXPIRED2", "did:plc:creator").await?;
6070
6071        let n = expire_old_codes(&pool).await?;
6072        assert_eq!(n, 1, "the sweep counted the redeemed code");
6073        let status: String = sqlx::query_scalar("SELECT status FROM invite_codes WHERE code = ?1")
6074            .bind(&code)
6075            .fetch_one(&pool)
6076            .await?;
6077        assert_eq!(status, "redeemed", "the sweep rewrote a redemption");
6078        Ok(())
6079    }
6080
6081    // -----------------------------------------------------------------------
6082    // Hardening caps: per-DID sub count, global feed count, per-feed entry trim.
6083    // -----------------------------------------------------------------------
6084
6085    #[tokio::test]
6086    async fn count_helpers_track_feeds_and_subs() -> Result<()> {
6087        let pool = init_url("sqlite::memory:").await?;
6088        assert_eq!(count_feeds(&pool).await?, 0);
6089
6090        let mut ids = Vec::new();
6091        for i in 0..3 {
6092            let id = upsert_feed(
6093                &pool,
6094                &NewFeed {
6095                    url: format!("https://f{i}.example/feed.xml"),
6096                    ..Default::default()
6097                },
6098            )
6099            .await?;
6100            ids.push(id);
6101        }
6102        assert_eq!(count_feeds(&pool).await?, 3);
6103
6104        let did = "did:plc:capcheck";
6105        assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
6106        replace_sub_refs(&pool, did, &ids).await?;
6107        assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
6108        Ok(())
6109    }
6110
6111    #[tokio::test]
6112    async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
6113        let pool = init_url("sqlite::memory:").await?;
6114        let feed_id = upsert_feed(
6115            &pool,
6116            &NewFeed {
6117                url: "https://firehose.example/feed.xml".to_string(),
6118                ..Default::default()
6119            },
6120        )
6121        .await?;
6122
6123        // Insert 5 entries with ascending published dates, cap retained to 2.
6124        let batch: Vec<NewEntry> = (0..5)
6125            .map(|i| NewEntry {
6126                guid: format!("g-{i}"),
6127                title: Some(format!("E{i}")),
6128                published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
6129                ..Default::default()
6130            })
6131            .collect();
6132        insert_entries(&pool, feed_id, &batch, 2).await?;
6133
6134        let did = "did:plc:trim";
6135        replace_sub_refs(&pool, did, &[feed_id]).await?;
6136        let kept = entries_for_feed(&pool, did, feed_id).await?;
6137        assert_eq!(
6138            kept.len(),
6139            2,
6140            "over-cap feed trimmed to the newest 2 entries"
6141        );
6142        // Newest first: g-4 (2026-07-05), g-3 (2026-07-04).
6143        assert_eq!(kept[0].guid, "g-4");
6144        assert_eq!(kept[1].guid, "g-3");
6145        Ok(())
6146    }
6147
6148    /// Regression: an UNDATED entry (NULL `published`) that was fetched most
6149    /// recently must NOT be evicted in favour of an older *dated* entry. The
6150    /// trim orders by `COALESCE(published, fetched_at) DESC`; under the old
6151    /// `ORDER BY published DESC` a NULL-published row sorts LAST and is dropped
6152    /// first even when it is the freshest thing in the feed.
6153    #[tokio::test]
6154    async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
6155        let pool = init_url("sqlite::memory:").await?;
6156        let feed_id = upsert_feed(
6157            &pool,
6158            &NewFeed {
6159                url: "https://undated.example/feed.xml".to_string(),
6160                ..Default::default()
6161            },
6162        )
6163        .await?;
6164
6165        // Two OLD dated entries (fetched long ago), plus one UNDATED entry
6166        // fetched most recently. Cap = 2, so exactly one row must be evicted.
6167        let batch = vec![
6168            NewEntry {
6169                guid: "old-dated-1".to_string(),
6170                title: Some("Old A".to_string()),
6171                published: Some("2026-07-01T00:00:00Z".to_string()),
6172                fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
6173                ..Default::default()
6174            },
6175            NewEntry {
6176                guid: "old-dated-2".to_string(),
6177                title: Some("Old B".to_string()),
6178                published: Some("2026-07-02T00:00:00Z".to_string()),
6179                fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
6180                ..Default::default()
6181            },
6182            NewEntry {
6183                guid: "fresh-undated".to_string(),
6184                title: Some("Fresh undated".to_string()),
6185                published: None,
6186                fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
6187                ..Default::default()
6188            },
6189        ];
6190        insert_entries(&pool, feed_id, &batch, 2).await?;
6191
6192        let did = "did:plc:undated";
6193        replace_sub_refs(&pool, did, &[feed_id]).await?;
6194        let kept = entries_for_feed(&pool, did, feed_id).await?;
6195        assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
6196        let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
6197        assert!(
6198            guids.contains(&"fresh-undated"),
6199            "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
6200        );
6201        assert!(
6202            guids.contains(&"old-dated-2"),
6203            "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
6204        );
6205        assert!(
6206            !guids.contains(&"old-dated-1"),
6207            "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
6208        );
6209        Ok(())
6210    }
6211
6212    /// **It must actually GROW — the name used to be a lie.**
6213    ///
6214    /// The earlier body was three lines asserting only `before > 0`. There was
6215    /// no second measurement, so `db_size_bytes` returning a constant `1` passed.
6216    /// That matters because this number is the poller's disk watermark: a size
6217    /// that never moves means the pause never trips and the volume fills
6218    /// instead.
6219    #[tokio::test]
6220    async fn db_size_is_positive_and_grows() -> Result<()> {
6221        let pool = init_url("sqlite::memory:").await?;
6222        let before = db_size_bytes(&pool).await?;
6223        assert!(before > 0, "a schema-initialised DB has a non-zero size");
6224
6225        // Enough rows that the file must gain pages, not just fill slack.
6226        seed_big_entries(&pool, "did:plc:growth", 400).await?;
6227
6228        let after = db_size_bytes(&pool).await?;
6229        assert!(
6230            after > before,
6231            "the database grew by {} bytes after 400 seeded entries; the size is \
6232             not tracking the data, so the disk watermark can never trip",
6233            after.saturating_sub(before),
6234        );
6235        Ok(())
6236    }
6237
6238    /// `purge_did_data` removes every per-DID row the caller owns (read/star
6239    /// state, cursors, sub_ref projection, beta seat, created invite codes) —
6240    /// and touches no other DID's rows nor the shared feeds/entries cache.
6241    #[tokio::test]
6242    async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
6243        let pool = init_url("sqlite::memory:").await?;
6244
6245        // A shared feed + entry both DIDs can subscribe to.
6246        let feed_id = upsert_feed(
6247            &pool,
6248            &NewFeed {
6249                url: "https://example.com/feed.xml".to_string(),
6250                title: Some("Example".to_string()),
6251                ..Default::default()
6252            },
6253        )
6254        .await?;
6255        insert_entries(
6256            &pool,
6257            feed_id,
6258            &[NewEntry {
6259                guid: "g-1".to_string(),
6260                url: Some("https://example.com/a".to_string()),
6261                title: Some("First".to_string()),
6262                published: Some("2026-07-10T08:00:00Z".to_string()),
6263                ..Default::default()
6264            }],
6265            0,
6266        )
6267        .await?;
6268        let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
6269            .fetch_one(&pool)
6270            .await?;
6271
6272        let victim = "did:plc:victim";
6273        let bystander = "did:plc:bystander";
6274
6275        // Seed BOTH DIDs with a full spread of per-DID rows.
6276        for did in [victim, bystander] {
6277            replace_sub_refs(&pool, did, &[feed_id]).await?;
6278            assert!(mark_read(&pool, did, entry_id, true).await?);
6279            assert!(mark_starred(&pool, did, entry_id, true).await?);
6280            upsert_cursor(
6281                &pool,
6282                &ReadCursor {
6283                    did: did.to_string(),
6284                    feed_url: "https://example.com/feed.xml".to_string(),
6285                    read_through: Some("2026-07-10T08:00:00Z".to_string()),
6286                    read_ids: "[]".to_string(),
6287                    unread_ids: "[]".to_string(),
6288                    dirty: false,
6289                    pds_created: false,
6290                    updated_at: now_rfc3339(),
6291                },
6292            )
6293            .await?;
6294            grant_access(&pool, did, Some("h.example"), "admin", None).await?;
6295            mint_code(&pool, did, 3600).await?;
6296        }
6297
6298        // Purge only the victim.
6299        let counts = purge_did_data(&pool, victim).await?;
6300        assert_eq!(
6301            counts.entry_state, 1,
6302            "one entry_state row (read+star merge)"
6303        );
6304        assert_eq!(counts.read_cursor, 1);
6305        assert_eq!(counts.sub_ref, 1);
6306        assert_eq!(counts.beta_access, 1);
6307        assert_eq!(counts.invite_codes, 1);
6308        assert_eq!(counts.total(), 5);
6309
6310        // The victim has zero rows left in every per-DID table.
6311        let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
6312            .bind(victim)
6313            .fetch_one(&pool)
6314            .await?;
6315        assert_eq!(es, 0, "victim still had entry_state rows");
6316        let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
6317            .bind(victim)
6318            .fetch_one(&pool)
6319            .await?;
6320        assert_eq!(rc, 0, "victim still had read_cursor rows");
6321        let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
6322            .bind(victim)
6323            .fetch_one(&pool)
6324            .await?;
6325        assert_eq!(sr, 0, "victim still had sub_ref rows");
6326        assert!(
6327            !has_beta_access(&pool, victim).await?,
6328            "victim still had a beta seat"
6329        );
6330        let victim_codes: i64 =
6331            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6332                .bind(victim)
6333                .fetch_one(&pool)
6334                .await?;
6335        assert_eq!(victim_codes, 0);
6336
6337        // The bystander is untouched.
6338        assert!(has_beta_access(&pool, bystander).await?);
6339        let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
6340        assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
6341        let bystander_codes: i64 =
6342            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6343                .bind(bystander)
6344                .fetch_one(&pool)
6345                .await?;
6346        assert_eq!(bystander_codes, 1);
6347
6348        // The shared cache is intact.
6349        assert_eq!(count_feeds(&pool).await?, 1);
6350
6351        // Idempotent: purging again removes nothing.
6352        let again = purge_did_data(&pool, victim).await?;
6353        assert_eq!(again.total(), 0);
6354
6355        Ok(())
6356    }
6357
6358    /// A departing DID leaves back-references on rows that belong to OTHER DIDs:
6359    ///   * the invite code it *redeemed* to join (inviter's row: `invitee_did`);
6360    ///   * seats it *granted* to others (`beta_access.granted_by`).
6361    /// `purge_did_data` must scrub both so no per-DID residue survives, while
6362    /// leaving those other DIDs' rows otherwise intact (their access is kept).
6363    #[tokio::test]
6364    async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
6365        let pool = init_url("sqlite::memory:").await?;
6366
6367        let inviter = "did:plc:inviter";
6368        let leaver = "did:plc:leaver";
6369        let friend = "did:plc:friend";
6370
6371        // inviter mints a code; leaver redeems it to join (stamps invitee_did).
6372        let inviter_code = mint_code(&pool, inviter, 3600).await?;
6373        grant_access(&pool, inviter, None, "admin", None).await?;
6374        assert_eq!(
6375            redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
6376            Ok(())
6377        );
6378
6379        // leaver mints a code; friend redeems it (stamps friend's granted_by).
6380        let leaver_code = mint_code(&pool, leaver, 3600).await?;
6381        assert_eq!(
6382            redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
6383            Ok(())
6384        );
6385
6386        // Precondition: the leaver DID is present in both back-reference columns.
6387        let invitee_before: i64 =
6388            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6389                .bind(leaver)
6390                .fetch_one(&pool)
6391                .await?;
6392        assert_eq!(
6393            invitee_before, 1,
6394            "leaver should be an invitee before purge"
6395        );
6396        let granted_before: i64 =
6397            sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6398                .bind(leaver)
6399                .fetch_one(&pool)
6400                .await?;
6401        assert_eq!(granted_before, 1, "leaver should be a granter before purge");
6402
6403        // Purge the leaver.
6404        let counts = purge_did_data(&pool, leaver).await?;
6405        assert_eq!(
6406            counts.invitee_scrubbed, 1,
6407            "the redeemed code's invitee_did"
6408        );
6409        assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
6410
6411        // No residue: the leaver DID appears in NEITHER back-reference column.
6412        let invitee_after: i64 =
6413            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6414                .bind(leaver)
6415                .fetch_one(&pool)
6416                .await?;
6417        assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
6418        let granted_after: i64 =
6419            sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6420                .bind(leaver)
6421                .fetch_one(&pool)
6422                .await?;
6423        assert_eq!(granted_after, 0, "leaver survived in granted_by");
6424
6425        // The other DIDs' rows are kept: the friend still has a seat (redacted
6426        // granter), and the inviter's code row still exists (invitee NULLed).
6427        assert!(
6428            has_beta_access(&pool, friend).await?,
6429            "friend's seat must survive the leaver's scrub"
6430        );
6431        let friend_granted_by: String =
6432            sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
6433                .bind(friend)
6434                .fetch_one(&pool)
6435                .await?;
6436        assert_eq!(friend_granted_by, REDACTED_DID);
6437        let inviter_code_rows: i64 =
6438            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6439                .bind(inviter)
6440                .fetch_one(&pool)
6441                .await?;
6442        assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
6443
6444        Ok(())
6445    }
6446
6447    // -- F2: consecutive-error count drives the poll backoff -----------------
6448
6449    /// **Rows that failed only because we could not poll them are cleared.**
6450    ///
6451    /// Excluding `at://` from `due_feeds` stops NEW failures; it does nothing
6452    /// about the ones already recorded. This instance carries 19 such rows at
6453    /// 35+ consecutive errors each — accumulated entirely by our own refusal to
6454    /// fetch a scheme we had not implemented. Left alone they keep counting
6455    /// toward `in_backoff` and `badly_broken`, so a public page would report
6456    /// unsupported feeds as broken publishers forever, with no poll that could
6457    /// ever clear them since they are no longer selected.
6458    ///
6459    /// Safe to re-run because of WHAT it clears, not because the count cannot
6460    /// grow: only rows never polled successfully (`last_polled IS NULL`) — see
6461    /// `the_at_uri_error_clearing_spares_a_row_that_has_been_polled`.
6462    #[tokio::test]
6463    async fn the_migration_clears_error_counts_on_unpollable_at_uri_rows() -> Result<()> {
6464        let pool = init_url("sqlite::memory:").await?;
6465        for url in [
6466            "https://real.example/feed.xml",
6467            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
6468        ] {
6469            upsert_feed(
6470                &pool,
6471                &NewFeed {
6472                    url: url.to_string(),
6473                    ..Default::default()
6474                },
6475            )
6476            .await?;
6477            sqlx::query(
6478                "UPDATE feeds SET consecutive_errors = 35, last_error_kind = 'fetch', \
6479                 last_error = 'unsupported scheme' WHERE url = ?1",
6480            )
6481            .bind(url)
6482            .execute(&pool)
6483            .await?;
6484        }
6485
6486        apply_migrations(&pool).await?;
6487
6488        let (at_errors, at_kind, at_detail): (i64, Option<String>, Option<String>) =
6489            sqlx::query_as(sqlx::AssertSqlSafe(format!(
6490                "SELECT consecutive_errors, last_error_kind, last_error FROM feeds \
6491                 WHERE kind = '{}'",
6492                crate::feed::FeedKind::Publication.as_str()
6493            )))
6494            .fetch_one(&pool)
6495            .await?;
6496        assert_eq!(at_errors, 0, "an unpollable row kept its failure count");
6497        // A row with no errors carries no reason — the invariant
6498        // `reset_feed_errors` upholds, and the migration must too.
6499        assert_eq!(at_kind, None, "an unpollable row kept its failure kind");
6500        assert_eq!(at_detail, None, "an unpollable row kept its failure detail");
6501
6502        // A real feed's failure history is NOT touched — it is still meaningful.
6503        let http_errors: i64 = sqlx::query_scalar(
6504            "SELECT consecutive_errors FROM feeds WHERE url = 'https://real.example/feed.xml'",
6505        )
6506        .fetch_one(&pool)
6507        .await?;
6508        assert_eq!(http_errors, 35, "a real feed's history was discarded");
6509        Ok(())
6510    }
6511
6512    /// **An `at://` feed is never selected for polling.**
6513    ///
6514    /// Nothing can poll one: `poll_feed` goes through `net::guarded_get`, whose
6515    /// `check_scheme` refuses any non-http(s) scheme, and the standard.site
6516    /// reader is not wired to the scheduler. Selecting them anyway does not
6517    /// leave the feature dormant — it manufactures a permanent failure per row,
6518    /// which since the cause histogram is *published* as an unreachable
6519    /// publisher. This instance already carries 19 such rows, subscribed before
6520    /// the scheme was refused.
6521    ///
6522    /// They are skipped rather than failed: unsupported is not broken, and the
6523    /// difference is the whole point of recording a cause at all.
6524    #[tokio::test]
6525    async fn an_at_uri_feed_is_never_due_for_polling() -> Result<()> {
6526        let pool = init_url("sqlite::memory:").await?;
6527        for url in [
6528            "https://example.com/feed.xml",
6529            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
6530            "at://alice.example.com/site.standard.publication/3lab",
6531        ] {
6532            upsert_feed(
6533                &pool,
6534                &NewFeed {
6535                    url: url.to_string(),
6536                    ..Default::default()
6537                },
6538            )
6539            .await?;
6540        }
6541        // All three have a NULL next_poll, which sorts FIRST — so if at:// were
6542        // selectable at all it would be selected before the http feed.
6543        let due = due_feeds(&pool, "2026-09-20T00:00:00Z", 50).await?;
6544        let urls: Vec<&str> = due.iter().map(|f| f.url.as_str()).collect();
6545        assert_eq!(
6546            urls,
6547            ["https://example.com/feed.xml"],
6548            "an at:// feed was handed to the poller"
6549        );
6550        Ok(())
6551    }
6552
6553    #[tokio::test]
6554    async fn feed_error_count_bumps_and_resets() -> Result<()> {
6555        let pool = init_url("sqlite::memory:").await?;
6556        let url = "https://broken.example/feed.xml";
6557        upsert_feed(
6558            &pool,
6559            &NewFeed {
6560                url: url.to_string(),
6561                ..Default::default()
6562            },
6563        )
6564        .await?;
6565
6566        // A fresh feed starts at 0 errors.
6567        let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
6568        assert_eq!(feed.consecutive_errors, 0);
6569
6570        // N consecutive failures grow the count 1,2,3, and — fed through
6571        // `backoff_for` — the backoff grows with it (never latched at the floor).
6572        let mut last = std::time::Duration::ZERO;
6573        for expected in 1..=3 {
6574            let count = bump_feed_errors(
6575                &pool,
6576                url,
6577                crate::feed::FailureKind::Fetch,
6578                "connection refused",
6579            )
6580            .await?;
6581            assert_eq!(count, expected, "bump returns the new count");
6582            let backoff = crate::feed::backoff_for(count as u32);
6583            assert!(
6584                backoff >= last,
6585                "backoff must not shrink as errors accumulate"
6586            );
6587            last = backoff;
6588        }
6589        // Growth actually happened (2 errors backs off longer than 1).
6590        assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
6591        assert_eq!(
6592            get_feed_by_url(&pool, url)
6593                .await?
6594                .unwrap()
6595                .consecutive_errors,
6596            3
6597        );
6598
6599        // A success resets the streak to 0 (back to the normal cadence).
6600        reset_feed_errors(&pool, url).await?;
6601        assert_eq!(
6602            get_feed_by_url(&pool, url)
6603                .await?
6604                .unwrap()
6605                .consecutive_errors,
6606            0
6607        );
6608        Ok(())
6609    }
6610
6611    /// **A recovered feed keeps no reason for having failed.**
6612    ///
6613    /// Added because a mutation found this untested: deleting the
6614    /// `last_error_kind = NULL, last_error = NULL` half of `reset_feed_errors`
6615    /// left the entire suite green. The histogram filters on
6616    /// `consecutive_errors > 0`, so a stale row would not inflate the public
6617    /// count — but anything reading the row directly would be handed a cause
6618    /// that stopped applying, which is the exact failure this column was added
6619    /// to end. A guarantee nothing checks is a comment.
6620    #[tokio::test]
6621    async fn a_successful_poll_clears_the_recorded_failure_reason() -> Result<()> {
6622        let pool = init_url("sqlite::memory:").await?;
6623        let url = "https://recovers.example/feed.xml";
6624        upsert_feed(
6625            &pool,
6626            &NewFeed {
6627                url: url.to_string(),
6628                ..Default::default()
6629            },
6630        )
6631        .await?;
6632
6633        bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "SENTINEL_WHY").await?;
6634        let failing: (Option<String>, Option<String>) =
6635            sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
6636                .bind(url)
6637                .fetch_one(&pool)
6638                .await?;
6639        assert_eq!(
6640            failing.0.as_deref(),
6641            Some("fetch"),
6642            "the kind was not stored"
6643        );
6644        assert_eq!(
6645            failing.1.as_deref(),
6646            Some("SENTINEL_WHY"),
6647            "the detail was not stored"
6648        );
6649
6650        reset_feed_errors(&pool, url).await?;
6651        let recovered: (Option<String>, Option<String>) =
6652            sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
6653                .bind(url)
6654                .fetch_one(&pool)
6655                .await?;
6656        assert_eq!(
6657            recovered.0, None,
6658            "a healthy feed still names a failure kind"
6659        );
6660        assert_eq!(
6661            recovered.1, None,
6662            "a healthy feed still carries error detail"
6663        );
6664        Ok(())
6665    }
6666
6667    /// **The closed vocabulary is closed where it is READ, not only written.**
6668    ///
6669    /// `FailureKind::parse` promises that a kind string from a newer build is
6670    /// not "silently attributed to a cause this one recognises" — and the
6671    /// histogram's comment leaned on it. But review found `parse` had zero
6672    /// production callers: `poll_health` handed the raw column to the public
6673    /// template, so an unrecognised string got its own bucket, rendered
6674    /// verbatim. The protection existed only as a doc comment.
6675    ///
6676    /// A row written by a future build must land in `unknown`.
6677    #[tokio::test]
6678    async fn an_unrecognised_failure_kind_folds_into_unknown() -> Result<()> {
6679        let pool = init_url("sqlite::memory:").await?;
6680        for (url, kind) in [
6681            ("https://a.example/f.xml", Some("fetch")),
6682            ("https://b.example/f.xml", Some("quota")), // a newer build's kind
6683            ("https://c.example/f.xml", None),          // a legacy row
6684        ] {
6685            upsert_feed(
6686                &pool,
6687                &NewFeed {
6688                    url: url.to_string(),
6689                    ..Default::default()
6690                },
6691            )
6692            .await?;
6693            sqlx::query(
6694                "UPDATE feeds SET consecutive_errors = 1, last_error_kind = ?2 WHERE url = ?1",
6695            )
6696            .bind(url)
6697            .bind(kind)
6698            .execute(&pool)
6699            .await?;
6700        }
6701        let now = chrono::Utc::now();
6702        let health = poll_health(
6703            &pool,
6704            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
6705            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
6706        )
6707        .await?;
6708        let mut kinds = health.failure_kinds.clone();
6709        kinds.sort();
6710        assert_eq!(
6711            kinds,
6712            vec![("fetch".to_string(), 1), ("unknown".to_string(), 2)],
6713            "an unrecognised kind reached the public histogram as its own bucket: {:?}",
6714            health.failure_kinds
6715        );
6716        Ok(())
6717    }
6718
6719    /// **The migration is exercised against a table that predates the columns.**
6720    ///
6721    /// Every other test here builds a fresh database, where `CREATE TABLE`
6722    /// already contains `last_error_kind` / `last_error` — so `ensure_column`,
6723    /// the code path that actually runs against the production volume, was
6724    /// never executed by any of them. A bad `ALTER` would have been found at
6725    /// boot, on the one machine, by crash-looping: `apply_migrations` runs
6726    /// inside `init`, and the entrypoint takes the container down when a child
6727    /// dies.
6728    ///
6729    /// Builds the OLD table shape by hand, puts a failing row in it, migrates,
6730    /// and asserts both that the columns arrive and that the pre-existing row
6731    /// survives with NULLs rather than being rewritten or dropped.
6732    #[tokio::test]
6733    async fn the_last_error_columns_migrate_onto_a_table_that_predates_them() -> Result<()> {
6734        let pool = init_url("sqlite::memory:").await?;
6735
6736        // Drop the current shape and rebuild the pre-migration one.
6737        sqlx::query("DROP TABLE feeds").execute(&pool).await?;
6738        sqlx::query(
6739            "CREATE TABLE feeds (
6740                id                 INTEGER PRIMARY KEY AUTOINCREMENT,
6741                url                TEXT NOT NULL UNIQUE,
6742                title              TEXT,
6743                site_url           TEXT,
6744                etag               TEXT,
6745                last_modified      TEXT,
6746                last_polled        TEXT,
6747                next_poll          TEXT,
6748                consecutive_errors INTEGER NOT NULL DEFAULT 0
6749            )",
6750        )
6751        .execute(&pool)
6752        .await?;
6753        sqlx::query("INSERT INTO feeds (url, consecutive_errors) VALUES (?1, 7)")
6754            .bind("https://legacy.example/feed.xml")
6755            .execute(&pool)
6756            .await?;
6757
6758        apply_migrations(&pool).await?;
6759
6760        // The columns exist...
6761        let cols: Vec<String> = sqlx::query("PRAGMA table_info(feeds)")
6762            .fetch_all(&pool)
6763            .await?
6764            .iter()
6765            .map(|r| r.get::<String, _>("name"))
6766            .collect();
6767        assert!(cols.iter().any(|c| c == "last_error_kind"), "{cols:?}");
6768        assert!(cols.iter().any(|c| c == "last_error"), "{cols:?}");
6769
6770        // ...and the pre-existing row is intact, with no invented cause.
6771        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
6772            "SELECT consecutive_errors, last_error_kind, last_error FROM feeds WHERE url = ?1",
6773        )
6774        .bind("https://legacy.example/feed.xml")
6775        .fetch_one(&pool)
6776        .await?;
6777        assert_eq!(row.0, 7, "the migration disturbed an existing error count");
6778        assert_eq!(row.1, None, "a legacy row was given a cause it never had");
6779        assert_eq!(row.2, None);
6780
6781        // And it is idempotent — `init` runs this on every boot.
6782        apply_migrations(&pool).await?;
6783        Ok(())
6784    }
6785
6786    /// The stored detail is bounded — it is a remote server's text on an
6787    /// unattended path.
6788    #[tokio::test]
6789    async fn the_stored_error_detail_is_truncated() -> Result<()> {
6790        let pool = init_url("sqlite::memory:").await?;
6791        let url = "https://verbose.example/feed.xml";
6792        upsert_feed(
6793            &pool,
6794            &NewFeed {
6795                url: url.to_string(),
6796                ..Default::default()
6797            },
6798        )
6799        .await?;
6800        bump_feed_errors(
6801            &pool,
6802            url,
6803            crate::feed::FailureKind::Body,
6804            &"x".repeat(10_000),
6805        )
6806        .await?;
6807        let stored: (Option<String>,) =
6808            sqlx::query_as("SELECT last_error FROM feeds WHERE url = ?1")
6809                .bind(url)
6810                .fetch_one(&pool)
6811                .await?;
6812        assert_eq!(stored.0.unwrap().chars().count(), MAX_ERROR_DETAIL_CHARS);
6813        Ok(())
6814    }
6815
6816    // -- F3: db_size_bytes ignores freed pages and drops after reclaim -------
6817
6818    /// A new on-disk database must be created in INCREMENTAL mode.
6819    ///
6820    /// This is the whole fix for new instances: `auto_vacuum` was read by
6821    /// `reclaim` and set nowhere, so every database ran in NONE and `reclaim`
6822    /// always took its full-`VACUUM` branch — the one that cannot complete on a
6823    /// volume under the pressure that triggered the sweep. The pragma only binds
6824    /// on a database with no tables yet, so "at creation" is the load-bearing
6825    /// part, not "somewhere in init".
6826    #[tokio::test]
6827    async fn a_new_database_is_created_in_incremental_vacuum_mode() -> Result<()> {
6828        let dir = std::env::temp_dir();
6829        let path = dir.join(format!("fr-autovac-{}.db", std::process::id()));
6830        for p in [
6831            path.display().to_string(),
6832            format!("{}-wal", path.display()),
6833            format!("{}-shm", path.display()),
6834        ] {
6835            std::fs::remove_file(&p).ok();
6836        }
6837        let pool = init_url(&format!("sqlite://{}", path.display())).await?;
6838
6839        assert_eq!(
6840            auto_vacuum_mode(&pool).await?,
6841            AutoVacuum::Incremental,
6842            "a fresh database is still in the mode where reclaim needs a full VACUUM"
6843        );
6844        // And the WAL is bounded rather than growing to its high-water mark
6845        // forever.
6846        let limit: i64 = sqlx::query_scalar("PRAGMA journal_size_limit")
6847            .fetch_one(&pool)
6848            .await?;
6849        assert_eq!(
6850            limit, WAL_SIZE_LIMIT_BYTES,
6851            "journal_size_limit not applied"
6852        );
6853
6854        // Being INCREMENTAL, the migration is a no-op — which is what makes the
6855        // flag safe for an operator to run without checking first.
6856        assert_eq!(
6857            migrate_to_incremental_vacuum(&pool, None).await?,
6858            VacuumMigration::NotNeeded(AutoVacuum::Incremental)
6859        );
6860
6861        pool.close().await;
6862        for p in [
6863            path.display().to_string(),
6864            format!("{}-wal", path.display()),
6865            format!("{}-shm", path.display()),
6866        ] {
6867            std::fs::remove_file(&p).ok();
6868        }
6869        Ok(())
6870    }
6871
6872    /// The migration refuses itself when the volume cannot hold the rebuild.
6873    ///
6874    /// A full `VACUUM` writes a complete second copy, so attempting one without
6875    /// headroom burns I/O on a box that has none and finishes nothing. Refusing
6876    /// is the entire reason this is an operator step rather than something
6877    /// `reclaim` does on its own.
6878    #[tokio::test]
6879    async fn the_vacuum_migration_refuses_without_headroom() -> Result<()> {
6880        let dir = std::env::temp_dir();
6881        let path = dir.join(format!("fr-autovac-none-{}.db", std::process::id()));
6882        for p in [
6883            path.display().to_string(),
6884            format!("{}-wal", path.display()),
6885            format!("{}-shm", path.display()),
6886        ] {
6887            std::fs::remove_file(&p).ok();
6888        }
6889        // Build a database the way one that predates this change looks: create
6890        // the file in NONE mode explicitly, then populate it.
6891        let url = format!("sqlite://{}", path.display());
6892        let opts = SqliteConnectOptions::from_str(&url)?
6893            .create_if_missing(true)
6894            .foreign_keys(true)
6895            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
6896            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
6897        let pool = SqlitePoolOptions::new()
6898            .min_connections(1)
6899            .max_connections(1)
6900            .connect_with(opts)
6901            .await?;
6902        init_schema(&pool).await?;
6903        assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::None);
6904
6905        // Zero free space: refused, and the mode is untouched.
6906        let refused = migrate_to_incremental_vacuum(&pool, Some(0)).await?;
6907        assert!(
6908            matches!(refused, VacuumMigration::RefusedNoHeadroom { .. }),
6909            "expected a refusal, got {refused:?}"
6910        );
6911        assert_eq!(
6912            auto_vacuum_mode(&pool).await?,
6913            AutoVacuum::None,
6914            "a refused migration must not have changed the mode"
6915        );
6916
6917        // With headroom it runs, and the database ends up INCREMENTAL — which is
6918        // what makes `reclaim` cheap from then on.
6919        let done = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
6920        let VacuumMigration::Migrated {
6921            bytes_after,
6922            file_after,
6923            ..
6924        } = done
6925        else {
6926            panic!("expected a migration, got {done:?}");
6927        };
6928        assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::Incremental);
6929        // The reported size must not include the WAL the VACUUM just filled. In
6930        // WAL mode a VACUUM writes the whole rebuilt database through the WAL,
6931        // so without the truncating checkpoint this reads as roughly double —
6932        // "the migration doubled my database", from the one line the command
6933        // prints.
6934        let file_after = file_after.expect("an on-disk database has a file size") as i64;
6935        assert!(
6936            bytes_after <= file_after * 2,
6937            "bytes_after ({bytes_after}) is inflated by an untruncated WAL against a \
6938             {file_after}-byte file"
6939        );
6940
6941        pool.close().await;
6942        for p in [
6943            path.display().to_string(),
6944            format!("{}-wal", path.display()),
6945            format!("{}-shm", path.display()),
6946        ] {
6947            std::fs::remove_file(&p).ok();
6948        }
6949        Ok(())
6950    }
6951
6952    /// **R6 benchmark: what the retention sweep actually costs, and what fixes it.**
6953    ///
6954    /// `#[ignore]` — builds a ~1M-row database once per shape per scale (ten
6955    /// times), so it is a measurement tool rather than a test. Run with:
6956    ///
6957    /// ```text
6958    /// cargo test --lib -- --ignored --nocapture r6_measure_retention_sweep
6959    /// ```
6960    ///
6961    /// It exists because R6 was "every delete batch re-scans `entry_state`" and
6962    /// the honest answer was "measure before changing an index". Kept so the next
6963    /// candidate index can be tried against the same fixture rather than a new
6964    /// one. Findings are recorded in `design/REVIEW-ROUND-2.md`.
6965    #[tokio::test]
6966    #[ignore]
6967    async fn r6_measure_retention_sweep() -> Result<()> {
6968        const FEEDS: i64 = 500;
6969        const PER_FEED: i64 = 2_000; // matches `max_entries_per_feed`
6970        const PINNED: i64 = 50_000; // entry_state rows a reader has touched
6971
6972        /// Build the fixture, apply `extra_indexes`, then plan and time a sweep.
6973        async fn run(
6974            label: &str,
6975            extra_indexes: &[&str],
6976            pinned: i64,
6977            old_list_form: bool,
6978        ) -> Result<()> {
6979            let dir = std::env::temp_dir();
6980            let path = dir.join(format!("fr-r6-{}-{label}.db", std::process::id()));
6981            // RAII, because every `?` between here and the end used to leak a
6982            // 1M-row fixture plus its -wal/-shm into the temp dir — six per run.
6983            struct Fixture(std::path::PathBuf);
6984            impl Fixture {
6985                fn wipe(&self) {
6986                    for p in [
6987                        self.0.display().to_string(),
6988                        format!("{}-wal", self.0.display()),
6989                        format!("{}-shm", self.0.display()),
6990                    ] {
6991                        std::fs::remove_file(&p).ok();
6992                    }
6993                }
6994            }
6995            impl Drop for Fixture {
6996                fn drop(&mut self) {
6997                    self.wipe();
6998                }
6999            }
7000            let fixture = Fixture(path.clone());
7001            fixture.wipe();
7002            let pool = init_url(&format!("sqlite://{}", path.display())).await?;
7003
7004            // Bulk-build with SQL: a million round trips would measure the
7005            // fixture, not the sweep. Recursive CTE because `generate_series` is
7006            // not compiled into the bundled SQLite.
7007            sqlx::query(
7008                "WITH RECURSIVE n(value) AS ( \
7009                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7010                 ) \
7011                 INSERT INTO feeds (url) \
7012                 SELECT 'https://f' || value || '.example/x.xml' FROM n",
7013            )
7014            .bind(FEEDS)
7015            .execute(&pool)
7016            .await
7017            .context("seeding feeds")?;
7018
7019            // Half the entries older than the window, half inside it.
7020            sqlx::query(
7021                "WITH RECURSIVE n(value) AS ( \
7022                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7023                 ) \
7024                 INSERT INTO entries (feed_id, guid, title, published, fetched_at) \
7025                 SELECT f.id, \
7026                        'g' || f.id || '-' || s.value, \
7027                        'Entry ' || s.value, \
7028                        CASE WHEN s.value % 2 = 0 THEN '2020-01-01T00:00:00Z' \
7029                             ELSE '2099-01-01T00:00:00Z' END, \
7030                        '2026-01-01T00:00:00Z' \
7031                 FROM feeds f, n s",
7032            )
7033            .bind(PER_FEED)
7034            .execute(&pool)
7035            .await?;
7036
7037            // **A REALISTIC pin distribution, which the first version did not
7038            // have.** It made every row `read=0,starred=0` or `read=1,starred=1`,
7039            // so 100% of `entry_state` matched `starred = 1 OR read = 0` — there
7040            // were no "read and not starred" rows at all, which is the commonest
7041            // state a reader leaves behind. That mattered: a PARTIAL index on the
7042            // pinned predicate then covers the whole table and cannot be
7043            // selective, so measuring one against that fixture measures nothing.
7044            //
7045            // 90% read-and-unstarred (evictable), 10% pinned, split between
7046            // starred and unread.
7047            sqlx::query(
7048                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
7049                 SELECT 'did:plc:reader', id, \
7050                        CASE WHEN id % 10 <> 0 THEN 1 \
7051                             WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7052                        CASE WHEN id % 10 <> 0 THEN 0 \
7053                             WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7054                        '2026-01-01T00:00:00Z' \
7055                 FROM entries LIMIT ?1",
7056            )
7057            .bind(pinned)
7058            .execute(&pool)
7059            .await?;
7060
7061            // Space is the other half of the trade: this is a 1 GB volume with a
7062            // 768 MiB watermark, so an index that buys time and costs disk can be
7063            // a net loss.
7064            let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7065                .fetch_one(&pool)
7066                .await?;
7067            let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
7068                .fetch_one(&pool)
7069                .await?;
7070            for idx in extra_indexes {
7071                sqlx::query(sqlx::AssertSqlSafe((*idx).to_string()))
7072                    .execute(&pool)
7073                    .await
7074                    .with_context(|| format!("creating {idx}"))?;
7075            }
7076            let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7077                .fetch_one(&pool)
7078                .await?;
7079            let index_bytes = (pages_after - pages_before) * page_size;
7080
7081            // What the index costs on the WRITE path — the poller inserts
7082            // constantly, the sweep runs once a day.
7083            let t_ins = std::time::Instant::now();
7084            sqlx::query(
7085                "WITH RECURSIVE n(value) AS ( \
7086                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 10000 \
7087                 ) \
7088                 INSERT INTO entries (feed_id, guid, published, fetched_at) \
7089                 SELECT 1, 'ins-' || value, '2099-06-01T00:00:00Z', '2026-01-01T00:00:00Z' \
7090                 FROM n",
7091            )
7092            .execute(&pool)
7093            .await?;
7094            let insert_10k = t_ins.elapsed();
7095
7096            // Give the planner statistics, as a long-lived instance would have.
7097            sqlx::query("ANALYZE").execute(&pool).await?;
7098
7099            // The plan must describe the query this run actually TIMES. It used
7100            // to be hardcoded to the `NOT IN` form regardless, so four of six
7101            // runs printed a plan for a different query than the one measured —
7102            // in the artifact kept precisely to be the evidence.
7103            let planned = if old_list_form {
7104                "EXPLAIN QUERY PLAN SELECT id FROM entries \
7105                 WHERE COALESCE(published, fetched_at) < '2026-06-01T00:00:00Z' \
7106                   AND id NOT IN (SELECT entry_id FROM entry_state \
7107                                  WHERE starred = 1 OR read = 0) \
7108                 LIMIT 1000"
7109            } else {
7110                "EXPLAIN QUERY PLAN SELECT e.id FROM entries e \
7111                 WHERE COALESCE(e.published, e.fetched_at) < '2026-06-01T00:00:00Z' \
7112                   AND NOT EXISTS (SELECT 1 FROM entry_state s \
7113                                   WHERE s.entry_id = e.id \
7114                                     AND (s.starred = 1 OR s.read = 0)) \
7115                 LIMIT 1000"
7116            };
7117            let plan: Vec<String> = sqlx::query(sqlx::AssertSqlSafe(planned))
7118                .fetch_all(&pool)
7119                .await?
7120                .into_iter()
7121                .map(|r| r.get::<String, _>("detail"))
7122                .collect();
7123
7124            // ONE variant per fixture — running both against the same database
7125            // measured the second against an already-emptied table, which
7126            // reported a 0-row "win" the first time this was written.
7127            let cutoff = (chrono::Utc::now() - chrono::Duration::days(30))
7128                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7129            let t = std::time::Instant::now();
7130            let deleted = if old_list_form {
7131                // The shape `prune_old_entries` used to have: the pinned set as
7132                // an `IN` list, re-materialised on every batch.
7133                let mut n = 0u64;
7134                loop {
7135                    let got = sqlx::query(
7136                        "DELETE FROM entries WHERE id IN ( \
7137                             SELECT id FROM entries \
7138                             WHERE COALESCE(published, fetched_at) < ?1 \
7139                               AND id NOT IN ( \
7140                                   SELECT entry_id FROM entry_state \
7141                                   WHERE starred = 1 OR read = 0 \
7142                               ) \
7143                             LIMIT 1000)",
7144                    )
7145                    .bind(&cutoff)
7146                    .execute(&pool)
7147                    .await?
7148                    .rows_affected();
7149                    n += got;
7150                    if got == 0 {
7151                        break;
7152                    }
7153                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7154                }
7155                n
7156            } else {
7157                // NOTE the arms are not identical work: this one goes through the
7158                // real `prune_old_entries`, which also runs the hard-ceiling pass
7159                // and the cursor scrub. The bias therefore runs AGAINST the
7160                // shipped form, so a win measured here is a lower bound — but the
7161                // two numbers are not a like-for-like microbenchmark.
7162                prune_old_entries(&pool, 30, 3650, 0).await?
7163            };
7164            let elapsed = t.elapsed();
7165
7166            // State the fixture's shape, so a future reader cannot mistake a
7167            // degenerate distribution for a representative one again.
7168            let matching: i64 = sqlx::query_scalar(
7169                "SELECT COUNT(*) FROM entry_state WHERE starred = 1 OR read = 0",
7170            )
7171            .fetch_one(&pool)
7172            .await?;
7173            println!("\n=== {label}  (entry_state = {pinned}, pinned = {matching}) ===");
7174            println!(
7175                "  index cost: {:.1} MiB on disk, 10k inserts in {insert_10k:?}",
7176                index_bytes as f64 / 1024.0 / 1024.0
7177            );
7178            for l in &plan {
7179                println!("  plan: {l}");
7180            }
7181            println!(
7182                "  deleted {deleted} in {elapsed:?}  ({:?}/batch)",
7183                elapsed / (deleted as u32 / PRUNE_BATCH as u32).max(1)
7184            );
7185
7186            pool.close().await;
7187            drop(fixture);
7188            Ok(())
7189        }
7190
7191        // R6's own hypothesis was that the per-batch `entry_state` scan is the
7192        // cost. Both scales are measured because that scan grows with TOTAL
7193        // users, not with the feed being swept — 50k is one active reader,
7194        // 600k is the figure the schema comment cites as realistic.
7195        const AGE_IDX: &str =
7196            "CREATE INDEX idx_entries_age ON entries(COALESCE(published, fetched_at))";
7197        // The index R6 actually asked for. Its row is the one the rejection
7198        // turns on — "changes the plan, changes the time by nothing" — and an
7199        // earlier version of this benchmark dropped it, leaving that claim
7200        // resting on prose while the artifact kept to prove it could not.
7201        const PINNED_IDX: &str = "CREATE INDEX idx_es_pinned ON entry_state(entry_id) \
7202                                  WHERE starred = 1 OR read = 0";
7203        for pinned in [PINNED, 600_000] {
7204            // `false` = the shipped `prune_old_entries`, whatever shape it
7205            // currently uses; `true` = the raw `NOT IN` list form it replaced,
7206            // kept so the regression stays measurable rather than remembered.
7207            run("as shipped (NOT EXISTS)", &[], pinned, false).await?;
7208            run("old NOT IN list form", &[], pinned, true).await?;
7209            run("old NOT IN + pinned index", &[PINNED_IDX], pinned, true).await?;
7210            // The row that was never measured: the pinned index against the
7211            // query that SHIPPED, rather than against the one being deleted.
7212            // Rejecting it on the strength of the latter was the error.
7213            run("as shipped + pinned index", &[PINNED_IDX], pinned, false).await?;
7214            run("as shipped + age index", &[AGE_IDX], pinned, false).await?;
7215        }
7216        Ok(())
7217    }
7218
7219    /// **The migration must not ask the pool for anything while holding a
7220    /// connection.** A single-connection pool is always saturated, so any such
7221    /// call stalls for the full acquire timeout.
7222    ///
7223    /// This has now been introduced twice — once by acquiring a connection for
7224    /// the pragma pair, and once by resolving the temp directory inside that
7225    /// block. The second was worse than a stall: `main_db_path` swallows errors
7226    /// into `None`, so it waited 30 s and then silently skipped the pragma it
7227    /// existed to set. A wall-clock assertion is crude, but it is the only thing
7228    /// that distinguishes "works" from "works after a 30-second timeout".
7229    #[tokio::test]
7230    async fn the_vacuum_migration_never_waits_on_its_own_pool() -> Result<()> {
7231        let dir = std::env::temp_dir();
7232        let path = dir.join(format!("fr-nodeadlock-{}.db", std::process::id()));
7233        for p in [
7234            path.display().to_string(),
7235            format!("{}-wal", path.display()),
7236            format!("{}-shm", path.display()),
7237        ] {
7238            std::fs::remove_file(&p).ok();
7239        }
7240        let url = format!("sqlite://{}", path.display());
7241        let opts = SqliteConnectOptions::from_str(&url)?
7242            .create_if_missing(true)
7243            .foreign_keys(true)
7244            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7245            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7246        // ONE connection: any pool call made while the migration holds it will
7247        // block until the acquire timeout rather than deadlocking forever.
7248        let pool = SqlitePoolOptions::new()
7249            .min_connections(1)
7250            .max_connections(1)
7251            .connect_with(opts)
7252            .await?;
7253        init_schema(&pool).await?;
7254
7255        let t0 = std::time::Instant::now();
7256        let outcome = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
7257        let elapsed = t0.elapsed();
7258
7259        assert!(
7260            matches!(outcome, VacuumMigration::Migrated { .. }),
7261            "expected a migration, got {outcome:?}"
7262        );
7263        assert!(
7264            elapsed < std::time::Duration::from_secs(5),
7265            "the migration took {elapsed:?} on an empty database — it is waiting on \
7266             its own pool while holding a connection"
7267        );
7268
7269        pool.close().await;
7270        for p in [
7271            path.display().to_string(),
7272            format!("{}-wal", path.display()),
7273            format!("{}-shm", path.display()),
7274        ] {
7275            std::fs::remove_file(&p).ok();
7276        }
7277        Ok(())
7278    }
7279
7280    /// `reclaim` must NOT run a full VACUUM in NONE mode — the branch that used
7281    /// to be the only one that ever executed, and the one that cannot finish on
7282    /// a volume under the pressure that triggers a sweep.
7283    ///
7284    /// Observable without timing a VACUUM: a full VACUUM returns freed pages to
7285    /// the OS, so `page_count` falls. Skipping it leaves the allocation in
7286    /// place — while `db_size_bytes`, which subtracts the freelist, still drops.
7287    /// That pairing is the actual claim: the watermark does not latch even
7288    /// though the file does not shrink.
7289    #[tokio::test]
7290    async fn reclaim_does_not_full_vacuum_in_none_mode() -> Result<()> {
7291        let dir = std::env::temp_dir();
7292        let path = dir.join(format!("fr-noneclaim-{}.db", std::process::id()));
7293        for p in [
7294            path.display().to_string(),
7295            format!("{}-wal", path.display()),
7296            format!("{}-shm", path.display()),
7297        ] {
7298            std::fs::remove_file(&p).ok();
7299        }
7300        let url = format!("sqlite://{}", path.display());
7301        let opts = SqliteConnectOptions::from_str(&url)?
7302            .create_if_missing(true)
7303            .foreign_keys(true)
7304            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7305            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7306        let pool = SqlitePoolOptions::new()
7307            .min_connections(1)
7308            .max_connections(1)
7309            .connect_with(opts)
7310            .await?;
7311        init_schema(&pool).await?;
7312
7313        let feed_id = upsert_feed(
7314            &pool,
7315            &NewFeed {
7316                url: "https://none.example/f.xml".to_string(),
7317                ..Default::default()
7318            },
7319        )
7320        .await?;
7321        let entries: Vec<NewEntry> = (0..1500)
7322            .map(|i| NewEntry {
7323                guid: format!("n-{i}"),
7324                content_html: Some("x".repeat(800)),
7325                ..Default::default()
7326            })
7327            .collect();
7328        insert_entries(&pool, feed_id, &entries, 0).await?;
7329        // Fold the WAL in so the "full" baseline is file pages, not WAL churn.
7330        sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
7331            .execute(&pool)
7332            .await?;
7333        let used_full = db_size_bytes(&pool).await?;
7334
7335        sqlx::query("DELETE FROM entries").execute(&pool).await?;
7336        let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7337            .fetch_one(&pool)
7338            .await?;
7339
7340        reclaim(&pool).await?;
7341
7342        let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7343            .fetch_one(&pool)
7344            .await?;
7345        assert_eq!(
7346            pages_after, pages_before,
7347            "reclaim shrank the file in NONE mode, so it ran the full VACUUM this \
7348             branch exists to avoid"
7349        );
7350        // …and the watermark still falls, which is what makes skipping safe.
7351        // `db_size_bytes` subtracts the freelist, so the delete alone lowers it
7352        // even though the file kept every page it had allocated.
7353        let used_after = db_size_bytes(&pool).await?;
7354        assert!(
7355            used_after < used_full,
7356            "used size did not fall after the delete ({used_after} !< {used_full}); \
7357             without a VACUUM the DB-size watermark would latch the poller off"
7358        );
7359
7360        pool.close().await;
7361        for p in [
7362            path.display().to_string(),
7363            format!("{}-wal", path.display()),
7364            format!("{}-shm", path.display()),
7365        ] {
7366            std::fs::remove_file(&p).ok();
7367        }
7368        Ok(())
7369    }
7370
7371    #[tokio::test]
7372    async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
7373        // On-disk DB so VACUUM has a file to shrink (in-memory has no freelist to
7374        // speak of the same way). Temp path, cleaned up at the end.
7375        let dir = std::env::temp_dir();
7376        let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
7377        let url = format!("sqlite://{}", path.display());
7378        let pool = init_url(&url).await?;
7379
7380        let feed_id = upsert_feed(
7381            &pool,
7382            &NewFeed {
7383                url: "https://bulk.example/feed.xml".to_string(),
7384                ..Default::default()
7385            },
7386        )
7387        .await?;
7388
7389        // Insert a large batch so the file allocates real pages.
7390        let entries: Vec<NewEntry> = (0..2000)
7391            .map(|i| NewEntry {
7392                guid: format!("guid-{i}"),
7393                title: Some(format!("Entry number {i} with some padding text")),
7394                content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
7395                published: Some("2026-01-01T00:00:00Z".to_string()),
7396                ..Default::default()
7397            })
7398            .collect();
7399        insert_entries(&pool, feed_id, &entries, 0).await?;
7400        let full = db_size_bytes(&pool).await?;
7401        assert!(full > 0);
7402
7403        // Prune: delete every entry (the retention sweep's effect). This frees
7404        // pages onto the freelist but does NOT shrink the file yet.
7405        sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
7406            .bind(feed_id)
7407            .execute(&pool)
7408            .await?;
7409
7410        // Because db_size_bytes subtracts freelist pages, the USED size already
7411        // reflects the delete even before the file shrinks.
7412        let after_delete = db_size_bytes(&pool).await?;
7413        assert!(
7414            after_delete < full,
7415            "used size must drop once rows are deleted (freed pages excluded): \
7416             {after_delete} !< {full}"
7417        );
7418
7419        // Reclaim returns the freed pages to the OS; used size stays low (and the
7420        // file itself shrinks). The key property F3 needs: the watermark can now
7421        // fall back below its threshold instead of latching polling off.
7422        reclaim(&pool).await?;
7423        let after_reclaim = db_size_bytes(&pool).await?;
7424        assert!(
7425            after_reclaim <= after_delete,
7426            "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
7427        );
7428        assert!(
7429            after_reclaim < full,
7430            "after prune+reclaim the DB is smaller than when full: \
7431             {after_reclaim} !< {full}"
7432        );
7433
7434        drop(pool);
7435        let _ = std::fs::remove_file(&path);
7436        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
7437        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
7438        Ok(())
7439    }
7440
7441    // -- F4 support: pds_created flag round-trips + flips ---------------------
7442
7443    #[tokio::test]
7444    async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
7445        let pool = init_url("sqlite::memory:").await?;
7446        let did = "did:plc:f4";
7447        let feed_url = "https://example.com/feed.xml";
7448        upsert_cursor(
7449            &pool,
7450            &ReadCursor {
7451                did: did.to_string(),
7452                feed_url: feed_url.to_string(),
7453                read_through: None,
7454                read_ids: r#"["1"]"#.to_string(),
7455                unread_ids: "[]".to_string(),
7456                dirty: true,
7457                pds_created: false,
7458                updated_at: now_rfc3339(),
7459            },
7460        )
7461        .await?;
7462
7463        // A brand-new cursor's PDS record does NOT yet exist.
7464        let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7465        assert!(!c.pds_created, "first flush must emit a create, not update");
7466
7467        // Two bystanders: the same DID on another feed, another DID on the same
7468        // feed. **The UPDATE must be scoped to exactly one row.** With its WHERE
7469        // clause deleted this test still passed — it seeded one cursor, so
7470        // "every row" and "this row" were the same row. Unscoped, every DID's
7471        // every cursor is flagged as created, their readState records are never
7472        // created, and every later flush emits `update` against nothing.
7473        for (d, f) in [
7474            (did, "https://other.example/feed.xml"),
7475            ("did:plc:other", feed_url),
7476        ] {
7477            upsert_cursor(
7478                &pool,
7479                &ReadCursor {
7480                    did: d.to_string(),
7481                    feed_url: f.to_string(),
7482                    read_through: None,
7483                    read_ids: "[]".to_string(),
7484                    unread_ids: "[]".to_string(),
7485                    dirty: false,
7486                    pds_created: false,
7487                    updated_at: now_rfc3339(),
7488                },
7489            )
7490            .await?;
7491        }
7492
7493        // After the create-flush lands, the flag flips so future flushes update.
7494        mark_cursor_pds_created(&pool, did, feed_url).await?;
7495        let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7496        assert!(c.pds_created);
7497        for (d, f) in [
7498            (did, "https://other.example/feed.xml"),
7499            ("did:plc:other", feed_url),
7500        ] {
7501            let bystander = get_cursor(&pool, d, f).await?.unwrap();
7502            assert!(
7503                !bystander.pds_created,
7504                "marking ({did}, {feed_url}) also flagged ({d}, {f})"
7505            );
7506        }
7507        Ok(())
7508    }
7509
7510    // -- STORAGE HYGIENE: retention prune + orphan-id scrub -------------------
7511
7512    /// Count entries currently in the cache.
7513    async fn count_entries(pool: &SqlitePool) -> Result<i64> {
7514        Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
7515            .fetch_one(pool)
7516            .await?)
7517    }
7518
7519    /// **The rolling window and the hard ceiling do not touch a publication, and
7520    /// this is the test that says the feature works at all.**
7521    ///
7522    /// Measured on 2026-09-27 against three real publications: the newest
7523    /// document Standard.site offered was 131 days old, Annotated's 109, minus
7524    /// listens' 241. Under the 14-day window every one of them stored **zero**
7525    /// rows — a successful poll and an empty feed. So age is not the policy here;
7526    /// COUNT is (`max_entries_per_feed`), and the ceiling below is only the
7527    /// not-immortal backstop.
7528    ///
7529    /// Both directions in one test on purpose: the RSS twin must still be
7530    /// deleted, or "nothing is ever swept" would pass.
7531    #[tokio::test]
7532    async fn the_window_and_the_ceiling_spare_a_publication_but_not_an_rss_entry() -> Result<()> {
7533        let pool = init_url("sqlite::memory:").await?;
7534        let rss = upsert_feed(
7535            &pool,
7536            &NewFeed {
7537                url: "https://aged.example/feed.xml".to_string(),
7538                ..Default::default()
7539            },
7540        )
7541        .await?;
7542        let publication = upsert_feed(
7543            &pool,
7544            &NewFeed {
7545                url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
7546                    .to_string(),
7547                ..Default::default()
7548            },
7549        )
7550        .await?;
7551        // The kind column is what the sweep filters on, so assert the fixture
7552        // really produced two different kinds rather than trusting `FeedKind::of`.
7553        let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM feeds ORDER BY id")
7554            .fetch_all(&pool)
7555            .await?;
7556        assert_eq!(kinds, vec!["rss".to_string(), "publication".to_string()]);
7557
7558        // A year old, and READ by somebody — so the window's own sparing rule
7559        // ("starred or unread survives") cannot be what keeps either row.
7560        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7561            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7562        for feed_id in [rss, publication] {
7563            insert_entries(
7564                &pool,
7565                feed_id,
7566                &[NewEntry {
7567                    guid: format!("ancient-{feed_id}"),
7568                    published: Some(ancient.clone()),
7569                    fetched_at: Some(ancient.clone()),
7570                    ..Default::default()
7571                }],
7572                0,
7573            )
7574            .await?;
7575        }
7576        replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
7577        for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
7578            .fetch_all(&pool)
7579            .await?
7580        {
7581            mark_read(&pool, "did:plc:reader", id, true).await?;
7582        }
7583        assert_eq!(count_entries(&pool).await?, 2);
7584
7585        // **The shipped configuration, all three knobs at their defaults.** An
7586        // earlier version of this test passed `0` for the archive ceiling, so the
7587        // combination under test was not the one any instance runs; at 3650 the
7588        // publication's year-old document is inside the ceiling and must still
7589        // survive.
7590        let deleted = prune_old_entries(&pool, 14, 180, 3_650).await?;
7591        assert_eq!(deleted, 1, "exactly one of the two should have gone");
7592        let surviving: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM entries")
7593            .fetch_all(&pool)
7594            .await?;
7595        assert_eq!(
7596            surviving,
7597            vec![publication],
7598            "the publication's year-old document was swept — under the 14-day \
7599             window that is every document a real publication has, so the feed a \
7600             reader subscribed to would be permanently empty",
7601        );
7602        Ok(())
7603    }
7604
7605    /// **"Not aged out" must not mean "immortal".**
7606    ///
7607    /// The per-feed trim is what bounds a publication, and it only runs when a
7608    /// poll stores something — so entries of a feed nobody polls any more have
7609    /// nothing else to reap them. This ceiling is that backstop, and it spares
7610    /// nothing, for the same reason the hard ceiling spares nothing: a saved
7611    /// record whose entry is gone still renders from the PDS record as a link.
7612    #[tokio::test]
7613    async fn the_archive_ceiling_reaps_a_publication_entry_past_it() -> Result<()> {
7614        let pool = init_url("sqlite::memory:").await?;
7615        // An RSS twin, to pin that this pass is SCOPED. Verified needed: dropping
7616        // the `kind NOT IN` clause from it left all 909 tests passing, and that
7617        // mutation quietly re-enables age-based eviction for RSS on an instance
7618        // whose operator set both RSS knobs to zero.
7619        let rss = upsert_feed(
7620            &pool,
7621            &NewFeed {
7622                url: "https://not-swept.example/feed.xml".to_string(),
7623                ..Default::default()
7624            },
7625        )
7626        .await?;
7627        let publication = upsert_feed(
7628            &pool,
7629            &NewFeed {
7630                url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
7631                    .to_string(),
7632                ..Default::default()
7633            },
7634        )
7635        .await?;
7636        let ancient = (chrono::Utc::now() - chrono::Duration::days(400))
7637            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7638        let recent = now_rfc3339();
7639        insert_entries(
7640            &pool,
7641            publication,
7642            &[
7643                NewEntry {
7644                    guid: "past-the-ceiling".into(),
7645                    published: Some(ancient.clone()),
7646                    fetched_at: Some(ancient),
7647                    ..Default::default()
7648                },
7649                NewEntry {
7650                    guid: "inside-the-ceiling".into(),
7651                    published: Some(recent.clone()),
7652                    fetched_at: Some(recent),
7653                    ..Default::default()
7654                },
7655            ],
7656            0,
7657        )
7658        .await?;
7659        let long_ago = (chrono::Utc::now() - chrono::Duration::days(400))
7660            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7661        insert_entries(
7662            &pool,
7663            rss,
7664            &[NewEntry {
7665                guid: "rss-past-the-archive-ceiling".into(),
7666                published: Some(long_ago.clone()),
7667                fetched_at: Some(long_ago),
7668                ..Default::default()
7669            }],
7670            0,
7671        )
7672        .await?;
7673
7674        // STARRED, so this also pins that the ceiling spares nothing.
7675        replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
7676        for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
7677            .fetch_all(&pool)
7678            .await?
7679        {
7680            mark_starred(&pool, "did:plc:reader", id, true).await?;
7681        }
7682
7683        // Rolling window and hard ceiling off: the archive ceiling is the only
7684        // thing that can delete here.
7685        let deleted = prune_old_entries(&pool, 0, 0, 365).await?;
7686        assert_eq!(
7687            deleted, 1,
7688            "the entry past the archive ceiling was not reaped"
7689        );
7690        let mut guids: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
7691            .fetch_all(&pool)
7692            .await?;
7693        guids.sort();
7694        assert_eq!(
7695            guids,
7696            vec![
7697                "inside-the-ceiling".to_string(),
7698                "rss-past-the-archive-ceiling".to_string(),
7699            ],
7700            "the archive ceiling must reap the publication's over-age entry and \
7701             ONLY that — an RSS entry on an instance with both RSS knobs at zero \
7702             is one the operator chose to keep",
7703        );
7704
7705        // And zero disables it, consistently with the other two knobs.
7706        assert_eq!(
7707            prune_old_entries(&pool, 0, 0, 0).await?,
7708            0,
7709            "publication_retention_days = 0 still deleted something",
7710        );
7711        Ok(())
7712    }
7713
7714    /// **A retention window too large to be a date must disable that pass, not
7715    /// kill the sweeper.**
7716    ///
7717    /// Every knob parses from a `u32` with no upper bound, and `Duration::days` /
7718    /// `DateTime - TimeDelta` both panic out of range — measured, anything past
7719    /// roughly 96 million days, and `u32::MAX` is. A unit slip (seconds or
7720    /// milliseconds typed into a days field) reaches it.
7721    ///
7722    /// The old failure was quiet: this runs in a spawned task, so tokio catches
7723    /// the panic and the sweeper stops for the life of the process, taking the
7724    /// release valve for `db_size_watermark_bytes` with it — the one thing that
7725    /// stops polling for every reader on the instance.
7726    ///
7727    /// `standard_site::ingest_floor` already answers the same input with "no
7728    /// floor", and `Config::retention_for` exists to keep the two agreeing, so
7729    /// this is also the end of a disagreement: unrepresentable meant "store
7730    /// everything" on one side and "panic" on the other.
7731    #[tokio::test]
7732    async fn an_unrepresentable_retention_window_disables_the_pass_it_belongs_to() -> Result<()> {
7733        let pool = init_url("sqlite::memory:").await?;
7734        let feed_id = upsert_feed(
7735            &pool,
7736            &NewFeed {
7737                url: "https://absurd.example/feed.xml".to_string(),
7738                ..Default::default()
7739            },
7740        )
7741        .await?;
7742        let ancient = (chrono::Utc::now() - chrono::Duration::days(1_000))
7743            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7744        insert_entries(
7745            &pool,
7746            feed_id,
7747            &[NewEntry {
7748                guid: "ancient".into(),
7749                published: Some(ancient.clone()),
7750                fetched_at: Some(ancient),
7751                ..Default::default()
7752            }],
7753            0,
7754        )
7755        .await?;
7756
7757        // Each knob in turn, since each computes its own cutoff.
7758        let absurd = u32::MAX as i64;
7759        assert_eq!(
7760            prune_old_entries(&pool, absurd, 0, 0).await?,
7761            0,
7762            "an absurd rolling window deleted something",
7763        );
7764        assert_eq!(
7765            prune_old_entries(&pool, 0, absurd, 0).await?,
7766            0,
7767            "an absurd hard ceiling deleted something",
7768        );
7769        assert_eq!(
7770            prune_old_entries(&pool, 0, 0, absurd).await?,
7771            0,
7772            "an absurd archive ceiling deleted something",
7773        );
7774        assert_eq!(
7775            count_entries(&pool).await?,
7776            1,
7777            "the entry went away under a window that cannot even be expressed",
7778        );
7779
7780        // And the sweep still works for the same knobs at a sane value — a
7781        // function that returned early on every input would satisfy the above.
7782        assert_eq!(
7783            prune_old_entries(&pool, 30, 0, 0).await?,
7784            1,
7785            "a 30-day window did not delete a 1000-day-old entry",
7786        );
7787        Ok(())
7788    }
7789
7790    /// The SQL list and the Rust slice are asserted equal, for the same reason
7791    /// [`POLLABLE_KINDS_SQL`] is: a literal here and a slice there is the drift
7792    /// the `kind` column was introduced to end.
7793    #[test]
7794    fn the_sql_aged_kind_list_matches_the_rust_one() {
7795        let expected = crate::feed::FeedKind::AGED
7796            .iter()
7797            .map(|k| format!("'{}'", k.as_str()))
7798            .collect::<Vec<_>>()
7799            .join(", ");
7800        assert_eq!(AGED_KINDS_SQL, expected);
7801    }
7802
7803    #[tokio::test]
7804    async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
7805        let pool = init_url("sqlite::memory:").await?;
7806        let feed_id = upsert_feed(
7807            &pool,
7808            &NewFeed {
7809                url: "https://ret.example/feed.xml".to_string(),
7810                ..Default::default()
7811            },
7812        )
7813        .await?;
7814
7815        let recent = now_rfc3339();
7816        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7817            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7818
7819        // One fresh (published now), one ancient (published a year ago), and one
7820        // UNDATED-but-freshly-fetched (published NULL, fetched_at now) — the last
7821        // must survive because COALESCE falls back to fetched_at, not to "old".
7822        insert_entries(
7823            &pool,
7824            feed_id,
7825            &[
7826                NewEntry {
7827                    guid: "fresh".into(),
7828                    published: Some(recent.clone()),
7829                    fetched_at: Some(recent.clone()),
7830                    ..Default::default()
7831                },
7832                NewEntry {
7833                    guid: "ancient".into(),
7834                    published: Some(ancient.clone()),
7835                    fetched_at: Some(ancient.clone()),
7836                    ..Default::default()
7837                },
7838                NewEntry {
7839                    guid: "undated-fresh".into(),
7840                    published: None,
7841                    fetched_at: Some(recent.clone()),
7842                    ..Default::default()
7843                },
7844            ],
7845            0,
7846        )
7847        .await?;
7848        assert_eq!(count_entries(&pool).await?, 3);
7849        // Subscribe so mark_read is authorized to write an entry_state row.
7850        replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
7851
7852        // Give the ancient entry an entry_state row so we can prove the FK cascade.
7853        let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
7854            .fetch_one(&pool)
7855            .await?;
7856        let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
7857        assert!(wrote, "mark_read must write with a sub_ref in place");
7858        let state_before: i64 =
7859            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
7860                .bind(ancient_id)
7861                .fetch_one(&pool)
7862                .await?;
7863        assert_eq!(state_before, 1);
7864
7865        // Prune at a 90-day window: only the ancient entry is old.
7866        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
7867        assert_eq!(deleted, 1, "only the year-old entry should be pruned");
7868        assert_eq!(
7869            count_entries(&pool).await?,
7870            2,
7871            "fresh + undated-fresh survive"
7872        );
7873
7874        // The surviving guids are exactly the two fresh ones.
7875        let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
7876            .fetch_all(&pool)
7877            .await?;
7878        assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
7879
7880        // entry_state for the deleted entry cascaded away via the FK.
7881        let state_after: i64 =
7882            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
7883                .bind(ancient_id)
7884                .fetch_one(&pool)
7885                .await?;
7886        assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
7887
7888        // days == 0 disables the rolling WINDOW. The 3650-day ceiling still runs
7889        // (see `a_disabled_window_does_not_disable_the_ceiling`); it deletes
7890        // nothing here because both survivors are fresh.
7891        assert_eq!(prune_old_entries(&pool, 0, 3650, 0).await?, 0);
7892        assert_eq!(count_entries(&pool).await?, 2);
7893        Ok(())
7894    }
7895
7896    #[tokio::test]
7897    async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
7898        let pool = init_url("sqlite::memory:").await?;
7899        let did = "did:plc:reader";
7900        let feed_url = "https://orphan.example/feed.xml";
7901        let feed_id = upsert_feed(
7902            &pool,
7903            &NewFeed {
7904                url: feed_url.to_string(),
7905                ..Default::default()
7906            },
7907        )
7908        .await?;
7909        // Caller subscribes so mark-read is authorized to project into the cursor.
7910        replace_sub_refs(&pool, did, &[feed_id]).await?;
7911
7912        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7913            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7914        let recent = now_rfc3339();
7915        insert_entries(
7916            &pool,
7917            feed_id,
7918            &[
7919                NewEntry {
7920                    guid: "old".into(),
7921                    published: Some(ancient.clone()),
7922                    fetched_at: Some(ancient.clone()),
7923                    ..Default::default()
7924                },
7925                NewEntry {
7926                    guid: "new".into(),
7927                    published: Some(recent.clone()),
7928                    fetched_at: Some(recent.clone()),
7929                    ..Default::default()
7930                },
7931            ],
7932            0,
7933        )
7934        .await?;
7935        let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
7936            .fetch_one(&pool)
7937            .await?;
7938        let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
7939            .fetch_one(&pool)
7940            .await?;
7941
7942        // Mark BOTH read — the cursor's read_ids now references both entry ids.
7943        mark_read(&pool, did, old_id, true).await?;
7944        mark_read(&pool, did, new_id, true).await?;
7945        let before = get_cursor(&pool, did, feed_url).await?.unwrap();
7946        let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
7947        assert!(ids_before.contains(&old_id.to_string()));
7948        assert!(ids_before.contains(&new_id.to_string()));
7949
7950        // Prune the old entry — its id must be scrubbed from the cursor's id-set.
7951        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
7952        assert_eq!(deleted, 1);
7953        let after = get_cursor(&pool, did, feed_url).await?.unwrap();
7954        let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
7955        assert_eq!(
7956            ids_after,
7957            vec![new_id.to_string()],
7958            "orphaned (deleted) entry id must be removed; live id kept"
7959        );
7960        // The scrub re-dirties the cursor so the flusher resyncs the PDS record.
7961        assert!(
7962            after.dirty,
7963            "cursor must be marked dirty after orphan scrub"
7964        );
7965        Ok(())
7966    }
7967
7968    #[tokio::test]
7969    async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
7970        // The per-feed max_entries trim path must ALSO scrub orphaned cursor ids.
7971        let pool = init_url("sqlite::memory:").await?;
7972        let did = "did:plc:reader";
7973        let feed_url = "https://trim.example/feed.xml";
7974        let feed_id = upsert_feed(
7975            &pool,
7976            &NewFeed {
7977                url: feed_url.to_string(),
7978                ..Default::default()
7979            },
7980        )
7981        .await?;
7982        replace_sub_refs(&pool, did, &[feed_id]).await?;
7983
7984        // Two entries, cap of 2 for now (no trim yet).
7985        insert_entries(
7986            &pool,
7987            feed_id,
7988            &[
7989                NewEntry {
7990                    guid: "a".into(),
7991                    published: Some("2026-01-01T00:00:00Z".into()),
7992                    ..Default::default()
7993                },
7994                NewEntry {
7995                    guid: "b".into(),
7996                    published: Some("2026-01-02T00:00:00Z".into()),
7997                    ..Default::default()
7998                },
7999            ],
8000            2,
8001        )
8002        .await?;
8003        let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
8004            .fetch_one(&pool)
8005            .await?;
8006        mark_read(&pool, did, a_id, true).await?;
8007
8008        // Insert a newer entry with cap=1 → the oldest ('a') is trimmed away.
8009        insert_entries(
8010            &pool,
8011            feed_id,
8012            &[NewEntry {
8013                guid: "c".into(),
8014                published: Some("2026-01-03T00:00:00Z".into()),
8015                ..Default::default()
8016            }],
8017            1,
8018        )
8019        .await?;
8020        // 'a' is gone.
8021        let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
8022            .fetch_one(&pool)
8023            .await?;
8024        assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
8025
8026        // The cursor no longer references the trimmed id.
8027        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
8028        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
8029        assert!(
8030            !ids.contains(&a_id.to_string()),
8031            "trimmed entry id must be scrubbed from the cursor"
8032        );
8033        Ok(())
8034    }
8035
8036    /// A sweep spanning several batches must still delete everything.
8037    ///
8038    /// The batching exists to make the write-lock hold interruptible, not to
8039    /// make the sweep partial — so the obvious way to get it wrong is an
8040    /// off-by-one that leaves a batch behind, or a loop that exits on the first
8041    /// short batch instead of the first empty one.
8042    #[tokio::test]
8043    async fn a_sweep_larger_than_one_batch_still_drains() -> Result<()> {
8044        let pool = init_url("sqlite::memory:").await?;
8045        let feed_id = upsert_feed(
8046            &pool,
8047            &NewFeed {
8048                url: "https://bulk.example/f.xml".to_string(),
8049                ..Default::default()
8050            },
8051        )
8052        .await?;
8053        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8054            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8055        // Deliberately not a multiple of PRUNE_BATCH, so the final batch is
8056        // short and the loop has to keep going to the empty one.
8057        let count = (PRUNE_BATCH * 2 + 137) as usize;
8058        let entries: Vec<NewEntry> = (0..count)
8059            .map(|i| NewEntry {
8060                guid: format!("bulk-{i}"),
8061                published: Some(old.clone()),
8062                ..Default::default()
8063            })
8064            .collect();
8065        insert_entries(&pool, feed_id, &entries, 0).await?;
8066        assert_eq!(count_entries(&pool).await? as usize, count);
8067
8068        let deleted = prune_old_entries(&pool, 30, 180, 0).await?;
8069        assert_eq!(deleted as usize, count, "the sweep left rows behind");
8070        assert_eq!(count_entries(&pool).await?, 0);
8071        Ok(())
8072    }
8073
8074    /// What a sweep driven by a lock test actually did.
8075    ///
8076    /// `Contended` is NOT a failure. `SQLITE_BUSY` on the pruner is an outcome
8077    /// production expects and handles — `scheduler.rs` logs it and the next tick
8078    /// retries — so a test that treats it as a regression is stricter than the
8079    /// system it guards, and fails for a reason its own assertions are not
8080    /// about. See #146.
8081    enum SweepOutcome {
8082        Completed(u64),
8083        Contended,
8084    }
8085
8086    /// True for the `SQLITE_BUSY` FAMILY anywhere in the chain.
8087    ///
8088    /// Matched on the DRIVER CODE, not on the message text: "database is
8089    /// locked" is a string another error could plausibly carry, and this
8090    /// decides whether a test failure is suppressed.
8091    ///
8092    /// **Masked to the primary code.** sqlx-sqlite's `code()` returns
8093    /// `sqlite3_extended_errcode` verbatim, so comparing it to `"5"` matches
8094    /// only bare `SQLITE_BUSY` and treats the WAL variants as hard failures:
8095    /// `BUSY_RECOVERY` (261), `BUSY_SNAPSHOT` (517), `BUSY_TIMEOUT` (773).
8096    /// This database runs in WAL mode and `store.rs` already documents hitting
8097    /// `SQLITE_BUSY_SNAPSHOT`, so that gap is not hypothetical — the narrowing
8098    /// would have rejected the very class this tolerance exists for.
8099    ///
8100    /// `& 0xFF` is how SQLite defines the relationship: the low byte of an
8101    /// extended code IS the primary code.
8102    fn is_sqlite_busy(err: &anyhow::Error) -> bool {
8103        err.chain().any(|e| {
8104            e.downcast_ref::<sqlx::Error>().is_some_and(|e| match e {
8105                sqlx::Error::Database(db) => db
8106                    .code()
8107                    .and_then(|c| c.parse::<i32>().ok())
8108                    .is_some_and(is_busy_code),
8109                _ => false,
8110            })
8111        })
8112    }
8113
8114    /// The classification, split out so the WAL variants are TESTABLE.
8115    ///
8116    /// A `BUSY_SNAPSHOT` cannot be produced on demand in a test, so without
8117    /// this the claim that 261/517/773 are tolerated would be a comment and
8118    /// nothing else. The wiring — that `is_sqlite_busy` consults this at all —
8119    /// is pinned separately by `a_busy_sweep_is_reported_as_contended_not_as_a_failure`,
8120    /// which drives a real `SQLITE_BUSY` end to end.
8121    fn is_busy_code(code: i32) -> bool {
8122        code & 0xFF == 5
8123    }
8124
8125    /// **The whole `SQLITE_BUSY` family, and nothing else.**
8126    #[test]
8127    fn busy_codes_cover_the_wal_variants() {
8128        for code in [
8129            5,   // SQLITE_BUSY
8130            261, // SQLITE_BUSY_RECOVERY
8131            517, // SQLITE_BUSY_SNAPSHOT
8132            773, // SQLITE_BUSY_TIMEOUT
8133        ] {
8134            assert!(
8135                is_busy_code(code),
8136                "{code} is in the BUSY family but would be treated as a hard failure"
8137            );
8138        }
8139        for code in [
8140            0,   // SQLITE_OK
8141            1,   // SQLITE_ERROR
8142            6,   // SQLITE_LOCKED — adjacent, and deliberately NOT tolerated
8143            262, // SQLITE_LOCKED_SHAREDCACHE
8144            11,  // SQLITE_CORRUPT
8145        ] {
8146            assert!(
8147                !is_busy_code(code),
8148                "{code} is not contention, but would be swallowed as though it were"
8149            );
8150        }
8151    }
8152
8153    /// Run the batched delete, separating "the write lock was contended" from
8154    /// "the loop misbehaved". Only the second is this test's subject.
8155    async fn sweep_tolerating_busy(
8156        pool: &SqlitePool,
8157        select_ids: &str,
8158        cutoff: &str,
8159        label: &str,
8160    ) -> Result<SweepOutcome> {
8161        match delete_in_batches(pool, select_ids, cutoff, label).await {
8162            Ok(n) => Ok(SweepOutcome::Completed(n)),
8163            // Contended, not broken. Narrowed to SQLITE_BUSY on purpose: every
8164            // other error still fails the caller, so this is not a blanket
8165            // `let _ =` that would delete the test while keeping its name.
8166            Err(err) if is_sqlite_busy(&err) => Ok(SweepOutcome::Contended),
8167            Err(err) => Err(err),
8168        }
8169    }
8170
8171    /// **A sweep that loses the write lock is inconclusive, not a failure.**
8172    ///
8173    /// CI hit this on `main` at `d05a716`: the sweeper took `SQLITE_BUSY` and
8174    /// the test reported a regression, on a tree whose only changes were two
8175    /// version strings and a changelog.
8176    ///
8177    /// Forced deterministically rather than waiting for a contended runner — it
8178    /// did not reproduce in 48 local runs — by holding a write transaction open
8179    /// and giving the sweep a `busy_timeout` short enough to give up at once.
8180    #[tokio::test]
8181    async fn a_busy_sweep_is_reported_as_contended_not_as_a_failure() -> Result<()> {
8182        struct TempDb(std::path::PathBuf);
8183        impl Drop for TempDb {
8184            fn drop(&mut self) {
8185                for suffix in ["", "-wal", "-shm"] {
8186                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8187                }
8188            }
8189        }
8190        let path = std::env::temp_dir().join(format!("fr-busysweep-{}.db", std::process::id()));
8191        drop(TempDb(path.clone()));
8192        let _tmp = TempDb(path.clone());
8193        let url = format!("sqlite://{}", path.display());
8194        let pool = init_url(&url).await?;
8195
8196        let feed_id = upsert_feed(
8197            &pool,
8198            &NewFeed {
8199                url: "https://busy.example/f.xml".to_string(),
8200                ..Default::default()
8201            },
8202        )
8203        .await?;
8204        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8205            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8206        let entries: Vec<NewEntry> = (0..4)
8207            .map(|i| NewEntry {
8208                guid: format!("busy-{i}"),
8209                url: Some(format!("https://busy.example/{i}")),
8210                title: Some(format!("e{i}")),
8211                published: Some(old.clone()),
8212                ..Default::default()
8213            })
8214            .collect();
8215        insert_entries(&pool, feed_id, &entries, 1_000).await?;
8216
8217        // A sweep pool that gives up on a contended write immediately.
8218        let sweep_pool = SqlitePoolOptions::new()
8219            .max_connections(1)
8220            .connect_with(
8221                url.parse::<sqlx::sqlite::SqliteConnectOptions>()?
8222                    .busy_timeout(std::time::Duration::from_millis(2)),
8223            )
8224            .await?;
8225
8226        // Hold the write lock for the duration of the sweep below.
8227        let mut blocker = pool.acquire().await?;
8228        sqlx::query("BEGIN IMMEDIATE")
8229            .execute(&mut *blocker)
8230            .await?;
8231
8232        let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8233            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8234        let outcome = sweep_tolerating_busy(
8235            &sweep_pool,
8236            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8237            &cutoff,
8238            "busy-sweep-test",
8239        )
8240        .await;
8241
8242        sqlx::query("ROLLBACK").execute(&mut *blocker).await.ok();
8243
8244        match outcome {
8245            Ok(SweepOutcome::Contended) => Ok(()),
8246            Ok(SweepOutcome::Completed(n)) => panic!(
8247                "the sweep completed ({n} rows) while the write lock was held — \
8248                 the fixture is not actually contending, so this test proves nothing"
8249            ),
8250            Err(err) => panic!(
8251                "a contended sweep was reported as a failure rather than as \
8252                 inconclusive; production logs this and retries on the next \
8253                 tick (scheduler.rs): {err:#}"
8254            ),
8255        }
8256    }
8257
8258    /// **A sweep error that is NOT `SQLITE_BUSY` must still fail.**
8259    ///
8260    /// `sweep_tolerating_busy` claims to narrow its tolerance to contention.
8261    /// Without this, that claim is unenforced: widening the arm to `Err(_) =>
8262    /// Contended` swallows every sweep error — a malformed query, a missing
8263    /// table, a corrupt file — and the whole suite stays green. Measured, not
8264    /// assumed: that mutation passed 733 tests before this test existed.
8265    #[tokio::test]
8266    async fn a_non_busy_sweep_error_still_fails() -> Result<()> {
8267        let pool = init_url("sqlite::memory:").await?;
8268        // A table that does not exist: SQLITE_ERROR (1), not SQLITE_BUSY (5).
8269        let outcome = sweep_tolerating_busy(
8270            &pool,
8271            "SELECT id FROM no_such_table WHERE created < ?1",
8272            "2026-01-01T00:00:00Z",
8273            "bad-query-test",
8274        )
8275        .await;
8276
8277        match outcome {
8278            Err(err) => {
8279                assert!(
8280                    !is_sqlite_busy(&err),
8281                    "fixture drifted: this must be a non-BUSY error, got {err:#}"
8282                );
8283                Ok(())
8284            }
8285            Ok(SweepOutcome::Contended) => panic!(
8286                "a malformed sweep was reported as lock contention — the \
8287                 tolerance is a blanket error swallow, not a narrowing"
8288            ),
8289            Ok(SweepOutcome::Completed(n)) => {
8290                panic!("a sweep over a missing table reported {n} rows deleted")
8291            }
8292        }
8293    }
8294
8295    /// **An UNCONTENDED sweep must report `Completed`.**
8296    ///
8297    /// This exists to stop the `Contended` arm above becoming a way to never
8298    /// run the hand-off assertions. Make `sweep_tolerating_busy` return
8299    /// `Contended` unconditionally and the sweep-lock test still passes — it
8300    /// just silently stops testing anything. This one fails instead.
8301    ///
8302    /// That is the difference between tolerating a real contention loss and
8303    /// deleting a test while keeping its name.
8304    #[tokio::test]
8305    async fn a_sweep_with_no_contention_completes() -> Result<()> {
8306        struct TempDb(std::path::PathBuf);
8307        impl Drop for TempDb {
8308            fn drop(&mut self) {
8309                for suffix in ["", "-wal", "-shm"] {
8310                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8311                }
8312            }
8313        }
8314        let path = std::env::temp_dir().join(format!("fr-calmsweep-{}.db", std::process::id()));
8315        drop(TempDb(path.clone()));
8316        let _tmp = TempDb(path.clone());
8317        let pool = init_url(&format!("sqlite://{}", path.display())).await?;
8318
8319        let feed_id = upsert_feed(
8320            &pool,
8321            &NewFeed {
8322                url: "https://calm.example/f.xml".to_string(),
8323                ..Default::default()
8324            },
8325        )
8326        .await?;
8327        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8328            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8329        let entries: Vec<NewEntry> = (0..3)
8330            .map(|i| NewEntry {
8331                guid: format!("calm-{i}"),
8332                published: Some(old.clone()),
8333                ..Default::default()
8334            })
8335            .collect();
8336        insert_entries(&pool, feed_id, &entries, 1_000).await?;
8337
8338        let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8339            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8340        match sweep_tolerating_busy(
8341            &pool,
8342            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8343            &cutoff,
8344            "calm-sweep-test",
8345        )
8346        .await?
8347        {
8348            SweepOutcome::Completed(n) => {
8349                assert_eq!(n, 3, "the uncontended sweep did not delete the fixture");
8350                Ok(())
8351            }
8352            SweepOutcome::Contended => panic!(
8353                "nothing was holding the write lock, yet the sweep reported \
8354                 contention — every test that skips on `Contended` is now \
8355                 skipping unconditionally"
8356            ),
8357        }
8358    }
8359
8360    /// **The sweep must not lock other writers out for its duration.**
8361    ///
8362    /// The whole sweep used to be one transaction — both deletes plus a global
8363    /// cursor scrub that loads every `read_cursor` row and then issues a
8364    /// per-cursor live-ids query. SQLite is single-writer with a 5 s
8365    /// `busy_timeout`, so every mark-read, login write and cursor flush failed
8366    /// for that whole span.
8367    ///
8368    /// On-disk (WAL) because the in-memory pool is deliberately
8369    /// single-connection, which would make a concurrency test meaningless.
8370    ///
8371    /// **The writer runs on its own pool with a short `busy_timeout`, and the
8372    /// runtime is multi-thread.** Both are load-bearing — a 5 s `busy_timeout`
8373    /// on a shared runtime is what made this test flake on CI. See the comment
8374    /// on the writer pool and the `attempts` assertion.
8375    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8376    async fn a_writer_gets_through_while_the_sweep_runs() -> Result<()> {
8377        // **Cleanup on EVERY exit, including a panicking assertion.**
8378        //
8379        // The three `remove_file` calls used to sit after the assertions, so any
8380        // failure leaked the database and its `-wal`/`-shm` — 2.7–12.8 MB a time,
8381        // and this test is deliberately the one most likely to fail. Worse, setup
8382        // removed only the `.db`, so a recycled PID paired a fresh database with a
8383        // stale WAL. A guard drops on the unwind path too and takes all three.
8384        struct TempDb(std::path::PathBuf);
8385        impl Drop for TempDb {
8386            fn drop(&mut self) {
8387                for suffix in ["", "-wal", "-shm"] {
8388                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8389                }
8390            }
8391        }
8392        let dir = std::env::temp_dir();
8393        let path = dir.join(format!("fr-sweeplock-{}.db", std::process::id()));
8394        // Drops the previous run's leftovers, WAL and all, before opening.
8395        drop(TempDb(path.clone()));
8396        let _tmp = TempDb(path.clone());
8397        let url = format!("sqlite://{}", path.display());
8398        let pool = init_url(&url).await?;
8399
8400        let feed_id = upsert_feed(
8401            &pool,
8402            &NewFeed {
8403                url: "https://lock.example/f.xml".to_string(),
8404                ..Default::default()
8405            },
8406        )
8407        .await?;
8408        // **The fixture is DERIVED from the batch count, not described by it.**
8409        //
8410        // Every assertion below reasons about "ten hand-off windows". That was
8411        // prose — a `const BATCHES: u32 = 10` sitting next to a `PRUNE_BATCH *
8412        // 10` fixture with nothing tying them together. Editing the fixture
8413        // alone to `PRUNE_BATCH * 4` left the floor still demanding ten
8414        // hand-offs' worth of time from a four-batch loop, and correct code was
8415        // accused of not handing the lock over at all (1 run in 6). Now the
8416        // compiler carries the coupling.
8417        const BATCHES: i64 = 10;
8418        // **The 50% ceiling below is only safe because BATCHES is large.**
8419        //
8420        // `max_refused_run / attempts` is bounded by roughly `1 / BATCHES` only
8421        // because the fixture opens that many hand-off windows. Shrink it and
8422        // correct code walks into the ceiling: measured with production code
8423        // untouched and the per-batch hold grown 10x, `BATCHES = 4` gives ratios
8424        // of 0.21–0.35 and `BATCHES = 2` gives 0.45–0.56, **failing 3 runs in
8425        // 5**. The comment above invites editing this fixture; this stops that
8426        // edit from silently turning the assertion against the code it guards.
8427        const _: () = assert!(
8428            BATCHES >= 5,
8429            "the 50% ceiling assumes ~1/BATCHES; below 5 batches correct code              false-fails",
8430        );
8431        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8432            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8433        let entries: Vec<NewEntry> = (0..(PRUNE_BATCH * BATCHES) as usize)
8434            .map(|i| NewEntry {
8435                guid: format!("lock-{i}"),
8436                published: Some(old.clone()),
8437                ..Default::default()
8438            })
8439            .collect();
8440        insert_entries(&pool, feed_id, &entries, 0).await?;
8441
8442        // **The writer gets its OWN pool, with a SHORT `busy_timeout`.**
8443        //
8444        // This is the fix for the CI flake described on the `attempts` assertion
8445        // below, and it is two separate changes.
8446        //
8447        // *Its own pool*, so the only thing that can block a write is SQLite's
8448        // write lock — the thing under test. Sharing the 5-connection pool with
8449        // the sweep meant a write could also stall waiting to ACQUIRE a pooled
8450        // connection the sweep was holding, which is a confounder that looks
8451        // identical from the outside.
8452        //
8453        // *A short `busy_timeout`*, so a contended write FAILS FAST and the loop
8454        // takes another shot. At the production 5 s, SQLite's busy handler backs
8455        // off internally — 1, 2, 5, 10, 25, 50, 100 ms and up — all inside a
8456        // single `execute()`. The writer therefore gets ONE attempt per blocked
8457        // write, and once the ladder reaches 100 ms it sleeps straight past the
8458        // `PRUNE_BATCH_HANDOFF` windows `delete_in_batches` opens. Failing fast
8459        // turns one low-probability attempt into hundreds of independent ones:
8460        // measured 54 attempts at 5 ms, 517 at 2 ms, over the same sweep.
8461        const WRITER_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2);
8462        let writer_pool = SqlitePoolOptions::new()
8463            .min_connections(1)
8464            .max_connections(1)
8465            .connect_with(
8466                SqliteConnectOptions::from_str(&url)?
8467                    .foreign_keys(true)
8468                    .busy_timeout(WRITER_BUSY_TIMEOUT)
8469                    .log_statements(tracing::log::LevelFilter::Debug),
8470            )
8471            .await?;
8472
8473        let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
8474        let writer_done = std::sync::Arc::clone(&done);
8475        let writer = tokio::spawn(async move {
8476            // `(when the attempt STARTED, whether it landed)`.
8477            //
8478            // The ORDER is what the assertions read, not the timestamps: they
8479            // count consecutive failures. The instants serve only to select the
8480            // attempts made inside the measured window — the writer is spawned
8481            // before `t0`, so a plain counter would fold in attempts that can
8482            // never appear in `during`.
8483            //
8484            // (An earlier version of this comment, left behind by the switch away
8485            // from elapsed time, said completions were recorded and that "the
8486            // timestamps are the load-bearing part". Neither is true now.)
8487            let mut outcomes: Vec<(std::time::Instant, bool)> = Vec::new();
8488            // Kept for the failure message: if the writes are failing for a
8489            // reason that is NOT lock contention, nothing lands and the test
8490            // fails — this is what says why. Timestamped so the test can drop it
8491            // when it describes an attempt OUTSIDE the measured window; the
8492            // writer starts before `t0`, so the very first error is usually from
8493            // an attempt the assertions never look at.
8494            let mut first_err: Option<(std::time::Instant, String)> = None;
8495            while !writer_done.load(std::sync::atomic::Ordering::Relaxed) {
8496                let started = std::time::Instant::now();
8497                match grant_access(
8498                    &writer_pool,
8499                    &format!("did:plc:writer{}", outcomes.len()),
8500                    None,
8501                    "sweep-test",
8502                    None,
8503                )
8504                .await
8505                {
8506                    Ok(()) => outcomes.push((started, true)),
8507                    // Expected: the sweep holds the write lock right now.
8508                    // Retrying is the entire point, so this is counted, not
8509                    // fatal. A `?` here would abort the writer on the first
8510                    // contended write and destroy the measurement.
8511                    Err(err) => {
8512                        outcomes.push((started, false));
8513                        if first_err.is_none() {
8514                            first_err = Some((started, format!("{err:#}")));
8515                        }
8516                    }
8517                }
8518                tokio::task::yield_now().await;
8519            }
8520            writer_pool.close().await;
8521            (outcomes, first_err)
8522        });
8523
8524        // **Drive `delete_in_batches` directly, not `prune_old_entries`.**
8525        //
8526        // The subject is the batched delete loop and whether it hands the write
8527        // lock over between batches. `prune_old_entries` wraps it in work that
8528        // is not that — two delete passes plus `prune_orphan_cursor_ids` — so
8529        // timing the whole call measures a window in which the lock was never
8530        // meant to be held throughout, and writes landing outside the loop
8531        // count as though the loop had handed the lock over.
8532        //
8533        // A correction to what this comment first claimed. It said the cursor
8534        // scrub was a tail that "grows with the number of rows deleted", and
8535        // that this explained a `42 of 358` measurement. **That is false, and
8536        // measured to be false**: this fixture creates no `read_cursor` rows at
8537        // all, so the scrub does one `SELECT` over an empty table and loops zero
8538        // times — 0.16–2 ms, 0.03–0.5% of the window, at any fixture size. It
8539        // cannot explain anything. Narrowing the window is still right, for the
8540        // reason above; the mechanism originally given for it was not real.
8541        //
8542        // The consequence worth stating: because the fixture has no cursors, the
8543        // old form never covered the scrub's locking either — it only appeared
8544        // to. Nothing here regressed. `prune_orphan_cursor_ids` holding the lock
8545        // across a whole pass is a real production invariant (see its own doc)
8546        // and remains untested; that needs a test with actual cursors, not this
8547        // one.
8548        let hard_cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8549            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8550        let t0 = std::time::Instant::now();
8551        let outcome = sweep_tolerating_busy(
8552            &pool,
8553            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8554            &hard_cutoff,
8555            "sweep-lock-test",
8556        )
8557        .await?;
8558        let sweep = t0.elapsed();
8559
8560        // **Teardown happens on BOTH paths, before the outcome is inspected.**
8561        //
8562        // The contended arm below used to carry its own copy of these two lines.
8563        // A probe proved that arm is never reached by the suite — a `panic!` in
8564        // it failed nothing — so it was five lines of unexercised teardown that
8565        // would run for the first time on a contended CI runner, which is
8566        // exactly when it has to work. Hoisting leaves the arm with nothing that
8567        // can be wrong.
8568        done.store(true, std::sync::atomic::Ordering::Relaxed);
8569        let (outcomes, first_err) = writer.await?;
8570
8571        let deleted = match outcome {
8572            SweepOutcome::Completed(n) => n,
8573            // **Inconclusive, not a regression.** The sweeper lost the write
8574            // lock, which says nothing about whether it hands the lock over
8575            // between batches — the property below. Production logs this and
8576            // retries on the next tick (`scheduler.rs`), so a test that failed
8577            // here would be stricter than the system it guards. Observed on CI
8578            // at `d05a716`, on a tree with no `.rs` change at all.
8579            //
8580            // `a_sweep_with_no_contention_completes` is what stops this arm
8581            // becoming a way to never run the assertions.
8582            SweepOutcome::Contended => {
8583                eprintln!(
8584                    "sweep-lock test INCONCLUSIVE: the sweeper took SQLITE_BUSY; \
8585                     the hand-off assertions did not run"
8586                );
8587                return Ok(());
8588            }
8589        };
8590        let sweep_end = t0 + sweep;
8591        // Attempts actually made inside the measured window, in order.
8592        let inside: Vec<bool> = outcomes
8593            .iter()
8594            .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8595            .map(|(_, ok)| *ok)
8596            .collect();
8597        let attempts = inside.len();
8598        let during = inside.iter().filter(|ok| **ok).count();
8599        // **The longest unbroken run of REFUSED attempts.**
8600        //
8601        // Counted in attempts, not elapsed time — see the note on the assertion
8602        // for why that distinction is the whole point.
8603        let max_refused_run = {
8604            let (mut worst, mut run) = (0usize, 0usize);
8605            for ok in &inside {
8606                run = if *ok { 0 } else { run + 1 };
8607                worst = worst.max(run);
8608            }
8609            worst
8610        };
8611        let why = first_err
8612            .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8613            .map(|(_, e)| format!(" (first in-window write error: {e})"))
8614            .unwrap_or_default();
8615
8616        assert_eq!(deleted as usize, entries.len());
8617        // **The sweep has to BE batched before anything downstream means
8618        // anything, and this floor is derived, not calibrated.**
8619        //
8620        // The fixture is `PRUNE_BATCH * 10` rows, all older than the hard
8621        // ceiling, so the hard-ceiling delete drains them in ten full batches
8622        // and stands down `PRUNE_BATCH_HANDOFF` after each. A genuinely batched
8623        // sweep therefore cannot finish in under `10 * PRUNE_BATCH_HANDOFF` on
8624        // any machine, however fast its disk — the sleeps are a floor the
8625        // hardware cannot undercut, and the deletes themselves only add to it.
8626        //
8627        // A loop that has LOST its batching is faster, not slower: measured at
8628        // 56 ms with the `LIMIT` dropped, against 305 ms batched. That is why
8629        // this fires before the two assertions below — without it, removing the
8630        // batching starves the writer of attempts and gets reported as "invalid
8631        // measurement", blaming the test for the defect it just detected.
8632        //
8633        // **Partial coverage, measured rather than asserted.** Two mutations
8634        // that keep the loop looking roughly batched are caught only sometimes:
8635        //
8636        //   `LIMIT` dropped (no batching at all)   3-4 runs in 5-6, MOSTLY by
8637        //                                          the refusal assertion below,
8638        //                                          not by this floor
8639        //   `PRUNE_BATCH_HANDOFF` sleep removed    1 run in 5-6, by this floor
8640        //
8641        // (An earlier version attributed both to this floor. Re-measured: of
8642        // four catches of the `LIMIT` mutation in six runs, three panicked at
8643        // the refusal assertion and one here.)
8644        //
8645        // Both were caught more often — 5/5 and 3/5 — by the wall-clock form
8646        // this replaced. That is a real coverage loss and it was taken on
8647        // purpose: the wall-clock form FALSE-FAILED correct code, which is a
8648        // worse defect than missing a deliberate deletion of a commented line.
8649        // See the note on the assertion below for the measurement.
8650        //
8651        // Nothing here is tuned to make those two reliable. Doing so means
8652        // thresholding a rate, which is what this test has now been wrong about
8653        // three separate times.
8654        let handoff_floor = PRUNE_BATCH_HANDOFF * BATCHES as u32;
8655        assert!(
8656            sweep > handoff_floor,
8657            "the delete loop finished in {sweep:?}, under the {handoff_floor:?} that \
8658             {BATCHES} batches of `PRUNE_BATCH_HANDOFF` alone would take — it is not \
8659             handing the write lock over between batches at all"
8660        );
8661        // **Assert a RATIO OF TWO DURATIONS THAT SCALE TOGETHER.**
8662        //
8663        // Three thresholds have now failed here, each for the same reason: they
8664        // compared something machine-scaled against something fixed.
8665        //
8666        //   `worst * 3 < sweep`  — broke when the sweep got FASTER (the
8667        //                          `NOT EXISTS` rewrite, 1.49x) and tightened a
8668        //                          threshold calibrated against the slow version.
8669        //   `wrote >= 10`        — a raw count is writes-per-unit-time, so it
8670        //                          measured the runner. Flaked on CI at 4 writes.
8671        //   `during * 2 >=`      — a success FRACTION, which I claimed was
8672        //   `attempts`             scale-free. It is not, and this is the
8673        //                          important one, because the argument sounds
8674        //                          right. Successes come from the FIXED
8675        //                          `BATCHES * PRUNE_BATCH_HANDOFF` of open
8676        //                          window divided by write latency; failures
8677        //                          come from the machine-scaled lock hold
8678        //                          divided by the FIXED `WRITER_BUSY_TIMEOUT`.
8679        //                          Slow the machine by k and the fraction decays
8680        //                          as roughly 1/(1 + k²c) — quadratically,
8681        //                          toward failure. Measured with production code
8682        //                          fully correct and only the per-batch hold
8683        //                          grown 10x: **188/949 (19.8%) and 383/1028
8684        //                          (37.3%), two false failures in three runs**,
8685        //                          at loop durations of 3.4 s. CPU saturation
8686        //                          cannot find this — it slows writer and
8687        //                          sweeper together, which is the wrong axis.
8688        //
8689        //   `max_gap * 2 <`     — the longest WALL-CLOCK stretch with no write
8690        //   `sweep`               landing, against the loop's duration. Both
8691        //                         sides scale with the machine, which fixed the
8692        //                         fraction's problem and introduced a new one:
8693        //                         a gap opens when the writer is DESCHEDULED
8694        //                         just as surely as when the lock is held.
8695        //                         Observed under 4x CPU saturation, full suite:
8696        //                         `went 319.95ms of 609.83ms` — while **622 of
8697        //                         626 attempts landed**. The lock was fine; the
8698        //                         writer task simply did not run for 320 ms.
8699        //
8700        // So count REFUSALS, not time. The longest unbroken run of `SQLITE_BUSY`
8701        // against the number of attempts made:
8702        //
8703        //   handed over : the lock is free for `PRUNE_BATCH_HANDOFF` after every
8704        //                 batch, so the longest refused run is bounded by about
8705        //                 one batch's worth of attempts.
8706        //   held across : every attempt in the window is refused — 100%.
8707        //
8708        // **The ~10% this comment used to quote for the handed-over case is not
8709        // what the shipped configuration produces.** Measured here: 0.001–0.05,
8710        // and in roughly a quarter of runs the writer is refused ZERO times
8711        // (`max_refused_run == 0`, every attempt landing), so the assertion is
8712        // vacuously true and certifies the hand-off by never observing one. That
8713        // is a weak test, not a wrong one — but it is worth knowing that the
8714        // enormous margin comes from the writer rarely colliding at all, not
8715        // from a measured 10%. 10% is what appears only once the per-batch hold
8716        // dominates the hand-off (`PRUNE_BATCH` x10 gives 0.115–0.143).
8717        //
8718        // This is immune to descheduling in a way no wall-clock measure can be:
8719        // a starved writer makes no attempts, so it contributes to neither side
8720        // of the ratio. Machine speed still cancels, because both sides are
8721        // counts of the same attempts. Re-checked against the failure above:
8722        // 622 of 626 landing means a refused run of at most 4, nowhere near the
8723        // 313 it would take to trip.
8724        //
8725        // **Detection is near all-or-nothing, and that is a known limit rather
8726        // than an oversight.** Holding one transaction across only the FIRST
8727        // HALF of the batches — production code otherwise correct — is not
8728        // caught at all:
8729        //
8730        //   batches held in one tx (of 10)   runs failing
8731        //   5                                0 of 6   (ratios 0.05-0.27)
8732        //   7                                2 of 6
8733        //   9                                5 of 5
8734        //   10                               22 of 22
8735        //
8736        // The ratio systematically UNDERSTATES the wall-clock fraction the lock
8737        // was held, because a refused attempt costs ~2 ms and leaves the sweeper
8738        // running uncontended, while a successful write actively blocks it and
8739        // stretches the loop. So attempts pile up during free time. The
8740        // "10% vs 100%" framing above describes the endpoints, not the curve.
8741        //
8742        // Closing that would mean measuring the wall-clock SPAN of a refusal run
8743        // rather than its length — which is most of the way back to `max_gap`,
8744        // the form that false-failed correct code on a descheduled writer. Given
8745        // this assertion has now been wrong four times in a row, and the current
8746        // one has zero false failures across 134 runs in six environments while
8747        // catching the real defect 22/22, a fifth redesign to catch a
8748        // half-transaction — a mutation no plausible edit produces — is not a
8749        // trade worth making. Stated here so the next reader knows the gap is
8750        // chosen, not missed.
8751        //
8752        // Measured, with the apparatus verified before each run:
8753        //
8754        //   correct, 1x / 10x per-batch hold   passes
8755        //   one tx across batches, 1x          CAUGHT — refused 128 of 129
8756        //   one tx across batches, 10x         CAUGHT — refused 1841 of 1843
8757        //   `LIMIT` dropped                    caught 3 runs in 5 (by the floor)
8758        //   hand-off sleep removed             caught 1 run  in 5 (by the floor)
8759        //
8760        // The last two were 5/5 and 3/5 under the wall-clock form. Losing that
8761        // is the price of not false-failing correct code, and it is the right
8762        // way round: the named defect is now caught by two orders of magnitude,
8763        // and the mutations that got weaker are deliberate deletions of lines
8764        // that carry their own explanation.
8765        assert!(
8766            attempts >= 20,
8767            "the writer only got {attempts} attempts inside a {sweep:?} delete loop \
8768             — too few for the ratio below to mean anything. That is USUALLY an \
8769             invalid measurement rather than a held lock, but note that a loop \
8770             holding the lock throughout is itself one cause of a starved writer, \
8771             so check {during} (landed) before concluding the test is at \
8772             fault{why}"
8773        );
8774        assert!(
8775            max_refused_run * 2 < attempts,
8776            "the delete loop refused {max_refused_run} consecutive write attempts out \
8777             of {attempts} ({during} landed) — a loop that hands the write lock over \
8778             between batches refuses at most about one batch's worth in a row; one \
8779             that holds the lock across them refuses nearly every attempt it sees{why}"
8780        );
8781
8782        pool.close().await;
8783        // `_tmp` removes the database, WAL and shm as it drops — on this path
8784        // and on the unwind from any assertion above.
8785        Ok(())
8786    }
8787
8788    /// **The sparing predicate must quantify over ALL DIDs, not just one.**
8789    ///
8790    /// `entry_state`'s primary key is `(did, entry_id)`, so several readers can
8791    /// hold rows on the same shared entry. The window spares an entry when ANY of
8792    /// them has starred it or left it unread — one person's star protects the
8793    /// cached copy everyone reads.
8794    ///
8795    /// This is the ONLY case where `id NOT IN (…)` and the correlated
8796    /// `NOT EXISTS` that replaced it could diverge, and it had no test. Every
8797    /// other retention test writes one `entry_state` row per entry under a single
8798    /// DID, where the two forms are trivially identical — so the claim that the
8799    /// suite made the equivalence executable was false when it was written. It is
8800    /// true now.
8801    #[tokio::test]
8802    async fn sparing_honours_every_did_not_just_one() -> Result<()> {
8803        let pool = init_url("sqlite::memory:").await?;
8804        let feed_id = upsert_feed(
8805            &pool,
8806            &NewFeed {
8807                url: "https://shared.example/f.xml".to_string(),
8808                ..Default::default()
8809            },
8810        )
8811        .await?;
8812        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8813            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8814        let guids = [
8815            "nobody-touched",      // no state row at all -> evicted
8816            "both-read-unstarred", // two DIDs, both read+unstarred -> evicted
8817            "one-starred",         // A read+unstarred, B starred -> SPARED by B
8818            "one-unread",          // A read+unstarred, B unread   -> SPARED by B
8819        ];
8820        let entries: Vec<NewEntry> = guids
8821            .iter()
8822            .map(|g| NewEntry {
8823                guid: (*g).to_string(),
8824                published: Some(old.clone()),
8825                ..Default::default()
8826            })
8827            .collect();
8828        insert_entries(&pool, feed_id, &entries, 0).await?;
8829
8830        let id_of = |g: &'static str| {
8831            let pool = pool.clone();
8832            async move {
8833                sqlx::query_scalar::<_, i64>("SELECT id FROM entries WHERE guid = ?1")
8834                    .bind(g)
8835                    .fetch_one(&pool)
8836                    .await
8837                    .unwrap()
8838            }
8839        };
8840        // (did, entry, read, starred)
8841        let rows: [(&str, &'static str, i64, i64); 6] = [
8842            ("did:plc:a", "both-read-unstarred", 1, 0),
8843            ("did:plc:b", "both-read-unstarred", 1, 0),
8844            ("did:plc:a", "one-starred", 1, 0),
8845            ("did:plc:b", "one-starred", 1, 1),
8846            ("did:plc:a", "one-unread", 1, 0),
8847            ("did:plc:b", "one-unread", 0, 0),
8848        ];
8849        for (did, guid, read, starred) in rows {
8850            let id = id_of(guid).await;
8851            sqlx::query(
8852                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
8853                 VALUES (?1, ?2, ?3, ?4, '2026-01-01T00:00:00Z')",
8854            )
8855            .bind(did)
8856            .bind(id)
8857            .bind(read)
8858            .bind(starred)
8859            .execute(&pool)
8860            .await?;
8861        }
8862
8863        // Window only — no ceiling, so nothing is swept for age alone.
8864        let deleted = prune_old_entries(&pool, 30, 0, 0).await?;
8865        assert_eq!(
8866            deleted, 2,
8867            "expected the untouched and the all-read entries to go"
8868        );
8869
8870        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
8871            .fetch_all(&pool)
8872            .await?;
8873        assert_eq!(
8874            left,
8875            vec!["one-starred".to_string(), "one-unread".to_string()],
8876            "a second reader's star or unread mark must spare the SHARED entry"
8877        );
8878        Ok(())
8879    }
8880
8881    /// **A mark-read landing during the scrub must not be overwritten.**
8882    ///
8883    /// Moving the scrub out of the sweep's transaction removed a multi-minute
8884    /// write-lock hold and introduced a lost update in its place: the id-sets
8885    /// were read into a snapshot up front and written back unguarded, so a
8886    /// `mark_read` arriving mid-pass had its id silently dropped — and the
8887    /// rewrite set `dirty = 1`, so the flusher pushed the truncated set to the
8888    /// PDS as authoritative. Local `entry_state` still said read, so the loss was
8889    /// invisible here and visible only in every other atproto client.
8890    ///
8891    /// **⚠️ THIS TEST DOES NOT PROVE THAT, AND THE NAME NO LONGER CLAIMS IT.**
8892    ///
8893    /// The mark-read below lands BEFORE the scrub is called, not during it — so
8894    /// a snapshot-then-write implementation taking its snapshot at the top of
8895    /// `prune_orphan_cursor_ids` would see it too, and pass. The discriminator
8896    /// does not discriminate; what is actually pinned is the ordinary outcome:
8897    /// orphaned ids go, live ids stay.
8898    ///
8899    /// What the lost-update shape is really prevented by is a TYPE fact, not
8900    /// this test: `scrub_one_cursor(pool, did, feed_url)` is handed no id-sets,
8901    /// so it cannot write back anything but what it read itself, and
8902    /// re-introducing the bug means changing its signature.
8903    ///
8904    /// Proving it by test needs a real interleave — hold the write lock on a
8905    /// second connection, let the scrub block on it, commit a `mark_read`, then
8906    /// release — which needs a file-backed database and, without a hook inside
8907    /// the pass, a sleep to be sure the key snapshot has already run. A sleep is
8908    /// how this suite gets flaky in CI, and a flaky test is worse than an honest
8909    /// one, so it is left undone and written down instead.
8910    #[tokio::test]
8911    async fn the_cursor_scrub_drops_orphans_and_keeps_live_ids() -> Result<()> {
8912        let pool = init_url("sqlite::memory:").await?;
8913        let did = "did:plc:race";
8914        let feed_url = "https://race.example/f.xml";
8915        let feed_id = upsert_feed(
8916            &pool,
8917            &NewFeed {
8918                url: feed_url.to_string(),
8919                ..Default::default()
8920            },
8921        )
8922        .await?;
8923        insert_entries(
8924            &pool,
8925            feed_id,
8926            &[
8927                NewEntry {
8928                    guid: "live".to_string(),
8929                    ..Default::default()
8930                },
8931                NewEntry {
8932                    guid: "doomed".to_string(),
8933                    ..Default::default()
8934                },
8935            ],
8936            0,
8937        )
8938        .await?;
8939        replace_sub_refs(&pool, did, &[feed_id]).await?;
8940        let live_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'live'")
8941            .fetch_one(&pool)
8942            .await?;
8943        let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
8944            .fetch_one(&pool)
8945            .await?;
8946
8947        // A cursor holding only the id that is about to be deleted.
8948        upsert_cursor(
8949            &pool,
8950            &ReadCursor {
8951                did: did.to_string(),
8952                feed_url: feed_url.to_string(),
8953                read_through: None,
8954                read_ids: format!("[\"{doomed_id}\"]"),
8955                unread_ids: "[]".to_string(),
8956                dirty: false,
8957                pds_created: false,
8958                updated_at: now_rfc3339(),
8959            },
8960        )
8961        .await?;
8962        sqlx::query("DELETE FROM entries WHERE guid = 'doomed'")
8963            .execute(&pool)
8964            .await?;
8965
8966        // A reader marks the surviving entry read. NOTE this lands before the
8967        // scrub, not during it — see the caveat on this test. It is here because
8968        // the live id must survive the pass, not because it catches the race.
8969        mark_read(&pool, did, live_id, true).await?;
8970
8971        assert_eq!(prune_orphan_cursor_ids(&pool, None).await?, 1);
8972
8973        let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
8974        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
8975        assert_eq!(
8976            ids,
8977            vec![live_id.to_string()],
8978            "the scrub dropped a live id"
8979        );
8980        assert!(
8981            !ids.contains(&doomed_id.to_string()),
8982            "the orphaned id survived the scrub"
8983        );
8984        Ok(())
8985    }
8986
8987    /// The cursor scrub still happens — it just no longer rides inside the
8988    /// delete transaction. Moving it out is only safe because it is idempotent;
8989    /// this pins that it still runs at all, which is the thing a "move it out"
8990    /// refactor can silently drop.
8991    #[tokio::test]
8992    async fn the_sweep_still_scrubs_orphaned_cursor_ids() -> Result<()> {
8993        let pool = init_url("sqlite::memory:").await?;
8994        let did = "did:plc:scrub";
8995        let feed_url = "https://scrub.example/f.xml";
8996        let feed_id = upsert_feed(
8997            &pool,
8998            &NewFeed {
8999                url: feed_url.to_string(),
9000                ..Default::default()
9001            },
9002        )
9003        .await?;
9004        let old = (chrono::Utc::now() - chrono::Duration::days(400))
9005            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9006        insert_entries(
9007            &pool,
9008            feed_id,
9009            &[NewEntry {
9010                guid: "doomed".to_string(),
9011                published: Some(old),
9012                ..Default::default()
9013            }],
9014            0,
9015        )
9016        .await?;
9017        let doomed = entries_for_feed(&pool, did, feed_id).await;
9018        // `entries_for_feed` is sub_ref-scoped; read the id directly instead.
9019        drop(doomed);
9020        let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
9021            .fetch_one(&pool)
9022            .await?;
9023
9024        upsert_cursor(
9025            &pool,
9026            &ReadCursor {
9027                did: did.to_string(),
9028                feed_url: feed_url.to_string(),
9029                read_through: None,
9030                read_ids: format!("[\"{doomed_id}\"]"),
9031                unread_ids: "[]".to_string(),
9032                dirty: false,
9033                pds_created: false,
9034                updated_at: now_rfc3339(),
9035            },
9036        )
9037        .await?;
9038
9039        assert_eq!(prune_old_entries(&pool, 30, 180, 0).await?, 1);
9040
9041        let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
9042        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
9043        assert!(
9044            ids.is_empty(),
9045            "the deleted entry's id survived in the cursor: {ids:?}"
9046        );
9047        assert!(cursor.dirty, "a rewritten cursor must be re-flushed");
9048        Ok(())
9049    }
9050
9051    #[tokio::test]
9052    async fn prune_and_reclaim_drops_db_size() -> Result<()> {
9053        // On-disk DB so VACUUM has a file to shrink.
9054        let dir = std::env::temp_dir();
9055        let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
9056        let url = format!("sqlite://{}", path.display());
9057        let pool = init_url(&url).await?;
9058
9059        let feed_id = upsert_feed(
9060            &pool,
9061            &NewFeed {
9062                url: "https://bulk.example/feed.xml".to_string(),
9063                ..Default::default()
9064            },
9065        )
9066        .await?;
9067        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
9068            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9069        let entries: Vec<NewEntry> = (0..2000)
9070            .map(|i| NewEntry {
9071                guid: format!("guid-{i}"),
9072                content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
9073                published: Some(ancient.clone()),
9074                fetched_at: Some(ancient.clone()),
9075                ..Default::default()
9076            })
9077            .collect();
9078        insert_entries(&pool, feed_id, &entries, 0).await?;
9079        let full = db_size_bytes(&pool).await?;
9080        assert!(full > 0);
9081
9082        // A retention sweep prunes every (year-old) entry, then reclaim shrinks.
9083        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
9084        assert_eq!(deleted, 2000);
9085        reclaim(&pool).await?;
9086        let after = db_size_bytes(&pool).await?;
9087        assert!(
9088            after < full,
9089            "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
9090        );
9091
9092        drop(pool);
9093        let _ = std::fs::remove_file(&path);
9094        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9095        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9096        Ok(())
9097    }
9098
9099    // ---- B1: an existing PRE-0.2.2 invite_codes table (no intended_did) must
9100    // migrate cleanly, not crash-loop boot. ----------------------------------
9101
9102    #[tokio::test]
9103    async fn migrates_pre_intended_did_invite_codes_table() -> Result<()> {
9104        // Build an on-disk DB whose `invite_codes` table has the OLD 0.2.1 shape
9105        // (NO `intended_did` column, and therefore no `intended_did` index), then
9106        // run init_schema/migrations against it — this is exactly the existing-prod
9107        // volume that blocker B1 crash-looped (the SCHEMA's `CREATE INDEX ...
9108        // (intended_did, ...)` fired before the ALTER TABLE added the column).
9109        let dir = std::env::temp_dir();
9110        let path = dir.join(format!("fr-b1-{}.db", std::process::id()));
9111        let url = format!("sqlite://{}", path.display());
9112
9113        // Open a raw pool WITHOUT init_schema and hand-build the old table shape.
9114        let opts = SqliteConnectOptions::from_str(&url)?
9115            .create_if_missing(true)
9116            .foreign_keys(true);
9117        let pool = SqlitePoolOptions::new()
9118            .min_connections(1)
9119            .max_connections(1)
9120            .connect_with(opts)
9121            .await?;
9122        sqlx::query(
9123            r#"CREATE TABLE invite_codes (
9124                code         TEXT PRIMARY KEY,
9125                creator_did  TEXT NOT NULL,
9126                status       TEXT NOT NULL,
9127                invitee_did  TEXT,
9128                created_at   INTEGER NOT NULL,
9129                expires_at   INTEGER NOT NULL,
9130                redeemed_at  INTEGER
9131            );"#,
9132        )
9133        .execute(&pool)
9134        .await?;
9135        // Seed a legacy active code so the migration runs against real data.
9136        sqlx::query(
9137            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9138             VALUES ('FEATHER-LEGACY00', 'did:plc:old', 'active', 1, 9999999999)",
9139        )
9140        .execute(&pool)
9141        .await?;
9142
9143        // The column is genuinely absent to start with (pre-condition of B1).
9144        let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9145            .fetch_all(&pool)
9146            .await?
9147            .iter()
9148            .map(|r| r.get::<String, _>("name"))
9149            .collect();
9150        assert!(
9151            !cols.iter().any(|c| c == "intended_did"),
9152            "pre-condition: legacy table must lack intended_did"
9153        );
9154
9155        // THE FIX: init_schema must succeed (not error with "no such column").
9156        init_schema(&pool)
9157            .await
9158            .expect("init_schema on a pre-0.2.2 invite_codes table must not crash");
9159
9160        // Post-condition: the column now exists, both indexes were created, and the
9161        // legacy row is intact.
9162        let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9163            .fetch_all(&pool)
9164            .await?
9165            .iter()
9166            .map(|r| r.get::<String, _>("name"))
9167            .collect();
9168        assert!(cols.iter().any(|c| c == "intended_did"));
9169        let idx: Vec<String> = sqlx::query(
9170            "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='invite_codes'",
9171        )
9172        .fetch_all(&pool)
9173        .await?
9174        .iter()
9175        .map(|r| r.get::<String, _>("name"))
9176        .collect();
9177        assert!(idx.iter().any(|n| n == "idx_invite_codes_intended"));
9178        assert!(idx.iter().any(|n| n == "idx_invite_codes_intended_active"));
9179
9180        // Idempotent: running it again is a no-op, not an error.
9181        init_schema(&pool)
9182            .await
9183            .expect("re-running init_schema must be idempotent");
9184
9185        // **The OAuth tables must exist too.** They live in this database, and
9186        // creating them only when the Rust backend is selected would make the
9187        // first request after a cutover flip fail with "no such table" -- at the
9188        // one moment nobody wants to find out a migration was missed. They are
9189        // empty and harmless while the sidecar is serving.
9190        let tables: Vec<String> =
9191            sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table'")
9192                .fetch_all(&pool)
9193                .await
9194                .unwrap();
9195        for table in ["oauth_state", "oauth_session", "oauth_nonce"] {
9196            assert!(
9197                tables.iter().any(|t| t == table),
9198                "{table} is missing, so the rust backend would fail on its first request: {tables:?}"
9199            );
9200        }
9201
9202        // The legacy code still redeems (NULL intended_did → open, as before).
9203        let out = redeem_code(&pool, "FEATHER-LEGACY00", "did:plc:new", None, 100).await?;
9204        assert_eq!(out, Ok(()));
9205
9206        drop(pool);
9207        let _ = std::fs::remove_file(&path);
9208        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9209        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9210        Ok(())
9211    }
9212
9213    // ---- 0.3.9: the schema a RELEASED binary left behind must upgrade. ------
9214    //
9215    // B1 above hand-built the old shape of ONE table, so it could only catch the
9216    // mistake it was written for. 0.3.9 made the same mistake on `feeds` — an
9217    // index in the base SCHEMA on `kind`, a column only `apply_migrations` adds
9218    // — and crash-looped production on its first boot, while every test here
9219    // passed, because every other test starts from an empty file. These start
9220    // from the schema a released binary actually created (dumped, not
9221    // transcribed), so they cover every table at once: v0.3.8, the release
9222    // before the bug, and v0.2.0, the oldest and furthest-migrated shape.
9223
9224    /// A fresh in-memory pool on ONE connection that never expires. The bug
9225    /// class is DDL order, which does not depend on a file, and a file named by
9226    /// pid leaks on a failed run and then fails the next run whose pid matches,
9227    /// at the fixture's first CREATE TABLE, before it tests anything.
9228    async fn upgrade_test_pool() -> Result<SqlitePool> {
9229        let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true);
9230        Ok(SqlitePoolOptions::new()
9231            .min_connections(1)
9232            .max_connections(1)
9233            .idle_timeout(None)
9234            .max_lifetime(None)
9235            .connect_with(opts)
9236            .await?)
9237    }
9238
9239    /// Every table's columns (with type, NOT NULL, default and pk) and every
9240    /// index (with uniqueness, partiality and its columns in order), as one
9241    /// comparable set. Column ORDER is left out on purpose: `ALTER TABLE ADD
9242    /// COLUMN` appends, so a migrated table legitimately orders differently
9243    /// from a fresh one.
9244    async fn schema_shape(pool: &SqlitePool) -> Result<std::collections::BTreeSet<String>> {
9245        let mut shape = std::collections::BTreeSet::new();
9246        let tables: Vec<String> = sqlx::query_scalar(
9247            "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
9248        )
9249        .fetch_all(pool)
9250        .await?;
9251        for t in tables {
9252            for r in sqlx::query(
9253                r#"SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)"#,
9254            )
9255            .bind(&t)
9256            .fetch_all(pool)
9257            .await?
9258            {
9259                shape.insert(format!(
9260                    "column {t}.{} {} notnull={} default={:?} pk={}",
9261                    r.get::<String, _>("name"),
9262                    r.get::<String, _>("type"),
9263                    r.get::<i64, _>("notnull"),
9264                    r.get::<Option<String>, _>("dflt_value"),
9265                    r.get::<i64, _>("pk"),
9266                ));
9267            }
9268            for r in sqlx::query(r#"SELECT name, "unique", partial FROM pragma_index_list(?)"#)
9269                .bind(&t)
9270                .fetch_all(pool)
9271                .await?
9272            {
9273                let name: String = r.get("name");
9274                let cols: Vec<String> =
9275                    sqlx::query_scalar("SELECT name FROM pragma_index_info(?) ORDER BY seqno")
9276                        .bind(&name)
9277                        .fetch_all(pool)
9278                        .await?;
9279                shape.insert(format!(
9280                    "index {t}.{name} unique={} partial={} ({})",
9281                    r.get::<i64, _>("unique"),
9282                    r.get::<i64, _>("partial"),
9283                    cols.join(", "),
9284                ));
9285            }
9286        }
9287        Ok(shape)
9288    }
9289
9290    /// Load `fixture`, seed rows the way an old binary inserted them, run the
9291    /// current `init_schema`, and require the result to be indistinguishable
9292    /// in shape from a fresh database, with `kind` back-filled correctly.
9293    async fn assert_upgrades_from(version: &str, fixture: &'static str) -> Result<()> {
9294        let pool = upgrade_test_pool().await?;
9295        sqlx::raw_sql(fixture).execute(&pool).await?;
9296
9297        let has_kind = |pool: SqlitePool| async move {
9298            Ok::<_, anyhow::Error>(
9299                sqlx::query_scalar::<_, i64>(
9300                    "SELECT count(*) FROM pragma_table_info('feeds') WHERE name = 'kind'",
9301                )
9302                .fetch_one(&pool)
9303                .await?
9304                    == 1,
9305            )
9306        };
9307        assert!(
9308            !has_kind(pool.clone()).await?,
9309            "pre-condition: a {version} feeds table has no kind column"
9310        );
9311
9312        // One row of each kind, inserted the way the old binary did: without `kind`.
9313        let publication = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
9314        for u in ["https://example.com/feed.xml", publication] {
9315            sqlx::query("INSERT INTO feeds (url) VALUES (?)")
9316                .bind(u)
9317                .execute(&pool)
9318                .await?;
9319        }
9320
9321        init_schema(&pool)
9322            .await
9323            .unwrap_or_else(|e| panic!("init_schema must upgrade a {version} database: {e:#}"));
9324
9325        let kinds: Vec<(String, String)> =
9326            sqlx::query_as("SELECT url, kind FROM feeds ORDER BY id")
9327                .fetch_all(&pool)
9328                .await?;
9329        assert_eq!(
9330            kinds,
9331            vec![
9332                (
9333                    "https://example.com/feed.xml".to_string(),
9334                    "rss".to_string()
9335                ),
9336                (publication.to_string(), "publication".to_string()),
9337            ],
9338            "{version}: existing rows are back-filled from their URL"
9339        );
9340        // What it indexes, not only its name: an `idx_feeds_kind` on the wrong
9341        // column passed a name check. (The shape comparison below also covers
9342        // this; this one names the bug that shipped.)
9343        let indexed: Vec<String> = sqlx::query_scalar(
9344            "SELECT name FROM pragma_index_info('idx_feeds_kind') ORDER BY seqno",
9345        )
9346        .fetch_all(&pool)
9347        .await?;
9348        assert_eq!(
9349            indexed,
9350            vec!["kind".to_string()],
9351            "{version}: idx_feeds_kind exists, on feeds(kind), after the column"
9352        );
9353
9354        // The general check: anything a fresh database has that the upgraded
9355        // one lacks, or the reverse, is a migration gap.
9356        let fresh = upgrade_test_pool().await?;
9357        init_schema(&fresh).await?;
9358        let (want, got) = (schema_shape(&fresh).await?, schema_shape(&pool).await?);
9359        assert!(
9360            want == got,
9361            "{version}: upgraded schema differs from a fresh one\n  missing: {:#?}\n  extra: {:#?}",
9362            want.difference(&got).collect::<Vec<_>>(),
9363            got.difference(&want).collect::<Vec<_>>(),
9364        );
9365
9366        // And a second boot over the upgraded database is a no-op, not an error.
9367        init_schema(&pool)
9368            .await
9369            .unwrap_or_else(|e| panic!("{version}: re-running init_schema failed: {e:#}"));
9370        Ok(())
9371    }
9372
9373    #[tokio::test]
9374    async fn a_v0_3_8_database_upgrades_to_the_current_schema() -> Result<()> {
9375        assert_upgrades_from(
9376            "v0.3.8",
9377            include_str!("../tests/fixtures/schema-v0.3.8.sql"),
9378        )
9379        .await
9380    }
9381
9382    #[tokio::test]
9383    async fn a_v0_2_0_database_upgrades_to_the_current_schema() -> Result<()> {
9384        assert_upgrades_from(
9385            "v0.2.0",
9386            include_str!("../tests/fixtures/schema-v0.2.0.sql"),
9387        )
9388        .await
9389    }
9390
9391    // ---- B2: a code minted FOR a specific DID is redeemable ONLY by that DID. --
9392
9393    #[tokio::test]
9394    async fn redeem_enforces_intended_did_binding() -> Result<()> {
9395        let pool = init_url("sqlite::memory:").await?;
9396        // Mint a claim FOR did:plc:A (the follower the bot posted the link to).
9397        let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:A").await?;
9398
9399        // A DIFFERENT DID (a throwaway that stole the public link) is refused as if
9400        // the code didn't exist — no seat granted, code still active.
9401        let stolen = redeem_code(&pool, &code, "did:plc:B", Some("thief.bsky"), 100).await?;
9402        assert_eq!(stolen, Err(RedeemError::NotFound));
9403        assert!(!has_beta_access(&pool, "did:plc:B").await?);
9404        assert_eq!(count_active_codes(&pool).await?, 1, "code must stay active");
9405
9406        // The INTENDED DID redeems successfully.
9407        let ok = redeem_code(&pool, &code, "did:plc:A", Some("alice.bsky"), 100).await?;
9408        assert_eq!(ok, Ok(()));
9409        assert!(has_beta_access(&pool, "did:plc:A").await?);
9410
9411        // A NULL-intended (admin/browser) code stays open to anyone (unchanged).
9412        let open = mint_code(&pool, "did:plc:admin", 3600).await?;
9413        let anyone = redeem_code(&pool, &open, "did:plc:C", None, 100).await?;
9414        assert_eq!(anyone, Ok(()));
9415        assert!(has_beta_access(&pool, "did:plc:C").await?);
9416        Ok(())
9417    }
9418
9419    // ---- S4: at most one ACTIVE code per intended DID; a concurrent second mint
9420    // hits the partial-unique index, and is_intended_active_conflict recognises it.
9421
9422    #[tokio::test]
9423    async fn intended_active_partial_unique_index_blocks_double_mint() -> Result<()> {
9424        let pool = init_url("sqlite::memory:").await?;
9425        // First mint for the DID succeeds.
9426        mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup").await?;
9427        // A SECOND active mint for the SAME DID violates the partial unique index.
9428        let err = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9429            .await
9430            .expect_err("second active mint for the same DID must fail the unique index");
9431        assert!(
9432            is_intended_active_conflict(&err),
9433            "the conflict must be recognised so the web layer can recover: {err:?}"
9434        );
9435        // Still exactly one active code for the DID.
9436        assert!(find_active_code_for_did(&pool, "did:plc:dup")
9437            .await?
9438            .is_some());
9439
9440        // Once the first code is redeemed (no longer active), a fresh mint for the
9441        // DID is allowed again (partial index only constrains active rows).
9442        let existing = find_active_code_for_did(&pool, "did:plc:dup")
9443            .await?
9444            .unwrap();
9445        redeem_code(&pool, &existing, "did:plc:dup", None, 100).await??;
9446        mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9447            .await
9448            .expect("a new mint is allowed after the prior one is redeemed");
9449
9450        // And the conflict helper does NOT fire on an unrelated error (a PRIMARY KEY
9451        // clash on `code`, i.e. a different constraint).
9452        sqlx::query(
9453            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9454             VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9455        )
9456        .execute(&pool)
9457        .await?;
9458        let pk_err = sqlx::query(
9459            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9460             VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9461        )
9462        .execute(&pool)
9463        .await
9464        .expect_err("duplicate PRIMARY KEY must error");
9465        let as_anyhow = anyhow::Error::new(pk_err);
9466        assert!(
9467            !is_intended_active_conflict(&as_anyhow),
9468            "a non-intended-index conflict must NOT be mistaken for the recover-able one"
9469        );
9470        Ok(())
9471    }
9472
9473    #[tokio::test]
9474    async fn purge_expires_orphaned_active_intended_code() -> Result<()> {
9475        // Cheap nit: purging a DID that is the TARGET of an active claim must both
9476        // NULL intended_did AND expire the (now orphaned) active code, so it stops
9477        // counting against the mint cap for its full TTL.
9478        let pool = init_url("sqlite::memory:").await?;
9479        let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:leaver").await?;
9480        assert_eq!(count_active_codes(&pool).await?, 1);
9481
9482        purge_did_data(&pool, "did:plc:leaver").await?;
9483
9484        // The code is no longer active (expired), so it no longer counts.
9485        assert_eq!(
9486            count_active_codes(&pool).await?,
9487            0,
9488            "orphaned code must be expired by purge, not left active"
9489        );
9490        // And intended_did was scrubbed.
9491        let intended: Option<String> =
9492            sqlx::query("SELECT intended_did FROM invite_codes WHERE code = ?1")
9493                .bind(&code)
9494                .fetch_one(&pool)
9495                .await?
9496                .get("intended_did");
9497        assert!(intended.is_none(), "intended_did must be NULLed");
9498        Ok(())
9499    }
9500
9501    /// A `(key, source)` observation upserts in place: two writes for the same
9502    /// relay leave ONE row, carrying the newer value.
9503    #[tokio::test]
9504    async fn network_stat_upserts_per_source() -> Result<()> {
9505        let pool = init_url("sqlite::memory:").await?;
9506        let mut stat = NetworkStat {
9507            key: ADOPTION_STAT_KEY.to_string(),
9508            source: "https://relay1.us-west.bsky.network".to_string(),
9509            value: 1,
9510            truncated: false,
9511            observed_at: "2026-08-12T00:00:00Z".to_string(),
9512        };
9513        record_network_stat(&pool, &stat).await?;
9514        stat.value = 4;
9515        stat.observed_at = "2026-08-13T00:00:00Z".to_string();
9516        record_network_stat(&pool, &stat).await?;
9517
9518        let rows: i64 = sqlx::query("SELECT COUNT(*) AS n FROM network_stat")
9519            .fetch_one(&pool)
9520            .await?
9521            .get("n");
9522        assert_eq!(rows, 1, "the same relay must update, not duplicate");
9523        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9524            .await?
9525            .expect("a stat");
9526        assert_eq!(latest.value, 4);
9527        assert_eq!(latest.observed_at, "2026-08-13T00:00:00Z");
9528        Ok(())
9529    }
9530
9531    /// **Regression (v0.2.9 review).** Once a slow walk can return a PARTIAL
9532    /// count, a plain upsert lets it overwrite a complete, larger one — moving
9533    /// the published "at least N" DOWN because a relay was slow, not because
9534    /// adoption fell. A truncated observation may only ever raise the floor.
9535    #[tokio::test]
9536    async fn a_truncated_observation_never_lowers_a_stored_count() -> Result<()> {
9537        let pool = init_url("sqlite::memory:").await?;
9538        let mut stat = NetworkStat {
9539            key: ADOPTION_STAT_KEY.to_string(),
9540            source: "https://relay1.us-west.bsky.network".to_string(),
9541            value: 2000,
9542            truncated: false,
9543            observed_at: "2026-08-13T00:00:00Z".to_string(),
9544        };
9545        record_network_stat(&pool, &stat).await?;
9546
9547        // A budget-truncated walk that only got one page in.
9548        stat.value = 500;
9549        stat.truncated = true;
9550        stat.observed_at = "2026-08-14T00:00:00Z".to_string();
9551        record_network_stat(&pool, &stat).await?;
9552
9553        let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9554            .await?
9555            .expect("a stat");
9556        assert_eq!(kept.value, 2000, "a partial walk must not lower the count");
9557        assert!(!kept.truncated, "and must not mark the kept row truncated");
9558        assert_eq!(kept.observed_at, "2026-08-13T00:00:00Z");
9559
9560        // A truncated observation that RAISES the floor is still accepted...
9561        stat.value = 3000;
9562        record_network_stat(&pool, &stat).await?;
9563        assert_eq!(
9564            latest_network_stat(&pool, ADOPTION_STAT_KEY)
9565                .await?
9566                .expect("a stat")
9567                .value,
9568            3000
9569        );
9570
9571        // ...and a COMPLETE observation wins even when it is smaller, because
9572        // repos genuinely can go away and a full walk is authoritative.
9573        stat.value = 42;
9574        stat.truncated = false;
9575        record_network_stat(&pool, &stat).await?;
9576        assert_eq!(
9577            latest_network_stat(&pool, ADOPTION_STAT_KEY)
9578                .await?
9579                .expect("a stat")
9580                .value,
9581            42,
9582            "a complete walk is authoritative even when it shrinks"
9583        );
9584
9585        // An EQUAL-valued truncated observation must not downgrade the row
9586        // either: it proves nothing the stored complete count did not already
9587        // prove, but flipping `truncated` would silently degrade /about from
9588        // "42" to "at least 42" with no change in actual adoption. The strict
9589        // `<` in the guard let exactly this through — the equal case is the one
9590        // the two assertions above cannot reach, because both move the value.
9591        stat.truncated = true;
9592        stat.observed_at = "2026-08-15T00:00:00Z".to_string();
9593        record_network_stat(&pool, &stat).await?;
9594        let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9595            .await?
9596            .expect("a stat");
9597        assert_eq!(kept.value, 42);
9598        assert!(
9599            !kept.truncated,
9600            "an equal truncated observation must not mark the kept row truncated"
9601        );
9602        assert_eq!(
9603            kept.observed_at, "2026-08-14T00:00:00Z",
9604            "the rejected observation must not have rewritten the row at all"
9605        );
9606        Ok(())
9607    }
9608
9609    /// Relays disagree by design (non-archival indexes); the max is surfaced.
9610    #[tokio::test]
9611    async fn latest_network_stat_picks_the_max_across_sources() -> Result<()> {
9612        let pool = init_url("sqlite::memory:").await?;
9613        for (source, value, truncated) in [
9614            ("https://relay1.us-west.bsky.network", 2i64, false),
9615            ("https://relay1.us-east.bsky.network", 40i64, true),
9616        ] {
9617            record_network_stat(
9618                &pool,
9619                &NetworkStat {
9620                    key: ADOPTION_STAT_KEY.to_string(),
9621                    source: source.to_string(),
9622                    value,
9623                    truncated,
9624                    observed_at: "2026-08-13T00:00:00Z".to_string(),
9625                },
9626            )
9627            .await?;
9628        }
9629        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9630            .await?
9631            .expect("a stat");
9632        assert_eq!(latest.value, 40);
9633        assert_eq!(latest.source, "https://relay1.us-east.bsky.network");
9634        // `truncated` round-trips as a bool.
9635        assert!(latest.truncated);
9636        Ok(())
9637    }
9638
9639    #[tokio::test]
9640    async fn latest_network_stat_is_none_on_an_empty_table() -> Result<()> {
9641        let pool = init_url("sqlite::memory:").await?;
9642        assert!(latest_network_stat(&pool, ADOPTION_STAT_KEY)
9643            .await?
9644            .is_none());
9645        Ok(())
9646    }
9647
9648    /// **The lookup is keyed.** Every existing network-stat test writes only
9649    /// `ADOPTION_STAT_KEY`, so the `WHERE key = ?1` never discriminated; with
9650    /// it widened to `OR 1=1` the suite stayed green. The public `/stats`
9651    /// page asks for the adoption count, and unkeyed it would render the
9652    /// largest value of ANY stat as the network size.
9653    #[tokio::test]
9654    async fn latest_network_stat_ignores_other_keys() -> Result<()> {
9655        let pool = init_url("sqlite::memory:").await?;
9656        for (key, source, value) in [
9657            (ADOPTION_STAT_KEY, "https://relay1.example", 40),
9658            ("some.other.metric", "https://relay1.example", 9_999),
9659        ] {
9660            record_network_stat(
9661                &pool,
9662                &NetworkStat {
9663                    key: key.to_string(),
9664                    source: source.to_string(),
9665                    value,
9666                    truncated: false,
9667                    observed_at: now_rfc3339(),
9668                },
9669            )
9670            .await?;
9671        }
9672        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9673            .await?
9674            .expect("the adoption stat was recorded");
9675        assert_eq!(
9676            latest.value, 40,
9677            "another key's value was returned as the adoption count"
9678        );
9679        Ok(())
9680    }
9681
9682    // ── poll health (the public stats page) ─────────────────────────────────
9683
9684    /// Seed a feed row **through the real writer**, so its `kind` is whatever
9685    /// production would store.
9686    ///
9687    /// This used to be a raw `INSERT`, which took the `kind` column's
9688    /// `DEFAULT 'rss'`. That is correct for an http(s) URL and silently wrong
9689    /// for an `at://` one — the helper claimed to seed a row the poller skips
9690    /// while seeding one it selects.
9691    async fn feed_polled(
9692        pool: &SqlitePool,
9693        url: &str,
9694        last_polled: Option<&str>,
9695        next_poll: Option<&str>,
9696    ) {
9697        upsert_feed(
9698            pool,
9699            &NewFeed {
9700                url: url.to_string(),
9701                last_polled: last_polled.map(str::to_string),
9702                next_poll: next_poll.map(str::to_string),
9703                ..Default::default()
9704            },
9705        )
9706        .await
9707        .unwrap();
9708    }
9709
9710    /// The numbers on the public page must describe the poller's actual state.
9711    #[tokio::test]
9712    async fn poll_health_counts_tracked_recent_and_overdue() -> anyhow::Result<()> {
9713        let pool = init_url("sqlite::memory:").await?;
9714        let now = "2026-01-01T12:00:00Z";
9715        let hour_ago = "2026-01-01T11:00:00Z";
9716
9717        // Polled 10 minutes ago, due in 50 minutes: healthy.
9718        feed_polled(
9719            &pool,
9720            "https://a.example/f",
9721            Some("2026-01-01T11:50:00Z"),
9722            Some("2026-01-01T12:50:00Z"),
9723        )
9724        .await;
9725        // Polled 3 hours ago and overdue: the backlog case.
9726        feed_polled(
9727            &pool,
9728            "https://b.example/f",
9729            Some("2026-01-01T09:00:00Z"),
9730            Some("2026-01-01T10:00:00Z"),
9731        )
9732        .await;
9733        // Never polled: counts as overdue (next_poll IS NULL), and must not
9734        // corrupt the "oldest poll" figure with a NULL.
9735        feed_polled(&pool, "https://c.example/f", None, None).await;
9736
9737        let h = poll_health(&pool, now, hour_ago).await?;
9738        assert_eq!(h.feeds_tracked, 3);
9739        assert_eq!(
9740            h.polled_last_hour, 1,
9741            "only the 11:50 poll is within the hour"
9742        );
9743        assert_eq!(h.overdue, 2, "the stale feed and the never-polled one");
9744        assert_eq!(
9745            h.last_poll_secs_ago,
9746            Some(600),
9747            "most recent poll was 10 minutes ago"
9748        );
9749        // **A never-polled feed IS the worst staleness.**
9750        //
9751        // This originally asserted `Some(10_800)` — the oldest FINITE age — and
9752        // in doing so pinned a defect: `MIN` skips NULLs, so the page reported
9753        // "3h ago" while a quarter of the feeds had never been fetched at all.
9754        // The figure read healthiest in the most degraded state, which is the
9755        // opposite of what a health page is for.
9756        assert_eq!(
9757            h.oldest_poll_secs_ago, None,
9758            "a never-polled feed must outrank any finite age"
9759        );
9760        assert_eq!(h.never_polled, 1);
9761
9762        // With every feed polled, the finite worst case is reported again.
9763        sqlx::query("UPDATE feeds SET last_polled = ?1 WHERE last_polled IS NULL")
9764            .bind("2026-01-01T09:00:00Z")
9765            .execute(&pool)
9766            .await?;
9767        let h = poll_health(&pool, now, hour_ago).await?;
9768        assert_eq!(h.never_polled, 0);
9769        assert_eq!(h.oldest_poll_secs_ago, Some(10_800));
9770        Ok(())
9771    }
9772
9773    /// **`/stats` measures the poller, so it counts only what the poller sees.**
9774    ///
9775    /// `due_feeds` skips `at://` rows; nothing ever advances their `next_poll`
9776    /// or sets `last_polled`. Counted, they read as overdue and never-polled
9777    /// forever, and force "oldest poll" to `never` — the same "unsupported
9778    /// shown as broken" the exclusion exists to end, moved to different rows on
9779    /// a public page. The same predicate decides both queries so they cannot
9780    /// drift.
9781    #[tokio::test]
9782    async fn poll_health_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
9783        let pool = init_url("sqlite::memory:").await?;
9784        let now = "2026-01-01T12:00:00Z";
9785        let hour_ago = "2026-01-01T11:00:00Z";
9786        feed_polled(
9787            &pool,
9788            "https://a.example/f",
9789            Some("2026-01-01T11:50:00Z"),
9790            Some("2026-01-01T12:50:00Z"),
9791        )
9792        .await;
9793        // Never polled, never due: the shape every at:// row has.
9794        feed_polled(
9795            &pool,
9796            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
9797            None,
9798            None,
9799        )
9800        .await;
9801
9802        let h = poll_health(&pool, now, hour_ago).await?;
9803        assert_eq!(
9804            h.feeds_tracked, 1,
9805            "an unpollable row was counted as tracked"
9806        );
9807        assert_eq!(h.overdue, 0, "an unpollable row was counted as overdue");
9808        assert_eq!(
9809            h.never_polled, 0,
9810            "an unpollable row was counted as never polled"
9811        );
9812        assert_eq!(
9813            h.oldest_poll_secs_ago,
9814            Some(600),
9815            "an unpollable row forced the oldest poll to `never`"
9816        );
9817        assert_eq!(h.polled_last_hour, 1);
9818        Ok(())
9819    }
9820
9821    /// **The admin's failing-feeds list is the poller's too.** `failing_feeds`
9822    /// feeds `/admin/metrics`; it was not given the exclusion both `/stats`
9823    /// queries got. An `at://` row that carries errors — from a rollback to a
9824    /// build that polled them, say — would then sit at the top of the one page
9825    /// an operator uses to diagnose "unsupported shown as broken", with no
9826    /// poll ever coming to clear it and the one-shot migration already spent.
9827    #[tokio::test]
9828    async fn failing_feeds_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
9829        let pool = init_url("sqlite::memory:").await?;
9830        for url in [
9831            "https://broken.example/feed.xml",
9832            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
9833        ] {
9834            upsert_feed(
9835                &pool,
9836                &NewFeed {
9837                    url: url.to_string(),
9838                    ..Default::default()
9839                },
9840            )
9841            .await?;
9842            bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
9843        }
9844        let failing = failing_feeds(&pool, 10).await?;
9845        let urls: Vec<&str> = failing.iter().map(|f| f.url.as_str()).collect();
9846        assert_eq!(
9847            urls,
9848            vec!["https://broken.example/feed.xml"],
9849            "an unpollable row was listed as a failing feed"
9850        );
9851        Ok(())
9852    }
9853
9854    /// **The clearing is idempotent by predicate, not by stamp.** It touches
9855    /// only rows that have never been polled successfully: `bump_feed_errors`
9856    /// never sets `last_polled`, both success paths do. So a row a wired
9857    /// reader has fetched once keeps its later failures across restarts, and
9858    /// a row that only ever failed under our own refusal is cleared at every
9859    /// boot — including after a rollback to a build that polled it. No
9860    /// version stamp, nothing for a test to rewind.
9861    #[tokio::test]
9862    async fn the_at_uri_error_clearing_spares_a_row_that_has_been_polled() -> anyhow::Result<()> {
9863        let pool = init_url("sqlite::memory:").await?;
9864        let polled = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/polled";
9865        let never = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/never";
9866        for url in [polled, never] {
9867            upsert_feed(
9868                &pool,
9869                &NewFeed {
9870                    url: url.to_string(),
9871                    ..Default::default()
9872                },
9873            )
9874            .await?;
9875            bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
9876        }
9877        // A wired reader fetched this one once, then it started failing.
9878        sqlx::query("UPDATE feeds SET last_polled = '2026-01-01T00:00:00Z' WHERE url = ?1")
9879            .bind(polled)
9880            .execute(&pool)
9881            .await?;
9882
9883        for boot in 1..=2 {
9884            apply_migrations(&pool).await?;
9885            let mut errors = std::collections::HashMap::new();
9886            for url in [polled, never] {
9887                let n: i64 =
9888                    sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
9889                        .bind(url)
9890                        .fetch_one(&pool)
9891                        .await?;
9892                errors.insert(url, n);
9893            }
9894            assert_eq!(
9895                errors[polled], 1,
9896                "boot {boot} wiped a polled row's failure"
9897            );
9898            assert_eq!(
9899                errors[never], 0,
9900                "boot {boot} left a never-polled row failing"
9901            );
9902        }
9903        Ok(())
9904    }
9905
9906    /// **The SQL kind list and the Rust one are the same list.** A literal in
9907    /// SQL and a slice in Rust is the drift the column exists to end; wiring
9908    /// the standard.site reader changes both, and this is what makes
9909    /// forgetting one a failure rather than a silently dormant feature.
9910    #[test]
9911    fn the_sql_kind_list_matches_the_rust_one() {
9912        let expected = crate::feed::FeedKind::POLLABLE
9913            .iter()
9914            .map(|k| format!("'{}'", k.as_str()))
9915            .collect::<Vec<_>>()
9916            .join(", ");
9917        assert_eq!(POLLABLE_KINDS_SQL, expected);
9918    }
9919
9920    /// **A feed's kind is recorded at insert, not re-derived from its URL.**
9921    ///
9922    /// "Can the poller fetch this?" was a substring predicate spliced into
9923    /// four statements, and a review found a fifth reader that had drifted
9924    /// from it. A column the writers set cannot drift: the Rust side decides
9925    /// once, SQL reads a value.
9926    #[tokio::test]
9927    async fn a_feed_row_records_its_kind_at_insert() -> anyhow::Result<()> {
9928        let pool = init_url("sqlite::memory:").await?;
9929        for (url, want) in [
9930            ("https://real.example/feed.xml", crate::feed::FeedKind::Rss),
9931            ("http://real.example/feed.xml", crate::feed::FeedKind::Rss),
9932            (
9933                "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
9934                crate::feed::FeedKind::Publication,
9935            ),
9936        ] {
9937            upsert_feed(
9938                &pool,
9939                &NewFeed {
9940                    url: url.to_string(),
9941                    ..Default::default()
9942                },
9943            )
9944            .await?;
9945            let got: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
9946                .bind(url)
9947                .fetch_one(&pool)
9948                .await?;
9949            assert_eq!(got, want.as_str(), "wrong kind recorded for {url}");
9950        }
9951        Ok(())
9952    }
9953
9954    /// **A row written before the column existed is back-filled from its URL.**
9955    /// That back-fill is the LAST use of the string predicate; every reader
9956    /// keys on `kind` afterwards.
9957    #[tokio::test]
9958    async fn the_migration_backfills_kind_from_the_url() -> anyhow::Result<()> {
9959        let pool = init_url("sqlite::memory:").await?;
9960        // A table that predates the column, with both shapes in it.
9961        sqlx::query("DROP TABLE feeds").execute(&pool).await?;
9962        sqlx::query(
9963            "CREATE TABLE feeds (
9964                 id INTEGER PRIMARY KEY AUTOINCREMENT,
9965                 url TEXT NOT NULL UNIQUE,
9966                 title TEXT, site_url TEXT, etag TEXT, last_modified TEXT,
9967                 last_polled TEXT, next_poll TEXT,
9968                 consecutive_errors INTEGER NOT NULL DEFAULT 0,
9969                 last_error_kind TEXT, last_error TEXT
9970             )",
9971        )
9972        .execute(&pool)
9973        .await?;
9974        for url in [
9975            "https://real.example/feed.xml",
9976            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
9977            "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
9978        ] {
9979            sqlx::query("INSERT INTO feeds (url) VALUES (?1)")
9980                .bind(url)
9981                .execute(&pool)
9982                .await?;
9983        }
9984
9985        apply_migrations(&pool).await?;
9986
9987        let kinds: Vec<(String, String)> =
9988            sqlx::query_as("SELECT url, kind FROM feeds ORDER BY url")
9989                .fetch_all(&pool)
9990                .await?;
9991        let by_url: std::collections::HashMap<_, _> = kinds.into_iter().collect();
9992        assert_eq!(by_url["https://real.example/feed.xml"], "rss");
9993        assert_eq!(
9994            by_url["at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"],
9995            "publication"
9996        );
9997        assert_eq!(
9998            by_url["At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac"],
9999            "publication",
10000            "the back-fill must recognise a non-canonical spelling, like every other guard"
10001        );
10002        Ok(())
10003    }
10004
10005    /// **`feeds.kind` is derived from the URL, so it has to be re-derivable.**
10006    ///
10007    /// The back-fill translated one direction only — a row the Rust side would
10008    /// call `rss` was never touched — which is correct for a one-time migration
10009    /// and wrong for a column that has to survive the rule changing. A kind that
10010    /// disagrees with its own URL is currently permanent: nothing re-reads it.
10011    #[tokio::test]
10012    async fn the_back_fill_corrects_a_kind_that_disagrees_with_the_url() -> anyhow::Result<()> {
10013        let pool = init_url("sqlite::memory:").await?;
10014        let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10015        for (url, wrong) in [
10016            ("https://real.example/feed.xml", "publication"),
10017            (at, "rss"),
10018        ] {
10019            sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, ?2)")
10020                .bind(url)
10021                .bind(wrong)
10022                .execute(&pool)
10023                .await?;
10024        }
10025
10026        apply_migrations(&pool).await?;
10027
10028        let by_url: std::collections::HashMap<String, String> =
10029            sqlx::query_as("SELECT url, kind FROM feeds")
10030                .fetch_all(&pool)
10031                .await?
10032                .into_iter()
10033                .collect();
10034        assert_eq!(
10035            by_url["https://real.example/feed.xml"], "rss",
10036            "an http feed marked as a publication stayed one, and nothing polls it"
10037        );
10038        assert_eq!(by_url[at], "publication", "the at:// direction regressed");
10039        Ok(())
10040    }
10041
10042    /// **Taking a row out of the poller orphans its poll state, so clear it.**
10043    ///
10044    /// `last_polled` is set here on purpose: the migration's other cleanup step
10045    /// only clears rows we never polled, so a row that HAS been polled proves
10046    /// this reset is the one doing the work. An error count left on a row the
10047    /// scheduler will never select again is hidden from `/stats`, which filters
10048    /// on kind — and if a later rule change readmits the row, it resumes at a
10049    /// backoff earned under a classification that no longer applies.
10050    #[tokio::test]
10051    async fn a_row_taken_out_of_the_poller_loses_the_poll_state_it_cannot_use() -> anyhow::Result<()>
10052    {
10053        let pool = init_url("sqlite::memory:").await?;
10054        let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10055        sqlx::query(
10056            "INSERT INTO feeds (url, kind, consecutive_errors, last_error_kind, last_error, \
10057             next_poll, last_polled) \
10058             VALUES (?1, 'rss', 7, 'fetch', 'connection refused', ?2, ?3)",
10059        )
10060        .bind(at)
10061        .bind("2026-09-10T00:00:00Z")
10062        .bind("2026-09-01T00:00:00Z")
10063        .execute(&pool)
10064        .await?;
10065
10066        apply_migrations(&pool).await?;
10067
10068        let (kind, errors, error_kind, error, next_poll): (
10069            String,
10070            i64,
10071            Option<String>,
10072            Option<String>,
10073            Option<String>,
10074        ) = sqlx::query_as(
10075            "SELECT kind, consecutive_errors, last_error_kind, last_error, next_poll \
10076             FROM feeds WHERE url = ?1",
10077        )
10078        .bind(at)
10079        .fetch_one(&pool)
10080        .await?;
10081        assert_eq!(kind, "publication", "the row was not reclassified at all");
10082        assert_eq!(
10083            (errors, error_kind, error, next_poll),
10084            (0, None, None, None),
10085            "a row the scheduler will never select again kept its backoff and failure history"
10086        );
10087        Ok(())
10088    }
10089
10090    /// **A row we cannot read must not stop the process from starting.**
10091    ///
10092    /// This runs on the boot path. Refusing to start is a strictly worse
10093    /// outcome than declining to have an opinion about one row, and it is a
10094    /// failure mode the SQL predicate this replaced did not have: it evaluated
10095    /// a non-text `url` happily and returned false.
10096    #[tokio::test]
10097    async fn an_unreadable_feeds_row_does_not_stop_the_boot() -> anyhow::Result<()> {
10098        let pool = init_url("sqlite::memory:").await?;
10099        sqlx::query("INSERT INTO feeds (url, kind) VALUES (X'ff41', 'rss')")
10100            .execute(&pool)
10101            .await?;
10102        sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, 'publication')")
10103            .bind("https://real.example/feed.xml")
10104            .execute(&pool)
10105            .await?;
10106
10107        apply_migrations(&pool).await?;
10108
10109        let corrected: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10110            .bind("https://real.example/feed.xml")
10111            .fetch_one(&pool)
10112            .await?;
10113        assert_eq!(
10114            corrected, "rss",
10115            "one unreadable row aborted the pass before the readable ones were corrected"
10116        );
10117        let untouched: String =
10118            sqlx::query_scalar("SELECT kind FROM feeds WHERE typeof(url) = 'blob'")
10119                .fetch_one(&pool)
10120                .await?;
10121        assert_eq!(
10122            untouched, "rss",
10123            "a row we declined to classify was classified anyway"
10124        );
10125        Ok(())
10126    }
10127
10128    /// Re-subscribing must re-derive the kind, not preserve whatever is there.
10129    ///
10130    /// `upsert_feed` binds `FeedKind::of` on the way in, but its conflict clause
10131    /// never carried `kind`, so the value a row was first written with is the
10132    /// value it keeps. Harmless while the rule is fixed; the rule is about to
10133    /// change.
10134    #[tokio::test]
10135    async fn a_re_upsert_re_derives_the_kind() -> anyhow::Result<()> {
10136        let pool = init_url("sqlite::memory:").await?;
10137        let url = "https://real.example/feed.xml";
10138        let feed = NewFeed {
10139            url: url.to_string(),
10140            ..Default::default()
10141        };
10142        upsert_feed(&pool, &feed).await?;
10143        sqlx::query("UPDATE feeds SET kind = 'publication' WHERE url = ?1")
10144            .bind(url)
10145            .execute(&pool)
10146            .await?;
10147
10148        upsert_feed(&pool, &feed).await?;
10149
10150        let kind: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10151            .bind(url)
10152            .fetch_one(&pool)
10153            .await?;
10154        assert_eq!(
10155            kind, "rss",
10156            "a second subscription to the same URL kept the stale classification"
10157        );
10158        Ok(())
10159    }
10160
10161    /// **The readers key on `kind`, not on the URL.** A row whose kind says
10162    /// publication is unpollable even if its URL looks ordinary — which is
10163    /// what makes the column, rather than the string, the source of truth.
10164    #[tokio::test]
10165    async fn the_poller_and_the_pages_key_on_kind() -> anyhow::Result<()> {
10166        let pool = init_url("sqlite::memory:").await?;
10167        upsert_feed(
10168            &pool,
10169            &NewFeed {
10170                url: "https://looks-ordinary.example/feed.xml".to_string(),
10171                ..Default::default()
10172            },
10173        )
10174        .await?;
10175        // Force the kind independently of the URL: only the column should matter.
10176        sqlx::query("UPDATE feeds SET kind = 'publication' WHERE url LIKE 'https://looks%'")
10177            .execute(&pool)
10178            .await?;
10179
10180        let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10181        assert!(due.is_empty(), "due_feeds read the URL, not the kind");
10182        assert_eq!(
10183            unpollable_feeds(&pool).await?,
10184            1,
10185            "unpollable_feeds read the URL"
10186        );
10187
10188        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10189        assert_eq!(h.feeds_tracked, 0, "poll_health read the URL, not the kind");
10190        Ok(())
10191    }
10192
10193    /// **SQL and Rust agree on what an at-URI is — case-insensitively.**
10194    ///
10195    /// This test used to pin the opposite, and pinned a bug. It asserted that a
10196    /// mixed-case `At://` row IS handed to the poller, reasoning that the Rust
10197    /// guards use a case-sensitive `strip_prefix` so "every other check treats
10198    /// it as a plain URL". They do not: URL schemes are case-insensitive, so
10199    /// `Url::parse` folds `At://` to scheme `at`, which `net::check_scheme`
10200    /// refuses — and the DID form does not parse at all. Such a row can only
10201    /// fail, every tick, forever, and be published in the `fetch` bucket as an
10202    /// unreachable publisher. That is the exact conflation the exclusion exists
10203    /// to end.
10204    ///
10205    /// Recognition is case-insensitive on both sides now. Storing one is still
10206    /// refused: `feeds.url` is UNIQUE, so two spellings of one publication are
10207    /// two rows — the same rule the canonical-handle check applies.
10208    #[tokio::test]
10209    async fn a_mixed_case_at_uri_is_unpollable_on_both_sides() -> anyhow::Result<()> {
10210        let pool = init_url("sqlite::memory:").await?;
10211        let odd = "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10212        assert!(
10213            !crate::feed::is_storable_feed_url(odd, true),
10214            "a non-canonical spelling must not be storable"
10215        );
10216        upsert_feed(
10217            &pool,
10218            &NewFeed {
10219                url: odd.to_string(),
10220                ..Default::default()
10221            },
10222        )
10223        .await?;
10224        let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10225        assert!(
10226            due.is_empty(),
10227            "a row nothing can fetch was handed to the poller: {:?}",
10228            due.iter().map(|f| &f.url).collect::<Vec<_>>()
10229        );
10230
10231        // And the boot-time clearing reaches it, so a legacy row that already
10232        // accrued errors stops counting as a broken publisher.
10233        bump_feed_errors(&pool, odd, crate::feed::FailureKind::Fetch, "refused").await?;
10234        apply_migrations(&pool).await?;
10235        let n: i64 = sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
10236            .bind(odd)
10237            .fetch_one(&pool)
10238            .await?;
10239        assert_eq!(n, 0, "the clearing skipped a mixed-case at-URI row");
10240        Ok(())
10241    }
10242
10243    /// **The global feeds ceiling counts every row, including unpollable ones
10244    /// — deliberately, and visibly.**
10245    ///
10246    /// `count_feeds` is a fifth reader of "is this an at-URI" that does NOT use
10247    /// the unpollable kinds, and that is the right call: the ceiling bounds
10248    /// STORAGE on a small box, and an unpollable row occupies a row. What was
10249    /// wrong is that the capacity it consumed appeared on no surface — `/stats`
10250    /// measures the poller and excludes them, so an operator could be at the
10251    /// cap while every page said otherwise. `unpollable_feeds` is what
10252    /// `/admin/metrics` renders to close that gap.
10253    #[tokio::test]
10254    async fn the_ceiling_counts_unpollable_rows_and_they_are_countable() -> anyhow::Result<()> {
10255        let pool = init_url("sqlite::memory:").await?;
10256        for url in [
10257            "https://real.example/feed.xml",
10258            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
10259            "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
10260        ] {
10261            upsert_feed(
10262                &pool,
10263                &NewFeed {
10264                    url: url.to_string(),
10265                    ..Default::default()
10266                },
10267            )
10268            .await?;
10269        }
10270        assert_eq!(
10271            count_feeds(&pool).await?,
10272            3,
10273            "the ceiling must bound storage, so every row counts"
10274        );
10275        assert_eq!(
10276            unpollable_feeds(&pool).await?,
10277            2,
10278            "both at-URI spellings are unpollable and must be countable"
10279        );
10280        Ok(())
10281    }
10282
10283    /// A fresh instance has no polls yet. The page must say so rather than
10284    /// rendering a zero that reads as "polled just now".
10285    #[tokio::test]
10286    async fn poll_health_on_an_empty_instance_reports_no_polls() -> anyhow::Result<()> {
10287        let pool = init_url("sqlite::memory:").await?;
10288        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10289        assert_eq!(h.feeds_tracked, 0);
10290        assert_eq!(h.last_poll_secs_ago, None);
10291        assert_eq!(h.oldest_poll_secs_ago, None);
10292        Ok(())
10293    }
10294
10295    /// A poll timestamped in the future — clock skew, or a restored backup —
10296    /// reads as "just now", never as a negative age.
10297    #[tokio::test]
10298    async fn a_future_poll_timestamp_does_not_go_negative() -> anyhow::Result<()> {
10299        let pool = init_url("sqlite::memory:").await?;
10300        feed_polled(
10301            &pool,
10302            "https://a.example/f",
10303            Some("2026-01-01T13:00:00Z"),
10304            None,
10305        )
10306        .await;
10307        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10308        assert_eq!(h.last_poll_secs_ago, Some(0));
10309        Ok(())
10310    }
10311
10312    // ── retention is a CACHE policy, not a data-retention policy ────────────
10313
10314    async fn aged_entry(pool: &SqlitePool, url: &str, days_old: i64) -> i64 {
10315        let when = (chrono::Utc::now() - chrono::Duration::days(days_old))
10316            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
10317        sqlx::query("INSERT INTO feeds (url) VALUES (?1) ON CONFLICT(url) DO NOTHING")
10318            .bind("https://f.example/feed")
10319            .execute(pool)
10320            .await
10321            .unwrap();
10322        let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds WHERE url = ?1")
10323            .bind("https://f.example/feed")
10324            .fetch_one(pool)
10325            .await
10326            .unwrap();
10327        sqlx::query("INSERT INTO entries (feed_id, guid, url, title, published, fetched_at) VALUES (?1,?2,?3,'t',?4,?4)")
10328            .bind(feed_id).bind(url).bind(url).bind(&when)
10329            .execute(pool).await.unwrap();
10330        sqlx::query_scalar("SELECT id FROM entries WHERE guid = ?1")
10331            .bind(url)
10332            .fetch_one(pool)
10333            .await
10334            .unwrap()
10335    }
10336
10337    async fn mark(pool: &SqlitePool, entry_id: i64, read: i64, starred: i64) {
10338        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')")
10339            .bind(entry_id).bind(read).bind(starred)
10340            .execute(pool).await.unwrap();
10341    }
10342
10343    /// **A STARRED article is never evicted, however old.**
10344    ///
10345    /// The starred view joins `entries`, and `entry_state` cascades on delete,
10346    /// so pruning a starred entry removed it from the starred list entirely —
10347    /// and the content is not recoverable, because a feed serves only its last
10348    /// few dozen items. The PDS keeps the saved RECORD; it has never held the
10349    /// article.
10350    #[tokio::test]
10351    async fn retention_keeps_starred_and_unread_entries() -> anyhow::Result<()> {
10352        let pool = init_url("sqlite::memory:").await?;
10353        let old_read = aged_entry(&pool, "old-read", 30).await;
10354        let old_starred = aged_entry(&pool, "old-starred", 30).await;
10355        let old_unread = aged_entry(&pool, "old-unread", 30).await;
10356        let recent_read = aged_entry(&pool, "recent-read", 1).await;
10357        mark(&pool, old_read, 1, 0).await;
10358        mark(&pool, old_starred, 1, 1).await; // read AND starred
10359        mark(&pool, old_unread, 0, 0).await;
10360        mark(&pool, recent_read, 1, 0).await;
10361
10362        let deleted = prune_old_entries(&pool, 14, 3650, 0).await?;
10363        assert_eq!(deleted, 1, "only the old, read, unstarred entry should go");
10364
10365        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10366            .fetch_all(&pool)
10367            .await?;
10368        assert_eq!(left, vec!["old-starred", "old-unread", "recent-read"]);
10369        Ok(())
10370    }
10371
10372    /// An entry nobody has interacted with at all — no `entry_state` row — is
10373    /// still evicted once it ages out. Otherwise the cache never shrinks, since
10374    /// most entries are never opened.
10375    #[tokio::test]
10376    async fn retention_evicts_entries_with_no_reader_state() -> anyhow::Result<()> {
10377        let pool = init_url("sqlite::memory:").await?;
10378        aged_entry(&pool, "untouched-old", 30).await;
10379        aged_entry(&pool, "untouched-new", 1).await;
10380        assert_eq!(prune_old_entries(&pool, 14, 3650, 0).await?, 1);
10381        Ok(())
10382    }
10383
10384    /// **A recently-polled feed is NOT made due again.**
10385    ///
10386    /// `due_feeds` treats NULL as due immediately, so an unbounded nudge from a
10387    /// page handler turned every reload of the starred view into another poll of
10388    /// those feeds — outbound amplification against third-party origins, and one
10389    /// reader monopolising a poll budget that is shared and already the binding
10390    /// constraint on user count.
10391    #[tokio::test]
10392    async fn a_recently_polled_feed_is_not_nudged_again() -> anyhow::Result<()> {
10393        let pool = init_url("sqlite::memory:").await?;
10394        let recent = "2026-01-01T11:59:00Z";
10395        let stale_before = "2026-01-01T11:00:00Z"; // one hour before "now"
10396
10397        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10398            .bind("https://fresh.example/f")
10399            .bind(recent)
10400            .bind("2026-01-01T12:59:00Z")
10401            .execute(&pool)
10402            .await?;
10403        // Polled long ago: this one SHOULD be nudged.
10404        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10405            .bind("https://stale.example/f")
10406            .bind("2026-01-01T06:00:00Z")
10407            .bind("2026-01-01T07:00:00Z")
10408            .execute(&pool)
10409            .await?;
10410
10411        mark_feed_due(&pool, "https://fresh.example/f", stale_before).await?;
10412        mark_feed_due(&pool, "https://stale.example/f", stale_before).await?;
10413
10414        let fresh: Option<String> =
10415            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://fresh.example/f'")
10416                .fetch_one(&pool)
10417                .await?;
10418        let stale: Option<String> =
10419            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://stale.example/f'")
10420                .fetch_one(&pool)
10421                .await?;
10422
10423        assert!(
10424            fresh.is_some(),
10425            "a feed polled a minute ago was made due again — a reload loop is an \
10426             amplification vector"
10427        );
10428        assert!(stale.is_none(), "a long-unpolled feed should be nudged");
10429        Ok(())
10430    }
10431
10432    /// A feed that has never been polled is always nudgeable — there is no
10433    /// recent fetch to argue it would be wasted.
10434    #[tokio::test]
10435    async fn a_never_polled_feed_is_nudged() -> anyhow::Result<()> {
10436        let pool = init_url("sqlite::memory:").await?;
10437        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, NULL, ?2)")
10438            .bind("https://new.example/f")
10439            .bind("2026-01-01T12:59:00Z")
10440            .execute(&pool)
10441            .await?;
10442        mark_feed_due(&pool, "https://new.example/f", "2026-01-01T11:00:00Z").await?;
10443        let next: Option<String> =
10444            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://new.example/f'")
10445                .fetch_one(&pool)
10446                .await?;
10447        assert!(next.is_none());
10448        Ok(())
10449    }
10450
10451    /// **The hard ceiling is the bound that sparing would otherwise remove.**
10452    ///
10453    /// "Mark unread" is a one-click control and `entries` is shared across every
10454    /// reader, so an unbounded `read = 0` exception lets one person pin rows
10455    /// permanently — and since the poller stops entirely above
10456    /// `db_size_watermark_bytes` with this DELETE as its only release valve,
10457    /// those pins could stop polling for everyone.
10458    #[tokio::test]
10459    async fn the_hard_ceiling_evicts_even_starred_and_unread() -> anyhow::Result<()> {
10460        let pool = init_url("sqlite::memory:").await?;
10461        let ancient_starred = aged_entry(&pool, "ancient-starred", 400).await;
10462        let ancient_unread = aged_entry(&pool, "ancient-unread", 400).await;
10463        let recent_starred = aged_entry(&pool, "recent-starred", 30).await;
10464        mark(&pool, ancient_starred, 1, 1).await;
10465        mark(&pool, ancient_unread, 0, 0).await;
10466        mark(&pool, recent_starred, 1, 1).await;
10467
10468        // 14-day soft window, 180-day hard ceiling.
10469        prune_old_entries(&pool, 14, 180, 0).await?;
10470
10471        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10472            .fetch_all(&pool)
10473            .await?;
10474        assert_eq!(
10475            left,
10476            vec!["recent-starred"],
10477            "past the ceiling nothing is pinned — otherwise one reader can stall the poller \
10478             for every reader"
10479        );
10480        Ok(())
10481    }
10482
10483    /// The per-feed trim spares starred entries too. It was fixed in the
10484    /// retention sweep and NOT here, which left the documented guarantee false —
10485    /// and this path runs on every poll of every feed rather than daily.
10486    #[tokio::test]
10487    async fn the_per_feed_trim_spares_starred_entries() -> anyhow::Result<()> {
10488        let pool = init_url("sqlite::memory:").await?;
10489        let old_starred = aged_entry(&pool, "old-starred", 5).await;
10490        mark(&pool, old_starred, 1, 1).await;
10491        for i in 0..5 {
10492            aged_entry(&pool, &format!("filler-{i}"), 1).await;
10493        }
10494        let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds LIMIT 1")
10495            .fetch_one(&pool)
10496            .await?;
10497
10498        // Trim hard enough that the older starred entry would be cut. The trim
10499        // runs inside `insert_entries`, so drive it the way production does.
10500        insert_entries(&pool, feed_id, &[], 2).await?;
10501
10502        let left: Vec<String> =
10503            sqlx::query_scalar("SELECT guid FROM entries WHERE guid = 'old-starred'")
10504                .fetch_all(&pool)
10505                .await?;
10506        assert_eq!(
10507            left,
10508            vec!["old-starred"],
10509            "the per-feed trim evicted a starred entry"
10510        );
10511        Ok(())
10512    }
10513
10514    /// **When more entries are starred than the cap, the NEWEST starred ones
10515    /// are spared.** The sparing subquery orders by date and takes `cap`; the
10516    /// existing tests seed one starred row (fewer than the cap, so the order
10517    /// never chooses) or assert only a count. With `DESC` flipped to `ASC` the
10518    /// suite stayed green — and in production the trim would spare the OLDEST
10519    /// starred articles and evict the newest, on every poll of every feed.
10520    #[tokio::test]
10521    async fn the_trim_spares_the_newest_starred_entries_when_over_cap() -> anyhow::Result<()> {
10522        let pool = init_url("sqlite::memory:").await?;
10523        // Five starred entries, one per day, cap of two: only the two newest
10524        // may survive.
10525        let mut ids = Vec::new();
10526        for days_old in 1..=5 {
10527            let id = aged_entry(&pool, &format!("starred-{days_old}"), days_old).await;
10528            mark(&pool, id, 1, 1).await;
10529            ids.push((days_old, id));
10530        }
10531        let feed_id: i64 = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
10532            .bind(ids[0].1)
10533            .fetch_one(&pool)
10534            .await?;
10535        insert_entries(&pool, feed_id, &[], 2).await?;
10536
10537        let mut survivors: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
10538            .fetch_all(&pool)
10539            .await?;
10540        survivors.sort();
10541        assert_eq!(
10542            survivors,
10543            vec!["starred-1".to_string(), "starred-2".to_string()],
10544            "the trim spared the wrong starred entries"
10545        );
10546        Ok(())
10547    }
10548}