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);
288-- The list and prev/next queries order on `COALESCE(published, fetched_at)`
289-- (#187). Measured on the real query shape (LEFT JOIN entry_state, EXISTS
290-- sub_ref), this index serves them as well as it served bare `published`; a
291-- `(feed_id, published, fetched_at)` replacement was tried and was ~3.8x
292-- slower on the default prev/next query, which never chose it (review of #213).
293CREATE INDEX IF NOT EXISTS idx_entries_feed_published ON entries (feed_id, published);
294
295CREATE TABLE IF NOT EXISTS entry_state (
296    did        TEXT NOT NULL,
297    entry_id   INTEGER NOT NULL REFERENCES entries (id) ON DELETE CASCADE,
298    read       INTEGER NOT NULL DEFAULT 0,
299    starred    INTEGER NOT NULL DEFAULT 0,
300    updated_at TEXT NOT NULL,
301    PRIMARY KEY (did, entry_id)
302);
303CREATE INDEX IF NOT EXISTS idx_entry_state_did_read ON entry_state (did, read);
304-- The FK child key. `entry_id` is the TRAILING column of the primary key, so
305-- without this index it is not the leading column of anything and SQLite must
306-- FULL SCAN entry_state for EVERY row deleted from `entries` to service
307-- ON DELETE CASCADE.
308--
309-- That is not theoretical. Measured on 600k entry_state rows: 500 deletes took
310-- 10.3s and 2,000 took 38.3s, against a busy_timeout of 5s — so any retention
311-- sweep removing more than roughly 260 entries made every concurrent writer
312-- (star, mark-read, OAuth session write) fail with SQLITE_BUSY. With this index
313-- the same 32,850-row delete goes from ~10 minutes to 0.7s.
314--
315-- It also fixes the per-feed trim, whose starred-sparing subquery scans
316-- entry_state on every poll of every feed and scales with TOTAL rows across all
317-- users rather than with the feed being trimmed (2ms -> 21ms at 1M rows).
318CREATE INDEX IF NOT EXISTS idx_entry_state_entry_id ON entry_state (entry_id);
319
320-- Per-DID subscription projection. The shared `feeds`/`entries` cache is
321-- deduped by URL and NOT owned by any single DID; `sub_ref` records which
322-- feeds a given DID actually subscribes to (mirrored from the caller's PDS
323-- subscription set on every resolve/sync). Every entry/feed READ and every
324-- read/star MUTATION is scoped through this table so one user can never read
325-- or mutate another user's cached articles. Rows are refreshed by
326-- `replace_sub_refs`.
327CREATE TABLE IF NOT EXISTS sub_ref (
328    did     TEXT NOT NULL,
329    feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
330    PRIMARY KEY (did, feed_id)
331);
332CREATE INDEX IF NOT EXISTS idx_sub_ref_feed ON sub_ref (feed_id);
333
334CREATE TABLE IF NOT EXISTS read_cursor (
335    did          TEXT NOT NULL,
336    feed_url     TEXT NOT NULL,
337    read_through TEXT,
338    read_ids     TEXT NOT NULL DEFAULT '[]',
339    unread_ids   TEXT NOT NULL DEFAULT '[]',
340    dirty        INTEGER NOT NULL DEFAULT 0,
341    pds_created  INTEGER NOT NULL DEFAULT 0,
342    updated_at   TEXT NOT NULL,
343    PRIMARY KEY (did, feed_url)
344);
345CREATE INDEX IF NOT EXISTS idx_read_cursor_dirty ON read_cursor (did, dirty);
346-- The (did, feed_url) PRIMARY KEY can't serve a feed_url-only lookup (did is the
347-- leading column). The retention path's orphan-cursor cleanup filters cursors by
348-- feed_url alone, so give it an index.
349CREATE INDEX IF NOT EXISTS idx_read_cursor_feed_url ON read_cursor (feed_url);
350
351CREATE TABLE IF NOT EXISTS beta_access (
352    did              TEXT PRIMARY KEY,
353    handle           TEXT,
354    granted_by       TEXT NOT NULL,
355    granted_at       INTEGER NOT NULL,
356    invite_code_used TEXT
357);
358
359CREATE TABLE IF NOT EXISTS invite_codes (
360    code         TEXT PRIMARY KEY,
361    creator_did  TEXT NOT NULL,
362    status       TEXT NOT NULL,
363    invitee_did  TEXT,
364    -- The follower DID a bot-minted claim was minted FOR (recorded at mint time,
365    -- distinct from `invitee_did` which is stamped at redeem). This is the
366    -- server-side idempotency key: a second `POST /bot/claims` for a DID that
367    -- already holds an outstanding active code returns the SAME code instead of
368    -- minting a duplicate, so a bot-host state loss cannot re-mint per follower.
369    intended_did TEXT,
370    created_at   INTEGER NOT NULL,
371    expires_at   INTEGER NOT NULL,
372    redeemed_at  INTEGER
373);
374CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
375-- NOTE: the `intended_did` indexes are created in `apply_migrations`, AFTER the
376-- `intended_did` column is ensured. They MUST NOT live in this base SCHEMA batch:
377-- on an existing pre-0.2.2 volume the `CREATE TABLE IF NOT EXISTS invite_codes`
378-- above is a no-op (the table already exists without `intended_did`), so a
379-- `CREATE INDEX ... (intended_did, ...)` here would fail with "no such column"
380-- and crash-loop the boot before migrations ever run.
381
382-- Network-observation counters (v0.2.8, design/NETWORK-SPEC.md §4.3). One row
383-- per (metric, relay): the adoption probe records how many repos a given relay
384-- has INDEXED as holding a collection. We store the COUNT, never the DID list —
385-- persisting the DIDs would build a durable register of "accounts that use an
386-- RSS reader" on our disk for a feature whose only output is an integer. This
387-- table is a PROJECTION, not a source of truth: `DROP TABLE` it and the next
388-- probe rebuilds it, and nothing in the reader path reads it. Bounded forever at
389-- (metrics × relays) rows, so it never interacts with the DB-size watermark.
390CREATE TABLE IF NOT EXISTS network_stat (
391    key         TEXT NOT NULL,   -- e.g. 'adoption.subscription'
392    source      TEXT NOT NULL,   -- the relay host the number came from
393    value       INTEGER NOT NULL,
394    truncated   INTEGER NOT NULL DEFAULT 0,
395    observed_at TEXT NOT NULL,
396    PRIMARY KEY (key, source)
397);
398-- Repo-operation timings, for comparing the two backends across a CUTOVER.
399--
400-- Persisted rather than held in memory because flipping the backend requires a
401-- restart, and an in-memory table would lose the outgoing backend's numbers at
402-- exactly the moment they became worth comparing against. These rows are the
403-- only reason a "side by side" table can show two backends at once.
404--
405-- `repo_timing` is a bounded window of recent samples (pruned per backend+op);
406-- `repo_timing_total` carries the all-time counts, which must survive that
407-- pruning or a long-running backend would appear to have served fewer calls
408-- than a fresh one.
409CREATE TABLE IF NOT EXISTS repo_timing (
410    id       INTEGER PRIMARY KEY AUTOINCREMENT,
411    backend  TEXT    NOT NULL,
412    op       TEXT    NOT NULL,
413    micros   INTEGER NOT NULL,
414    ok       INTEGER NOT NULL,
415    at       INTEGER NOT NULL
416);
417
418CREATE INDEX IF NOT EXISTS idx_repo_timing_key ON repo_timing(backend, op, id);
419
420CREATE TABLE IF NOT EXISTS repo_timing_total (
421    backend    TEXT    NOT NULL,
422    op         TEXT    NOT NULL,
423    ok_count   INTEGER NOT NULL DEFAULT 0,
424    err_count  INTEGER NOT NULL DEFAULT 0,
425    PRIMARY KEY (backend, op)
426);
427
428"#;
429
430/// RFC3339 timestamp for "now" (UTC, seconds precision), used as the default for
431/// `*_at` columns. Uses `chrono` to match the shape written by [`crate::feed`]
432/// and [`crate::web`] (one timestamp format across the whole crate).
433fn now_rfc3339() -> String {
434    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
435}
436
437/// Open the per-DID SQLite cache described by [`Config`] (its `db_path`), run
438/// schema creation, and return the pool.
439///
440/// This is the entrypoint `main` calls: it derives the sqlx SQLite URL from the
441/// configured filesystem path and delegates to [`init_url`]. Kept separate from
442/// [`init_url`] so tests can open an in-memory database directly.
443pub async fn init(config: &Config) -> Result<Pool> {
444    // sqlx wants a `sqlite://<path>` URL; build it from the configured path.
445    let db_url = format!("sqlite://{}", config.db_path.display());
446    init_url(&db_url).await
447}
448
449/// Open (creating if needed) the SQLite database at `db_url`, run schema
450/// creation, and return a connection pool.
451///
452/// `db_url` is a sqlx SQLite URL, e.g. `sqlite://featherreader.db` or
453/// `sqlite::memory:` for an ephemeral in-memory database. The file is created
454/// if it does not exist; WAL journaling is enabled for on-disk databases and
455/// foreign keys are enforced on every connection.
456/// Ceiling the WAL is truncated back to at each checkpoint.
457///
458/// The WAL lives on the same volume as the database and counts against the same
459/// 1 GB, but nothing bounded it: SQLite grows the WAL to fit the largest
460/// transaction it has ever seen and never shrinks it again without this limit.
461const WAL_SIZE_LIMIT_BYTES: i64 = 64 * 1024 * 1024;
462
463pub async fn init_url(db_url: &str) -> Result<Pool> {
464    // An in-memory DB must run on a SINGLE connection: each `:memory:` connection
465    // is a *separate* database, and a multi-connection in-memory pool can also
466    // deadlock a writer against an idle pooled connection's shared-cache table
467    // read-lock (SQLITE_LOCKED, code 262 — which `busy_timeout` does NOT retry;
468    // seen as a Linux-only flaky failure in redeem_code's UPDATE). On-disk uses
469    // WAL + a 5-connection pool as normal.
470    let is_memory = db_url.contains(":memory:");
471    let mut opts = SqliteConnectOptions::from_str(db_url)
472        .with_context(|| format!("invalid sqlite url: {db_url}"))?
473        .create_if_missing(true)
474        .foreign_keys(true);
475    // WAL is a no-op / unsupported for :memory:, so only request it on-disk.
476    if !is_memory {
477        opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
478        // **Incremental auto-vacuum, set at CREATION.**
479        //
480        // `auto_vacuum` was read by `reclaim` and never set anywhere, so every
481        // database ran in SQLite's default NONE mode and `reclaim` always took
482        // its full-`VACUUM` branch — daily, and again after every prune. A full
483        // VACUUM needs free disk roughly equal to the live database because it
484        // writes a whole new file, which is exactly what is scarce under the
485        // disk pressure that triggers a sweep; on a ~700 MiB database on a 1 GB
486        // volume it cannot complete at all.
487        //
488        // This pragma only takes effect on a database with no tables yet, so it
489        // fixes NEW instances permanently and does nothing to existing ones —
490        // deliberately. Changing it on a populated database requires running the
491        // very full VACUUM that is unsafe here, so that is a separate,
492        // operator-invoked step: see [`migrate_to_incremental_vacuum`].
493        opts = opts.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental);
494        // Truncate the WAL back down at checkpoints. Without a limit, a WAL
495        // grown once by a single large transaction stays that size for the life
496        // of the file — permanently occupying volume the watermark is trying to
497        // protect. The batched retention deletes keep transactions small now, so
498        // in practice the WAL should rarely approach this; the limit is what
499        // makes that a guarantee rather than a hope.
500        opts = opts.pragma("journal_size_limit", WAL_SIZE_LIMIT_BYTES.to_string());
501    }
502    // Under a concurrent write burst (the poller's insert_entries tx racing the
503    // web layer's mark_read / redeem_code tx) SQLite would otherwise return
504    // SQLITE_BUSY the instant a writer holds the lock. `busy_timeout` makes a
505    // blocked connection WAIT (retry) for up to this long before erroring, so
506    // short lock contention resolves transparently instead of surfacing a
507    // spurious failure. Mirrors the OAuth sidecar's `stores.ts`
508    // (`PRAGMA busy_timeout = 5000`). 5 s is comfortably above any single
509    // FeatherReader transaction.
510    opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
511    // Quiet sqlx's per-statement query logging.
512    opts = opts.log_statements(tracing::log::LevelFilter::Debug);
513
514    let pool = SqlitePoolOptions::new()
515        // Keep at least one connection alive so an in-memory DB isn't dropped
516        // (each `:memory:` connection is a *separate* database otherwise).
517        .min_connections(1)
518        .max_connections(if is_memory { 1 } else { 5 })
519        .connect_with(opts)
520        .await
521        .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
522
523    init_schema(&pool).await?;
524    Ok(pool)
525}
526
527/// Run the idempotent schema creation. Split out so callers/tests can (re)apply
528/// it against an already-open pool.
529pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
530    // `execute` runs the multi-statement batch (sqlite allows this).
531    sqlx::query(SCHEMA)
532        .execute(pool)
533        .await
534        .context("failed to create schema")?;
535    apply_migrations(pool).await?;
536    // The Rust OAuth client's tables live in the same database. Created
537    // UNCONDITIONALLY, not only when that backend is selected: the tables are
538    // empty and harmless under the sidecar, whereas creating them lazily would
539    // make the first request after a cutover flip fail with "no such table" --
540    // at the one moment nobody wants to discover a migration was missed.
541    crate::oauth::store::init_schema(pool)
542        .await
543        .context("failed to create the OAuth schema")?;
544    Ok(())
545}
546
547/// Apply additive, idempotent migrations to bring an EXISTING database up to the
548/// current [`SCHEMA`]. `CREATE TABLE IF NOT EXISTS` never alters a table that
549/// already exists, so a column added to a shipped table must be back-filled here
550/// (SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe `table_info` first).
551async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
552    // feeds.consecutive_errors — drives the exponential poll backoff. Older DBs
553    // predate the column; add it (defaulting to 0) if it is missing.
554    ensure_column(
555        pool,
556        "PRAGMA table_info(feeds)",
557        "consecutive_errors",
558        "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
559    )
560    .await?;
561    // feeds.last_error_kind / feeds.last_error — WHY a feed is failing, not just
562    // how often. `consecutive_errors` recorded a count and nothing else, which is
563    // how a systematic defect across sixty feeds stayed indistinguishable from
564    // sixty dead blogs until #159: every one of them was our own 304 handling,
565    // and the table could not say so. Nullable, and NULL once a poll succeeds.
566    ensure_column(
567        pool,
568        "PRAGMA table_info(feeds)",
569        "last_error_kind",
570        "ALTER TABLE feeds ADD COLUMN last_error_kind TEXT",
571    )
572    .await?;
573    ensure_column(
574        pool,
575        "PRAGMA table_info(feeds)",
576        "last_error",
577        "ALTER TABLE feeds ADD COLUMN last_error TEXT",
578    )
579    .await?;
580
581    // feeds.kind — what the poller does with a row. Older DBs predate it and
582    // get `'rss'` from the DEFAULT, which is wrong for the at:// rows, so it is
583    // back-filled below.
584    ensure_column(
585        pool,
586        "PRAGMA table_info(feeds)",
587        "kind",
588        "ALTER TABLE feeds ADD COLUMN kind TEXT NOT NULL DEFAULT 'rss'",
589    )
590    .await?;
591    // Here, not in the base SCHEMA batch: it names a column that only exists
592    // after the line above. See the note beside `idx_feeds_next_poll`.
593    sqlx::query("CREATE INDEX IF NOT EXISTS idx_feeds_kind ON feeds (kind)")
594        .execute(pool)
595        .await
596        .context("creating idx_feeds_kind")?;
597
598    // **Re-derived in Rust, every row, every start — not translated once.**
599    //
600    // `kind` is a pure function of `url`, so it is a cache, and a cache that is
601    // only ever written forward goes stale the moment the function changes.
602    // The first version of this was a one-directional SQL `UPDATE` carrying its
603    // own copy of the rule as a string predicate: it agreed with
604    // `FeedKind::of` on the day it was written, translated `rss` to
605    // `publication` and never the reverse, and had no way to notice either
606    // fact. Asking the Rust classifier about every row instead means the column
607    // cannot disagree with the one function that defines it, and a future kind
608    // — or a corrected rule — needs no migration of its own.
609    //
610    // Cheap by shape, not by assumption: it writes only rows that are actually
611    // wrong, so the steady state is a single scan of a table that holds one row
612    // per subscribed feed.
613    let rows = sqlx::query("SELECT id, url, kind FROM feeds")
614        .fetch_all(pool)
615        .await
616        .context("reading feeds to re-derive kind")?;
617    let mut tx = pool.begin().await.context("begin kind re-derivation")?;
618    let (mut to_pollable, mut to_unpollable, mut unreadable) = (0u64, 0u64, 0u64);
619    for row in rows {
620        // **A row we cannot read is skipped, not fatal.** This runs on the boot
621        // path, so anything that returns `Err` here is the difference between a
622        // wedged poller and a site that will not start. A `url` or `kind` that
623        // is not decodable as text takes no opinion from us and keeps whatever
624        // it has; every reader downstream already treats an unknown kind as
625        // unpollable. Nothing sqlx writes produces such a row — it binds `&str`
626        // as TEXT everywhere — so reaching this means the file was edited by
627        // hand, which is exactly when refusing to boot is the least helpful
628        // thing to do.
629        let (Ok(id), Ok(url), Ok(kind)) = (
630            row.try_get::<i64, _>("id"),
631            row.try_get::<String, _>("url"),
632            row.try_get::<String, _>("kind"),
633        ) else {
634            unreadable += 1;
635            continue;
636        };
637        let want = crate::feed::FeedKind::of(&url);
638        if kind == want.as_str() {
639            continue;
640        }
641        sqlx::query("UPDATE feeds SET kind = ?1 WHERE id = ?2")
642            .bind(want.as_str())
643            .bind(id)
644            .execute(&mut *tx)
645            .await
646            .with_context(|| format!("re-deriving kind for feed {id}"))?;
647        if crate::feed::FeedKind::POLLABLE.contains(&want) {
648            to_pollable += 1;
649        } else {
650            // **Declaring a row unpollable orphans its poll state, so clear
651            // it.** A backoff horizon and an error count belong to a feed the
652            // scheduler selects; on a row it will never select again they are
653            // dead, and not inert. They are hidden from `/stats` and the cause
654            // histogram, which filter on kind, so they rot unseen — and if a
655            // later rule change makes the row pollable again it resumes at
656            // `backoff_for(n)` on an `n` earned under a classification that no
657            // longer applies, which for seven prior errors is a first retry ten
658            // hours out instead of five minutes.
659            //
660            // Narrower than the step below, deliberately: that one clears only
661            // rows we never polled, on the grounds that a real feed's history
662            // still means something. This clears rows whose history can no
663            // longer mean anything, because nothing will add to it or act on
664            // it.
665            sqlx::query(
666                "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, \
667                 last_error = NULL, next_poll = NULL WHERE id = ?1",
668            )
669            .bind(id)
670            .execute(&mut *tx)
671            .await
672            .with_context(|| format!("clearing orphaned poll state for feed {id}"))?;
673            to_unpollable += 1;
674        }
675    }
676    tx.commit().await.context("commit kind re-derivation")?;
677    // Quiet in the steady state, which is every boot where nothing changed.
678    // Split by direction because the two mean opposite things to an operator:
679    // one puts feeds back in the poller's queue, the other takes them out of
680    // every figure `/stats` reports.
681    if to_pollable > 0 || to_unpollable > 0 {
682        tracing::info!(
683            to_pollable,
684            to_unpollable,
685            "feeds.kind re-derived from the URL"
686        );
687    }
688    if unreadable > 0 {
689        tracing::warn!(
690            unreadable,
691            "feeds rows are not readable as text; their kind was left alone"
692        );
693    }
694
695    // **Clear failure counts on rows we never actually polled.**
696    //
697    // `due_feeds` excludes them by kind (see `feed::FeedKind`) — but rows
698    // subscribed before the scheme check already carry the errors OUR refusal
699    // produced. Left alone they would count as failing forever, since no poll
700    // that could clear them will ever be scheduled.
701    //
702    // A real feed's history is untouched: it still means something. The
703    // recorded reason goes with the count: a row with no errors must carry no
704    // reason, which is what `reset_feed_errors` promises and a test asserts.
705    //
706    // **Idempotent by predicate.** `last_polled` is set only by a successful
707    // poll — `bump_feed_errors` never touches it — so `last_polled IS NULL`
708    // selects exactly the rows whose every error came from our own refusal.
709    // A row a wired standard.site reader has fetched once keeps its later
710    // failures across restarts; a row that only ever failed under the refusal
711    // is cleared at every boot, including after a rollback to a build that
712    // polled it. A version stamp was the first design and left that rollback
713    // case a permanent hole (re-accumulated errors hidden by the filters,
714    // never cleared). Trade-off accepted: a publication that has never once
715    // succeeded restarts its backoff at the floor on every boot.
716    sqlx::query(sqlx::AssertSqlSafe(format!(
717        "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
718         WHERE kind NOT IN ({POLLABLE_KINDS_SQL}) AND last_polled IS NULL \
719         AND consecutive_errors > 0"
720    )))
721    .execute(pool)
722    .await
723    .context("clearing error counts on unpollable at:// feeds")?;
724    // read_cursor.pds_created — tracks whether a feed's readState record has been
725    // created in the PDS, so the first flush emits a `create` (not a bare
726    // `update`, which errors on a not-yet-existing record). Older DBs predate it.
727    ensure_column(
728        pool,
729        "PRAGMA table_info(read_cursor)",
730        "pds_created",
731        "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
732    )
733    .await?;
734    // invite_codes.intended_did — the follower DID a bot claim was minted for, the
735    // server-side idempotency key for `POST /bot/claims`. Older DBs (before the
736    // follow→invite bot) predate it; it is nullable (browser/admin-minted codes
737    // leave it NULL).
738    ensure_column(
739        pool,
740        "PRAGMA table_info(invite_codes)",
741        "intended_did",
742        "ALTER TABLE invite_codes ADD COLUMN intended_did TEXT",
743    )
744    .await?;
745    // Indexes on `intended_did` are created HERE (not in the base SCHEMA batch)
746    // because they reference a column that only exists after the migration above.
747    // On an existing pre-0.2.2 DB the `invite_codes` CREATE TABLE is a no-op, so
748    // an index on `intended_did` in SCHEMA would fail before this migration ran
749    // (that was blocker B1). All are `IF NOT EXISTS`, so re-running is a no-op.
750    //
751    // Look up an outstanding active claim by the DID it was minted for (bot dedupe).
752    sqlx::query(
753        "CREATE INDEX IF NOT EXISTS idx_invite_codes_intended \
754         ON invite_codes (intended_did, status)",
755    )
756    .execute(pool)
757    .await
758    .context("creating idx_invite_codes_intended")?;
759    // Enforce at MOST one outstanding active claim per intended DID. This makes
760    // the bot's dedupe check-then-mint race-safe: two concurrent `POST /bot/claims`
761    // for the same follower can no longer both insert an active code (the second
762    // INSERT hits this unique constraint). Partial so it only constrains active
763    // bot-minted rows — redeemed/expired rows and NULL-intended (admin/browser)
764    // codes are unconstrained. (Blocker/should-fix S4.)
765    sqlx::query(
766        "CREATE UNIQUE INDEX IF NOT EXISTS idx_invite_codes_intended_active \
767         ON invite_codes (intended_did) \
768         WHERE intended_did IS NOT NULL AND status = 'active'",
769    )
770    .execute(pool)
771    .await
772    .context("creating idx_invite_codes_intended_active")?;
773
774    // **Re-date rows stored with a future date before ingest refused them**
775    // (#188). An item dated 2999 that has since left its feed is never polled
776    // again to be corrected, so it would stay first in the list and survive the
777    // per-feed cap. Cleared, `fetched_at` dates it. The same bound ingest uses;
778    // a no-op once there are none.
779    let ceiling = (chrono::Utc::now()
780        + chrono::Duration::days(crate::feed::MAX_FUTURE_PUBLISHED_DAYS))
781    .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
782    sqlx::query("UPDATE entries SET published = NULL WHERE published > ?1")
783        .bind(&ceiling)
784        .execute(pool)
785        .await
786        .context("clearing stored future publication dates")?;
787    Ok(())
788}
789
790/// Add a column via `alter_sql` iff `info_sql` (a `PRAGMA table_info(<table>)`)
791/// does not already report `column`. All three SQL args are hard-coded internal
792/// literals (never user input), so they are safe `&'static str`s — the table name
793/// can't be a bind parameter in `PRAGMA`, which is why they're passed whole.
794async fn ensure_column(
795    pool: &SqlitePool,
796    info_sql: &'static str,
797    column: &str,
798    alter_sql: &'static str,
799) -> Result<()> {
800    let rows = sqlx::query(info_sql)
801        .fetch_all(pool)
802        .await
803        .with_context(|| format!("{info_sql} failed"))?;
804    let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
805    if !present {
806        sqlx::query(alter_sql)
807            .execute(pool)
808            .await
809            .with_context(|| format!("adding column {column} via {alter_sql}"))?;
810    }
811    Ok(())
812}
813
814/// Insert a feed by URL, or update its metadata if the URL already exists.
815/// Returns the feed's row id (existing or newly assigned).
816///
817/// EVERY updatable column is COALESCE'd, so `None` means "leave alone" for all
818/// of them and a partial upsert cannot clobber a field it never mentioned.
819///
820/// `etag`/`last_modified` were the exception until now, and the exception was
821/// silently disabling conditional GET for the entire instance. `set_next_poll`
822/// in the scheduler supplies only `url` + `next_poll` after every single poll,
823/// which wrote both validators back to NULL — so `304 Not Modified` was
824/// unreachable and every feed was re-downloaded, re-parsed, re-sanitised and
825/// re-inserted in full, hourly, forever. `feed::touch_polled` had discovered the
826/// same trap earlier and worked around it in its own caller by re-reading the
827/// row first; that local fix is what let the next caller walk into it.
828///
829/// A stale validator is not a hazard: if the origin no longer issues one it
830/// ignores our `If-None-Match` and returns `200`, and if it still matches then
831/// `304` was the correct answer anyway.
832pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
833    let row = sqlx::query(
834        r#"
835        INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll, kind)
836        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
837        ON CONFLICT (url) DO UPDATE SET
838            title         = COALESCE(excluded.title, feeds.title),
839            site_url      = COALESCE(excluded.site_url, feeds.site_url),
840            etag          = COALESCE(excluded.etag, feeds.etag),
841            last_modified = COALESCE(excluded.last_modified, feeds.last_modified),
842            last_polled   = COALESCE(excluded.last_polled, feeds.last_polled),
843            next_poll     = COALESCE(excluded.next_poll, feeds.next_poll),
844            -- Not COALESCE: `kind` is derived from the URL, and `excluded`
845            -- always carries the current answer. Preserving the stored value
846            -- would make a row's classification a function of when it was
847            -- first subscribed rather than of what it is.
848            kind          = excluded.kind
849        RETURNING id
850        "#,
851    )
852    .bind(&feed.url)
853    .bind(&feed.title)
854    .bind(&feed.site_url)
855    .bind(&feed.etag)
856    .bind(&feed.last_modified)
857    .bind(&feed.last_polled)
858    .bind(&feed.next_poll)
859    // Decided once, in Rust, and never re-derived from the URL by SQL.
860    .bind(crate::feed::FeedKind::of(&feed.url).as_str())
861    .fetch_one(pool)
862    .await
863    .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
864
865    Ok(row.get::<i64, _>("id"))
866}
867
868/// Fetch a feed by its URL, if present.
869pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
870    let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
871        .bind(url)
872        .fetch_optional(pool)
873        .await
874        .with_context(|| format!("get_feed_by_url failed for {url}"))?;
875    Ok(feed)
876}
877
878/// The `kind` values the scheduler may select, as a SQL list.
879///
880/// Pinned against [`crate::feed::FeedKind::POLLABLE`] by
881/// `the_sql_kind_list_matches_the_rust_one` — a literal here and a slice there
882/// is exactly the drift the column was introduced to end, so the two are
883/// asserted equal rather than trusted. Wiring the standard.site reader means
884/// changing both, and that test is what makes forgetting one a failure.
885pub(crate) const POLLABLE_KINDS_SQL: &str = "'rss', 'publication'";
886
887/// The `kind` values the retention **window** applies to, as a SQL list.
888///
889/// Pinned against [`crate::feed::FeedKind::AGED`] by
890/// `the_sql_aged_kind_list_matches_the_rust_one`, for the same reason
891/// [`POLLABLE_KINDS_SQL`] is pinned against `POLLABLE`.
892///
893/// Why a publication is not in it: see `FeedKind::AGED`. Measured — a 14-day
894/// window stored zero rows from every real publication tried, because their
895/// newest documents were 109 to 241 days old.
896pub(crate) const AGED_KINDS_SQL: &str = "'rss'";
897
898/// How many rows the poller will never select — the capacity consumed by feeds
899/// that cannot be fetched.
900///
901/// Rendered on `/admin/metrics` because the global ceiling counts these rows
902/// (see [`count_feeds`]) while `/stats` does not, so without this the cap could
903/// be reached with every public number saying otherwise.
904pub async fn unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
905    sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
906        "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
907    )))
908    .fetch_one(pool)
909    .await
910    .context("counting unpollable feeds")
911}
912
913/// How many rows the poller will never select. Test-only: the assertion the
914/// at:// tests make, spelled once, against the predicate the code uses.
915#[cfg(test)]
916pub(crate) async fn count_unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
917    sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
918        "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
919    )))
920    .fetch_one(pool)
921    .await
922    .context("counting unpollable feeds")
923}
924
925/// The scheduler's hot query: feeds whose `next_poll` is due (`<= as_of`, or
926/// never polled), oldest-due first. `as_of` is an RFC3339 timestamp.
927pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
928    let sql = format!(
929        r#"
930        SELECT * FROM feeds
931        WHERE (next_poll IS NULL OR next_poll <= ?1)
932          -- Only POLLABLE kinds are due; an `unsupported` row is skipped, not
933          -- failed. The why lives on `feed::FeedKind::POLLABLE`.
934          AND kind IN ({POLLABLE_KINDS_SQL})
935        ORDER BY next_poll IS NOT NULL, next_poll ASC
936        LIMIT ?2
937        "#
938    );
939    let feeds = sqlx::query_as::<_, Feed>(sqlx::AssertSqlSafe(sql))
940        .bind(as_of)
941        .bind(limit)
942        .fetch_all(pool)
943        .await
944        .context("due_feeds failed")?;
945    Ok(feeds)
946}
947
948/// One failing feed, named, for the ADMIN view only.
949///
950/// The public `/stats` histogram is counts by cause and nothing else, by that
951/// page's own stated promise. This is the other half: the coarse bucket
952/// `fetch` covers DNS failure, timeout, SSRF refusal and — as #159 proved —
953/// this reader's own bugs, so a count alone cannot separate "the publishers are
954/// gone" from "we are broken". The detail can, and it lives behind the
955/// `ALLOWED_DIDS` gate where per-feed data is already permitted.
956#[derive(Debug, Clone, PartialEq, Eq)]
957pub struct FailingFeed {
958    pub url: String,
959    pub consecutive_errors: i64,
960    /// `None` for a row that predates the column — see the `unknown` bucket.
961    pub kind: Option<String>,
962    pub detail: Option<String>,
963}
964
965/// Every currently-failing feed with its recorded cause, worst first.
966///
967/// **Admin-gated callers only.** Bounded because this renders into one response
968/// and a large instance should not be able to make that response unbounded.
969pub async fn failing_feeds(pool: &SqlitePool, limit: i64) -> Result<Vec<FailingFeed>> {
970    // The same exclusion as `poll_health`: a row the poller never selects
971    // can never have its errors cleared, so listing it here would pin it to
972    // the top of the operator's page for good.
973    let sql = format!(
974        r#"
975        SELECT url, consecutive_errors, last_error_kind, last_error
976        FROM feeds
977        WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
978        ORDER BY consecutive_errors DESC, url ASC
979        LIMIT ?1
980        "#
981    );
982    let rows: Vec<(String, i64, Option<String>, Option<String>)> =
983        sqlx::query_as(sqlx::AssertSqlSafe(sql))
984            .bind(limit)
985            .fetch_all(pool)
986            .await
987            .context("listing failing feeds")?;
988    Ok(rows
989        .into_iter()
990        .map(|(url, consecutive_errors, kind, detail)| FailingFeed {
991            url,
992            consecutive_errors,
993            kind,
994            detail,
995        })
996        .collect())
997}
998
999/// Cap on the stored `last_error` detail. Remote text on an unattended path.
1000const MAX_ERROR_DETAIL_CHARS: usize = 300;
1001
1002/// Record a poll FAILURE for a feed: bump its `consecutive_errors` by one and
1003/// return the NEW count. The count drives the exponential poll backoff, so a
1004/// persistently-failing feed spaces its retries out toward the ceiling instead of
1005/// hammering the 5-minute floor forever. Reset to 0 by [`reset_feed_errors`] on
1006/// any success/304.
1007pub async fn bump_feed_errors(
1008    pool: &SqlitePool,
1009    url: &str,
1010    kind: crate::feed::FailureKind,
1011    detail: &str,
1012) -> Result<i64> {
1013    let row = sqlx::query(
1014        "UPDATE feeds SET consecutive_errors = consecutive_errors + 1, \
1015         last_error_kind = ?2, last_error = ?3 \
1016         WHERE url = ?1 RETURNING consecutive_errors",
1017    )
1018    .bind(url)
1019    .bind(kind.as_str())
1020    // **Truncated.** This is a remote server's error text on an unattended path;
1021    // an upstream that returns a megabyte of prose should cost a bounded row,
1022    // not an unbounded one.
1023    .bind(
1024        detail
1025            .chars()
1026            .take(MAX_ERROR_DETAIL_CHARS)
1027            .collect::<String>(),
1028    )
1029    .fetch_optional(pool)
1030    .await
1031    .with_context(|| format!("bump_feed_errors failed for {url}"))?;
1032    // If the feed row somehow vanished, treat it as the first error.
1033    Ok(row
1034        .map(|r| r.get::<i64, _>("consecutive_errors"))
1035        .unwrap_or(1))
1036}
1037
1038/// Schedule a feed's next poll `delay` from now.
1039///
1040/// Lived as a private fn in the scheduler until `web::add_subscription`
1041/// needed it too: a poll taken off the scheduler settled the error columns but
1042/// never rescheduled, so a re-subscribed working feed stayed parked on its stale
1043/// backoff horizon for up to 24h. One implementation, two callers.
1044///
1045/// `upsert_feed` COALESCEs unset fields, so supplying only url + next_poll bumps
1046/// the schedule without clobbering title/validators/last_polled.
1047pub async fn set_next_poll(pool: &SqlitePool, url: &str, delay: std::time::Duration) -> Result<()> {
1048    let next = chrono::Utc::now()
1049        + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::hours(1));
1050    let next_poll = next.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1051    let nf = NewFeed {
1052        url: url.to_string(),
1053        next_poll: Some(next_poll),
1054        ..Default::default()
1055    };
1056    upsert_feed(pool, &nf).await.map(|_| ())
1057}
1058
1059/// [`due_feeds`] for one kind only. The RSS poller and the publication poller
1060/// each select their own, so neither can be held by the other's reads.
1061pub async fn due_feeds_of_kind(
1062    pool: &SqlitePool,
1063    as_of: &str,
1064    kind: crate::feed::FeedKind,
1065    limit: i64,
1066) -> Result<Vec<Feed>> {
1067    sqlx::query_as::<_, Feed>(
1068        "SELECT * FROM feeds WHERE (next_poll IS NULL OR next_poll <= ?1) AND kind = ?2 \
1069         ORDER BY next_poll IS NOT NULL, next_poll ASC LIMIT ?3",
1070    )
1071    .bind(as_of)
1072    .bind(kind.as_str())
1073    .bind(limit)
1074    .fetch_all(pool)
1075    .await
1076    .context("due_feeds_of_kind failed")
1077}
1078
1079/// Spread the first polls of never-polled `kind` rows across `spread`.
1080///
1081/// **Admitting a kind to the poller makes every row of it due at once.**
1082/// `due_feeds` sorts `next_poll IS NULL` ahead of every dated row, and rows that
1083/// were never pollable have no schedule, so the boot that admits them hands the
1084/// poller a block that outranks every regular feed — including an overdue one —
1085/// until it drains (`feed::FeedKind::POLLABLE` documents the measurement). This
1086/// gives each such row its own slot in `[now, now + spread)`, in id order, so
1087/// the block arrives as a trickle. Rows that have been polled, or already carry
1088/// a schedule, are untouched. Returns how many rows were scheduled.
1089pub async fn stagger_unscheduled(
1090    pool: &SqlitePool,
1091    kind: crate::feed::FeedKind,
1092    spread: std::time::Duration,
1093) -> Result<u64> {
1094    let ids: Vec<i64> = sqlx::query_scalar(
1095        "SELECT id FROM feeds WHERE kind = ?1 AND next_poll IS NULL AND last_polled IS NULL \
1096         ORDER BY id",
1097    )
1098    .bind(kind.as_str())
1099    .fetch_all(pool)
1100    .await
1101    .context("listing unscheduled feeds to stagger")?;
1102    if ids.is_empty() {
1103        return Ok(0);
1104    }
1105    let now = chrono::Utc::now();
1106    let spread = chrono::Duration::from_std(spread).unwrap_or_else(|_| chrono::Duration::hours(1));
1107    let n = ids.len() as i32;
1108    let mut tx = pool.begin().await.context("begin stagger")?;
1109    for (i, id) in ids.iter().enumerate() {
1110        // Evenly spaced, the first due now: slot i of n across the spread.
1111        let at = now + spread * i as i32 / n;
1112        let next_poll = at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1113        sqlx::query("UPDATE feeds SET next_poll = ?1 WHERE id = ?2 AND next_poll IS NULL")
1114            .bind(next_poll)
1115            .bind(id)
1116            .execute(&mut *tx)
1117            .await
1118            .context("staggering a feed's first poll")?;
1119    }
1120    tx.commit().await.context("commit stagger")?;
1121    Ok(ids.len() as u64)
1122}
1123
1124/// Reset a feed's `consecutive_errors` to 0 after a successful poll (or a 304).
1125/// A no-op UPDATE if the row is missing.
1126pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
1127    // **Clears the reason too.** A stale `last_error` on a feed that is now
1128    // succeeding is worse than none: it is the aggregate below reporting a cause
1129    // that stopped applying, which is the failure this column exists to end.
1130    sqlx::query(
1131        "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
1132         WHERE url = ?1",
1133    )
1134    .bind(url)
1135    .execute(pool)
1136    .await
1137    .with_context(|| format!("reset_feed_errors failed for {url}"))?;
1138    Ok(())
1139}
1140
1141/// The feeds a `did` currently subscribes to, per its `sub_ref` projection.
1142/// Used by the PDS-unreachable fallback in `resolve_subscriptions` to render
1143/// the sidebar from the caller's OWN last-known subscriptions (fail closed)
1144/// rather than every cached feed.
1145pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
1146    let feeds = sqlx::query_as::<_, Feed>(
1147        r#"
1148        SELECT f.* FROM feeds f
1149        JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
1150        ORDER BY f.title IS NULL, f.title, f.url
1151        "#,
1152    )
1153    .bind(did)
1154    .fetch_all(pool)
1155    .await
1156    .with_context(|| format!("feeds_for_did failed for {did}"))?;
1157    Ok(feeds)
1158}
1159
1160/// The feed ids a `did` currently subscribes to (its `sub_ref` rows).
1161///
1162/// **Not bounded by `max_subs_per_did`.** This comment used to claim it was, and
1163/// callers leaned on that: the cap is enforced on the ADD and OPML paths only,
1164/// never on read, and `sub_ref` is rebuilt from whatever the PDS returns — which
1165/// any client can write to, bounded only by the list-pages ceiling at 20,000
1166/// records. A claim in a comment is not a bound.
1167///
1168/// Callers must therefore not assume a small result. The one that cared — the
1169/// list views' scope filter — no longer does: it passes the whole set as a
1170/// single `json_each` bind rather than one SQL placeholder per feed.
1171pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
1172    let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
1173        .bind(did)
1174        .fetch_all(pool)
1175        .await
1176        .with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
1177    Ok(ids)
1178}
1179
1180/// The number of feeds a `did` currently subscribes to (its `sub_ref` rows).
1181/// Backs the per-DID subscription cap enforced at the add/import paths.
1182pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
1183    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
1184        .bind(did)
1185        .fetch_one(pool)
1186        .await
1187        .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
1188    Ok(n)
1189}
1190
1191/// The number of distinct feeds in the shared cache. Backs the global feeds
1192/// ceiling checked before a brand-new feed is inserted.
1193pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
1194    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
1195        .fetch_one(pool)
1196        .await
1197        .context("count_feeds failed")?;
1198    Ok(n)
1199}
1200
1201/// The **used** size of the SQLite database, in bytes, computed as
1202/// `(page_count - freelist_count) * page_size`. Backs the DB-size watermark that
1203/// disables new polling.
1204///
1205/// Subtracting the freelist is what keeps the watermark from latching the poller
1206/// off: `page_count` counts pages the file has *allocated*, including ones freed
1207/// by a `DELETE` but not yet returned to the OS (SQLite keeps them on a freelist
1208/// for reuse and never shrinks the file without a VACUUM). Counting only the
1209/// live pages means a retention prune (which frees pages, see [`reclaim`]) is
1210/// actually reflected here, so the watermark can drop back below its threshold
1211/// and polling resumes. Cheap (three `PRAGMA` reads); works for file + `:memory:`.
1212///
1213/// **The WAL counts too.** This is the number the DB-size watermark compares
1214/// against a VOLUME size, and in WAL mode the `-wal` sidecar sits on that same
1215/// volume — so leaving it out understated exactly the quantity the watermark
1216/// exists to bound. It is added back below, best-effort: a WAL that cannot be
1217/// stat'd contributes zero rather than failing the check, since a watermark that
1218/// errors is worse than one that is slightly optimistic.
1219pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
1220    let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
1221        .fetch_one(pool)
1222        .await
1223        .context("PRAGMA page_count failed")?;
1224    let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1225        .fetch_one(pool)
1226        .await
1227        .context("PRAGMA freelist_count failed")?;
1228    let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
1229        .fetch_one(pool)
1230        .await
1231        .context("PRAGMA page_size failed")?;
1232    let used_pages = page_count.saturating_sub(freelist_count).max(0);
1233    Ok(used_pages
1234        .saturating_mul(page_size)
1235        .saturating_add(wal_bytes(pool).await))
1236}
1237
1238/// Bytes the write-ahead log currently occupies on the database's volume, or 0
1239/// when there is no WAL (`:memory:`, non-WAL journal modes) or it cannot be
1240/// stat'd. Best-effort by design — see [`db_size_bytes`].
1241async fn wal_bytes(pool: &SqlitePool) -> i64 {
1242    let Some(path) = main_db_path(pool).await else {
1243        return 0;
1244    };
1245    std::fs::metadata(format!("{path}-wal"))
1246        .map(|m| i64::try_from(m.len()).unwrap_or(i64::MAX))
1247        .unwrap_or(0)
1248}
1249
1250/// The main database's file path, or `None` for `:memory:`.
1251async fn main_db_path(pool: &SqlitePool) -> Option<String> {
1252    sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main' AND file <> ''")
1253        .fetch_optional(pool)
1254        .await
1255        .ok()
1256        .flatten()
1257}
1258
1259/// Freelist pages returned to the OS per `incremental_vacuum` step. At a 4 KiB
1260/// page that is ~8 MiB per batch — a short lock hold, and few enough steps that
1261/// a large reclaim is tens of statements rather than thousands.
1262const RECLAIM_BATCH_PAGES: i64 = 2_000;
1263
1264/// Backstop on the reclaim loop. `freelist_count == 0` and the no-progress check
1265/// are the real terminators; at [`RECLAIM_BATCH_PAGES`] this is 2M pages (~8 GiB),
1266/// far past anything a 1 GB volume holds.
1267const RECLAIM_MAX_BATCHES: usize = 1_000;
1268
1269/// Reclaim freed pages so the database file (and its used-page accounting) can
1270/// actually shrink after a retention/prune sweep DELETEs rows.
1271///
1272/// Without this, a `DELETE` moves pages onto the freelist but never shrinks the
1273/// file — so once the DB-size watermark trips and retention deletes rows,
1274/// `page_count` stays put and [`db_size_bytes`] (well, its raw `page_count`
1275/// form) would never fall back below the watermark, latching the poller off
1276/// forever. Call this AFTER a prune. It uses incremental vacuum when the database
1277/// is in `auto_vacuum = INCREMENTAL` mode (cheap, no full rewrite), and otherwise
1278/// falls back to a full `VACUUM`.
1279pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
1280    match auto_vacuum_mode(pool).await? {
1281        AutoVacuum::Incremental => {
1282            // **Bounded, like the deletes that precede it.**
1283            //
1284            // With no page argument this reclaims the ENTIRE freelist in one
1285            // transaction — handing straight back the write-lock hold that
1286            // batching the retention deletes had just won, immediately after the
1287            // sweep that created the freelist in the first place. Same shape as
1288            // `delete_in_batches`: a bounded unit of work, then an explicit
1289            // hand-off so a waiting writer actually gets in.
1290            // **Both early exits are LOUD.** Failing to reclaim is the failure
1291            // this function exists to prevent: `db_size_bytes` stays high,
1292            // `poll_due_once` keeps polling paused, and `/stats` says "paused"
1293            // with nothing anywhere saying reclaim gave up. Exiting silently
1294            // makes that indistinguishable from a sweep that had nothing to do.
1295            let mut drained = true;
1296            for batch in 0..RECLAIM_MAX_BATCHES {
1297                let before: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1298                    .fetch_one(pool)
1299                    .await
1300                    .context("PRAGMA freelist_count failed")?;
1301                if before == 0 {
1302                    break;
1303                }
1304                // A PRAGMA argument cannot be a bind parameter, and this one is
1305                // a `const i64` declared in this file — nothing external reaches
1306                // it.
1307                sqlx::query(sqlx::AssertSqlSafe(format!(
1308                    "PRAGMA incremental_vacuum({RECLAIM_BATCH_PAGES})"
1309                )))
1310                .execute(pool)
1311                .await
1312                .context("PRAGMA incremental_vacuum failed")?;
1313                let after: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1314                    .fetch_one(pool)
1315                    .await
1316                    .context("PRAGMA freelist_count failed")?;
1317                // No progress: either nothing more can be freed, or a
1318                // concurrent retention delete pushed `after` back up. Both leave
1319                // pages allocated, which is what an operator needs to know.
1320                //
1321                // This comment previously also claimed "a long-lived WAL read
1322                // snapshot pins freelist pages". MEASURED AND FALSE: with a
1323                // reader holding a snapshot taken BEFORE the delete, the
1324                // freelist still drained 2000 → 0 and `page_count` halved. A
1325                // reader blocks the CHECKPOINT, not the incremental vacuum — so
1326                // that case exits this loop through the SUCCESS path and is
1327                // reported below, not here.
1328                if after >= before {
1329                    tracing::warn!(
1330                        freelist_pages = after,
1331                        batches_run = batch + 1,
1332                        "reclaim stopped making progress with pages still on the \
1333                         freelist; the file will not shrink and the DB-size watermark \
1334                         may stay engaged until the next sweep"
1335                    );
1336                    drained = false;
1337                    break;
1338                }
1339                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1340                // `after > 0` matters: the final batch can drain the freelist
1341                // completely, in which case the loop reaches here having
1342                // SUCCEEDED and would otherwise log "with pages still on the
1343                // freelist" for an empty one — and suppress the success line.
1344                // This is the same guard `delete_in_batches` carries, and the
1345                // same defect it already had; reproduced here verbatim by
1346                // copying the loop's shape without its condition.
1347                if batch + 1 == RECLAIM_MAX_BATCHES && after > 0 {
1348                    tracing::warn!(
1349                        batches_run = batch + 1,
1350                        freelist_pages = after,
1351                        "reclaim hit its batch backstop with pages still on the \
1352                         freelist; the rest waits for the next sweep"
1353                    );
1354                    drained = false;
1355                }
1356            }
1357            if drained {
1358                tracing::debug!("reclaim: freelist drained");
1359            }
1360        }
1361        // SQLite already returns freed pages at every commit in this mode.
1362        // Nothing to do, and a VACUUM would be pure cost.
1363        AutoVacuum::Full => {}
1364        // **Deliberately a no-op, where this used to run a full VACUUM.**
1365        //
1366        // Nothing ever set `auto_vacuum`, so NONE was the mode every database
1367        // actually ran in — which made the full-VACUUM branch the one that
1368        // always executed, daily and after every prune. A full VACUUM writes a
1369        // complete second copy of the database, so it needs free disk roughly
1370        // equal to the live file; that is precisely what is missing under the
1371        // disk pressure that triggers a retention sweep. `poll_due_once` already
1372        // carries a comment explaining this danger and removed VACUUM from the
1373        // poll path — while leaving it in the retention path that runs under the
1374        // same pressure.
1375        //
1376        // Skipping it does NOT latch the DB-size watermark, which is the failure
1377        // this branch was written to prevent: `db_size_bytes` subtracts the
1378        // freelist, so a DELETE lowers the measured size with no VACUUM at all.
1379        // What is lost is the FILE shrinking, and the fix for that is to get the
1380        // database into INCREMENTAL mode — see `migrate_to_incremental_vacuum`,
1381        // which is operator-invoked precisely because it needs the one operation
1382        // that is unsafe to attempt automatically.
1383        AutoVacuum::None => {
1384            tracing::warn!(
1385                "auto_vacuum=NONE: skipping reclaim. Freed pages stay allocated and \
1386                 the file will not shrink. Run `featherreader --migrate-auto-vacuum` \
1387                 once, while the volume has headroom, to move this database to \
1388                 INCREMENTAL mode."
1389            );
1390        }
1391    }
1392
1393    // Truncate the WAL as well. It lives on the same volume and is counted by
1394    // `db_size_bytes`, so reclaiming database pages while leaving a WAL grown by
1395    // the sweep that just ran would give back part of the space and hold the
1396    // rest. Worth doing even in the NONE branch above, where it is the only
1397    // space this function can return at all.
1398    //
1399    // **A blocked checkpoint is the real way the file stays big, so it warns.**
1400    //
1401    // Measured: with a reader holding an open snapshot, `incremental_vacuum`
1402    // still drains the freelist and `page_count` halves — but the main file
1403    // stayed at 16.4 MB until the reader released and the checkpoint could
1404    // truncate it to 8.2 MB. So a reader does not stop the reclaim; it stops the
1405    // SHRINK. That is the operator-visible outcome (`db_size_bytes` counts the
1406    // WAL, and the watermark is compared against a volume), and it used to be
1407    // reported at `debug!` — below any realistic filter — while the loop above
1408    // warned loudly about a mechanism that does not actually occur.
1409    //
1410    // Not an error: the next sweep checkpoints again once the reader is gone.
1411    match checkpoint_wal(pool).await {
1412        Ok(true) => {}
1413        Ok(false) => tracing::warn!(
1414            "the WAL could not be truncated after reclaim (busy: a concurrent reader \
1415             OR writer held it); the freed pages are gone but the file has not \
1416             shrunk yet, and the DB-size watermark may stay engaged until the next \
1417             sweep"
1418        ),
1419        Err(err) => tracing::warn!(%err, "wal checkpoint after reclaim failed"),
1420    }
1421    Ok(())
1422}
1423
1424/// Run a truncating WAL checkpoint. `Ok(false)` means SQLite declined because a
1425/// reader held the WAL.
1426///
1427/// **The busy case is a ROW, not an error.** `PRAGMA wal_checkpoint` returns
1428/// `(busy, log_frames, checkpointed_frames)` and sets `busy = 1` when it could
1429/// not run — measured: `(1, 3, 3)` with one open read transaction versus
1430/// `(0, 0, 0)` without. So `if let Err(..)` never fires on the case it was
1431/// written for, and a caller that depends on the WAL actually being truncated
1432/// (the migration's size report does) would silently get the untruncated one.
1433async fn checkpoint_wal<'e, E>(conn: E) -> Result<bool>
1434where
1435    E: sqlx::Executor<'e, Database = sqlx::Sqlite>,
1436{
1437    let row: (i64, i64, i64) = sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
1438        .fetch_one(conn)
1439        .await
1440        .context("PRAGMA wal_checkpoint(TRUNCATE) failed")?;
1441    Ok(row.0 == 0)
1442}
1443
1444/// A database's `auto_vacuum` mode.
1445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1446pub enum AutoVacuum {
1447    /// 0 — freed pages stay on the freelist; only a full `VACUUM` returns them.
1448    None,
1449    /// 1 — SQLite returns freed pages at every commit.
1450    Full,
1451    /// 2 — freed pages are returned on demand by `PRAGMA incremental_vacuum`.
1452    Incremental,
1453}
1454
1455/// Read the database's `auto_vacuum` mode.
1456pub async fn auto_vacuum_mode(pool: &SqlitePool) -> Result<AutoVacuum> {
1457    let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1458        .fetch_one(pool)
1459        .await
1460        .context("PRAGMA auto_vacuum failed")?;
1461    Ok(match mode {
1462        1 => AutoVacuum::Full,
1463        2 => AutoVacuum::Incremental,
1464        _ => AutoVacuum::None,
1465    })
1466}
1467
1468/// What [`migrate_to_incremental_vacuum`] did.
1469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1470pub enum VacuumMigration {
1471    /// Already in a mode that reclaims; nothing was run.
1472    NotNeeded(AutoVacuum),
1473    /// Refused: not enough free space on the volume to hold the rebuilt file.
1474    ///
1475    /// `file_bytes` is the on-disk size, reported alongside the live-page figure
1476    /// the requirement is computed from, because on exactly this population
1477    /// (`NONE` mode, large freelist) the two differ a lot and only one of them
1478    /// matches what `ls -l` says.
1479    RefusedNoHeadroom {
1480        needed: u64,
1481        available: u64,
1482        file_bytes: Option<u64>,
1483    },
1484    /// Ran the pragma + full VACUUM; the database is now INCREMENTAL.
1485    Migrated {
1486        bytes_before: i64,
1487        bytes_after: i64,
1488        file_before: Option<u64>,
1489        file_after: Option<u64>,
1490    },
1491}
1492
1493/// Move a populated database from `auto_vacuum = NONE` to `INCREMENTAL`.
1494///
1495/// **Why this cannot happen at boot.** SQLite ignores `PRAGMA auto_vacuum` on a
1496/// database that already has tables unless it is followed by a full `VACUUM`,
1497/// which rebuilds the file. So the migration off the dangerous mode requires the
1498/// exact operation that is dangerous — a genuine chicken-and-egg, and the reason
1499/// this is an explicit operator step run when the volume has headroom rather
1500/// than something attempted lazily on a machine that is already under pressure.
1501///
1502/// Doing it automatically would also reintroduce the failure shape T2.1 just
1503/// removed: a boot-time VACUUM that cannot complete on a full volume, on a
1504/// supervisor that restarts the machine whenever a child exits, is a crash loop.
1505///
1506/// `available_bytes` is the caller's measurement of free space on the database's
1507/// volume (`None` where the platform cannot report it). The check is a refusal,
1508/// not a warning: starting a VACUUM that cannot finish wastes I/O on a box that
1509/// has none to spare. `VACUUM` itself is atomic — an interrupted one leaves the
1510/// original database intact — so the risk being managed here is wasted work and
1511/// a long write-lock hold, not corruption.
1512pub async fn migrate_to_incremental_vacuum(
1513    pool: &SqlitePool,
1514    available_bytes: Option<u64>,
1515) -> Result<VacuumMigration> {
1516    let mode = auto_vacuum_mode(pool).await?;
1517    if mode != AutoVacuum::None {
1518        return Ok(VacuumMigration::NotNeeded(mode));
1519    }
1520
1521    // **The on-disk file, not the live-page count.** `db_size_bytes` subtracts
1522    // the freelist, and the population this migration exists for is precisely
1523    // `auto_vacuum = NONE` with a large freelist — so the live size can be far
1524    // smaller than the file, and an operator comparing the refusal message to
1525    // `ls -l` would not trust either number. The rebuild is sized by the LIVE
1526    // pages (that is what gets copied), but the report shows both.
1527    let bytes_before = db_size_bytes(pool).await?;
1528    let file_before = main_db_file_bytes(pool).await;
1529    // Resolved BEFORE a connection is acquired below. Asking the pool for
1530    // anything while holding one of its connections deadlocks a saturated pool —
1531    // and a single-connection pool is always saturated. The first version of the
1532    // temp-directory block did exactly that, and because `main_db_path` swallows
1533    // errors into `None` it did not even fail loudly: it stalled for the full
1534    // acquire timeout and then silently skipped setting the directory, which is
1535    // the one thing it exists to do.
1536    let temp_dir = main_db_path(pool).await.and_then(|p| {
1537        std::path::Path::new(&p)
1538            .parent()
1539            .map(std::path::Path::to_path_buf)
1540    });
1541    let needed = (bytes_before.max(0) as u64).saturating_mul(2);
1542    if let Some(available) = available_bytes {
1543        if available < needed {
1544            return Ok(VacuumMigration::RefusedNoHeadroom {
1545                needed,
1546                available,
1547                file_bytes: file_before,
1548            });
1549        }
1550    }
1551
1552    // **One connection for both statements.**
1553    //
1554    // `PRAGMA auto_vacuum` on a populated database is connection-scoped INTENT
1555    // that only takes effect when the SAME connection runs the VACUUM. Issued
1556    // against the pool they can land on different connections, and the rebuild
1557    // then happens in NONE mode — caught by the `ensure!` below, so loud rather
1558    // than silent, but the operator has paid a whole-file rewrite for nothing on
1559    // a box chosen for being short of disk.
1560    let mut conn = pool
1561        .acquire()
1562        .await
1563        .context("acquiring a connection for the auto_vacuum migration")?;
1564
1565    // **Put the temp copy on the DATABASE's volume.**
1566    //
1567    // A VACUUM rebuilds through a temporary database, and the headroom check
1568    // above measures the data volume. `temp_store = FILE` alone only chooses
1569    // file-over-memory; it does NOT choose which filesystem, so the temp copy
1570    // resolved via `SQLITE_TMPDIR`/`TMPDIR`/`/var/tmp`/`/tmp` — the container
1571    // rootfs. The check could pass on `/data` and the VACUUM still hit
1572    // `SQLITE_FULL`, or fill the rootfs out from under Caddy.
1573    //
1574    // `temp_store_directory` is the pragma that actually decides — measured:
1575    // setting it alone moves the file, setting `temp_store = FILE` alone does
1576    // not. It is deprecated but fully functional in the bundled SQLite (3.51.3,
1577    // built without `SQLITE_OMIT_DEPRECATED`), and there is no non-deprecated
1578    // equivalent reachable from a connection.
1579    //
1580    // `temp_store = FILE` is kept as belt-and-braces rather than because it is
1581    // needed: this build's compile-time default is already FILE, but a build
1582    // defaulting to MEMORY would silently ignore the directory entirely.
1583    //
1584    // Note it sets the PROCESS-GLOBAL `sqlite3_temp_directory`, not connection
1585    // state — visible on other connections and other pools. Harmless because
1586    // this function is only reachable from the one-shot `--migrate-auto-vacuum`
1587    // CLI path, which does nothing else.
1588    sqlx::query("PRAGMA temp_store = FILE")
1589        .execute(&mut *conn)
1590        .await
1591        .context("PRAGMA temp_store = FILE failed")?;
1592    if let Some(dir) = temp_dir.clone() {
1593        // The path comes from SQLite's own `database_list`, not from a caller.
1594        let quoted = dir.display().to_string().replace('\'', "''");
1595        if let Err(err) = sqlx::query(sqlx::AssertSqlSafe(format!(
1596            "PRAGMA temp_store_directory = '{quoted}'"
1597        )))
1598        .execute(&mut *conn)
1599        .await
1600        {
1601            // Not fatal: the VACUUM can still succeed if the default temp
1602            // location happens to have room. But the headroom check is then
1603            // measuring the wrong filesystem, so say so.
1604            tracing::warn!(
1605                %err, dir = %dir.display(),
1606                "could not point SQLite's temp storage at the database volume; the \
1607                 headroom check may not cover where the VACUUM actually writes"
1608            );
1609        }
1610    }
1611
1612    // Order matters: the pragma records the INTENT, and the VACUUM is what
1613    // actually rewrites the file in the new mode. Reversed, the VACUUM would
1614    // rebuild in NONE mode and the pragma would then be ignored again.
1615    sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
1616        .execute(&mut *conn)
1617        .await
1618        .context("PRAGMA auto_vacuum = INCREMENTAL failed")?;
1619    sqlx::query("VACUUM")
1620        .execute(&mut *conn)
1621        .await
1622        .context("VACUUM failed during the auto_vacuum migration")?;
1623
1624    // Fold the WAL back in BEFORE measuring. A VACUUM in WAL mode writes the
1625    // entire rebuilt database through the WAL, which keeps that high-water size
1626    // until a truncating checkpoint — and `db_size_bytes` now counts the WAL. So
1627    // the one number this command reports read as "the migration doubled my
1628    // database", which is the opposite of what it did.
1629    match checkpoint_wal(&mut *conn).await {
1630        Ok(true) => {}
1631        // Reported, because the size this function returns is computed straight
1632        // after and would otherwise read as "the migration doubled my database"
1633        // with nothing saying why.
1634        Ok(false) => tracing::warn!(
1635            "the WAL could not be truncated (a concurrent reader OR writer held it), \
1636             so the reported size below includes it"
1637        ),
1638        Err(err) => tracing::warn!(%err, "post-migration wal checkpoint failed"),
1639    }
1640
1641    // Verified on the HELD connection, then released before anything that goes
1642    // back to the pool. The test pool is single-connection, and so is a
1643    // production pool that happens to be saturated — reaching for a second one
1644    // while still holding the first is a deadlock waiting for a busy moment.
1645    let after_raw: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1646        .fetch_one(&mut *conn)
1647        .await
1648        .context("PRAGMA auto_vacuum failed after the migration")?;
1649    drop(conn);
1650    let after = match after_raw {
1651        1 => AutoVacuum::Full,
1652        2 => AutoVacuum::Incremental,
1653        _ => AutoVacuum::None,
1654    };
1655    anyhow::ensure!(
1656        after == AutoVacuum::Incremental,
1657        "the auto_vacuum migration ran but the database is still in {after:?} mode"
1658    );
1659    Ok(VacuumMigration::Migrated {
1660        bytes_before,
1661        bytes_after: db_size_bytes(pool).await?,
1662        file_before,
1663        file_after: main_db_file_bytes(pool).await,
1664    })
1665}
1666
1667/// Size of the main database FILE on disk, or `None` for `:memory:` / an
1668/// unstattable path. Distinct from [`db_size_bytes`], which reports live pages.
1669async fn main_db_file_bytes(pool: &SqlitePool) -> Option<u64> {
1670    let path = main_db_path(pool).await?;
1671    std::fs::metadata(path).ok().map(|m| m.len())
1672}
1673
1674/// Insert a batch of entries for `feed_id`, deduping on `(feed_id, guid)`, then
1675/// trim the feed to at most [`crate::config`]-configured `max_entries_per_feed`
1676/// rows (newest by published date) so one firehose feed can't fill the disk.
1677///
1678/// On a GUID collision the existing entry is updated in place (title/url/body
1679/// may have changed on re-fetch) rather than duplicated. Runs in one
1680/// transaction. Returns the number of rows processed.
1681///
1682/// `max_entries_per_feed <= 0` disables the per-feed trim.
1683pub async fn insert_entries(
1684    pool: &SqlitePool,
1685    feed_id: i64,
1686    entries: &[NewEntry],
1687    max_entries_per_feed: i64,
1688) -> Result<u64> {
1689    let mut tx = pool.begin().await.context("begin insert_entries tx")?;
1690    let mut count: u64 = 0;
1691    for e in entries {
1692        let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
1693        let res = sqlx::query(
1694            r#"
1695            INSERT INTO entries
1696                (feed_id, guid, url, title, author, published, content_html, fetched_at)
1697            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1698            ON CONFLICT (feed_id, guid) DO UPDATE SET
1699                url          = excluded.url,
1700                title        = excluded.title,
1701                author       = excluded.author,
1702                published    = excluded.published,
1703                content_html = excluded.content_html
1704            "#,
1705        )
1706        .bind(feed_id)
1707        .bind(&e.guid)
1708        .bind(&e.url)
1709        .bind(&e.title)
1710        .bind(&e.author)
1711        .bind(&e.published)
1712        .bind(&e.content_html)
1713        .bind(&fetched_at)
1714        .execute(&mut *tx)
1715        .await
1716        .with_context(|| format!("insert entry {} failed", e.guid))?;
1717        count += res.rows_affected();
1718    }
1719
1720    // Entries-per-feed cap: keep only the newest `max_entries_per_feed` rows for
1721    // this feed, deleting the overflow in the same transaction. "Newest" is
1722    // COALESCE(published, fetched_at) so an UNDATED entry (NULL published) sorts
1723    // by when we fetched it (NOT NULL) rather than always sorting LAST and being
1724    // evicted first — otherwise a feed of undated items would trim its freshest
1725    // rows. This bounds a single firehose/misbehaving feed's storage footprint
1726    // independent of the global retention sweep. `<= 0` disables it.
1727    //
1728    // The bound is `2 * max_entries_per_feed`, not `max_entries_per_feed`: the
1729    // newest N by date, plus up to N starred. See the sparing subquery below.
1730    if max_entries_per_feed > 0 {
1731        sqlx::query(
1732            r#"
1733            DELETE FROM entries
1734            WHERE feed_id = ?1
1735              AND id NOT IN (
1736                  SELECT id FROM entries
1737                  WHERE feed_id = ?1
1738                  ORDER BY COALESCE(published, fetched_at) DESC, id DESC
1739                  LIMIT ?2
1740              )
1741              -- Starred entries survive the per-feed trim, exactly as they
1742              -- survive the retention sweep. This predicate was added to the
1743              -- sweep and NOT here, which left the documented guarantee
1744              -- ("starred entries are never evicted") false — and made this
1745              -- path, which runs on every poll of every feed rather than daily,
1746              -- the main producer of the very "starred but not cached" case the
1747              -- saved-record rendering exists to paper over.
1748              --
1749              -- The sparing is BOUNDED and SCOPED, and both matter:
1750              --
1751              -- Bounded, because the first version spared every starred row
1752              -- without limit, which did not weaken the cap so much as remove
1753              -- it — measured at cap=5 with 50 starred rows, 55 survived, 11x
1754              -- the cap. That is the same unbounded-sparing mistake the
1755              -- retention hard ceiling was added to fix, reintroduced in the
1756              -- other sweep. Worst case is now cap + cap.
1757              --
1758              -- Scoped, because `SELECT entry_id FROM entry_state WHERE
1759              -- starred = 1` reads EVERY starred row on the instance, for every
1760              -- poll of every feed — cost scaling with total users rather than
1761              -- with the feed being trimmed.
1762              AND id NOT IN (
1763                  SELECT e2.id FROM entries e2
1764                  WHERE e2.feed_id = ?1
1765                    AND EXISTS (
1766                        SELECT 1 FROM entry_state s
1767                        WHERE s.entry_id = e2.id AND s.starred = 1
1768                    )
1769                  ORDER BY COALESCE(e2.published, e2.fetched_at) DESC, e2.id DESC
1770                  LIMIT ?2
1771              )
1772            "#,
1773        )
1774        .bind(feed_id)
1775        .bind(max_entries_per_feed)
1776        .execute(&mut *tx)
1777        .await
1778        .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
1779    }
1780
1781    // Per-feed trim above may have DELETEd entries; their ids can linger in the
1782    // read_cursor exception sets (read_ids/unread_ids have no FK to entries), so
1783    // scrub the orphaned ids out of THIS feed's cursors in the same transaction.
1784    // Bounds id-set growth and keeps the flushed PDS record from referencing
1785    // entries that no longer exist. Scoped to the one feed for cheapness.
1786    if max_entries_per_feed > 0 {
1787        prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
1788    }
1789
1790    tx.commit().await.context("commit insert_entries tx")?;
1791    Ok(count)
1792}
1793
1794/// Make a feed due for polling on the next tick.
1795///
1796/// Used when a saved article is missing from the cache: if the reader still
1797/// subscribes to the feed, the poller may be able to bring the article back on
1798/// its own. Clearing `next_poll` is the whole mechanism — `due_feeds` treats
1799/// NULL as due — so this adds no synthetic rows and no special-case fetch path.
1800///
1801/// **Rate-limited by `not_polled_since`**, and that is not a nicety.
1802///
1803/// `due_feeds` treats a NULL `next_poll` as due immediately, so clearing it
1804/// unconditionally from a page handler meant every reload of the starred view
1805/// made those feeds due again — bypassing the poll interval entirely. That is
1806/// outbound amplification against third-party feed origins, and it lets one
1807/// reader's feeds monopolise a poll budget that is shared and already the
1808/// binding constraint on how many readers an instance can serve.
1809///
1810/// A feed polled within the window is left alone: if the article was not in the
1811/// feed a minute ago, another fetch now will not find it either. The nudge is
1812/// therefore worth at most one extra poll per feed per interval, which is the
1813/// cadence the poller already targets.
1814///
1815/// A no-op if the URL is not a known feed.
1816pub async fn mark_feed_due(
1817    pool: &SqlitePool,
1818    feed_url: &str,
1819    not_polled_since: &str,
1820) -> Result<()> {
1821    sqlx::query(
1822        "UPDATE feeds SET next_poll = NULL \
1823         WHERE url = ?1 AND (last_polled IS NULL OR last_polled < ?2)",
1824    )
1825    .bind(feed_url)
1826    .bind(not_polled_since)
1827    .execute(pool)
1828    .await
1829    .context("marking a feed due")?;
1830    Ok(())
1831}
1832
1833/// Delete entries whose age exceeds the retention window — the shared cache's
1834/// **rolling window** — except those a reader has starred or not yet read. "Age" is `COALESCE(published, fetched_at)` so an UNDATED
1835/// entry falls back to when it was fetched (never NULL) rather than being treated
1836/// as infinitely old. `entry_state` cascades via its `ON DELETE CASCADE` FK.
1837///
1838/// After the delete, orphaned entry ids are scrubbed out of every affected feed's
1839/// `read_cursor` exception sets (which have no FK to `entries`) so the id-sets do
1840/// not grow without bound and the flushed PDS record never references a vanished
1841/// entry. The caller (the retention sweep) should follow a non-zero return with
1842/// [`reclaim`] so freed pages return to the OS.
1843///
1844/// The two knobs are **independent**. `days == 0` disables the rolling window and
1845/// nothing else; `hard_days == 0` disables the ceiling and nothing else. Only
1846/// when both are off is this a no-op. Returns the number of entry rows deleted.
1847pub async fn prune_old_entries(
1848    pool: &SqlitePool,
1849    days: i64,
1850    hard_days: i64,
1851    publication_days: i64,
1852) -> Result<u64> {
1853    let now = chrono::Utc::now();
1854    // **A window too large to be a date disables that pass; it must not panic.**
1855    //
1856    // `chrono::Duration::days` and `DateTime - TimeDelta` both panic out of
1857    // range, and every knob here parses from a `u32` with no upper bound — so
1858    // `FEATHERREADER_RETENTION_DAYS=1000000000` (a plausible unit slip: seconds or
1859    // milliseconds typed into a days field) panicked this function. Measured:
1860    // anything past roughly 96 million days overflows, and `u32::MAX` does.
1861    //
1862    // The consequence was not a crash an operator would notice. This runs in a
1863    // spawned task, so tokio catches the panic and the retention sweeper simply
1864    // stops for the life of the process — silently, permanently, and taking the
1865    // release valve for `db_size_watermark_bytes` with it, which is the one thing
1866    // that stops polling for every reader.
1867    //
1868    // Disabled-not-panicking is also the answer `standard_site::ingest_floor`
1869    // already gives for the same input, and the two are supposed to mirror each
1870    // other — `Config::retention_for` exists to keep them agreeing. An
1871    // unrepresentable window meant "store everything" there and "panic" here.
1872    let at = |d: i64, knob: &str| -> Option<String> {
1873        let cutoff = chrono::Duration::try_days(d).and_then(|w| now.checked_sub_signed(w));
1874        if cutoff.is_none() {
1875            tracing::warn!(
1876                days = d,
1877                knob,
1878                "retention window is too large to express as a date; treating it as \
1879                 disabled for this sweep rather than failing the sweeper"
1880            );
1881        }
1882        cutoff.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
1883    };
1884
1885    let cutoff = (days > 0).then(|| at(days, "retention_days")).flatten();
1886    // **The third window, for the kinds age does not bound.** See
1887    // [`AGED_KINDS_SQL`] and `FeedKind::AGED`: a publication's entries are
1888    // bounded by COUNT (the per-feed trim), because a 14-day window stored zero
1889    // rows from every real publication measured. This is the backstop that keeps
1890    // "not aged out" from meaning "immortal" — the per-feed trim only runs when a
1891    // poll stores something, so rows belonging to a feed nobody polls any more
1892    // have nothing else to reap them.
1893    let publication_cutoff = (publication_days > 0)
1894        .then(|| at(publication_days, "publication_retention_days"))
1895        .flatten();
1896    // The ceiling only means anything if it is STRICTLY OLDER than the window.
1897    // At `0 < hard_days <= days` the two cutoffs coincide, and since the hard
1898    // delete spares nothing, it would delete exactly the rows the soft delete
1899    // exists to spare — turning the whole starred/unread exception into a no-op.
1900    // With no window at all (`days <= 0`) there is nothing to be inside of, so a
1901    // positive ceiling stands on its own.
1902    //
1903    // This used to be `hard_days.max(days)`, which clamps the wrong way: it made
1904    // `0` — the value an operator reaches for to turn a ceiling OFF, and the
1905    // documented "disabled" value for `RETENTION_DAYS` one line above it in the
1906    // same table — the single most destructive setting available, silently
1907    // purging starred and unread entries at the soft window. Measured: with
1908    // `days=14`, `hard=0` deleted a 30-day starred entry and a 30-day unread one.
1909    //
1910    // `<= 0` now means disabled, consistently with `days`. A contradictory
1911    // positive value is refused rather than reinterpreted downward.
1912    //
1913    // The ceiling is deliberately NOT gated on the window being enabled. It used
1914    // to be — this function returned on `days <= 0` before the ceiling was even
1915    // computed — which made `RETENTION_DAYS=0` mean "no window AND no ceiling":
1916    // the one configuration with no bound on the shared cache whatsoever. That
1917    // became load-bearing when the per-feed trim started sparing starred entries.
1918    // Before, the trim was a backstop for them; now nothing was. "I don't want a
1919    // rolling window" and "I don't want any ceiling at all" are different
1920    // statements, and are now configured separately.
1921    let hard_cutoff = if hard_days > 0 && (days <= 0 || hard_days > days) {
1922        at(hard_days, "retention_hard_days")
1923    } else {
1924        if hard_days > 0 {
1925            tracing::warn!(
1926                hard_days,
1927                days,
1928                "retention hard ceiling is not older than the retention window; \
1929                 ignoring it — set it above the window or to 0 to disable"
1930            );
1931        }
1932        None
1933    };
1934
1935    if cutoff.is_none() && hard_cutoff.is_none() && publication_cutoff.is_none() {
1936        return Ok(0);
1937    }
1938
1939    // **The hard ceiling — the bound that sparing would otherwise remove.**
1940    //
1941    // Sparing `read = 0` is not a small exception: "mark unread" is a one-click
1942    // UI control, and `entries` is SHARED across every reader on the instance.
1943    // Without a ceiling, one person can pin unbounded rows, and the pins are
1944    // permanent.
1945    //
1946    // That matters beyond disk. `poll_due_once` stops ALL polling once the
1947    // database crosses `db_size_watermark_bytes`, and the retention DELETE is
1948    // the documented release valve. Pinned rows can hold the valve shut
1949    // forever, so the failure mode is: one reader pins enough content, the DB
1950    // latches above the watermark, and polling stops for EVERY reader with no
1951    // self-healing path. The window used to be an unconditional bound; sparing
1952    // removed it, and this restores it.
1953    //
1954    // Starred entries go too at this age, and that is now safe: a saved record
1955    // whose entry is gone renders from the PDS record as a link card, so the
1956    // reader keeps the article's identity even when the cache does not keep its
1957    // text.
1958    let hard_deleted = match &hard_cutoff {
1959        Some(cutoff) => {
1960            delete_in_batches(
1961                pool,
1962                // Scoped to the kinds the window applies to. A publication's
1963                // entries answer to `publication_cutoff` below instead, which is
1964                // generous where this is tight — an archive read is not a cache
1965                // of the last few days.
1966                &format!(
1967                    "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
1968                     AND feed_id IN (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL}))"
1969                ),
1970                cutoff,
1971                "hard ceiling",
1972            )
1973            .await?
1974        }
1975        None => 0,
1976    };
1977    // **Entries a reader has DELIBERATELY marked are kept, whatever their age.**
1978    //
1979    // Precisely: an entry is spared when some DID has an `entry_state` row for
1980    // it with `starred = 1` or `read = 0`. An entry nobody has ever touched has
1981    // no `entry_state` row at all and is NOT spared, even though every read path
1982    // treats "no row" as unread.
1983    //
1984    // That asymmetry is deliberate and load-bearing. Sparing every never-touched
1985    // entry would spare essentially the whole table — almost no entry is ever
1986    // interacted with — which would make the window a no-op and leave the hard
1987    // ceiling as the only bound. The window is for evicting cache nobody claimed;
1988    // the exception is for the things a reader acted on.
1989    //
1990    // This comment used to read "starred and unread entries are kept", which is
1991    // the reading that would motivate exactly that change.
1992    //
1993    // The window is a cache eviction policy, not a data-retention policy. The
1994    // PDS is the source of truth for what a reader CHOSE — subscriptions,
1995    // folders, stars, read-state — but the entry CONTENT was never there. It
1996    // exists here and at the origin feed, and a feed typically serves only its
1997    // last few dozen items, so a pruned article is usually unrecoverable.
1998    //
1999    // Deleting indiscriminately therefore lost two things a reader would notice:
2000    // a starred article vanished from the starred view entirely (the view joins
2001    // `entries`, and `entry_state` cascades on the delete, so the star went with
2002    // it), and anything still unread disappeared before it was ever read. Both
2003    // are the opposite of a cache.
2004    //
2005    // This is what the documentation has always described; the query did not
2006    // implement it.
2007    let soft_deleted = match &cutoff {
2008        Some(cutoff) => {
2009            delete_in_batches(
2010                pool,
2011                // **`NOT EXISTS`, not `id NOT IN (…)`.**
2012                //
2013                // The list form materialises the ENTIRE pinned set on every
2014                // batch, and that set scales with total users rather than with
2015                // the feed being swept; this probes `idx_entry_state_entry_id`
2016                // per candidate row instead. Measured on 1M entries with 600k
2017                // `entry_state` rows of which 10% are pinned: **64.8 s as a list,
2018                // 43.6 s as a correlated exists — 1.49x, for no disk and no write
2019                // amplification.**
2020                //
2021                // **An earlier version of this comment claimed 2.4x, and that a
2022                // partial index on the pinned predicate "changed the time by
2023                // nothing at all". Both were artifacts of a bad fixture.** It
2024                // made every `entry_state` row match `starred = 1 OR read = 0` —
2025                // no "read and not starred" rows at all, which is the commonest
2026                // state a reader leaves behind. That inflated the list form's
2027                // cost (the materialised set was the whole table) and made a
2028                // PARTIAL index on that predicate cover 100% of rows, so it could
2029                // not be selective and duly did nothing.
2030                //
2031                // On a realistic distribution the review's proposed index is NOT
2032                // useless: it takes the list form from 64.8 s to 44.0 s, most of
2033                // the way to the rewrite. The rewrite is still the better change
2034                // because it costs no disk and no insert throughput — but it wins
2035                // by less than claimed, against an alternative that was dismissed
2036                // on a measurement of the wrong thing.
2037                //
2038                // Indexes are still declined, now on honest numbers: the pinned
2039                // index buys 12% (43.6 → 38.5 s) for 6.9 MiB, the age index 22%
2040                // (→ 33.9 s) for 27.9 MiB, both with write amplification on a
2041                // poller that inserts constantly, against a daily sweep that is
2042                // already batched and interruptible. See
2043                // `store::tests::r6_measure_retention_sweep`.
2044                //
2045                // Also strictly safer. `NOT IN` against a subquery containing a
2046                // NULL evaluates to NULL for every row, which would silently
2047                // delete nothing. `entry_state.entry_id` is `NOT NULL` today, so
2048                // the two are equivalent — but the equivalence depends on a
2049                // column constraint somewhere else, and `NOT EXISTS` does not.
2050                // `sparing_honours_every_did_not_just_one` pins the multi-DID
2051                // case, which is the only one where the forms could diverge.
2052                &format!(
2053                    "SELECT e.id FROM entries e \
2054                     WHERE COALESCE(e.published, e.fetched_at) < ?1 \
2055                       AND e.feed_id IN \
2056                           (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL})) \
2057                       AND NOT EXISTS ( \
2058                           SELECT 1 FROM entry_state s \
2059                           WHERE s.entry_id = e.id \
2060                             AND (s.starred = 1 OR s.read = 0) \
2061                       )"
2062                ),
2063                cutoff,
2064                "window",
2065            )
2066            .await?
2067        }
2068        None => 0,
2069    };
2070    // **The archive ceiling, for every kind the window does not cover.**
2071    //
2072    // `kind NOT IN` rather than `kind = 'publication'` deliberately: a kind added
2073    // later and left out of `FeedKind::AGED` inherits a bound here rather than
2074    // inheriting immortality. Spares nothing, for the reason the hard ceiling
2075    // spares nothing — a saved record whose entry is gone still renders from the
2076    // PDS record as a link card, so the reader keeps the article's identity.
2077    let publication_deleted = match &publication_cutoff {
2078        Some(cutoff) => {
2079            delete_in_batches(
2080                pool,
2081                &format!(
2082                    "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
2083                     AND feed_id IN (SELECT id FROM feeds WHERE kind NOT IN ({AGED_KINDS_SQL}))"
2084                ),
2085                cutoff,
2086                "archive ceiling",
2087            )
2088            .await?
2089        }
2090        None => 0,
2091    };
2092    let deleted = soft_deleted + hard_deleted + publication_deleted;
2093
2094    // Only touch cursors when rows actually went away — and OUTSIDE the deletes.
2095    //
2096    // This used to run inside the one transaction that wrapped both deletes,
2097    // which made the whole sweep a single write-lock hold: load every
2098    // `read_cursor` row, then issue a fresh per-cursor `SELECT … JOIN … WHERE
2099    // f.url = ?` returning up to `max_entries_per_feed` ids, all before the
2100    // commit. SQLite is single-writer and `busy_timeout` is 5 s, so for that
2101    // whole span every mark-read, every login write and every cursor flush
2102    // failed.
2103    //
2104    // Correctness survives the move because the scrub is idempotent — it
2105    // computes each cursor's surviving ids from what is in `entries` NOW, and
2106    // rewrites only cursors that actually change. If the process dies between
2107    // the deletes and the scrub, the next sweep finishes the job, and in the
2108    // meantime a stale id in an exception set is inert: the flusher sends it,
2109    // and it names an entry nobody can reach.
2110    if deleted > 0 {
2111        if let Err(err) = prune_orphan_cursor_ids(pool, None).await {
2112            // The deletes already committed and are the point of this call.
2113            // A failed scrub leaves stale ids to be cleaned up next sweep.
2114            tracing::warn!(%err, "retention sweep: cursor id scrub failed after the deletes");
2115        }
2116    }
2117
2118    Ok(deleted)
2119}
2120
2121/// Rows deleted per statement by [`delete_in_batches`].
2122///
2123/// Small enough that one batch — including its `entry_state` FK cascade — is a
2124/// short lock hold, large enough that a big sweep is tens of statements rather
2125/// than thousands.
2126const PRUNE_BATCH: i64 = 1_000;
2127
2128/// Backstop against a delete loop that never drains. `rows_affected == 0` is the
2129/// real terminator; this only bounds the damage if a future predicate change
2130/// makes that untrue. At [`PRUNE_BATCH`] this is 10M rows, far past anything a
2131/// 1 GB volume holds.
2132const PRUNE_MAX_BATCHES: usize = 10_000;
2133
2134/// How long [`delete_in_batches`] stands down between batches, so a writer
2135/// waiting on the SQLite write lock actually gets it rather than losing the race
2136/// to the loop's next statement.
2137///
2138/// Named because it is the one thing that makes batching a fix rather than
2139/// bookkeeping, and because `a_writer_gets_through_while_the_sweep_runs` derives
2140/// its "was this sweep long enough to measure" floor from it. A sweep that is
2141/// genuinely batched cannot finish faster than one hand-off per batch; that is a
2142/// structural lower bound, not a number calibrated against a particular machine.
2143const PRUNE_BATCH_HANDOFF: std::time::Duration = std::time::Duration::from_millis(10);
2144
2145/// Delete every entry matched by `select_ids` (a `SELECT id FROM entries …`
2146/// bound to one `?1` cutoff), in bounded batches, **one implicit transaction per
2147/// batch**.
2148///
2149/// The retention sweep used to be a single `DELETE` inside one explicit
2150/// transaction. On a populated instance that is one unbroken write-lock hold
2151/// covering tens of thousands of row deletes plus their `entry_state` cascades —
2152/// measured at ~10 minutes before `idx_entry_state_entry_id` existed, and still
2153/// a single indivisible span after it. Everything else that writes (mark-read,
2154/// login, cursor flush) has a 5 s `busy_timeout` and simply fails for the
2155/// duration.
2156///
2157/// Batching does not make the total work smaller; it makes it INTERRUPTIBLE. A
2158/// writer waiting on the lock gets in between batches instead of timing out, and
2159/// the short sleep below guarantees that window actually exists rather than
2160/// leaving it to chance against a tight loop.
2161///
2162/// A partial sweep is safe: each batch commits on its own, and the predicate is
2163/// a fixed cutoff, so a crash mid-sweep leaves fewer rows deleted and the next
2164/// run finishes the job.
2165async fn delete_in_batches(
2166    pool: &SqlitePool,
2167    select_ids: &str,
2168    cutoff: &str,
2169    label: &str,
2170) -> Result<u64> {
2171    let sql = format!("DELETE FROM entries WHERE id IN ({select_ids} LIMIT {PRUNE_BATCH})");
2172    let mut total: u64 = 0;
2173    for batch in 0..PRUNE_MAX_BATCHES {
2174        let n = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
2175            .bind(cutoff)
2176            .execute(pool)
2177            .await
2178            .with_context(|| format!("prune_old_entries {label} (cutoff {cutoff})"))?
2179            .rows_affected();
2180        total += n;
2181        if n == 0 {
2182            return Ok(total);
2183        }
2184        // Hand the write lock over, so the loop cannot re-acquire it the instant
2185        // it commits and leave a waiting writer to fight for the gap between two
2186        // statements. At `PRUNE_BATCH` rows per batch this adds one
2187        // `PRUNE_BATCH_HANDOFF` per 1,000 deleted rows to a sweep that runs once
2188        // a day.
2189        //
2190        // This comment has twice carried a number it could not support. It first
2191        // said a writer "still starves" without the hand-off; that was replaced
2192        // with "roughly 3x writer throughput", quoting one sample from each of
2193        // two runs. Repeated, the two distributions overlap heavily (medians
2194        // ~1.4 writes/ms with the sleep against ~1.0 without, and several
2195        // sleep-less runs beat the median with it), so 3x is not a figure this
2196        // comment can assert.
2197        //
2198        // What is defensible without a benchmark: removing it lets the loop
2199        // re-acquire immediately, so a waiting writer is left racing the gap
2200        // between two statements instead of being handed a window. Writers do
2201        // still get through either way. `a_writer_gets_through_while_the_sweep_runs`
2202        // catches the removal about three runs in five — see the note there; the
2203        // rest of the time the loop still looks batched, because it is.
2204        tokio::time::sleep(PRUNE_BATCH_HANDOFF).await;
2205        // Only warn if the backstop actually cut the sweep short. A final batch
2206        // that happened to drain the last rows would otherwise log "the rest
2207        // waits for the next run" with nothing left — and an operator who reads
2208        // that during an incident would go looking for a backlog that is not
2209        // there. `n < PRUNE_BATCH` means this batch found fewer rows than it
2210        // asked for, so there are none behind it.
2211        // Still a 1-in-`PRUNE_BATCH` false positive when the final batch drains
2212        // exactly a full batch with nothing behind it — distinguishing that
2213        // needs another COUNT per sweep, which is not worth paying to make a
2214        // backstop message that has never fired slightly more precise.
2215        if batch + 1 == PRUNE_MAX_BATCHES && n == PRUNE_BATCH as u64 {
2216            tracing::warn!(
2217                label,
2218                total,
2219                "retention sweep hit its batch backstop; the rest waits for the next run"
2220            );
2221        }
2222    }
2223    Ok(total)
2224}
2225
2226/// Scrub entry ids that no longer exist out of `read_cursor.read_ids` /
2227/// `unread_ids`. `read_cursor` is keyed by `(did, feed_url)` and its id-sets have
2228/// NO foreign key to `entries`, so a prune/trim that deletes entries would
2229/// otherwise leave dangling ids that (a) grow the sets without bound and (b) get
2230/// flushed to the PDS as references to vanished entries.
2231///
2232/// When `feed_id` is `Some`, only that feed's cursors are examined (the cheap
2233/// path used right after a per-feed trim); `None` scans every cursor (the
2234/// retention sweep, which can delete across many feeds at once). A cursor whose
2235/// sets actually change is rewritten and marked `dirty` so the flusher resyncs
2236/// it; unchanged cursors are left untouched (no spurious dirtying / PDS writes).
2237/// Returns the number of cursor rows modified.
2238///
2239/// This is the TRANSACTIONAL variant, used by the per-feed trim inside
2240/// `insert_entries`: it is scoped to one feed, examines that feed's cursors
2241/// only, and genuinely wants to land atomically with the trim that created the
2242/// orphans. The retention sweep uses [`prune_orphan_cursor_ids`] instead —
2243/// global scope inside one transaction is what made the sweep a multi-minute
2244/// write-lock hold.
2245async fn prune_orphan_cursor_ids_tx(
2246    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2247    feed_id: Option<i64>,
2248) -> Result<u64> {
2249    // The set of live entry ids we prune against. Scope to the feed's URL when a
2250    // feed_id is given so we filter only that feed's cursors against that feed's
2251    // entries; otherwise consider all cursors / all entries.
2252    let feed_url = match feed_id {
2253        Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
2254            Some(u) => Some(u),
2255            None => return Ok(0), // feed vanished mid-tx; nothing to prune
2256        },
2257        None => None,
2258    };
2259
2260    // Load the (did, feed_url, read_ids, unread_ids) of the candidate cursors.
2261    let cursors: Vec<(String, String, String, String)> = match &feed_url {
2262        Some(url) => sqlx::query(
2263            "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
2264        )
2265        .bind(url)
2266        .fetch_all(&mut **tx)
2267        .await
2268        .context("prune_orphan_cursor_ids: load feed cursors")?,
2269        None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
2270            .fetch_all(&mut **tx)
2271            .await
2272            .context("prune_orphan_cursor_ids: load all cursors")?,
2273    }
2274    .into_iter()
2275    .map(|r| {
2276        (
2277            r.get::<String, _>("did"),
2278            r.get::<String, _>("feed_url"),
2279            r.get::<String, _>("read_ids"),
2280            r.get::<String, _>("unread_ids"),
2281        )
2282    })
2283    .collect();
2284
2285    if cursors.is_empty() {
2286        return Ok(0);
2287    }
2288
2289    let now = now_rfc3339();
2290    let mut changed: u64 = 0;
2291    for (did, curl, read_ids, unread_ids) in cursors {
2292        // The live entry ids for THIS cursor's feed (join by URL — the cursor key).
2293        let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2294            "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2295        )
2296        .bind(&curl)
2297        .fetch_all(&mut **tx)
2298        .await
2299        .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
2300        .into_iter()
2301        .collect();
2302
2303        let new_read = filter_id_set_to_live(&read_ids, &live);
2304        let new_unread = filter_id_set_to_live(&unread_ids, &live);
2305        if new_read == read_ids && new_unread == unread_ids {
2306            continue; // nothing orphaned — leave the cursor (and its dirty flag) alone
2307        }
2308        sqlx::query(
2309            "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2310             WHERE did = ?1 AND feed_url = ?2",
2311        )
2312        .bind(&did)
2313        .bind(&curl)
2314        .bind(&new_read)
2315        .bind(&new_unread)
2316        .bind(&now)
2317        .execute(&mut **tx)
2318        .await
2319        .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
2320        changed += 1;
2321    }
2322    Ok(changed)
2323}
2324
2325/// [`prune_orphan_cursor_ids_tx`] over the pool — **no enclosing transaction**.
2326///
2327/// Same result, different locking. Each statement commits on its own, so the
2328/// single write lock is taken for one cursor rewrite at a time and released
2329/// between them, and the reads in between block nothing at all in WAL mode.
2330/// That matters because this is the global pass: the retention sweep's version
2331/// loads EVERY `read_cursor` row and then issues one live-ids query per cursor,
2332/// and holding all of that inside a transaction is what made a daily sweep look
2333/// like an outage to every writer on the instance.
2334///
2335/// **Each cursor's read-modify-write is one short transaction**, and that is not
2336/// optional. The first version of this loaded every cursor into a snapshot, then
2337/// walked them issuing an unguarded `UPDATE` per cursor from that snapshot. A
2338/// `mark_read` landing during the walk — seconds, on a global pass — had its new
2339/// id silently overwritten by the stale set, and the rewrite set `dirty = 1`, so
2340/// the flusher then pushed the truncated set to the PDS as authoritative. Local
2341/// `entry_state` still said read, so the loss was invisible here and visible
2342/// only in every OTHER atproto client. The transactional predecessor did not
2343/// have that bug: it held the write lock across the whole pass, so a concurrent
2344/// `mark_read` blocked and applied on top.
2345///
2346/// So the lock is not eliminated, it is SCOPED: one cursor's live-ids query plus
2347/// its update, rather than every cursor's. That keeps what T2.2 was for (a daily
2348/// sweep must not look like an outage) without trading it for lost writes.
2349///
2350/// Re-running is still safe — surviving ids are recomputed from the current
2351/// contents of `entries` — so dying partway just means the next sweep finishes.
2352///
2353/// `feed_id = Some(..)` scopes to one feed; `None` scans every cursor. Returns
2354/// the number of cursor rows modified.
2355async fn prune_orphan_cursor_ids(pool: &SqlitePool, feed_id: Option<i64>) -> Result<u64> {
2356    let feed_url = match feed_id {
2357        Some(fid) => match sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2358            .bind(fid)
2359            .fetch_optional(pool)
2360            .await
2361            .context("prune_orphan_cursor_ids: feed url")?
2362        {
2363            Some(u) => Some(u),
2364            None => return Ok(0),
2365        },
2366        None => None,
2367    };
2368
2369    // Only the KEYS come from this snapshot. The id-sets are deliberately not
2370    // read here — they are re-read inside each cursor's own transaction below,
2371    // because anything read out here is stale by the time it is written back.
2372    let keys: Vec<(String, String)> = match &feed_url {
2373        Some(url) => sqlx::query_as("SELECT did, feed_url FROM read_cursor WHERE feed_url = ?1")
2374            .bind(url)
2375            .fetch_all(pool)
2376            .await
2377            .context("prune_orphan_cursor_ids: load feed cursors")?,
2378        None => sqlx::query_as("SELECT did, feed_url FROM read_cursor")
2379            .fetch_all(pool)
2380            .await
2381            .context("prune_orphan_cursor_ids: load all cursors")?,
2382    };
2383
2384    let mut changed: u64 = 0;
2385    for (did, curl) in keys {
2386        // A cursor that vanished between the key snapshot and now is simply
2387        // skipped; a cursor that APPEARED is missed until the next sweep. Both
2388        // are fine — the scrub is housekeeping, not a correctness barrier.
2389        match scrub_one_cursor(pool, &did, &curl).await {
2390            Ok(true) => changed += 1,
2391            Ok(false) => {}
2392            // One bad cursor must not abandon the rest of the pass.
2393            Err(err) => tracing::warn!(%err, %did, feed = %curl, "cursor id scrub failed"),
2394        }
2395    }
2396    Ok(changed)
2397}
2398
2399/// Scrub one cursor's id-sets inside its own transaction. Returns whether the
2400/// row changed.
2401///
2402/// The read of the id-sets, the live-ids query and the write all happen under
2403/// one transaction, so a `mark_read` that lands mid-sweep either goes first (and
2404/// is included) or waits (and applies on top). Reading the sets outside and
2405/// writing them back later is the lost-update shape this function exists to
2406/// avoid — see [`prune_orphan_cursor_ids`].
2407async fn scrub_one_cursor(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<bool> {
2408    let mut tx = pool.begin().await.context("begin scrub_one_cursor tx")?;
2409
2410    let (_, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2411    // An empty exception set has nothing to orphan, and skipping it avoids the
2412    // live-ids query entirely — the dominant cost of this pass, and the common
2413    // case for a cursor sitting at its high-water mark.
2414    if is_empty_id_set(&read_ids) && is_empty_id_set(&unread_ids) {
2415        return Ok(false);
2416    }
2417
2418    let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2419        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2420    )
2421    .bind(feed_url)
2422    .fetch_all(&mut *tx)
2423    .await
2424    .with_context(|| format!("prune_orphan_cursor_ids: live ids for {feed_url}"))?
2425    .into_iter()
2426    .collect();
2427
2428    let new_read = filter_id_set_to_live(&read_ids, &live);
2429    let new_unread = filter_id_set_to_live(&unread_ids, &live);
2430    if new_read == read_ids && new_unread == unread_ids {
2431        return Ok(false); // nothing orphaned — leave the cursor (and its dirty flag) alone
2432    }
2433    sqlx::query(
2434        "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2435         WHERE did = ?1 AND feed_url = ?2",
2436    )
2437    .bind(did)
2438    .bind(feed_url)
2439    .bind(&new_read)
2440    .bind(&new_unread)
2441    .bind(now_rfc3339())
2442    .execute(&mut *tx)
2443    .await
2444    .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{feed_url}"))?;
2445    tx.commit().await.context("commit scrub_one_cursor tx")?;
2446    Ok(true)
2447}
2448
2449/// Whether a stored id-set is *textually* empty — `[]` or blank.
2450///
2451/// Deliberately NOT a parse: this is a fast pre-filter, and
2452/// [`filter_id_set_to_live`] remains the authority on what a set contains. An
2453/// unparseable value returns `false` here, so it goes through the full path and
2454/// gets canonicalised to `[]` rather than being skipped — the pre-filter fails
2455/// toward doing the work, which is the safe direction.
2456fn is_empty_id_set(raw: &str) -> bool {
2457    let t = raw.trim();
2458    t.is_empty() || t == "[]"
2459}
2460
2461/// Filter a JSON id-array string down to only ids present in `live`, returning
2462/// the canonical JSON-array-of-strings form (matching [`json_id_set_toggle`]). A
2463/// malformed input yields `[]`.
2464fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
2465    let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
2466        .ok()
2467        .map(|vals| {
2468            vals.into_iter()
2469                .filter_map(|v| match v {
2470                    serde_json::Value::Number(n) => n.as_i64(),
2471                    serde_json::Value::String(s) => s.parse::<i64>().ok(),
2472                    _ => None,
2473                })
2474                .filter(|id| live.contains(id))
2475                .collect()
2476        })
2477        .unwrap_or_default();
2478    let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
2479    serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
2480}
2481
2482/// Replace the per-DID subscription projection (`sub_ref`) for `did` with
2483/// exactly `feed_ids`, in one transaction.
2484///
2485/// Called from the web layer's subscription-resolve/sync path so `sub_ref`
2486/// always mirrors the caller's *current* PDS subscription set. This is the
2487/// authority every scoped read/mutation checks against — a feed the caller no
2488/// longer subscribes to drops out of their read surface immediately.
2489pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
2490    let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
2491    sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
2492        .bind(did)
2493        .execute(&mut *tx)
2494        .await
2495        .with_context(|| format!("clear sub_ref for {did}"))?;
2496    for &feed_id in feed_ids {
2497        sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
2498            .bind(did)
2499            .bind(feed_id)
2500            .execute(&mut *tx)
2501            .await
2502            .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
2503    }
2504    tx.commit().await.context("commit replace_sub_refs tx")?;
2505    Ok(())
2506}
2507
2508/// Whether `did` currently subscribes to the feed `feed_id` owns
2509/// (i.e. a `sub_ref` row exists). The authorization primitive behind every
2510/// per-DID scoped read/mutation.
2511pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
2512    let found: Option<i64> = sqlx::query_scalar(
2513        r#"
2514        SELECT 1
2515        FROM entries e
2516        JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
2517        WHERE e.id = ?2
2518        "#,
2519    )
2520    .bind(did)
2521    .bind(entry_id)
2522    .fetch_optional(pool)
2523    .await
2524    .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
2525    Ok(found.is_some())
2526}
2527
2528/// The exact `(sql, bind_count)` `list_entries` runs, for a view and scope.
2529///
2530/// **One path, so a test cannot assert on something the query is free to
2531/// ignore.** A named `LIST_PROJECTION` constant was not enough: the test read
2532/// the constant while `list_entries` passed `list_query_sql` whatever it liked,
2533/// so swapping in an inline literal containing `e.content_html` still shipped
2534/// green. The test now calls this.
2535fn list_entries_sql(view: ListView, feed_ids: Option<&[i64]>) -> (String, usize) {
2536    list_query_sql(Projection::EntryList, view, feed_ids)
2537}
2538
2539/// Which columns a list query may select.
2540///
2541/// **A closed type, not a `&str`.** A named constant was not enough and neither
2542/// was a helper function: both left `list_query_sql` taking an arbitrary string,
2543/// so a call site could pass an inline literal containing `e.content_html` and
2544/// ship green — twice over, which is how this ended up as an enum. The article
2545/// body is up to 20 KB per row and the list renders 50 at a time, so reading it
2546/// is the difference between a bounded response and a megabyte per page.
2547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2548enum Projection {
2549    /// The list view. Deliberately omits `content_html`.
2550    EntryList,
2551    Count,
2552    Ids,
2553    FeedCounts,
2554    StarredUrls,
2555}
2556
2557impl Projection {
2558    const fn columns(self) -> &'static str {
2559        match self {
2560            Projection::EntryList => {
2561                "e.id, e.feed_id, e.guid, e.url, e.title, e.published, \
2562                 COALESCE(s.read, 0) AS read, COALESCE(s.starred, 0) AS starred"
2563            }
2564            Projection::Count => "COUNT(*)",
2565            Projection::Ids => "e.id",
2566            Projection::FeedCounts => "e.feed_id, COUNT(*)",
2567            Projection::StarredUrls => "e.url, e.guid",
2568        }
2569    }
2570}
2571
2572/// The shared body of every list query: the per-DID `entry_state` LEFT JOIN, the
2573/// `sub_ref` authorization predicate, the view predicate and the optional
2574/// feed-id restriction. `projection` is spliced in as the `SELECT` list.
2575///
2576/// Returns the SQL plus the number of feed-id placeholders emitted, so the
2577/// caller knows where its own `LIMIT`/`OFFSET` placeholders start. `?1` is
2578/// always the DID; feed ids are `?2..`.
2579///
2580/// **Why the callers may assert this is SQL-safe.** Only three things vary, and
2581/// none is caller data: `projection` and [`ListView::predicate`] are `&'static
2582/// str` written in this file, and the feed-id restriction contributes only a
2583/// COUNT — the ids themselves are bound, never formatted in. Every runtime value
2584/// (the DID, the ids, the limit, the offset) reaches SQLite as a bind parameter.
2585fn list_query_sql(
2586    projection: Projection,
2587    view: ListView,
2588    feed_ids: Option<&[i64]>,
2589) -> (String, usize) {
2590    let cols = projection.columns();
2591    let scoped = feed_ids.is_some();
2592    let mut sql = format!(
2593        "SELECT {cols} \
2594         FROM entries e \
2595         LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1 \
2596         WHERE {} \
2597           AND EXISTS ( \
2598               SELECT 1 FROM sub_ref sr \
2599               WHERE sr.did = ?1 AND sr.feed_id = e.feed_id \
2600           )",
2601        view.predicate()
2602    );
2603    if scoped {
2604        // **ONE bind parameter for any scope size.**
2605        //
2606        // This used to emit one placeholder per feed id, so the SQL string and
2607        // the bind list both grew with the reader's subscription count — which
2608        // is PDS-supplied and bounded only by the 20,000-record list ceiling.
2609        //
2610        // That was reachable-broken, not merely ugly: `SQLITE_LIMIT_VARIABLE_NUMBER`
2611        // is 32766 on the bundled build, and the ids were bound TWICE per render
2612        // (the count query and the page query), so the effective ceiling was
2613        // ~16,383 feeds — below the list ceiling. Past it, `prepare` fails with
2614        // "too many SQL variables" and the reader's page 500s. Measured: 20,000
2615        // ids through `json_each` is a 108 KB bind that runs in 9.9 ms; 32,767
2616        // placeholders does not prepare at all.
2617        // The first attempt at bounding it truncated the subscription list
2618        // instead, which traded a query-shape problem for an access problem:
2619        // `sync_sub_refs` writes `sub_ref` from that list, so dropped feeds
2620        // became unreadable AND unmutatable. `json_each` removes the need to
2621        // choose — the whole set rides in as one JSON text bind.
2622        sql.push_str(" AND e.feed_id IN (SELECT value FROM json_each(?2))");
2623    }
2624    (sql, usize::from(scoped))
2625}
2626
2627/// Bind the DID and the optional feed-id restriction, in the order
2628/// [`list_query_sql`] emits them — `?1` the DID, `?2` the scope JSON when there
2629/// is one.
2630fn bind_list_scope<'q, O>(
2631    q: sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments>,
2632    did: &'q str,
2633    feed_ids: Option<&[i64]>,
2634) -> sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments> {
2635    let q = q.bind(did);
2636    match feed_ids {
2637        // Serialising i64s cannot fail; the fallback is an empty array, which
2638        // matches nothing — the fail-closed direction for a scope filter.
2639        Some(ids) => q.bind(serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string())),
2640        None => q,
2641    }
2642}
2643
2644/// One page of a list view, newest-published first, scoped to `did`'s
2645/// subscriptions (`sub_ref`) and optionally narrowed to `feed_ids`.
2646///
2647/// **`limit` is a required parameter, not a convenience.** This function
2648/// replaced three `SELECT e.*` queries that had no `LIMIT` at all and pulled the
2649/// article body they never used; leaving an unbounded variant next to the
2650/// bounded one would just be the same trap with a longer name. If a caller wants
2651/// "everything", it has to say how much everything is allowed to be. See
2652/// [`EntryListRow`] for what the projection deliberately omits and why.
2653///
2654/// `feed_ids = Some(&[])` means "no feeds in scope" and returns empty without
2655/// touching the database — distinct from `None`, which means "every feed this
2656/// DID subscribes to".
2657pub async fn list_entries(
2658    pool: &SqlitePool,
2659    did: &str,
2660    view: ListView,
2661    feed_ids: Option<&[i64]>,
2662    limit: i64,
2663    offset: i64,
2664) -> Result<Vec<EntryListRow>> {
2665    if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2666        return Ok(Vec::new());
2667    }
2668    let (mut sql, n) = list_entries_sql(view, feed_ids);
2669    sql.push_str(&format!(
2670        " ORDER BY COALESCE(e.published, e.fetched_at) DESC, e.id DESC LIMIT ?{} OFFSET ?{}",
2671        n + 2,
2672        n + 3
2673    ));
2674    let q = sqlx::query_as::<_, EntryListRow>(sqlx::AssertSqlSafe(sql));
2675    let rows = bind_list_scope(q, did, feed_ids)
2676        .bind(limit)
2677        .bind(offset.max(0))
2678        .fetch_all(pool)
2679        .await
2680        .with_context(|| format!("list_entries({view:?}) failed for {did}"))?;
2681    Ok(rows)
2682}
2683
2684/// How many entries the same scope + view would return, unpaged. Used for the
2685/// "N entries" heading and to decide whether a next-page link is warranted —
2686/// both of which used to read `entries.len()` off a fully materialized list.
2687pub async fn count_entries_for_view(
2688    pool: &SqlitePool,
2689    did: &str,
2690    view: ListView,
2691    feed_ids: Option<&[i64]>,
2692) -> Result<i64> {
2693    if feed_ids.is_some_and(<[i64]>::is_empty) {
2694        return Ok(0);
2695    }
2696    let (sql, _) = list_query_sql(Projection::Count, view, feed_ids);
2697    // `query_as` over a 1-tuple keeps one binding helper for both shapes.
2698    let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2699    let (n,) = bind_list_scope(q, did, feed_ids)
2700        .fetch_one(pool)
2701        .await
2702        .with_context(|| format!("count_entries_for_view({view:?}) failed for {did}"))?;
2703    Ok(n)
2704}
2705
2706/// The ordered entry ids for a scope + view — the same ordering [`list_entries`]
2707/// renders, used for the reader's prev/next links.
2708///
2709/// Ids only: this one genuinely spans the whole list rather than a page (prev/next
2710/// needs the reader's position in it), so it is the one query where row COUNT can
2711/// still be large. An id is 8 bytes against the 11.9 KB row this used to fetch,
2712/// and `limit` bounds it regardless. Past the limit, prev/next simply stops
2713/// finding neighbours — the article still opens.
2714pub async fn list_entry_ids(
2715    pool: &SqlitePool,
2716    did: &str,
2717    view: ListView,
2718    feed_ids: Option<&[i64]>,
2719    limit: i64,
2720) -> Result<Vec<i64>> {
2721    if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2722        return Ok(Vec::new());
2723    }
2724    let (mut sql, n) = list_query_sql(Projection::Ids, view, feed_ids);
2725    sql.push_str(&format!(
2726        " ORDER BY COALESCE(e.published, e.fetched_at) DESC, e.id DESC LIMIT ?{}",
2727        n + 2
2728    ));
2729    let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2730    let rows = bind_list_scope(q, did, feed_ids)
2731        .bind(limit)
2732        .fetch_all(pool)
2733        .await
2734        .with_context(|| format!("list_entry_ids({view:?}) failed for {did}"))?;
2735    Ok(rows.into_iter().map(|(id,)| id).collect())
2736}
2737
2738/// Unread counts per `feed_id` for a DID — the sidebar's per-feed badges.
2739///
2740/// Counted in SQL. The sidebar used to fetch every unread entry (bodies and all)
2741/// and count them in Rust, on every page with chrome, which is the single most
2742/// frequent instance of the projection problem [`EntryListRow`] describes.
2743pub async fn unread_counts_by_feed(
2744    pool: &SqlitePool,
2745    did: &str,
2746) -> Result<std::collections::HashMap<i64, i64>> {
2747    let (sql, _) = list_query_sql(Projection::FeedCounts, ListView::Unread, None);
2748    let rows =
2749        sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!("{sql} GROUP BY e.feed_id")))
2750            .bind(did)
2751            .fetch_all(pool)
2752            .await
2753            .with_context(|| format!("unread_counts_by_feed failed for {did}"))?;
2754    Ok(rows.into_iter().collect())
2755}
2756
2757/// The `(url, guid)` identity pairs of every cached starred entry for a DID.
2758///
2759/// The starred view matches PDS saved records against these to decide which
2760/// records the cache can render itself. It must span the whole starred set, not
2761/// the visible page: a record that looks uncached gets an un-save button that
2762/// deletes the PDS RECORD rather than un-starring the entry, so narrowing this
2763/// set changes what a click destroys. Identity strings only — no bodies.
2764///
2765/// **Truncation is reported, not absorbed.** The `limit` is a memory backstop,
2766/// but hitting it violates the invariant above — and the first version had no
2767/// way to say so and no `ORDER BY`, so it silently returned an ARBITRARY subset
2768/// and every starred article outside it rendered with a record-destroying
2769/// button. `Truncated` lets the caller fail closed instead, and the ordering
2770/// makes the subset at least deterministic across renders rather than
2771/// whatever the query planner felt like returning.
2772pub enum StarredIdentities {
2773    /// The complete set for this DID.
2774    All(Vec<(Option<String>, String)>),
2775    /// `limit` was reached, so this is a partial set and MUST NOT be used to
2776    /// decide that a record is uncached.
2777    Truncated,
2778}
2779
2780pub async fn starred_identities(
2781    pool: &SqlitePool,
2782    did: &str,
2783    limit: i64,
2784) -> Result<StarredIdentities> {
2785    let (mut sql, n) = list_query_sql(Projection::StarredUrls, ListView::Starred, None);
2786    // One past the limit, so reaching it is distinguishable from landing on it
2787    // exactly. Ordered by id so the rows are stable; `url`/`guid` are not
2788    // guaranteed unique or non-NULL, and the id is both.
2789    //
2790    // The placeholder index comes from `list_query_sql` rather than being
2791    // hardcoded: it was `?2` only because this call passes `None` for the scope,
2792    // which is the kind of coupling that breaks silently when the shared builder
2793    // changes shape — as it just did.
2794    sql.push_str(&format!(" ORDER BY e.id LIMIT ?{}", n + 2));
2795    let rows = sqlx::query_as::<_, (Option<String>, String)>(sqlx::AssertSqlSafe(sql))
2796        .bind(did)
2797        .bind(limit.saturating_add(1))
2798        .fetch_all(pool)
2799        .await
2800        .with_context(|| format!("starred_identities failed for {did}"))?;
2801    if rows.len() as i64 > limit {
2802        return Ok(StarredIdentities::Truncated);
2803    }
2804    Ok(StarredIdentities::All(rows))
2805}
2806
2807/// Mark a single entry read/unread for a DID, upserting the per-DID state row
2808/// and stamping `updated_at`. Preserves any existing `starred` bit. Also
2809/// projects the change into the per-`(did, feed_url)` [`ReadCursor`] and marks
2810/// it `dirty` so the batched flusher pushes it to the PDS (see
2811/// `project_entry_into_cursor`).
2812///
2813/// AUTHORIZED per-DID: the upsert only touches an entry the caller subscribes
2814/// to (`sub_ref`). Returns `true` if a row was written, `false` if `did` does
2815/// not subscribe to the entry's feed (the web layer maps that to a 404 —
2816/// a non-subscriber can never mutate another user's state).
2817pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
2818    let now = now_rfc3339();
2819    let mut tx = pool.begin().await.context("begin mark_read tx")?;
2820    let res = sqlx::query(
2821        r#"
2822        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2823        SELECT ?1, e.id, ?3, 0, ?4
2824        FROM entries e
2825        WHERE e.id = ?2
2826          AND EXISTS (
2827              SELECT 1 FROM sub_ref sr
2828              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2829          )
2830        ON CONFLICT (did, entry_id) DO UPDATE SET
2831            read       = excluded.read,
2832            updated_at = excluded.updated_at
2833        "#,
2834    )
2835    .bind(did)
2836    .bind(entry_id)
2837    .bind(read)
2838    .bind(&now)
2839    .execute(&mut *tx)
2840    .await
2841    .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
2842
2843    if res.rows_affected() == 0 {
2844        // Not authorized (no `sub_ref`) — nothing written, no cursor to dirty.
2845        tx.rollback().await.ok();
2846        return Ok(false);
2847    }
2848
2849    // Project the read/unread into this feed's read cursor (dirty=1) so the
2850    // flusher syncs it to the PDS. Same tx as the state write so a crash can't
2851    // leave the two out of step.
2852    project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
2853
2854    tx.commit().await.context("commit mark_read tx")?;
2855    Ok(true)
2856}
2857
2858/// Star/unstar a single entry for a DID (upsert, preserving `read`).
2859///
2860/// AUTHORIZED per-DID like [`mark_read`]: only touches an entry the caller
2861/// subscribes to. Returns `true` if a row was written, `false` if `did` does
2862/// not subscribe (→ 404 at the web layer).
2863pub async fn mark_starred(
2864    pool: &SqlitePool,
2865    did: &str,
2866    entry_id: i64,
2867    starred: bool,
2868) -> Result<bool> {
2869    let res = sqlx::query(
2870        r#"
2871        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2872        SELECT ?1, e.id, 0, ?3, ?4
2873        FROM entries e
2874        WHERE e.id = ?2
2875          AND EXISTS (
2876              SELECT 1 FROM sub_ref sr
2877              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2878          )
2879        ON CONFLICT (did, entry_id) DO UPDATE SET
2880            starred    = excluded.starred,
2881            updated_at = excluded.updated_at
2882        "#,
2883    )
2884    .bind(did)
2885    .bind(entry_id)
2886    .bind(starred)
2887    .bind(now_rfc3339())
2888    .execute(pool)
2889    .await
2890    .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
2891    Ok(res.rows_affected() > 0)
2892}
2893
2894/// Fold ids already covered by a high-water-mark into `read_through`, so the
2895/// exception set stops growing. Returns the new `read_through` when it advanced.
2896///
2897/// **What was wrong.** `read_through` was never COMPUTED — `project_entry_into_cursor`
2898/// only carried an existing value through, and it starts NULL, so in practice it
2899/// was always NULL. That left `read_ids` as the sole mechanism, growing one id
2900/// per article read, bounded only by `max_entries_per_feed` (2000) — while the
2901/// flusher caps the record at `ReadState::MAX_IDS` (1000) keeping the TAIL, with
2902/// no log line. Past 1000 read articles in one feed, the oldest read-state
2903/// silently stopped syncing, and those articles came back UNREAD in any other
2904/// atproto reader. The `cap` helper's own comment assumed "the exception sets
2905/// are expected to stay well under the cap in normal use"; against a 2000-entry
2906/// per-feed ceiling that does not hold.
2907///
2908/// **The rule.** `read_through` means "every entry at or before this time is
2909/// read". So it may advance only to a point with no unread entry at or before
2910/// it. That point is computed here as the newest entry timestamp STRICTLY OLDER
2911/// than the oldest unread entry — strictly, because entries can share a
2912/// timestamp, and a watermark equal to an unread entry's time would assert that
2913/// entry is read.
2914///
2915/// Once the watermark moves, every `read_ids` entry at or before it is
2916/// redundant and is dropped — that is the compaction. `unread_ids` is filtered
2917/// the same way; by construction nothing unread sits at or below the new
2918/// watermark, so it empties, but the filter is written rather than assumed so it
2919/// stays correct if that invariant ever shifts.
2920///
2921/// Timestamps compare lexicographically because every writer normalises to UTC
2922/// `...Z` at seconds precision (`feed::fmt_time`, `now_rfc3339`) — the same
2923/// assumption `poll_health` and the retention window already make.
2924pub async fn compact_cursor(
2925    pool: &SqlitePool,
2926    did: &str,
2927    feed_url: &str,
2928) -> Result<Option<String>> {
2929    let mut tx = pool.begin().await.context("begin compact_cursor tx")?;
2930    let (read_through, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2931
2932    // The oldest entry on this feed that `did` has NOT read. `NULL` = nothing
2933    // unread, in which case the watermark can cover the whole feed.
2934    let oldest_unread: Option<String> = sqlx::query_scalar(
2935        r#"
2936        SELECT MIN(COALESCE(e.published, e.fetched_at))
2937        FROM entries e
2938        JOIN feeds f ON f.id = e.feed_id
2939        LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
2940        WHERE f.url = ?2 AND COALESCE(s.read, 0) = 0
2941        "#,
2942    )
2943    .bind(did)
2944    .bind(feed_url)
2945    .fetch_one(&mut *tx)
2946    .await
2947    .with_context(|| format!("compact_cursor: oldest unread for {did}/{feed_url}"))?;
2948
2949    let watermark: Option<String> = match &oldest_unread {
2950        Some(oldest) => sqlx::query_scalar(
2951            r#"
2952            SELECT MAX(COALESCE(e.published, e.fetched_at))
2953            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            "#,
2956        )
2957        .bind(feed_url)
2958        .bind(oldest)
2959        .fetch_one(&mut *tx)
2960        .await
2961        .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2962        None => sqlx::query_scalar(
2963            r#"
2964            SELECT MAX(COALESCE(e.published, e.fetched_at))
2965            FROM entries e JOIN feeds f ON f.id = e.feed_id
2966            WHERE f.url = ?1
2967            "#,
2968        )
2969        .bind(feed_url)
2970        .fetch_one(&mut *tx)
2971        .await
2972        .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2973    };
2974
2975    // Nothing to cover, or the watermark is already at least this far along.
2976    // Never move it BACKWARDS: that would re-assert articles as unread.
2977    let Some(watermark) = watermark else {
2978        return Ok(None);
2979    };
2980    if read_through
2981        .as_deref()
2982        .is_some_and(|rt| rt >= &watermark[..])
2983    {
2984        return Ok(None);
2985    }
2986
2987    let keep_above = ids_published_after(&mut tx, feed_url, &read_ids, &watermark).await?;
2988    let keep_unread =
2989        ids_published_at_or_before(&mut tx, feed_url, &unread_ids, &watermark).await?;
2990
2991    write_cursor_sets(
2992        &mut tx,
2993        did,
2994        feed_url,
2995        Some(&watermark),
2996        &keep_above,
2997        &keep_unread,
2998        &now_rfc3339(),
2999    )
3000    .await?;
3001    tx.commit().await.context("commit compact_cursor tx")?;
3002    Ok(Some(watermark))
3003}
3004
3005/// The subset of `ids` whose entries are published strictly AFTER `watermark`,
3006/// as the canonical JSON array-of-strings the cursor stores.
3007async fn ids_published_after(
3008    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3009    feed_url: &str,
3010    ids: &str,
3011    watermark: &str,
3012) -> Result<String> {
3013    let live = ids_matching_watermark(tx, feed_url, watermark, true).await?;
3014    Ok(filter_id_set_to_live(ids, &live))
3015}
3016
3017/// The subset of `ids` whose entries are published at or BEFORE `watermark`.
3018async fn ids_published_at_or_before(
3019    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3020    feed_url: &str,
3021    ids: &str,
3022    watermark: &str,
3023) -> Result<String> {
3024    let live = ids_matching_watermark(tx, feed_url, watermark, false).await?;
3025    Ok(filter_id_set_to_live(ids, &live))
3026}
3027
3028/// Entry ids on `feed_url` on one side of `watermark`. `after = true` selects
3029/// strictly newer; `false` selects at-or-older.
3030async fn ids_matching_watermark(
3031    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3032    feed_url: &str,
3033    watermark: &str,
3034    after: bool,
3035) -> Result<std::collections::HashSet<i64>> {
3036    let sql = if after {
3037        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
3038         WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) > ?2"
3039    } else {
3040        "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
3041         WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) <= ?2"
3042    };
3043    Ok(sqlx::query_scalar::<_, i64>(sql)
3044        .bind(feed_url)
3045        .bind(watermark)
3046        .fetch_all(&mut **tx)
3047        .await
3048        .context("compact_cursor: ids on one side of the watermark")?
3049        .into_iter()
3050        .collect())
3051}
3052
3053/// Clear `did`'s star on any cached entry matching `url` or `guid`, **ignoring
3054/// the subscription projection**. Returns the number of `entry_state` rows
3055/// changed.
3056///
3057/// This closes a desync between the two places a star lives. The starred view
3058/// matches PDS saved records against cached entries through `sub_ref`, so an
3059/// entry that is cached AND starred in a feed the reader has since UNSUBSCRIBED
3060/// from does not match: it renders as an uncached row whose button is
3061/// `POST /saved/{rkey}/delete`. That deletes the PDS record and used to leave
3062/// `entry_state.starred = 1` behind — invisible, because the starred list is
3063/// `sub_ref`-scoped too, until the reader resubscribes and the star reappears
3064/// with no record backing it.
3065///
3066/// **Why omitting `sub_ref` is safe here, when it is the per-DID isolation hook
3067/// everywhere else.** Every row this can touch is keyed by `did` and this writes
3068/// only `starred = 0`. The worst a caller can do with it is clear one of their
3069/// OWN stars — which is what they just asked for. The predicate that matters for
3070/// isolation is the `did` in the `WHERE`, and it is not optional.
3071///
3072/// Matching on `url` OR `guid` mirrors how the view decides a record is already
3073/// cached, so the removal path and the render path agree on what "the same
3074/// article" means.
3075pub async fn clear_star_by_identity(
3076    pool: &SqlitePool,
3077    did: &str,
3078    url: Option<&str>,
3079    guid: Option<&str>,
3080) -> Result<u64> {
3081    // Neither identifier present: nothing to match on. Running the statement
3082    // would compare NULL to NULL and match nothing, but returning early says so.
3083    if url.is_none_or(str::is_empty) && guid.is_none_or(str::is_empty) {
3084        return Ok(0);
3085    }
3086    let res = sqlx::query(
3087        r#"
3088        UPDATE entry_state
3089        SET starred = 0, updated_at = ?4
3090        WHERE did = ?1
3091          AND starred = 1
3092          AND entry_id IN (
3093              SELECT id FROM entries
3094              WHERE (?2 IS NOT NULL AND url = ?2)
3095                 OR (?3 IS NOT NULL AND guid = ?3)
3096          )
3097        "#,
3098    )
3099    .bind(did)
3100    .bind(url.filter(|u| !u.is_empty()))
3101    .bind(guid.filter(|g| !g.is_empty()))
3102    .bind(now_rfc3339())
3103    .execute(pool)
3104    .await
3105    .with_context(|| format!("clear_star_by_identity failed for {did}"))?;
3106    Ok(res.rows_affected())
3107}
3108
3109/// Mark every entry of a feed read (or unread) for a DID in one statement —
3110/// backs the "mark-all-read (per feed)" action. Also projects the change into
3111/// the feed's per-DID [`ReadCursor`] (dirty=1) so the batched flusher syncs the
3112/// new read-state to the PDS.
3113pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
3114    let now = now_rfc3339();
3115    let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
3116    let res = sqlx::query(
3117        r#"
3118        INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
3119        SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
3120        WHERE e.feed_id = ?4
3121          AND EXISTS (
3122              SELECT 1 FROM sub_ref sr
3123              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3124          )
3125        ON CONFLICT (did, entry_id) DO UPDATE SET
3126            read       = excluded.read,
3127            updated_at = excluded.updated_at
3128        "#,
3129    )
3130    .bind(did)
3131    .bind(read)
3132    .bind(&now)
3133    .bind(feed_id)
3134    .execute(&mut *tx)
3135    .await
3136    .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
3137
3138    if res.rows_affected() > 0 {
3139        // Project every affected entry into this feed's read cursor. `feed_id`
3140        // maps to exactly one feed URL, so this is a single per-feed cursor —
3141        // batched, not per-article. Only runs when the caller was authorized
3142        // (some rows changed), so an unsubscribed feed leaves no cursor behind.
3143        project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
3144    }
3145
3146    tx.commit().await.context("commit mark_feed_read tx")?;
3147    Ok(res.rows_affected())
3148}
3149
3150// ---------------------------------------------------------------------------
3151// Read-cursor projection (wires the local read/unread mutation into the
3152// PDS-bound `read_cursor`, so the batched flusher actually pushes read-state)
3153// ---------------------------------------------------------------------------
3154
3155/// Add or remove an entry id from a JSON id-array string, returning the new JSON.
3156/// Membership is set-like (no duplicates) and order-stable (append on add). A
3157/// malformed input is treated as empty so a cosmetic parse issue never blocks a
3158/// projection.
3159fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
3160    let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
3161        .ok()
3162        .map(|vals| {
3163            vals.into_iter()
3164                .filter_map(|v| match v {
3165                    serde_json::Value::Number(n) => n.as_i64(),
3166                    serde_json::Value::String(s) => s.parse::<i64>().ok(),
3167                    _ => None,
3168                })
3169                .collect()
3170        })
3171        .unwrap_or_default();
3172    if present {
3173        if !ids.contains(&id) {
3174            ids.push(id);
3175        }
3176    } else {
3177        ids.retain(|&x| x != id);
3178    }
3179    // Serialize as a JSON array of strings (the shape the flusher / lexicon
3180    // expect — `community.lexicon.rss.readState.readIds` is a string array).
3181    let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
3182    serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
3183}
3184
3185/// The feed URL owning `feed_id`, if the row exists (cursors are keyed by URL,
3186/// not feed id — they mirror the PDS-side `readState.feedUrl`).
3187async fn feed_url_for_id_tx(
3188    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3189    feed_id: i64,
3190) -> Result<Option<String>> {
3191    let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
3192        .bind(feed_id)
3193        .fetch_optional(&mut **tx)
3194        .await
3195        .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
3196    Ok(url)
3197}
3198
3199/// Fetch the (read_through, read_ids, unread_ids) of an existing cursor, or the
3200/// empty defaults if there is none yet.
3201async fn cursor_sets(
3202    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3203    did: &str,
3204    feed_url: &str,
3205) -> Result<(Option<String>, String, String)> {
3206    let row = sqlx::query(
3207        "SELECT read_through, read_ids, unread_ids FROM read_cursor \
3208         WHERE did = ?1 AND feed_url = ?2",
3209    )
3210    .bind(did)
3211    .bind(feed_url)
3212    .fetch_optional(&mut **tx)
3213    .await
3214    .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
3215    Ok(match row {
3216        Some(r) => (
3217            r.get::<Option<String>, _>("read_through"),
3218            r.get::<String, _>("read_ids"),
3219            r.get::<String, _>("unread_ids"),
3220        ),
3221        None => (None, "[]".to_string(), "[]".to_string()),
3222    })
3223}
3224
3225/// Upsert the cursor row for `(did, feed_url)` with the given exception sets,
3226/// stamping `updated_at` and marking it `dirty` so `dirty_cursors` returns it.
3227async fn write_cursor_sets(
3228    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3229    did: &str,
3230    feed_url: &str,
3231    read_through: Option<&str>,
3232    read_ids: &str,
3233    unread_ids: &str,
3234    now: &str,
3235) -> Result<()> {
3236    sqlx::query(
3237        r#"
3238        INSERT INTO read_cursor
3239            (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3240        VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
3241        ON CONFLICT (did, feed_url) DO UPDATE SET
3242            read_through = excluded.read_through,
3243            read_ids     = excluded.read_ids,
3244            unread_ids   = excluded.unread_ids,
3245            dirty        = 1,
3246            updated_at   = excluded.updated_at
3247        "#,
3248    )
3249    .bind(did)
3250    .bind(feed_url)
3251    .bind(read_through)
3252    .bind(read_ids)
3253    .bind(unread_ids)
3254    .bind(now)
3255    .execute(&mut **tx)
3256    .await
3257    .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
3258    Ok(())
3259}
3260
3261/// Project a single entry's read/unread flip into its feed's read cursor.
3262///
3263/// The cursor mirrors `community.lexicon.rss.readState`: a `read_through`
3264/// high-water-mark plus two bounded exception sets. A per-article flip is
3265/// recorded in those sets (`read_ids` when read, `unread_ids` when unread), the
3266/// opposite set is cleared of the id, and the cursor is stamped + marked dirty.
3267/// This keeps the write batched by touching only the ONE per-feed cursor. (Note:
3268/// there is no compaction step yet that folds covered ids back into
3269/// `read_through`; the exception sets are expected to stay well under the cap.)
3270async fn project_entry_into_cursor(
3271    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3272    did: &str,
3273    entry_id: i64,
3274    read: bool,
3275    now: &str,
3276) -> Result<()> {
3277    // The entry's feed id → feed URL (the cursor key).
3278    let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
3279        .bind(entry_id)
3280        .fetch_optional(&mut **tx)
3281        .await
3282        .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
3283    let feed_id = match feed_id {
3284        Some(f) => f,
3285        None => return Ok(()), // entry vanished mid-tx; nothing to project
3286    };
3287    let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3288        Some(u) => u,
3289        None => return Ok(()),
3290    };
3291
3292    let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3293    // read=true: id joins read_ids, leaves unread_ids. read=false: the inverse.
3294    let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
3295    let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
3296    write_cursor_sets(
3297        tx,
3298        did,
3299        &feed_url,
3300        read_through.as_deref(),
3301        &read_ids,
3302        &unread_ids,
3303        now,
3304    )
3305    .await
3306}
3307
3308/// Project a mark-all-feed-read/unread into that feed's single read cursor.
3309///
3310/// Every entry the caller subscribes to on `feed_id` is folded into the cursor
3311/// in one write: on mark-all-READ each id joins `read_ids` (and leaves
3312/// `unread_ids`); on mark-all-UNREAD the inverse. Still ONE per-feed cursor row
3313/// (batched), stamped + dirtied for the flusher.
3314async fn project_feed_into_cursor(
3315    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3316    did: &str,
3317    feed_id: i64,
3318    read: bool,
3319    now: &str,
3320) -> Result<()> {
3321    let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3322        Some(u) => u,
3323        None => return Ok(()),
3324    };
3325
3326    // The entry ids on this feed the caller is authorized for (subscribes to).
3327    let ids: Vec<i64> = sqlx::query_scalar(
3328        r#"
3329        SELECT e.id FROM entries e
3330        WHERE e.feed_id = ?2
3331          AND EXISTS (
3332              SELECT 1 FROM sub_ref sr
3333              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3334          )
3335        "#,
3336    )
3337    .bind(did)
3338    .bind(feed_id)
3339    .fetch_all(&mut **tx)
3340    .await
3341    .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
3342
3343    let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3344    for id in ids {
3345        read_ids = json_id_set_toggle(&read_ids, id, read);
3346        unread_ids = json_id_set_toggle(&unread_ids, id, !read);
3347    }
3348    write_cursor_sets(
3349        tx,
3350        did,
3351        &feed_url,
3352        read_through.as_deref(),
3353        &read_ids,
3354        &unread_ids,
3355        now,
3356    )
3357    .await
3358}
3359
3360/// Test-only unbounded convenience wrappers over [`list_entries`].
3361///
3362/// Production code passes an explicit `limit`, because that is the whole point
3363/// of the change these replaced. Fixtures hold a handful of rows and asserting
3364/// on "the whole list" is what the tests actually mean, so they get a helper
3365/// with a stated ceiling instead of each spelling one out — and the ceiling is
3366/// high enough that a test hitting it is a broken fixture, not a truncation.
3367#[cfg(test)]
3368mod test_helpers {
3369    use super::*;
3370
3371    /// Far above any fixture; a test that reaches it has a bug of its own.
3372    const FIXTURE_MAX: i64 = 10_000;
3373
3374    pub(crate) async fn entries_for_feed(
3375        pool: &SqlitePool,
3376        did: &str,
3377        feed_id: i64,
3378    ) -> Result<Vec<EntryListRow>> {
3379        list_entries(pool, did, ListView::All, Some(&[feed_id]), FIXTURE_MAX, 0).await
3380    }
3381
3382    pub(crate) async fn get_unread_for_did(
3383        pool: &SqlitePool,
3384        did: &str,
3385    ) -> Result<Vec<EntryListRow>> {
3386        list_entries(pool, did, ListView::Unread, None, FIXTURE_MAX, 0).await
3387    }
3388
3389    pub(crate) async fn get_starred_for_did(
3390        pool: &SqlitePool,
3391        did: &str,
3392    ) -> Result<Vec<EntryListRow>> {
3393        list_entries(pool, did, ListView::Starred, None, FIXTURE_MAX, 0).await
3394    }
3395}
3396
3397#[cfg(test)]
3398pub(crate) use test_helpers::{entries_for_feed, get_starred_for_did, get_unread_for_did};
3399
3400/// Insert or update a per-`(did, feed_url)` read cursor, stamping `updated_at`.
3401/// The write path for local mark-read updates (and the seam a login-time PDS
3402/// merge would use, once that is wired).
3403pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
3404    sqlx::query(
3405        r#"
3406        INSERT INTO read_cursor
3407            (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3408        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
3409        ON CONFLICT (did, feed_url) DO UPDATE SET
3410            read_through = excluded.read_through,
3411            read_ids     = excluded.read_ids,
3412            unread_ids   = excluded.unread_ids,
3413            dirty        = excluded.dirty,
3414            updated_at   = excluded.updated_at
3415        "#,
3416    )
3417    .bind(&cursor.did)
3418    .bind(&cursor.feed_url)
3419    .bind(&cursor.read_through)
3420    .bind(&cursor.read_ids)
3421    .bind(&cursor.unread_ids)
3422    .bind(cursor.dirty)
3423    .bind(&cursor.updated_at)
3424    .execute(pool)
3425    .await
3426    .with_context(|| {
3427        format!(
3428            "upsert_cursor failed for {}/{}",
3429            cursor.did, cursor.feed_url
3430        )
3431    })?;
3432    Ok(())
3433}
3434
3435/// Fetch a single read cursor, if present.
3436pub async fn get_cursor(
3437    pool: &SqlitePool,
3438    did: &str,
3439    feed_url: &str,
3440) -> Result<Option<ReadCursor>> {
3441    let cursor = sqlx::query_as::<_, ReadCursor>(
3442        "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
3443    )
3444    .bind(did)
3445    .bind(feed_url)
3446    .fetch_optional(pool)
3447    .await
3448    .context("get_cursor failed")?;
3449    Ok(cursor)
3450}
3451
3452/// The flusher's hot query: every cursor with `dirty = 1` for a DID — the ones
3453/// whose read-state changed since the last batched PDS flush.
3454/// How many DIDs hold read-state that cannot currently be flushed: dirty
3455/// cursors with no OAuth session to send them with.
3456///
3457/// **The visible form of the parked state (#117).** The flusher deliberately
3458/// stops warning about these every round, and quiet-and-invisible would be a
3459/// worse bug than the noisy loop it replaces — so the count is surfaced on
3460/// `/admin/metrics`. A non-zero number is not itself an alarm: it is the normal
3461/// state of anyone signed out with unsynced reads. A number that only ever
3462/// grows is the thing to look at.
3463///
3464/// Rust-backend shaped: it asks about `oauth_session`, which is the Rust
3465/// backend's store. On the sidecar backend it over-reports, since those
3466/// sessions live in the sidecar's own database. Prod runs `rust` and the
3467/// sidecar is removed by #18.
3468pub async fn parked_readstate_dids(pool: &SqlitePool) -> Result<i64> {
3469    let row: (i64,) = sqlx::query_as(
3470        r#"
3471        SELECT COUNT(DISTINCT rc.did)
3472          FROM read_cursor rc
3473         WHERE rc.dirty = 1
3474           AND NOT EXISTS (SELECT 1 FROM oauth_session s WHERE s.sub = rc.did)
3475        "#,
3476    )
3477    .fetch_one(pool)
3478    .await
3479    .context("counting parked read-state DIDs")?;
3480    Ok(row.0)
3481}
3482
3483pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
3484    let cursors =
3485        sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
3486            .bind(did)
3487            .fetch_all(pool)
3488            .await
3489            .with_context(|| format!("dirty_cursors failed for {did}"))?;
3490    Ok(cursors)
3491}
3492
3493// ---------------------------------------------------------------------------
3494// Network observations (the adoption probe's projection)
3495// ---------------------------------------------------------------------------
3496
3497/// Record one relay's observation, keyed by `(key, source)` so each relay's
3498/// number is kept separately (non-archival relays legitimately disagree).
3499///
3500/// An upsert: the table is bounded forever at (metrics × relays) rows — two
3501/// today — so this can never grow the DB. It must stay an upsert and never
3502/// become a per-DID insert.
3503///
3504/// **A truncated observation never lowers a stored count.** A truncated walk
3505/// saw only part of the network, so a smaller number is evidence about the
3506/// *walk*, not about adoption. Without the guard, one slow run that managed a
3507/// single 500-repo page would overwrite a complete 2 000 and drag the published
3508/// "at least N" down — and because `latest_network_stat` takes the max across
3509/// sources, two relays behind the same operator degrade together, so `/about`
3510/// would sit at the lower figure until a full walk succeeded again. A COMPLETE
3511/// observation always wins, even when smaller (repos genuinely can disappear);
3512/// a truncated one may only ever raise the floor — and an EQUAL count raises
3513/// nothing, so it is rejected too. That is why the guard reads `<=` and not
3514/// `<`: the strict form let a truncated walk that merely matched the stored
3515/// number rewrite the row and flip `truncated` on, degrading "2 000" to "at
3516/// least 2 000" with no change in adoption.
3517pub async fn record_network_stat(pool: &SqlitePool, stat: &NetworkStat) -> Result<()> {
3518    sqlx::query(
3519        r#"
3520        INSERT INTO network_stat (key, source, value, truncated, observed_at)
3521        VALUES (?1, ?2, ?3, ?4, ?5)
3522        ON CONFLICT (key, source) DO UPDATE SET
3523            value       = excluded.value,
3524            truncated   = excluded.truncated,
3525            observed_at = excluded.observed_at
3526        WHERE NOT (excluded.truncated = 1 AND excluded.value <= network_stat.value)
3527        "#,
3528    )
3529    .bind(&stat.key)
3530    .bind(&stat.source)
3531    .bind(stat.value)
3532    .bind(stat.truncated)
3533    .bind(&stat.observed_at)
3534    .execute(pool)
3535    .await
3536    .with_context(|| {
3537        format!(
3538            "record_network_stat failed for {}/{}",
3539            stat.key, stat.source
3540        )
3541    })?;
3542    Ok(())
3543}
3544
3545/// The highest observation for `key` across every relay — the number to surface
3546/// (`design/NETWORK-SPEC.md` §4.1: relays disagree; show the max). `None` when no
3547/// probe has ever succeeded.
3548pub async fn latest_network_stat(pool: &SqlitePool, key: &str) -> Result<Option<NetworkStat>> {
3549    let stat = sqlx::query_as::<_, NetworkStat>(
3550        "SELECT key, source, value, truncated, observed_at FROM network_stat \
3551         WHERE key = ?1 ORDER BY value DESC, observed_at DESC LIMIT 1",
3552    )
3553    .bind(key)
3554    .fetch_optional(pool)
3555    .await
3556    .with_context(|| format!("latest_network_stat failed for {key}"))?;
3557    Ok(stat)
3558}
3559
3560/// Mark a cursor's PDS `readState` record as CREATED after the flush that first
3561/// created it, so subsequent flushes emit an `update` instead of another
3562/// `create`. Idempotent; a no-op if the row is gone.
3563pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
3564    sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
3565        .bind(did)
3566        .bind(feed_url)
3567        .execute(pool)
3568        .await
3569        .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
3570    Ok(())
3571}
3572
3573/// Set a cursor's `pds_created` flag to what the PDS was just observed to hold.
3574///
3575/// [`mark_cursor_pds_created`] only ever sets it, because a successful create is
3576/// the only event the flusher used to learn from. The read-state reconcile
3577/// (#241) learns from a listing, and a listing can say the record is GONE —
3578/// deleted by another client or a repo reset — so it needs the other direction
3579/// too, or every later flush sends `#update` to a key that does not exist.
3580pub async fn set_cursor_pds_created(
3581    pool: &SqlitePool,
3582    did: &str,
3583    feed_url: &str,
3584    created: bool,
3585) -> Result<()> {
3586    sqlx::query("UPDATE read_cursor SET pds_created = ?3 WHERE did = ?1 AND feed_url = ?2")
3587        .bind(did)
3588        .bind(feed_url)
3589        .bind(created)
3590        .execute(pool)
3591        .await
3592        .with_context(|| format!("set_cursor_pds_created failed for {did}/{feed_url}"))?;
3593    Ok(())
3594}
3595
3596/// Clear the `dirty` flag on a cursor after a successful PDS flush — but ONLY if
3597/// the row still carries the exact `flushed_updated_at` snapshot we flushed.
3598///
3599/// The flusher reads a cursor, sends it to the PDS (a network round-trip), then
3600/// clears `dirty`. A concurrent [`upsert_cursor`] (a fresh mark-read) can land
3601/// DURING that in-flight write, bumping `updated_at` and re-setting `dirty = 1`
3602/// for reads that were NOT in the flushed snapshot. An unconditional
3603/// `SET dirty = 0` would silently drop those reads. Guarding on the snapshot's
3604/// `updated_at` makes this a compare-and-swap: if `updated_at` changed under us,
3605/// zero rows update, the row stays dirty, and it re-flushes next round.
3606pub async fn clear_cursor_dirty(
3607    pool: &SqlitePool,
3608    did: &str,
3609    feed_url: &str,
3610    flushed_updated_at: &str,
3611) -> Result<()> {
3612    sqlx::query(
3613        "UPDATE read_cursor SET dirty = 0 \
3614         WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
3615    )
3616    .bind(did)
3617    .bind(feed_url)
3618    .bind(flushed_updated_at)
3619    .execute(pool)
3620    .await
3621    .context("clear_cursor_dirty failed")?;
3622    Ok(())
3623}
3624
3625// ---------------------------------------------------------------------------
3626// Closed-beta invite gate (beta_access + invite_codes)
3627// ---------------------------------------------------------------------------
3628//
3629// Ported in SHAPE from a prior Go beta-gate (RedeemCode / CreateInviteCode /
3630// code_gen) but deliberately trimmed for FeatherReader's before-public
3631// experiment: NO viral invite-budget tree, NO generation cap, NO waitlist /
3632// invite-request table, and SQLite instead of Mongo. A code is minted by an
3633// existing member (or admin), and redeeming it grants a seat while seats remain
3634// under the configured cap.
3635
3636/// Unix-epoch seconds for "now" — the integer time base for the beta tables.
3637pub(crate) fn now_unix() -> i64 {
3638    chrono::Utc::now().timestamp()
3639}
3640
3641/// The invite-code alphabet: uppercase letters + digits with the
3642/// visually-ambiguous glyphs removed (`I`, `O`, `0`, `1`) so a code read aloud
3643/// or copied by hand is unambiguous.
3644const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
3645
3646/// Human-facing prefix so a FeatherReader invite code is recognisable at a
3647/// glance.
3648const CODE_PREFIX: &str = "FEATHER-";
3649
3650/// Number of random characters after the prefix.
3651const CODE_BODY_LEN: usize = 8;
3652
3653/// Generate a random, unguessable invite code of the form `FEATHER-XXXXXXXX`.
3654///
3655/// Draws from the OS CSPRNG (`getrandom`) and maps each byte onto
3656/// `CODE_ALPHABET` via rejection sampling so the alphabet distribution is
3657/// uniform (no modulo bias). Infallible in practice; a `getrandom` failure
3658/// (no entropy source) propagates as an error rather than a weak code.
3659pub fn generate_invite_code() -> Result<String> {
3660    let n = CODE_ALPHABET.len() as u16; // 31
3661                                        // Largest multiple of `n` that fits in a byte; bytes at or above it are
3662                                        // rejected so every accepted byte maps uniformly onto the alphabet.
3663    let limit = 256 / n * n; // 256 - (256 % n)
3664    let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
3665    out.push_str(CODE_PREFIX);
3666    let mut got = 0;
3667    let mut buf = [0u8; 1];
3668    while got < CODE_BODY_LEN {
3669        getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
3670        let b = buf[0] as u16;
3671        if b < limit {
3672            out.push(CODE_ALPHABET[(b % n) as usize] as char);
3673            got += 1;
3674        }
3675    }
3676    Ok(out)
3677}
3678
3679/// Whether a DID currently holds a beta seat.
3680pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
3681    let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
3682        .bind(did)
3683        .fetch_optional(pool)
3684        .await
3685        .with_context(|| format!("has_beta_access failed for {did}"))?;
3686    Ok(row.is_some())
3687}
3688
3689/// Count the beta seats currently granted — the numerator checked against the
3690/// configured cap on redeem.
3691pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
3692    let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3693        .fetch_one(pool)
3694        .await
3695        .context("count_beta_access failed")?;
3696    Ok(row.get::<i64, _>("n"))
3697}
3698
3699/// Count `active`, unexpired invite codes — the outstanding-but-unredeemed seats
3700/// a bot has already promised. Added to [`count_beta_access`] this is the "seats
3701/// committed" figure the bot mint path (`POST /bot/claims`) checks against the
3702/// cap, so it doesn't over-promise more claims than seats remain (the redeem-time
3703/// cap in [`redeem_code`] is the hard backstop; this avoids telling a follower
3704/// "you're in" for a seat that will be full by the time they claim it).
3705pub async fn count_active_codes(pool: &SqlitePool) -> Result<i64> {
3706    let now = now_unix();
3707    let row = sqlx::query(
3708        "SELECT COUNT(*) AS n FROM invite_codes WHERE status = 'active' AND expires_at >= ?1",
3709    )
3710    .bind(now)
3711    .fetch_one(pool)
3712    .await
3713    .context("count_active_codes failed")?;
3714    Ok(row.get::<i64, _>("n"))
3715}
3716
3717/// Grant a beta seat directly (admin / seed path — no code consumed). Idempotent
3718/// on `did` (re-granting updates the row rather than erroring).
3719pub async fn grant_access(
3720    pool: &SqlitePool,
3721    did: &str,
3722    handle: Option<&str>,
3723    granted_by: &str,
3724    invite_code_used: Option<&str>,
3725) -> Result<()> {
3726    sqlx::query(
3727        r#"
3728        INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3729        VALUES (?1, ?2, ?3, ?4, ?5)
3730        ON CONFLICT (did) DO UPDATE SET
3731            handle           = COALESCE(excluded.handle, beta_access.handle),
3732            granted_by       = excluded.granted_by,
3733            invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
3734        "#,
3735    )
3736    .bind(did)
3737    .bind(handle)
3738    .bind(granted_by)
3739    .bind(now_unix())
3740    .bind(invite_code_used)
3741    .execute(pool)
3742    .await
3743    .with_context(|| format!("grant_access failed for {did}"))?;
3744    Ok(())
3745}
3746
3747/// Mint a new `active` invite code owned by `creator_did`, expiring `ttl_secs`
3748/// from now. Returns the generated code string. The browser/admin path leaves the
3749/// bot idempotency key (`intended_did`) NULL; see [`mint_code_for_did`] for the
3750/// bot path that records the target follower.
3751pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
3752    mint_code_inner(pool, creator_did, ttl_secs, None).await
3753}
3754
3755/// Like [`mint_code`] but records the follower `intended_did` the code is minted
3756/// FOR, so a later `POST /bot/claims` for the same DID can return the SAME code
3757/// (see [`find_active_code_for_did`]) rather than minting a duplicate. This is the
3758/// app-side idempotency backstop that survives a bot-host state loss.
3759pub async fn mint_code_for_did(
3760    pool: &SqlitePool,
3761    creator_did: &str,
3762    ttl_secs: i64,
3763    intended_did: &str,
3764) -> Result<String> {
3765    mint_code_inner(pool, creator_did, ttl_secs, Some(intended_did)).await
3766}
3767
3768async fn mint_code_inner(
3769    pool: &SqlitePool,
3770    creator_did: &str,
3771    ttl_secs: i64,
3772    intended_did: Option<&str>,
3773) -> Result<String> {
3774    let code = generate_invite_code()?;
3775    let now = now_unix();
3776    let expires_at = now.saturating_add(ttl_secs.max(0));
3777    sqlx::query(
3778        r#"
3779        INSERT INTO invite_codes
3780            (code, creator_did, status, invitee_did, intended_did, created_at, expires_at, redeemed_at)
3781        VALUES (?1, ?2, 'active', NULL, ?3, ?4, ?5, NULL)
3782        "#,
3783    )
3784    .bind(&code)
3785    .bind(creator_did)
3786    .bind(intended_did)
3787    .bind(now)
3788    .bind(expires_at)
3789    .execute(pool)
3790    .await
3791    .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
3792    Ok(code)
3793}
3794
3795/// Does this error chain represent the partial-unique-index conflict raised when
3796/// a SECOND active claim is minted for a DID that already has one
3797/// (`idx_invite_codes_intended_active`)? The web layer uses this to recover from a
3798/// lost mint race (S4): on a conflict it re-reads the winner's code instead of
3799/// 500-ing. Matches on the sqlx `Database` error's UNIQUE-constraint code (SQLite
3800/// 2067 / primary 19) AND the offending COLUMN in the message
3801/// (`invite_codes.intended_did` — SQLite names the column(s), not the index), so an
3802/// unrelated constraint violation (e.g. the `code` PRIMARY KEY) is NOT swallowed.
3803pub fn is_intended_active_conflict(err: &anyhow::Error) -> bool {
3804    for cause in err.chain() {
3805        if let Some(sqlx::Error::Database(db)) = cause.downcast_ref::<sqlx::Error>() {
3806            let msg = db.message();
3807            // SQLite reports UNIQUE violations with (primary) code 19 /
3808            // (extended) 2067; the message names the offending column(s), e.g.
3809            // "UNIQUE constraint failed: invite_codes.intended_did".
3810            let is_unique = db.code().as_deref() == Some("2067")
3811                || db.code().as_deref() == Some("19")
3812                || msg.contains("UNIQUE constraint failed");
3813            // Scope to the intended_did index specifically. Only that index and the
3814            // `code` PRIMARY KEY can raise a UNIQUE error here; the partial unique
3815            // index is the only one over `intended_did`, so the column reference
3816            // uniquely identifies it.
3817            if is_unique && msg.contains("invite_codes.intended_did") {
3818                return true;
3819            }
3820        }
3821    }
3822    false
3823}
3824
3825/// The `code` of an outstanding (`active`, unexpired) invite minted FOR the
3826/// follower `intended_did`, if one exists — the app-side idempotency lookup for
3827/// `POST /bot/claims`. `Some(code)` means "return this existing code, do NOT mint
3828/// a second"; `None` means "no live code for this DID — mint one".
3829///
3830/// S3 — this lookup ONLY returns `active`, UNEXPIRED codes; once a code passes
3831/// `expires_at` (or `expire_old_codes` flips it to `expired`) this returns `None`,
3832/// so the next `POST /bot/claims` MINTS A FRESH code for the DID. There is no
3833/// in-place "refresh" of an expired code (the partial-unique index only constrains
3834/// `active` rows, so a fresh mint after expiry is allowed). The bot then re-posts:
3835/// its record rkey is deterministic per DID, so the existing skeet is UPDATED in
3836/// place with the new claim URL (see the bot's `reconcile_stale_record`, S1) rather
3837/// than a second skeet being posted. NOTE: a bot-`delivered` follower whose link
3838/// expired UNCLAIMED is only re-minted if the bot re-processes that DID (a re-seen
3839/// follow, a `waitlisted` retry, or a bot-store reset); manual recovery is to clear
3840/// the bot's `handled` row for that DID so the next cycle re-mints + re-posts.
3841/// If several live codes somehow exist (a race), the soonest-expiring is returned.
3842pub async fn find_active_code_for_did(
3843    pool: &SqlitePool,
3844    intended_did: &str,
3845) -> Result<Option<String>> {
3846    let now = now_unix();
3847    let row = sqlx::query(
3848        "SELECT code FROM invite_codes
3849         WHERE intended_did = ?1 AND status = 'active' AND expires_at >= ?2
3850         ORDER BY expires_at ASC
3851         LIMIT 1",
3852    )
3853    .bind(intended_did)
3854    .bind(now)
3855    .fetch_optional(pool)
3856    .await
3857    .with_context(|| format!("find_active_code_for_did failed for {intended_did}"))?;
3858    Ok(row.map(|r| r.get::<String, _>("code")))
3859}
3860
3861/// Atomically redeem an invite code for `did`, granting a beta seat.
3862///
3863/// Runs entirely in one transaction so the capacity check and the seat grant
3864/// cannot race (two redeems can't both slip past a `cap - 1` count). Steps:
3865/// 1. verify the code exists, is `active`, and is not past `expires_at`;
3866/// 2. verify the current seat count is `< cap`;
3867/// 3. flip the code `active`→`redeemed` (stamping `invitee_did` + `redeemed_at`);
3868/// 4. insert the `beta_access` row.
3869///
3870/// On a policy failure returns the matching [`RedeemError`] (the tx rolls back);
3871/// a real SQLite error propagates as the outer [`anyhow::Error`].
3872pub async fn redeem_code(
3873    pool: &SqlitePool,
3874    code: &str,
3875    did: &str,
3876    handle: Option<&str>,
3877    cap: i64,
3878) -> Result<std::result::Result<(), RedeemError>> {
3879    let now = now_unix();
3880    let mut tx = pool.begin().await.context("begin redeem_code tx")?;
3881
3882    // Take the write lock at the START of the transaction. sqlx issues a plain
3883    // deferred BEGIN, so without this the capacity SELECT below runs under a read
3884    // snapshot: two concurrent redeems could both pass the gate, and the loser's
3885    // later UPDATE would fail with SQLITE_BUSY_SNAPSHOT (which busy_timeout does
3886    // NOT retry) — an opaque error instead of a clean CapacityFull. A leading
3887    // no-op write against the target row acquires the RESERVED lock immediately
3888    // (SQLite locks on any write statement, even one matching zero rows), so the
3889    // second redeem blocks on the first, then reads the post-commit seat count
3890    // and returns CapacityFull. (The cap already held via snapshot isolation;
3891    // this upgrades the failure mode from a hard error to the right one.)
3892    sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
3893        .bind(code)
3894        .execute(&mut *tx)
3895        .await
3896        .context("redeem_code: acquire write lock")?;
3897
3898    // 1. Look the code up.
3899    let row =
3900        sqlx::query("SELECT status, expires_at, intended_did FROM invite_codes WHERE code = ?1")
3901            .bind(code)
3902            .fetch_optional(&mut *tx)
3903            .await
3904            .context("redeem_code: lookup")?;
3905    let row = match row {
3906        Some(r) => r,
3907        None => return Ok(Err(RedeemError::NotFound)),
3908    };
3909    let status: String = row.get("status");
3910    let expires_at: i64 = row.get("expires_at");
3911    let intended_did: Option<String> = row.get("intended_did");
3912
3913    // DID-binding gate (blocker B2). A bot-minted claim link is posted PUBLICLY
3914    // with a non-confidential token, so anyone who sees a follower's reply could
3915    // redeem it with a throwaway account — defeating the follow-gate, the daily
3916    // sybil budget, and the rate limit. When the code was minted FOR a specific
3917    // follower (`intended_did IS NOT NULL`), only that DID may redeem it; anyone
3918    // else gets a `NotFound` (indistinguishable from a bad code — no oracle).
3919    // Codes with a NULL `intended_did` (admin/browser-minted) stay open, as
3920    // before — those are meant to be sharable.
3921    if let Some(bound) = intended_did.as_deref() {
3922        if bound != did {
3923            return Ok(Err(RedeemError::NotFound));
3924        }
3925    }
3926
3927    // Status gate: only an `active` code is redeemable. Anything already
3928    // redeemed/revoked is "already redeemed" from the redeemer's view; an
3929    // `expired` status (or a past expiry) is "expired".
3930    if status == "expired" || now > expires_at {
3931        return Ok(Err(RedeemError::Expired));
3932    }
3933    if status != "active" {
3934        return Ok(Err(RedeemError::AlreadyRedeemed));
3935    }
3936
3937    // 2. Capacity gate (inside the tx so it can't race a concurrent redeem).
3938    let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3939        .fetch_one(&mut *tx)
3940        .await
3941        .context("redeem_code: count")?
3942        .get("n");
3943    if count >= cap {
3944        return Ok(Err(RedeemError::CapacityFull));
3945    }
3946
3947    // 3. Flip the code active→redeemed. The `status = 'active'` guard in the
3948    // WHERE makes this a compare-and-swap: if a concurrent tx already flipped it
3949    // (despite the read above), zero rows change and we treat it as redeemed.
3950    let flipped = sqlx::query(
3951        r#"
3952        UPDATE invite_codes
3953        SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
3954        WHERE code = ?1 AND status = 'active'
3955        "#,
3956    )
3957    .bind(code)
3958    .bind(did)
3959    .bind(now)
3960    .execute(&mut *tx)
3961    .await
3962    .context("redeem_code: flip")?;
3963    if flipped.rows_affected() == 0 {
3964        return Ok(Err(RedeemError::AlreadyRedeemed));
3965    }
3966
3967    // 4. Grant the seat.
3968    sqlx::query(
3969        r#"
3970        INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3971        VALUES (?1, ?2, ?3, ?4, ?5)
3972        ON CONFLICT (did) DO UPDATE SET
3973            handle           = COALESCE(excluded.handle, beta_access.handle),
3974            invite_code_used = excluded.invite_code_used
3975        "#,
3976    )
3977    .bind(did)
3978    .bind(handle)
3979    // granted_by is the code's creator; look it up in-tx to keep provenance.
3980    .bind(
3981        sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
3982            .bind(code)
3983            .fetch_one(&mut *tx)
3984            .await
3985            .context("redeem_code: creator lookup")?
3986            .get::<String, _>("creator_did"),
3987    )
3988    .bind(now)
3989    .bind(code)
3990    .execute(&mut *tx)
3991    .await
3992    .context("redeem_code: grant")?;
3993
3994    tx.commit().await.context("commit redeem_code tx")?;
3995    Ok(Ok(()))
3996}
3997
3998/// Sweep: flip every `active` code whose `expires_at` is in the past to
3999/// `expired`. Returns the number of codes expired. Called periodically by the
4000/// scheduler.
4001pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
4002    let now = now_unix();
4003    let res = sqlx::query(
4004        "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
4005    )
4006    .bind(now)
4007    .execute(pool)
4008    .await
4009    .context("expire_old_codes failed")?;
4010    Ok(res.rows_affected())
4011}
4012
4013/// Seed the admin-bootstrap DIDs: for each, insert a `beta_access` row
4014/// (`granted_by = 'admin'`) if one does not already exist. Idempotent — an
4015/// existing seat is left untouched. Returns how many new seats were created.
4016pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
4017    let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
4018    let now = now_unix();
4019    let mut created = 0u64;
4020    for did in dids {
4021        let res = sqlx::query(
4022            r#"
4023            INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
4024            VALUES (?1, NULL, 'admin', ?2, NULL)
4025            ON CONFLICT (did) DO NOTHING
4026            "#,
4027        )
4028        .bind(did)
4029        .bind(now)
4030        .execute(&mut *tx)
4031        .await
4032        .with_context(|| format!("ensure_seed insert failed for {did}"))?;
4033        created += res.rows_affected();
4034    }
4035    tx.commit().await.context("commit ensure_seed tx")?;
4036    Ok(created)
4037}
4038
4039/// The row counts purged by [`purge_did_data`], for a confirmable success
4040/// message and for assertions in tests.
4041#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
4042pub struct PurgeCounts {
4043    /// `entry_state` rows removed (per-DID read/star flags).
4044    pub entry_state: u64,
4045    /// `read_cursor` rows removed (per-DID per-feed read cursors).
4046    pub read_cursor: u64,
4047    /// `sub_ref` rows removed (the DID's subscription projection).
4048    pub sub_ref: u64,
4049    /// `beta_access` rows removed (the DID's closed-beta seat: 0 or 1).
4050    pub beta_access: u64,
4051    /// `invite_codes` rows removed (codes this DID *created*).
4052    pub invite_codes: u64,
4053    /// `invite_codes` rows *scrubbed* (the code this DID *redeemed* to join —
4054    /// its `invitee_did` back-reference cleared to NULL, row kept).
4055    pub invitee_scrubbed: u64,
4056    /// `beta_access` rows *scrubbed* (seats this DID *granted* to others — the
4057    /// `granted_by` back-reference redacted to a sentinel, row kept).
4058    pub granted_by_scrubbed: u64,
4059}
4060
4061impl PurgeCounts {
4062    /// Total rows removed across every per-DID table. (Scrub counts are tracked
4063    /// separately — those rows belong to *other* DIDs and are redacted, not
4064    /// deleted — so they are excluded from the delete total.)
4065    pub fn total(&self) -> u64 {
4066        self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
4067    }
4068}
4069
4070/// Sentinel written into `beta_access.granted_by` when the granting DID deletes
4071/// its data: the column is `NOT NULL`, so we redact rather than NULL it. Keeps
4072/// the grantee's seat valid while removing the departed DID's back-reference.
4073pub const REDACTED_DID: &str = "__redacted__";
4074
4075/// Delete **all** local rows owned by `did` in a single transaction: the
4076/// per-DID read/star state (`entry_state`), per-feed read cursors
4077/// (`read_cursor`), the subscription projection (`sub_ref`), the closed-beta
4078/// seat (`beta_access`), and any invite codes this DID *created*
4079/// (`invite_codes`). The shared `feeds`/`entries` cache is intentionally left
4080/// intact — it is deduped and not owned by any single DID.
4081///
4082/// This is the local half of "delete my data": the caller pairs it with a
4083/// sidecar `POST /internal/revoke` so the OAuth tokens + sidecar session rows
4084/// are dropped too. Idempotent — deleting a DID with no rows returns all-zero
4085/// counts.
4086pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
4087    let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
4088
4089    let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
4090        .bind(did)
4091        .execute(&mut *tx)
4092        .await
4093        .with_context(|| format!("purge entry_state for {did}"))?
4094        .rows_affected();
4095
4096    let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
4097        .bind(did)
4098        .execute(&mut *tx)
4099        .await
4100        .with_context(|| format!("purge read_cursor for {did}"))?
4101        .rows_affected();
4102
4103    let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
4104        .bind(did)
4105        .execute(&mut *tx)
4106        .await
4107        .with_context(|| format!("purge sub_ref for {did}"))?
4108        .rows_affected();
4109
4110    let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
4111        .bind(did)
4112        .execute(&mut *tx)
4113        .await
4114        .with_context(|| format!("purge beta_access for {did}"))?
4115        .rows_affected();
4116
4117    let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
4118        .bind(did)
4119        .execute(&mut *tx)
4120        .await
4121        .with_context(|| format!("purge invite_codes for {did}"))?
4122        .rows_affected();
4123
4124    // Scrub the DID's back-references from rows that belong to OTHER DIDs so no
4125    // per-DID residue survives the delete:
4126    //   * the invite code this DID *redeemed* to join lives on the inviter's
4127    //     row (`invitee_did`) — NULL it out (column is nullable).
4128    //   * seats this DID *granted* to others carry `granted_by = <this did>` —
4129    //     redact to a sentinel (column is NOT NULL) so the grantee keeps access
4130    //     without retaining the departed DID.
4131    let invitee_scrubbed =
4132        sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
4133            .bind(did)
4134            .execute(&mut *tx)
4135            .await
4136            .with_context(|| format!("scrub invitee_did for {did}"))?
4137            .rows_affected();
4138
4139    // A departing DID may also be the TARGET of an outstanding bot claim
4140    // (`intended_did`, minted for them before they joined/left) — NULL it so no
4141    // per-DID residue survives. We ALSO expire the orphaned code in the same tx:
4142    // once `intended_did` is NULLed, an `active` row would otherwise keep counting
4143    // against the daily mint cap for its full 14-day TTL (and a re-follow would
4144    // double-count it), so `expired` it now. `redeemed`/already-`expired` rows are
4145    // untouched (the WHERE only matches `active`). (Cheap nit — purge orphan.)
4146    sqlx::query(
4147        "UPDATE invite_codes \
4148         SET intended_did = NULL, \
4149             status = CASE WHEN status = 'active' THEN 'expired' ELSE status END \
4150         WHERE intended_did = ?1",
4151    )
4152    .bind(did)
4153    .execute(&mut *tx)
4154    .await
4155    .with_context(|| format!("scrub intended_did for {did}"))?;
4156
4157    let granted_by_scrubbed =
4158        sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
4159            .bind(did)
4160            .bind(REDACTED_DID)
4161            .execute(&mut *tx)
4162            .await
4163            .with_context(|| format!("scrub granted_by for {did}"))?
4164            .rows_affected();
4165
4166    tx.commit().await.context("commit purge_did_data tx")?;
4167
4168    Ok(PurgeCounts {
4169        entry_state,
4170        read_cursor,
4171        sub_ref,
4172        beta_access,
4173        invite_codes,
4174        invitee_scrubbed,
4175        granted_by_scrubbed,
4176    })
4177}
4178
4179/// Aggregate poll health, for the public stats page.
4180///
4181/// **Deliberately aggregate-only.** No user counts, no error rates, no per-feed
4182/// detail: this is published to anyone, and a reader does not need to know how
4183/// many people use an instance or which feeds are failing. What it does answer
4184/// is the only question the page exists for — is the poller keeping up?
4185#[derive(Debug, Clone, PartialEq, Eq)]
4186pub struct PollHealth {
4187    /// Distinct feeds the poller is responsible for.
4188    pub feeds_tracked: i64,
4189    /// How many were polled within the last hour.
4190    pub polled_last_hour: i64,
4191    /// Feeds whose `next_poll` has passed — the backlog. A healthy instance
4192    /// clears this every tick; a growing number is the signal that the poller
4193    /// cannot keep up with the feed count.
4194    pub overdue: i64,
4195    /// Seconds since the most recent poll of any feed. `None` before the first.
4196    pub last_poll_secs_ago: Option<i64>,
4197    /// Seconds since the LEAST recently polled feed was polled — the worst
4198    /// staleness any reader is currently seeing.
4199    ///
4200    /// `None` when any feed has NEVER been polled, because that is a worse
4201    /// staleness than any finite age and reporting the finite one would make
4202    /// the page read healthiest exactly when it is least healthy.
4203    pub oldest_poll_secs_ago: Option<i64>,
4204    /// How many feeds have never been polled at all.
4205    pub never_polled: i64,
4206    /// Feeds currently in error backoff (`consecutive_errors > 0`).
4207    ///
4208    /// One of the two states that stop feeds updating, and previously visible
4209    /// nowhere: `consecutive_errors` was written by `bump_feed_errors` and read
4210    /// by nothing outside the backoff calculation — no page, no endpoint. Worse,
4211    /// a feed in backoff is NOT counted in `overdue`, because backoff is applied
4212    /// by pushing `next_poll` forward. So the one number a reader might have
4213    /// checked moved the wrong way: a feed failing every fetch made `overdue`
4214    /// look BETTER.
4215    pub in_backoff: i64,
4216    /// Of those, how many have reached `BADLY_BROKEN_ERRORS` consecutive
4217    /// failures — retried 2h40m apart rather than every 5 minutes.
4218    ///
4219    /// Not "will not recover on their own": the backoff ceiling is 24h at ten
4220    /// errors, and any of these recovers on its next successful poll. See
4221    /// `BADLY_BROKEN_ERRORS`.
4222    pub badly_broken: i64,
4223    /// Failing feeds grouped by **cause**, descending, as
4224    /// `(kind, count)` — `fetch`, `status`, `body`, `parse`.
4225    ///
4226    /// **Counts, never identities.** `/stats` is public and states that it
4227    /// reports machines rather than people: no per-feed detail, never which feed
4228    /// and never whose. A cause histogram keeps that promise and still answers
4229    /// the question `badly_broken` could not — whether sixty feeds are failing
4230    /// for sixty reasons or for one. Had this existed, #159 would have read
4231    /// `fetch: 60` on a page anyone could load, instead of costing a production
4232    /// investigation.
4233    pub failure_kinds: Vec<(String, i64)>,
4234}
4235
4236/// `consecutive_errors` at or above which a feed counts as `badly_broken`.
4237///
4238/// Chosen to mean "this is not a transient blip": `feed::backoff_for` climbs
4239/// exponentially, so by this many consecutive failures a feed is being retried
4240/// **2h40m apart** — `backoff_for(6)`.
4241///
4242/// **Not "at or near the ceiling", and not "effectively dead".** `BACKOFF_MAX`
4243/// is 24h and is first reached at *ten* errors, so a feed at this threshold is
4244/// still retried around nine times a day and recovers on its own the moment the
4245/// cause clears. Three doc comments claimed otherwise, and the claim was
4246/// load-bearing in the wrong direction.
4247///
4248/// **It says nothing about whose fault the failure is, and used to claim it
4249/// did.** This comment and the matching `/stats` copy read "almost certainly
4250/// gone rather than flaky" until 2026-09-20, when #159 found that 60-odd feeds
4251/// sat here because `guarded_get` was reading every `304 Not Modified` as a
4252/// malformed redirect. The publishers were live; the reader was broken. That
4253/// assertion is what stopped anyone looking, which is why `last_error_kind`
4254/// now exists — the row can answer the question the count never could.
4255const BADLY_BROKEN_ERRORS: i64 = 6;
4256
4257/// Compute [`PollHealth`] as of `now` (RFC3339, seconds precision — the same
4258/// format the scheduler writes, so the comparisons are lexicographic).
4259pub async fn poll_health(pool: &SqlitePool, now: &str, hour_ago: &str) -> Result<PollHealth> {
4260    // **Only what the poller sees.** `due_feeds` skips `at://` rows, so nothing
4261    // ever advances their `next_poll` or sets `last_polled`; counted here they
4262    // read as overdue and never-polled forever and force "oldest poll" to
4263    // `never` — unsupported shown as broken, on a public page, permanently.
4264    // The same predicate as the scheduler's, so the two cannot disagree.
4265    let aggregate = format!(
4266        r#"
4267        SELECT
4268            COUNT(*),
4269            COALESCE(SUM(CASE WHEN last_polled IS NOT NULL AND last_polled >= ?2 THEN 1 ELSE 0 END), 0),
4270            COALESCE(SUM(CASE WHEN next_poll IS NULL OR next_poll <= ?1 THEN 1 ELSE 0 END), 0),
4271            MAX(last_polled),
4272            -- NULL-AWARE. `MIN` skips NULLs, so an instance where most feeds
4273            -- had NEVER been polled reported the freshest of the few that had —
4274            -- the figure read healthiest in the most degraded state, which is
4275            -- the opposite of what a health page is for. A never-polled feed IS
4276            -- the worst staleness, so it wins outright.
4277            CASE WHEN SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END) > 0
4278                 THEN NULL ELSE MIN(last_polled) END,
4279            SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END),
4280            COALESCE(SUM(CASE WHEN consecutive_errors > 0 THEN 1 ELSE 0 END), 0),
4281            COALESCE(SUM(CASE WHEN consecutive_errors >= ?3 THEN 1 ELSE 0 END), 0)
4282        FROM feeds
4283        WHERE kind IN ({POLLABLE_KINDS_SQL})
4284        "#
4285    );
4286    #[allow(clippy::type_complexity)]
4287    let row: (i64, i64, i64, Option<String>, Option<String>, i64, i64, i64) =
4288        sqlx::query_as(sqlx::AssertSqlSafe(aggregate))
4289            .bind(now)
4290            .bind(hour_ago)
4291            .bind(BADLY_BROKEN_ERRORS)
4292            .fetch_one(pool)
4293            .await
4294            .context("computing poll health")?;
4295
4296    // A second, tiny query rather than a join: the histogram groups rows the
4297    // aggregate above collapses, and one statement doing both would make the
4298    // counts above harder to read than the extra round trip is worth.
4299    //
4300    // **Every failing feed lands in a bucket, so this sums to `in_backoff`.**
4301    //
4302    // A row that predates the column is failing with no recorded cause, and it
4303    // must not be attributed to some other feed's reason — but it must not
4304    // vanish either. Filtering them out made the breakdown silently disagree
4305    // with the `Failing` figure beside it: on a migrated database that is EVERY
4306    // currently-failing feed, so the page would have read "70 failing" next to
4307    // "3 fetch" with 67 unexplained and no indication a remainder existed.
4308    //
4309    // `unknown` is a deliberate bucket rather than an omission. It cannot
4310    // collide with a real kind — `FailureKind::as_str` never returns it, and
4311    // `FailureKind::parse("unknown")` is `None`.
4312    let histogram = format!(
4313        r#"
4314        -- **`failure_kind`, not `kind`.** Aliasing this `kind` collided with
4315        -- the `feeds.kind` column added for the poller: SQLite resolved
4316        -- `GROUP BY kind` to the table column, so every failing feed collapsed
4317        -- into ONE bucket labelled from an arbitrary row — a public page
4318        -- reporting "10 fetch" for ten unrelated causes. Caught by
4319        -- `an_unrecognised_failure_kind_folds_into_unknown`.
4320        SELECT COALESCE(last_error_kind, 'unknown') AS failure_kind, COUNT(*) AS n
4321        FROM feeds
4322        WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
4323        GROUP BY failure_kind
4324        ORDER BY n DESC, failure_kind ASC
4325        "#
4326    );
4327    let kinds: Vec<(String, i64)> = sqlx::query_as(sqlx::AssertSqlSafe(histogram))
4328        .fetch_all(pool)
4329        .await
4330        .context("computing the failure-cause histogram")?;
4331
4332    // **Close the vocabulary where it is READ.** `FailureKind::parse` promised
4333    // that a kind from a newer build would not be attributed to a cause this
4334    // one recognises — but nothing called it, so the raw column reached the
4335    // public template and an unrecognised string rendered as its own bucket.
4336    // Fold anything `parse` rejects into `unknown`, then re-aggregate and
4337    // re-order, so the histogram only ever shows the four kinds this build
4338    // knows plus the one honest bucket for what it does not.
4339    let mut folded: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
4340    for (kind, n) in kinds {
4341        let key = if kind == "unknown" || crate::feed::FailureKind::parse(&kind).is_some() {
4342            kind
4343        } else {
4344            "unknown".to_string()
4345        };
4346        *folded.entry(key).or_insert(0) += n;
4347    }
4348    let mut kinds: Vec<(String, i64)> = folded.into_iter().collect();
4349    kinds.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4350
4351    Ok(PollHealth {
4352        feeds_tracked: row.0,
4353        polled_last_hour: row.1,
4354        overdue: row.2,
4355        last_poll_secs_ago: secs_between(row.3.as_deref(), now),
4356        oldest_poll_secs_ago: secs_between(row.4.as_deref(), now),
4357        never_polled: row.5,
4358        in_backoff: row.6,
4359        badly_broken: row.7,
4360        failure_kinds: kinds,
4361    })
4362}
4363
4364/// Whole seconds from `then` to `now`, or `None` if `then` is absent or
4365/// unparseable. Never negative: a clock skew that puts a poll in the future
4366/// reads as "just now" rather than as a negative age.
4367fn secs_between(then: Option<&str>, now: &str) -> Option<i64> {
4368    let then = chrono::DateTime::parse_from_rfc3339(then?).ok()?;
4369    let now = chrono::DateTime::parse_from_rfc3339(now).ok()?;
4370    Some((now - then).num_seconds().max(0))
4371}
4372
4373#[cfg(test)]
4374mod tests {
4375    use super::*;
4376
4377    const PUB_A: &str = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3laa";
4378
4379    /// Step 2 of the 0.4.0 plan: a stored publication is pollable.
4380    #[tokio::test]
4381    async fn a_due_publication_is_handed_to_the_poller() -> Result<()> {
4382        let pool = init_url("sqlite::memory:").await?;
4383        upsert_feed(
4384            &pool,
4385            &NewFeed {
4386                url: PUB_A.into(),
4387                ..Default::default()
4388            },
4389        )
4390        .await?;
4391        let due = due_feeds(&pool, "2999-01-01T00:00:00Z", 50).await?;
4392        assert!(
4393            due.iter().any(|f| f.url == PUB_A),
4394            "a publication row is not handed to the poller"
4395        );
4396        Ok(())
4397    }
4398
4399    #[tokio::test]
4400    async fn admitting_publications_staggers_their_first_poll() -> Result<()> {
4401        let pool = init_url("sqlite::memory:").await?;
4402        for i in 0..19 {
4403            let url =
4404                format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3l{i:02}");
4405            upsert_feed(
4406                &pool,
4407                &NewFeed {
4408                    url,
4409                    ..Default::default()
4410                },
4411            )
4412            .await?;
4413        }
4414        // Already polled, and an RSS row: neither is touched.
4415        upsert_feed(
4416            &pool,
4417            &NewFeed {
4418                url: "https://rss.example/feed.xml".into(),
4419                ..Default::default()
4420            },
4421        )
4422        .await?;
4423        let n = stagger_unscheduled(
4424            &pool,
4425            crate::feed::FeedKind::Publication,
4426            std::time::Duration::from_secs(3600),
4427        )
4428        .await?;
4429        assert_eq!(n, 19, "not every unscheduled publication was scheduled");
4430        let slots: Vec<String> = sqlx::query_scalar(
4431            "SELECT next_poll FROM feeds WHERE kind = 'publication' ORDER BY next_poll",
4432        )
4433        .fetch_all(&pool)
4434        .await?;
4435        let distinct: std::collections::BTreeSet<_> = slots.iter().collect();
4436        assert_eq!(distinct.len(), 19, "publications share slots: {slots:?}");
4437        let rss: Option<String> = sqlx::query_scalar(
4438            "SELECT next_poll FROM feeds WHERE url = 'https://rss.example/feed.xml'",
4439        )
4440        .fetch_one(&pool)
4441        .await?;
4442        assert_eq!(rss, None, "an RSS row was rescheduled");
4443        let again = stagger_unscheduled(
4444            &pool,
4445            crate::feed::FeedKind::Publication,
4446            std::time::Duration::from_secs(3600),
4447        )
4448        .await?;
4449        assert_eq!(
4450            again, 0,
4451            "a second boot re-staggered rows that already had a slot"
4452        );
4453        Ok(())
4454    }
4455
4456    /// The point of the stagger: an overdue RSS feed is not starved by a block
4457    /// of newly admitted rows.
4458    #[tokio::test]
4459    async fn admitted_rows_do_not_outrank_an_overdue_rss_feed() -> Result<()> {
4460        let pool = init_url("sqlite::memory:").await?;
4461        for i in 0..5 {
4462            let url =
4463                format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3l{i:02}");
4464            upsert_feed(
4465                &pool,
4466                &NewFeed {
4467                    url,
4468                    ..Default::default()
4469                },
4470            )
4471            .await?;
4472        }
4473        upsert_feed(
4474            &pool,
4475            &NewFeed {
4476                url: "https://overdue.example/feed.xml".into(),
4477                next_poll: Some("2000-01-01T00:00:00Z".into()),
4478                ..Default::default()
4479            },
4480        )
4481        .await?;
4482        stagger_unscheduled(
4483            &pool,
4484            crate::feed::FeedKind::Publication,
4485            std::time::Duration::from_secs(3600),
4486        )
4487        .await?;
4488        let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4489        let first = due_feeds(&pool, &now, 1).await?;
4490        assert_eq!(first[0].url, "https://overdue.example/feed.xml");
4491        Ok(())
4492    }
4493
4494    /// **A re-poll refreshes `published`; it never refreshes `fetched_at`.**
4495    ///
4496    /// The asymmetry is the whole reason a date must be stable. `published`
4497    /// comes back from the publisher on every poll, so a value the mapper
4498    /// recomputes — "now", say — is rewritten every hour and the row can never
4499    /// age. `fetched_at` is written once, at first insert, so an entry stored
4500    /// with no date is effectively dated when we first saw it, and that date
4501    /// does hold still. Both the per-feed cap and the retention sweep order on
4502    /// `COALESCE(published, fetched_at)`, so which of the two a row lands in
4503    /// decides whether it can ever be evicted or swept.
4504    #[tokio::test]
4505    async fn a_repoll_refreshes_published_but_never_fetched_at() -> Result<()> {
4506        let pool = init_url("sqlite::memory:").await?;
4507        let feed_id = upsert_feed(
4508            &pool,
4509            &NewFeed {
4510                url: "https://example.com/f.xml".to_string(),
4511                ..Default::default()
4512            },
4513        )
4514        .await?;
4515        let seen = |at: &str| {
4516            vec![NewEntry {
4517                guid: "g".to_string(),
4518                published: Some(at.to_string()),
4519                fetched_at: Some(at.to_string()),
4520                ..Default::default()
4521            }]
4522        };
4523        insert_entries(&pool, feed_id, &seen("2026-01-01T00:00:00Z"), 0).await?;
4524        insert_entries(&pool, feed_id, &seen("2026-09-20T00:00:00Z"), 0).await?;
4525
4526        let (published, fetched_at): (Option<String>, String) =
4527            sqlx::query_as("SELECT published, fetched_at FROM entries WHERE guid = 'g'")
4528                .fetch_one(&pool)
4529                .await?;
4530        assert_eq!(
4531            published.as_deref(),
4532            Some("2026-09-20T00:00:00Z"),
4533            "the second poll's date did not replace the first"
4534        );
4535        assert_eq!(
4536            fetched_at, "2026-01-01T00:00:00Z",
4537            "fetched_at moved, so an undated entry would never age either"
4538        );
4539        Ok(())
4540    }
4541
4542    /// A partial upsert must not erase the conditional-GET validators.
4543    ///
4544    /// `set_next_poll` supplies only `url` + `next_poll` and runs after EVERY
4545    /// poll of EVERY feed. While `upsert_feed` assigned etag/last_modified
4546    /// unconditionally, that call wrote both back to NULL, so `If-None-Match`
4547    /// was never sent, `304` was unreachable, and every feed was re-downloaded
4548    /// and re-parsed in full on every cycle. Nothing failed; it was invisible.
4549    #[tokio::test]
4550    async fn validators_survive_a_partial_upsert() -> Result<()> {
4551        let pool = init_url("sqlite::memory:").await?;
4552        let url = "https://example.com/feed.xml";
4553
4554        upsert_feed(
4555            &pool,
4556            &NewFeed {
4557                url: url.to_string(),
4558                etag: Some("\"abc123\"".to_string()),
4559                last_modified: Some("Wed, 01 Jan 2026 00:00:00 GMT".to_string()),
4560                ..Default::default()
4561            },
4562        )
4563        .await?;
4564
4565        // Exactly what `scheduler::set_next_poll` sends.
4566        upsert_feed(
4567            &pool,
4568            &NewFeed {
4569                url: url.to_string(),
4570                next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4571                ..Default::default()
4572            },
4573        )
4574        .await?;
4575
4576        let feed = get_feed_by_url(&pool, url).await?.expect("feed");
4577        assert_eq!(
4578            feed.etag.as_deref(),
4579            Some("\"abc123\""),
4580            "a partial upsert erased the ETag, disabling conditional GET"
4581        );
4582        assert_eq!(
4583            feed.last_modified.as_deref(),
4584            Some("Wed, 01 Jan 2026 00:00:00 GMT"),
4585            "a partial upsert erased Last-Modified"
4586        );
4587        assert_eq!(feed.next_poll.as_deref(), Some("2026-07-12T00:00:00Z"));
4588        Ok(())
4589    }
4590
4591    /// A hard ceiling that is not strictly older than the window is IGNORED.
4592    ///
4593    /// `hard_days.max(days)` made `0` — the obvious "off" value, and the
4594    /// documented disable value for `RETENTION_DAYS` — collapse the ceiling onto
4595    /// the soft window, where the delete spares nothing. The starred and unread
4596    /// rows the window exists to protect were purged at `retention_days`.
4597    #[tokio::test]
4598    async fn a_ceiling_inside_the_window_is_ignored_not_applied() -> Result<()> {
4599        for hard in [0_i64, 1, 7, 14] {
4600            let pool = init_url("sqlite::memory:").await?;
4601            let feed_id = upsert_feed(
4602                &pool,
4603                &NewFeed {
4604                    url: "https://example.com/f.xml".to_string(),
4605                    ..Default::default()
4606                },
4607            )
4608            .await?;
4609            let old = (chrono::Utc::now() - chrono::Duration::days(30))
4610                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4611            insert_entries(
4612                &pool,
4613                feed_id,
4614                &[
4615                    NewEntry {
4616                        guid: "starred-30d".to_string(),
4617                        published: Some(old.clone()),
4618                        ..Default::default()
4619                    },
4620                    NewEntry {
4621                        guid: "unread-30d".to_string(),
4622                        published: Some(old.clone()),
4623                        ..Default::default()
4624                    },
4625                ],
4626                0,
4627            )
4628            .await?;
4629            // Both need an explicit `entry_state` row: sparing keys off a
4630            // DELIBERATE mark, and an entry with no row at all is unclaimed
4631            // cache that the window is supposed to evict.
4632            sqlx::query(
4633                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4634                 SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z'
4635                 FROM entries WHERE guid = 'starred-30d'",
4636            )
4637            .execute(&pool)
4638            .await?;
4639            sqlx::query(
4640                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4641                 SELECT 'did:plc:x', id, 0, 0, '2026-01-01T00:00:00Z'
4642                 FROM entries WHERE guid = 'unread-30d'",
4643            )
4644            .execute(&pool)
4645            .await?;
4646
4647            prune_old_entries(&pool, 14, hard, 0).await?;
4648
4649            let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4650                .fetch_one(&pool)
4651                .await?;
4652            assert_eq!(
4653                left, 2,
4654                "hard_days={hard} destroyed starred/unread rows at the soft window"
4655            );
4656        }
4657        Ok(())
4658    }
4659
4660    /// Turning the rolling window off must NOT also turn the ceiling off.
4661    ///
4662    /// `prune_old_entries` used to return on `days <= 0` before the ceiling was
4663    /// even computed, so `RETENTION_DAYS=0` — advertised as "disables eviction" —
4664    /// meant no window AND no ceiling. That is the one configuration with no
4665    /// bound on the shared cache at all, and it stopped being survivable when the
4666    /// per-feed trim started sparing starred entries: nothing was left to catch
4667    /// them. The two knobs are independent now.
4668    #[tokio::test]
4669    async fn a_disabled_window_does_not_disable_the_ceiling() -> Result<()> {
4670        let pool = init_url("sqlite::memory:").await?;
4671        let feed_id = upsert_feed(
4672            &pool,
4673            &NewFeed {
4674                url: "https://example.com/f.xml".to_string(),
4675                ..Default::default()
4676            },
4677        )
4678        .await?;
4679        let age = |d: i64| {
4680            (chrono::Utc::now() - chrono::Duration::days(d))
4681                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4682        };
4683        insert_entries(
4684            &pool,
4685            feed_id,
4686            &[
4687                NewEntry {
4688                    guid: "starred-400d".to_string(),
4689                    published: Some(age(400)),
4690                    ..Default::default()
4691                },
4692                NewEntry {
4693                    guid: "starred-30d".to_string(),
4694                    published: Some(age(30)),
4695                    ..Default::default()
4696                },
4697            ],
4698            0,
4699        )
4700        .await?;
4701        // Star both, so only the ceiling can remove either one — the soft
4702        // window's exception would spare them both even if it did run.
4703        sqlx::query(
4704            "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4705             SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z' FROM entries",
4706        )
4707        .execute(&pool)
4708        .await?;
4709
4710        // No rolling window; a 180-day ceiling.
4711        let deleted = prune_old_entries(&pool, 0, 180, 0).await?;
4712
4713        assert_eq!(
4714            deleted, 1,
4715            "retention_days=0 skipped the hard ceiling, leaving the cache unbounded"
4716        );
4717        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
4718            .fetch_all(&pool)
4719            .await?;
4720        assert_eq!(
4721            left,
4722            vec!["starred-30d".to_string()],
4723            "the ceiling removed the wrong rows with the window disabled"
4724        );
4725        Ok(())
4726    }
4727
4728    /// With BOTH knobs off, nothing is deleted — that is the documented
4729    /// "no eviction at all" configuration, and it must stay a true no-op rather
4730    /// than falling through to one of the two deletes with a degenerate cutoff.
4731    #[tokio::test]
4732    async fn both_knobs_off_deletes_nothing() -> Result<()> {
4733        let pool = init_url("sqlite::memory:").await?;
4734        let feed_id = upsert_feed(
4735            &pool,
4736            &NewFeed {
4737                url: "https://example.com/f.xml".to_string(),
4738                ..Default::default()
4739            },
4740        )
4741        .await?;
4742        insert_entries(
4743            &pool,
4744            feed_id,
4745            &[NewEntry {
4746                guid: "ancient".to_string(),
4747                published: Some(
4748                    (chrono::Utc::now() - chrono::Duration::days(9999))
4749                        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4750                ),
4751                ..Default::default()
4752            }],
4753            0,
4754        )
4755        .await?;
4756
4757        assert_eq!(prune_old_entries(&pool, 0, 0, 0).await?, 0);
4758        let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4759            .fetch_one(&pool)
4760            .await?;
4761        assert_eq!(left, 1);
4762        Ok(())
4763    }
4764
4765    /// Starred sparing must not remove the per-feed cap.
4766    ///
4767    /// The first version spared every starred row without limit: at cap=5 with
4768    /// 50 starred entries, 55 survived — 11x the cap, i.e. no cap at all.
4769    #[tokio::test]
4770    async fn per_feed_trim_stays_bounded_when_everything_is_starred() -> Result<()> {
4771        let pool = init_url("sqlite::memory:").await?;
4772        let feed_id = upsert_feed(
4773            &pool,
4774            &NewFeed {
4775                url: "https://example.com/f.xml".to_string(),
4776                ..Default::default()
4777            },
4778        )
4779        .await?;
4780        let entries: Vec<NewEntry> = (0..100)
4781            .map(|i| NewEntry {
4782                guid: format!("g-{i}"),
4783                published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
4784                ..Default::default()
4785            })
4786            .collect();
4787        insert_entries(&pool, feed_id, &entries, 0).await?;
4788        sqlx::query(
4789            "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4790             SELECT 'did:plc:x', id, 0, 1, '2026-01-01T00:00:00Z'
4791             FROM entries LIMIT 50",
4792        )
4793        .execute(&pool)
4794        .await?;
4795
4796        // Re-run the trim with cap = 5.
4797        insert_entries(&pool, feed_id, &[], 5).await?;
4798
4799        let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4800            .fetch_one(&pool)
4801            .await?;
4802        assert!(
4803            left <= 10,
4804            "per-feed trim kept {left} rows for a cap of 5; sparing removed the bound"
4805        );
4806        Ok(())
4807    }
4808
4809    /// Init an in-memory SQLite, insert a feed + entries, read them back.
4810    #[tokio::test]
4811    async fn init_insert_readback() -> Result<()> {
4812        let pool = init_url("sqlite::memory:").await?;
4813
4814        // Insert a feed.
4815        let feed_id = upsert_feed(
4816            &pool,
4817            &NewFeed {
4818                url: "https://example.com/feed.xml".to_string(),
4819                title: Some("Example".to_string()),
4820                site_url: Some("https://example.com".to_string()),
4821                next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4822                ..Default::default()
4823            },
4824        )
4825        .await?;
4826        assert!(feed_id > 0);
4827
4828        // Read the feed back by URL.
4829        let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
4830            .await?
4831            .expect("feed should exist");
4832        assert_eq!(feed.id, feed_id);
4833        assert_eq!(feed.title.as_deref(), Some("Example"));
4834        assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
4835
4836        // Upsert on the same URL updates rather than duplicating.
4837        let feed_id2 = upsert_feed(
4838            &pool,
4839            &NewFeed {
4840                url: "https://example.com/feed.xml".to_string(),
4841                title: Some("Example (renamed)".to_string()),
4842                ..Default::default()
4843            },
4844        )
4845        .await?;
4846        assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
4847
4848        // Insert two entries.
4849        let n = insert_entries(
4850            &pool,
4851            feed_id,
4852            &[
4853                NewEntry {
4854                    guid: "guid-1".to_string(),
4855                    url: Some("https://example.com/a".to_string()),
4856                    title: Some("First".to_string()),
4857                    published: Some("2026-07-10T08:00:00Z".to_string()),
4858                    content_html: Some("<p>hello</p>".to_string()),
4859                    ..Default::default()
4860                },
4861                NewEntry {
4862                    guid: "guid-2".to_string(),
4863                    url: Some("https://example.com/b".to_string()),
4864                    title: Some("Second".to_string()),
4865                    published: Some("2026-07-11T08:00:00Z".to_string()),
4866                    ..Default::default()
4867                },
4868            ],
4869            0, // per-feed trim disabled for this test
4870        )
4871        .await?;
4872        assert_eq!(n, 2);
4873
4874        // The reader must subscribe to the feed for the scoped reads to return
4875        // its entries (per-DID isolation projection).
4876        let did = "did:plc:abc123";
4877        replace_sub_refs(&pool, did, &[feed_id]).await?;
4878
4879        // Read entries back (newest-published first).
4880        let entries = entries_for_feed(&pool, did, feed_id).await?;
4881        assert_eq!(entries.len(), 2);
4882        assert_eq!(entries[0].guid, "guid-2");
4883        assert_eq!(entries[1].guid, "guid-1");
4884        // The body is stored, but it is NOT in the list projection — that is the
4885        // point of `EntryListRow`. Read it the way the single-entry reader does.
4886        let body: Option<String> =
4887            sqlx::query_scalar("SELECT content_html FROM entries WHERE guid = 'guid-1'")
4888                .fetch_one(&pool)
4889                .await?;
4890        assert_eq!(body.as_deref(), Some("<p>hello</p>"));
4891
4892        // Re-inserting the same GUID dedups (updates in place, no new row).
4893        let n2 = insert_entries(
4894            &pool,
4895            feed_id,
4896            &[NewEntry {
4897                guid: "guid-1".to_string(),
4898                title: Some("First (edited)".to_string()),
4899                ..Default::default()
4900            }],
4901            0,
4902        )
4903        .await?;
4904        assert_eq!(n2, 1);
4905        assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
4906
4907        // --- per-DID read state ---
4908        let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
4909
4910        // Both entries start unread.
4911        assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
4912
4913        // Mark one read; unread count drops to 1.
4914        mark_read(&pool, did, e1, true).await?;
4915        let unread = get_unread_for_did(&pool, did).await?;
4916        assert_eq!(unread.len(), 1);
4917        assert_eq!(unread[0].guid, "guid-2");
4918
4919        // Star it; it shows in the starred list.
4920        mark_starred(&pool, did, e1, true).await?;
4921        let starred = get_starred_for_did(&pool, did).await?;
4922        assert_eq!(starred.len(), 1);
4923        assert_eq!(starred[0].id, e1);
4924
4925        // Mark-all-read clears the remaining unread.
4926        mark_feed_read(&pool, did, feed_id, true).await?;
4927        assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
4928
4929        // --- read cursor (batched-sync bookkeeping) ---
4930        let cursor = ReadCursor {
4931            did: did.to_string(),
4932            feed_url: "https://example.com/feed.xml".to_string(),
4933            read_through: Some("2026-07-11T08:00:00Z".to_string()),
4934            read_ids: "[]".to_string(),
4935            unread_ids: "[]".to_string(),
4936            dirty: true,
4937            pds_created: false,
4938            updated_at: now_rfc3339(),
4939        };
4940        upsert_cursor(&pool, &cursor).await?;
4941
4942        let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
4943            .await?
4944            .expect("cursor should exist");
4945        assert_eq!(
4946            fetched.read_through.as_deref(),
4947            Some("2026-07-11T08:00:00Z")
4948        );
4949        assert!(fetched.dirty);
4950
4951        // The flusher sees exactly one dirty cursor.
4952        let dirty = dirty_cursors(&pool, did).await?;
4953        assert_eq!(dirty.len(), 1);
4954        let flushed_at = dirty[0].updated_at.clone();
4955
4956        // After a flush, clearing dirty (with the flushed snapshot's updated_at)
4957        // removes it from the flusher's view.
4958        clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
4959        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
4960
4961        Ok(())
4962    }
4963
4964    // -----------------------------------------------------------------------
4965    // The bounded, body-free list projection.
4966    //
4967    // The three queries these replaced were `SELECT e.*` with no `LIMIT`. Both
4968    // halves of that are load-bearing on a 512 MB box: the projection dragged
4969    // an ~11.9 KB article body per row that no list surface reads, and the
4970    // missing bound let one reader's backlog decide how much a handler
4971    // allocates.
4972    // -----------------------------------------------------------------------
4973
4974    /// Seed `count` entries in one feed, each with a large body, subscribed by
4975    /// `did`. Returns the feed id.
4976    async fn seed_big_entries(pool: &SqlitePool, did: &str, count: usize) -> Result<i64> {
4977        let feed_id = upsert_feed(
4978            pool,
4979            &NewFeed {
4980                url: "https://example.com/big.xml".to_string(),
4981                ..Default::default()
4982            },
4983        )
4984        .await?;
4985        let body = "x".repeat(20_000);
4986        let entries: Vec<NewEntry> = (0..count)
4987            .map(|i| NewEntry {
4988                guid: format!("guid-{i:04}"),
4989                url: Some(format!("https://example.com/a/{i}")),
4990                title: Some(format!("Article {i}")),
4991                // Descending guid order matches descending published order, so
4992                // assertions can name the rows they expect.
4993                published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
4994                content_html: Some(body.clone()),
4995                ..Default::default()
4996            })
4997            .collect();
4998        insert_entries(pool, feed_id, &entries, 0).await?;
4999        replace_sub_refs(pool, did, &[feed_id]).await?;
5000        Ok(feed_id)
5001    }
5002
5003    /// Review of #213: the ceiling only stops NEW future dates. A row stored
5004    /// with one before it, whose item has since left its feed, is never polled
5005    /// again to be corrected — so it stayed first in the list and survived the
5006    /// per-feed cap forever. Startup re-dates it.
5007    #[tokio::test]
5008    async fn a_stored_future_date_is_cleared_at_startup() -> Result<()> {
5009        let pool = init_url("sqlite::memory:").await?;
5010        let feed_id = upsert_feed(
5011            &pool,
5012            &NewFeed {
5013                url: "https://clock.example/f.xml".into(),
5014                ..Default::default()
5015            },
5016        )
5017        .await?;
5018        let tomorrow = (chrono::Utc::now() + chrono::Duration::days(1))
5019            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
5020        for (guid, published) in [
5021            ("bogus", "2999-01-01T00:00:00Z"),
5022            ("soon", tomorrow.as_str()),
5023        ] {
5024            sqlx::query(
5025                "INSERT INTO entries (feed_id, guid, published, fetched_at) \
5026                 VALUES (?1, ?2, ?3, '2026-07-11T00:00:00Z')",
5027            )
5028            .bind(feed_id)
5029            .bind(guid)
5030            .bind(published)
5031            .execute(&pool)
5032            .await?;
5033        }
5034        apply_migrations(&pool).await?;
5035        let dated: Vec<(String, Option<String>)> =
5036            sqlx::query_as("SELECT guid, published FROM entries ORDER BY guid")
5037                .fetch_all(&pool)
5038                .await?;
5039        assert_eq!(
5040            dated[0],
5041            ("bogus".to_string(), None),
5042            "a 2999 date survived startup"
5043        );
5044        assert_eq!(
5045            dated[1].1.as_deref(),
5046            Some(tomorrow.as_str()),
5047            "a near-future date was cleared"
5048        );
5049        Ok(())
5050    }
5051
5052    /// **The cap, the reading list and prev/next must agree about what an undated
5053    /// entry's date IS.** They did not, and the disagreement had a direction.
5054    ///
5055    /// The per-feed keep-set and both retention sweeps order on
5056    /// `COALESCE(published, fetched_at)` — correctly, because a feed of undated
5057    /// items would otherwise trim its own freshest rows. The reading list
5058    /// ordered on bare `e.published DESC`, and in SQLite `NULL` sorts LAST under
5059    /// `DESC`. So one undated entry was simultaneously the NEWEST row in the
5060    /// feed as far as eviction was concerned, and the OLDEST row in every list
5061    /// view — parked below years of read articles where no reader would see it,
5062    /// while the cap declined to drop it to make room for something they would.
5063    ///
5064    /// `site.standard.document` makes `publishedAt` optional, so publication
5065    /// feeds reach this far more readily than RSS ever did.
5066    ///
5067    /// Both directions here: the undated row must come first, AND the two dated
5068    /// rows must stay in their own order, or "order by nothing" would pass.
5069    ///
5070    /// **On the index worry, measured on the query the app actually sends.**
5071    /// #187 flagged that a `COALESCE` in `ORDER BY` cannot use
5072    /// `idx_entries_feed_published` for ordering. The real list and prev/next
5073    /// queries (LEFT JOIN `entry_state`, EXISTS `sub_ref`) did not use it for
5074    /// ordering before this change either, and timing them at 40 feeds x 1,000
5075    /// entries showed the new ordering costs nothing on the existing index. A
5076    /// `(feed_id, published, fetched_at)` index meant to keep them covering was
5077    /// never chosen on the default prev/next query and made it ~3.8x slower,
5078    /// so it was not kept (review of #213).
5079    #[tokio::test]
5080    async fn an_undated_entry_leads_the_reading_list_as_it_leads_the_cap() -> Result<()> {
5081        let pool = init_url("sqlite::memory:").await?;
5082        let did = "did:plc:undated";
5083        let feed_id = upsert_feed(
5084            &pool,
5085            &NewFeed {
5086                url: "https://undated.example/f.xml".to_string(),
5087                ..Default::default()
5088            },
5089        )
5090        .await?;
5091        insert_entries(
5092            &pool,
5093            feed_id,
5094            &[
5095                NewEntry {
5096                    guid: "dated-old".to_string(),
5097                    title: Some("Old".to_string()),
5098                    published: Some("2024-01-01T00:00:00Z".to_string()),
5099                    ..Default::default()
5100                },
5101                NewEntry {
5102                    guid: "dated-new".to_string(),
5103                    title: Some("Newer".to_string()),
5104                    published: Some("2025-01-01T00:00:00Z".to_string()),
5105                    ..Default::default()
5106                },
5107                // No `published` at all — dated by `fetched_at`, which is now,
5108                // so it is the freshest row in the feed.
5109                NewEntry {
5110                    guid: "undated".to_string(),
5111                    title: Some("Undated".to_string()),
5112                    ..Default::default()
5113                },
5114            ],
5115            0,
5116        )
5117        .await?;
5118        replace_sub_refs(&pool, did, &[feed_id]).await?;
5119
5120        let rows = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5121        let order: Vec<&str> = rows.iter().map(|r| r.guid.as_str()).collect();
5122        assert_eq!(
5123            order,
5124            vec!["undated", "dated-new", "dated-old"],
5125            "the list disagrees with the cap about an undated entry's date",
5126        );
5127
5128        // `list_entry_ids` is the sequence PREV/NEXT walks — its only non-test
5129        // caller is `web::neighbors_in_scope`. Ordered differently from the
5130        // list, "next entry" would take the reader somewhere that is not the
5131        // next row on screen. (`mark_read` and `mark_all_read` are id-based and
5132        // never use this ordering; an earlier version of this comment said they
5133        // did, naming a failure that cannot happen and omitting the one that
5134        // can.)
5135        let ids = list_entry_ids(&pool, did, ListView::All, None, 100).await?;
5136        let by_guid: std::collections::HashMap<i64, &str> =
5137            rows.iter().map(|r| (r.id, r.guid.as_str())).collect();
5138        let id_order: Vec<&str> = ids.iter().filter_map(|i| by_guid.get(i).copied()).collect();
5139        assert_eq!(
5140            id_order,
5141            vec!["undated", "dated-new", "dated-old"],
5142            "the id projection orders differently from the list it projects",
5143        );
5144        Ok(())
5145    }
5146
5147    /// `limit` is honoured, and `offset` walks the same ordering without gaps or
5148    /// repeats. Against the unbounded originals the first assertion returned all
5149    /// 250 rows.
5150    #[tokio::test]
5151    async fn list_entries_is_bounded_and_pages_without_overlap() -> Result<()> {
5152        let pool = init_url("sqlite::memory:").await?;
5153        let did = "did:plc:pager";
5154        seed_big_entries(&pool, did, 250).await?;
5155
5156        let page1 = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5157        assert_eq!(page1.len(), 100, "limit was not applied");
5158        let page2 = list_entries(&pool, did, ListView::All, None, 100, 100).await?;
5159        let page3 = list_entries(&pool, did, ListView::All, None, 100, 200).await?;
5160        assert_eq!(page3.len(), 50, "the last page should be the remainder");
5161
5162        let walked: Vec<i64> = page1
5163            .iter()
5164            .chain(&page2)
5165            .chain(&page3)
5166            .map(|e| e.id)
5167            .collect();
5168        let unique: std::collections::HashSet<i64> = walked.iter().copied().collect();
5169        assert_eq!(unique.len(), 250, "paging repeated or skipped rows");
5170
5171        // And the walk is the same order an unpaged read would produce.
5172        let whole = list_entries(&pool, did, ListView::All, None, 1_000, 0).await?;
5173        assert_eq!(
5174            walked,
5175            whole.iter().map(|e| e.id).collect::<Vec<_>>(),
5176            "paging changed the ordering"
5177        );
5178
5179        // **The tie-break is pinned, not left to the engine.** The seed gives
5180        // 250 rows only 28 distinct dates, so the order is mostly ties; with
5181        // the `id DESC` tie-break deleted, SQLite happened to return ties in a
5182        // stable order and both assertions above still held. The expected
5183        // order is computed from the seed pattern here — newest date first,
5184        // then newest id — and must match exactly.
5185        let mut expected: Vec<(i64, i64)> = whole
5186            .iter()
5187            .map(|e| {
5188                let day: i64 = e.published.as_deref().unwrap()[8..10].parse().unwrap();
5189                (day, e.id)
5190            })
5191            .collect();
5192        expected.sort_by(|a, b| b.cmp(a));
5193        assert_eq!(
5194            walked,
5195            expected.iter().map(|(_, id)| *id).collect::<Vec<_>>(),
5196            "ties are not broken by newest id"
5197        );
5198
5199        assert_eq!(
5200            count_entries_for_view(&pool, did, ListView::All, None).await?,
5201            250,
5202            "the unpaged count must survive paging"
5203        );
5204        Ok(())
5205    }
5206
5207    /// The list projection must not read `content_html`.
5208    ///
5209    /// A type-level fact — `EntryListRow` has no body field — so the test proves
5210    /// it the only way that survives a refactor: by asking SQLite what the query
5211    /// it runs actually names. `SELECT e.*` would list every column.
5212    #[tokio::test]
5213    async fn the_list_projection_does_not_name_the_body_column() -> Result<()> {
5214        let pool = init_url("sqlite::memory:").await?;
5215        let did = "did:plc:projection";
5216        seed_big_entries(&pool, did, 3).await?;
5217
5218        // **The projection the query actually runs**, not a copy re-typed here.
5219        // The earlier version passed its own literal to `list_query_sql` and
5220        // asserted on that, so adding `e.content_html` to `list_entries` left
5221        // this green.
5222        let (sql, _) = list_entries_sql(ListView::All, None);
5223        assert!(
5224            !sql.contains("content_html") && !sql.contains("e.*"),
5225            "the list query reads the article body: {sql}"
5226        );
5227
5228        // And the rows really do come back without it, which is what bounds the
5229        // per-request allocation.
5230        let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5231        assert_eq!(rows.len(), 3);
5232        let widest = rows
5233            .iter()
5234            .map(|r| {
5235                r.guid.len()
5236                    + r.url.as_deref().map_or(0, str::len)
5237                    + r.title.as_deref().map_or(0, str::len)
5238            })
5239            .max()
5240            .unwrap_or(0);
5241        assert!(
5242            widest < 1_000,
5243            "a list row carries {widest} bytes of text; the 20,000-byte body leaked in"
5244        );
5245        Ok(())
5246    }
5247
5248    /// **A large scope must not become a large SQL statement.**
5249    ///
5250    /// The scope filter used to emit one placeholder per feed id, so the SQL
5251    /// string and the bind list both grew with a reader's subscription count —
5252    /// which comes from the PDS and is bounded only by a 20,000-record list
5253    /// ceiling. The first attempt at fixing that truncated the subscription
5254    /// list, which silently removed the reader's access to the dropped feeds
5255    /// (`sub_ref` is written from the same list). `json_each` takes the whole
5256    /// set as ONE bind, so neither trade-off is needed.
5257    #[tokio::test]
5258    async fn a_large_scope_is_one_bind_and_still_filters() -> Result<()> {
5259        let pool = init_url("sqlite::memory:").await?;
5260        let did = "did:plc:widescope";
5261
5262        // 300 feeds, one entry each; the scope names 200 of them.
5263        let mut all_ids = Vec::new();
5264        for i in 0..300 {
5265            let feed_id = upsert_feed(
5266                &pool,
5267                &NewFeed {
5268                    url: format!("https://wide{i}.example/f.xml"),
5269                    ..Default::default()
5270                },
5271            )
5272            .await?;
5273            insert_entries(
5274                &pool,
5275                feed_id,
5276                &[NewEntry {
5277                    guid: format!("w-{i}"),
5278                    ..Default::default()
5279                }],
5280                0,
5281            )
5282            .await?;
5283            all_ids.push(feed_id);
5284        }
5285        replace_sub_refs(&pool, did, &all_ids).await?;
5286
5287        let scope: Vec<i64> = all_ids.iter().copied().take(200).collect();
5288        let rows = list_entries(&pool, did, ListView::All, Some(&scope), 1_000, 0).await?;
5289        assert_eq!(rows.len(), 200, "the scope filter did not narrow correctly");
5290        let in_scope: std::collections::HashSet<i64> = scope.iter().copied().collect();
5291        assert!(
5292            rows.iter().all(|r| in_scope.contains(&r.feed_id)),
5293            "a feed outside the scope came back"
5294        );
5295        assert_eq!(
5296            count_entries_for_view(&pool, did, ListView::All, Some(&scope)).await?,
5297            200
5298        );
5299
5300        // The statement itself carries no per-id placeholders — that is the
5301        // property, and it is what stops the SQL growing with the reader.
5302        let (sql, n) = list_query_sql(Projection::Ids, ListView::All, Some(&scope));
5303        assert_eq!(n, 1, "the scope must contribute exactly one placeholder");
5304        assert!(
5305            sql.contains("json_each(?2)") && !sql.contains("?3"),
5306            "the scope is still expanded into per-id placeholders: {sql}"
5307        );
5308        Ok(())
5309    }
5310
5311    /// Scope is applied INSIDE the query, so a page is a page of rows the reader
5312    /// will see. Filtering after the `LIMIT` (what the handler used to do) made
5313    /// pages arbitrarily short for any narrowed scope.
5314    #[tokio::test]
5315    async fn a_feed_scope_narrows_the_query_not_the_page() -> Result<()> {
5316        let pool = init_url("sqlite::memory:").await?;
5317        let did = "did:plc:scope";
5318        let wanted = seed_big_entries(&pool, did, 10).await?;
5319
5320        let other = upsert_feed(
5321            &pool,
5322            &NewFeed {
5323                url: "https://other.example/f.xml".to_string(),
5324                ..Default::default()
5325            },
5326        )
5327        .await?;
5328        let noise: Vec<NewEntry> = (0..40)
5329            .map(|i| NewEntry {
5330                guid: format!("noise-{i}"),
5331                // Newer than everything in `wanted`, so an unscoped query would
5332                // fill the whole page with these.
5333                published: Some("2027-01-01T00:00:00Z".to_string()),
5334                ..Default::default()
5335            })
5336            .collect();
5337        insert_entries(&pool, other, &noise, 0).await?;
5338        replace_sub_refs(&pool, did, &[wanted, other]).await?;
5339
5340        let scoped = list_entries(&pool, did, ListView::All, Some(&[wanted]), 10, 0).await?;
5341        assert_eq!(
5342            scoped.len(),
5343            10,
5344            "the scoped page came back short — the filter ran after the LIMIT"
5345        );
5346        assert!(scoped.iter().all(|e| e.feed_id == wanted));
5347
5348        // An EMPTY scope means "no feeds in scope", not "every feed".
5349        assert!(list_entries(&pool, did, ListView::All, Some(&[]), 10, 0)
5350            .await?
5351            .is_empty());
5352        assert_eq!(
5353            count_entries_for_view(&pool, did, ListView::All, Some(&[])).await?,
5354            0
5355        );
5356        Ok(())
5357    }
5358
5359    /// The per-row `read` / `starred` bits come off the row's own join, matching
5360    /// what the separate full-set queries used to compute — including the
5361    /// "no `entry_state` row means unread" rule the views depend on.
5362    #[tokio::test]
5363    async fn list_rows_carry_their_own_read_and_star_bits() -> Result<()> {
5364        let pool = init_url("sqlite::memory:").await?;
5365        let did = "did:plc:bits";
5366        seed_big_entries(&pool, did, 3).await?;
5367        let ids: Vec<i64> = list_entries(&pool, did, ListView::All, None, 10, 0)
5368            .await?
5369            .iter()
5370            .map(|e| e.id)
5371            .collect();
5372
5373        mark_read(&pool, did, ids[0], true).await?;
5374        mark_starred(&pool, did, ids[1], true).await?;
5375
5376        let all = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5377        let by_id = |id: i64| all.iter().find(|e| e.id == id).expect("row present");
5378        assert!(by_id(ids[0]).read && !by_id(ids[0]).starred);
5379        assert!(!by_id(ids[1]).read && by_id(ids[1]).starred);
5380        // Never touched: no state row at all, which must read as unread.
5381        assert!(!by_id(ids[2]).read && !by_id(ids[2]).starred);
5382
5383        // And the view predicates agree with the bits.
5384        let unread = list_entries(&pool, did, ListView::Unread, None, 10, 0).await?;
5385        assert_eq!(unread.len(), 2);
5386        assert!(unread.iter().all(|e| !e.read));
5387        let starred = list_entries(&pool, did, ListView::Starred, None, 10, 0).await?;
5388        assert_eq!(starred.len(), 1);
5389        assert_eq!(starred[0].id, ids[1]);
5390        Ok(())
5391    }
5392
5393    /// The sidebar's per-feed unread badges, counted in SQL rather than by
5394    /// materializing every unread entry and filtering in Rust.
5395    #[tokio::test]
5396    async fn unread_counts_are_per_feed_and_exclude_read_rows() -> Result<()> {
5397        let pool = init_url("sqlite::memory:").await?;
5398        let did = "did:plc:counts";
5399        let a = seed_big_entries(&pool, did, 5).await?;
5400        let b = upsert_feed(
5401            &pool,
5402            &NewFeed {
5403                url: "https://b.example/f.xml".to_string(),
5404                ..Default::default()
5405            },
5406        )
5407        .await?;
5408        insert_entries(
5409            &pool,
5410            b,
5411            &[
5412                NewEntry {
5413                    guid: "b-1".to_string(),
5414                    ..Default::default()
5415                },
5416                NewEntry {
5417                    guid: "b-2".to_string(),
5418                    ..Default::default()
5419                },
5420            ],
5421            0,
5422        )
5423        .await?;
5424        replace_sub_refs(&pool, did, &[a, b]).await?;
5425
5426        let first_a = list_entries(&pool, did, ListView::All, Some(&[a]), 1, 0).await?[0].id;
5427        mark_read(&pool, did, first_a, true).await?;
5428
5429        let counts = unread_counts_by_feed(&pool, did).await?;
5430        assert_eq!(counts.get(&a).copied(), Some(4));
5431        assert_eq!(counts.get(&b).copied(), Some(2));
5432
5433        // A feed the DID does not subscribe to contributes nothing.
5434        replace_sub_refs(&pool, did, &[b]).await?;
5435        let counts = unread_counts_by_feed(&pool, did).await?;
5436        assert_eq!(counts.get(&a), None);
5437        assert_eq!(counts.get(&b).copied(), Some(2));
5438        Ok(())
5439    }
5440
5441    /// **Read-state compaction: the water-mark must absorb the id set.**
5442    ///
5443    /// `read_through` was never computed, so `read_ids` was the only mechanism
5444    /// and grew one id per article read against a 2000-entry per-feed ceiling —
5445    /// while the flusher truncates the record at 1000, keeping the tail. Past
5446    /// 1000 read articles in a feed, the oldest read-state stopped syncing and
5447    /// those articles came back UNREAD in every other atproto reader.
5448    #[tokio::test]
5449    async fn compaction_folds_read_ids_into_the_water_mark() -> Result<()> {
5450        let pool = init_url("sqlite::memory:").await?;
5451        let did = "did:plc:compact";
5452        let feed_url = "https://compact.example/f.xml";
5453        let feed_id = upsert_feed(
5454            &pool,
5455            &NewFeed {
5456                url: feed_url.to_string(),
5457                ..Default::default()
5458            },
5459        )
5460        .await?;
5461        // 40 entries, oldest first by published date.
5462        let entries: Vec<NewEntry> = (0..40)
5463            .map(|i| NewEntry {
5464                guid: format!("c-{i:03}"),
5465                published: Some(format!("2026-01-{:02}T00:00:00Z", i + 1)),
5466                ..Default::default()
5467            })
5468            .collect();
5469        insert_entries(&pool, feed_id, &entries, 0).await?;
5470        replace_sub_refs(&pool, did, &[feed_id]).await?;
5471
5472        let all = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5473        // Oldest first, so the read prefix is contiguous from the start.
5474        let mut oldest_first = all.clone();
5475        oldest_first.reverse();
5476        for row in oldest_first.iter().take(30) {
5477            mark_read(&pool, did, row.id, true).await?;
5478        }
5479
5480        let before = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5481        assert!(before.read_through.is_none(), "read_through starts unset");
5482        let before_ids: Vec<String> = serde_json::from_str(&before.read_ids)?;
5483        assert_eq!(before_ids.len(), 30, "every read is its own exception");
5484
5485        let watermark = compact_cursor(&pool, did, feed_url)
5486            .await?
5487            .expect("the water-mark must advance");
5488
5489        let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5490        assert_eq!(after.read_through.as_deref(), Some(watermark.as_str()));
5491        let after_ids: Vec<String> = serde_json::from_str(&after.read_ids)?;
5492        assert!(
5493            after_ids.is_empty(),
5494            "a contiguous read prefix must fold entirely into the water-mark, left {after_ids:?}"
5495        );
5496        // The 30th entry is read and the 31st is not, so the mark sits on the
5497        // 30th — STRICTLY below the oldest unread, never equal to it.
5498        assert_eq!(watermark, "2026-01-30T00:00:00Z");
5499        assert!(after.dirty, "a rewritten cursor must be re-flushed");
5500        Ok(())
5501    }
5502
5503    /// The water-mark may never cover an unread entry, and may never move
5504    /// backwards. Both would re-assert articles as read that are not.
5505    #[tokio::test]
5506    async fn compaction_stops_below_the_oldest_unread_entry() -> Result<()> {
5507        let pool = init_url("sqlite::memory:").await?;
5508        let did = "did:plc:gap";
5509        let feed_url = "https://gap.example/f.xml";
5510        let feed_id = upsert_feed(
5511            &pool,
5512            &NewFeed {
5513                url: feed_url.to_string(),
5514                ..Default::default()
5515            },
5516        )
5517        .await?;
5518        let entries: Vec<NewEntry> = (0..10)
5519            .map(|i| NewEntry {
5520                guid: format!("g-{i:02}"),
5521                published: Some(format!("2026-02-{:02}T00:00:00Z", i + 1)),
5522                ..Default::default()
5523            })
5524            .collect();
5525        insert_entries(&pool, feed_id, &entries, 0).await?;
5526        replace_sub_refs(&pool, did, &[feed_id]).await?;
5527
5528        let mut oldest_first = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5529        oldest_first.reverse();
5530        // Read everything EXCEPT the third-oldest: a hole at 2026-02-03.
5531        for (i, row) in oldest_first.iter().enumerate() {
5532            if i != 2 {
5533                mark_read(&pool, did, row.id, true).await?;
5534            }
5535        }
5536
5537        let watermark = compact_cursor(&pool, did, feed_url)
5538            .await?
5539            .expect("advances");
5540        assert_eq!(
5541            watermark, "2026-02-02T00:00:00Z",
5542            "the water-mark jumped the unread hole"
5543        );
5544        let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5545        let kept: Vec<String> = serde_json::from_str(&after.read_ids)?;
5546        assert_eq!(
5547            kept.len(),
5548            7,
5549            "the 7 reads ABOVE the hole must stay as explicit exceptions"
5550        );
5551        // The unread hole is above the water-mark, so it needs no unread
5552        // exception — everything above the mark is unread by default.
5553        let unread: Vec<String> = serde_json::from_str(&after.unread_ids)?;
5554        assert!(
5555            unread.is_empty(),
5556            "redundant unread exceptions survived: {unread:?}"
5557        );
5558
5559        // Idempotent, and never backwards: re-running changes nothing.
5560        assert_eq!(
5561            compact_cursor(&pool, did, feed_url).await?,
5562            None,
5563            "a second compaction moved a water-mark that was already correct"
5564        );
5565        Ok(())
5566    }
5567
5568    /// Nothing read yet, or nothing in the feed: compaction must be a no-op
5569    /// rather than inventing a water-mark that asserts the backlog is read.
5570    #[tokio::test]
5571    async fn compaction_never_invents_a_water_mark() -> Result<()> {
5572        let pool = init_url("sqlite::memory:").await?;
5573        let did = "did:plc:none";
5574        let feed_url = "https://none.example/f.xml";
5575        let feed_id = upsert_feed(
5576            &pool,
5577            &NewFeed {
5578                url: feed_url.to_string(),
5579                ..Default::default()
5580            },
5581        )
5582        .await?;
5583        replace_sub_refs(&pool, did, &[feed_id]).await?;
5584
5585        // Empty feed: no entries at all.
5586        assert_eq!(compact_cursor(&pool, did, feed_url).await?, None);
5587
5588        insert_entries(
5589            &pool,
5590            feed_id,
5591            &[
5592                NewEntry {
5593                    guid: "n-1".to_string(),
5594                    published: Some("2026-03-01T00:00:00Z".to_string()),
5595                    ..Default::default()
5596                },
5597                NewEntry {
5598                    guid: "n-2".to_string(),
5599                    published: Some("2026-03-02T00:00:00Z".to_string()),
5600                    ..Default::default()
5601                },
5602            ],
5603            0,
5604        )
5605        .await?;
5606
5607        // Nothing read: the OLDEST entry is unread, so there is no timestamp
5608        // strictly below it and the mark cannot move at all.
5609        assert_eq!(
5610            compact_cursor(&pool, did, feed_url).await?,
5611            None,
5612            "a water-mark appeared with nothing read — that asserts the backlog is read"
5613        );
5614        Ok(())
5615    }
5616
5617    /// **The unsave desync: clearing a star must work for an UNSUBSCRIBED feed.**
5618    ///
5619    /// That is the whole case. Every other starred path is `sub_ref`-scoped, so
5620    /// an entry that is cached AND starred in a feed the reader has since
5621    /// unsubscribed from is invisible to all of them — including the starred
5622    /// list itself. Its PDS record therefore renders as "not cached", and the
5623    /// button on that row deletes the record. If clearing the local star were
5624    /// `sub_ref`-scoped too, it would silently do nothing, and the star would
5625    /// reappear with no record behind it the moment the reader resubscribed.
5626    #[tokio::test]
5627    async fn a_star_can_be_cleared_after_unsubscribing_from_its_feed() -> Result<()> {
5628        let pool = init_url("sqlite::memory:").await?;
5629        let did = "did:plc:unsub";
5630        let feed_id = seed_big_entries(&pool, did, 3).await?;
5631        let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5632        let target = rows[0].clone();
5633        mark_starred(&pool, did, target.id, true).await?;
5634        assert_eq!(get_starred_for_did(&pool, did).await?.len(), 1);
5635
5636        // Unsubscribe. The entry stays cached and stays starred, but every
5637        // sub_ref-scoped read now skips it.
5638        replace_sub_refs(&pool, did, &[]).await?;
5639        assert!(
5640            get_starred_for_did(&pool, did).await?.is_empty(),
5641            "fixture precondition: the star must be invisible to the scoped read"
5642        );
5643        assert!(
5644            matches!(
5645                starred_identities(&pool, did, 1_000).await?,
5646                StarredIdentities::All(ref v) if v.is_empty()
5647            ),
5648            "fixture precondition: the identity lookup must miss it too"
5649        );
5650        let still_starred: i64 =
5651            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5652                .bind(did)
5653                .fetch_one(&pool)
5654                .await?;
5655        assert_eq!(
5656            still_starred, 1,
5657            "the star is still there, just unreachable"
5658        );
5659
5660        // The removal path must reach it anyway.
5661        let cleared =
5662            clear_star_by_identity(&pool, did, target.url.as_deref(), Some(&target.guid)).await?;
5663        assert_eq!(cleared, 1, "the star survived the unsave");
5664        let after: i64 =
5665            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5666                .bind(did)
5667                .fetch_one(&pool)
5668                .await?;
5669        assert_eq!(after, 0);
5670
5671        // Resubscribing must NOT bring it back.
5672        replace_sub_refs(&pool, did, &[feed_id]).await?;
5673        assert!(
5674            get_starred_for_did(&pool, did).await?.is_empty(),
5675            "the star came back after resubscribing — the desync is still there"
5676        );
5677        Ok(())
5678    }
5679
5680    /// It clears only the CALLER's star, and only for the matching article.
5681    ///
5682    /// Omitting `sub_ref` is safe precisely because `did` is not optional; this
5683    /// pins that, and that a non-matching identity is a no-op rather than a
5684    /// wildcard.
5685    #[tokio::test]
5686    async fn clearing_a_star_touches_only_that_did_and_that_article() -> Result<()> {
5687        let pool = init_url("sqlite::memory:").await?;
5688        let mine = "did:plc:mine";
5689        let theirs = "did:plc:theirs";
5690        let feed_id = seed_big_entries(&pool, mine, 3).await?;
5691        replace_sub_refs(&pool, theirs, &[feed_id]).await?;
5692        let rows = list_entries(&pool, mine, ListView::All, None, 10, 0).await?;
5693
5694        for r in &rows {
5695            mark_starred(&pool, mine, r.id, true).await?;
5696            mark_starred(&pool, theirs, r.id, true).await?;
5697        }
5698
5699        let target = &rows[1];
5700        assert_eq!(
5701            clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5702            1
5703        );
5704
5705        let count = |did: &'static str| {
5706            let pool = pool.clone();
5707            async move {
5708                sqlx::query_scalar::<_, i64>(
5709                    "SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1",
5710                )
5711                .bind(did)
5712                .fetch_one(&pool)
5713                .await
5714                .unwrap()
5715            }
5716        };
5717        assert_eq!(count(mine).await, 2, "it cleared more than the one article");
5718        assert_eq!(count(theirs).await, 3, "it cleared another DID's stars");
5719
5720        // An identity that matches nothing is a no-op, not a wildcard.
5721        assert_eq!(
5722            clear_star_by_identity(&pool, mine, Some("https://nope.example/x"), Some("nope"))
5723                .await?,
5724            0
5725        );
5726        assert_eq!(count(mine).await, 2);
5727        // **Clearing an already-cleared star is a no-op**, reported as one:
5728        // `web::unsave` branches on `Ok(0)` vs `Ok(n)` to decide whether a
5729        // local star was actually cleared. This used to be untested — every
5730        // article here was starred first — so `starred = 1` in the WHERE clause
5731        // could be widened to `IN (0, 1)` with the suite green, rewriting
5732        // `updated_at` on rows that changed nothing and logging clears that
5733        // never happened.
5734        let before: String = sqlx::query_scalar(
5735            "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5736        )
5737        .bind(mine)
5738        .bind(target.id)
5739        .fetch_one(&pool)
5740        .await?;
5741        assert_eq!(
5742            clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5743            0,
5744            "a second clear reported rows it did not change"
5745        );
5746        let after: String = sqlx::query_scalar(
5747            "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5748        )
5749        .bind(mine)
5750        .bind(target.id)
5751        .fetch_one(&pool)
5752        .await?;
5753        assert_eq!(before, after, "a no-op clear rewrote updated_at");
5754
5755        // And neither identifier present does nothing at all.
5756        assert_eq!(clear_star_by_identity(&pool, mine, None, None).await?, 0);
5757        assert_eq!(
5758            clear_star_by_identity(&pool, mine, Some(""), Some("")).await?,
5759            0
5760        );
5761        assert_eq!(count(mine).await, 2);
5762        Ok(())
5763    }
5764
5765    /// `starred_identities` must span the WHOLE starred set, not a page.
5766    ///
5767    /// The starred view matches PDS saved records against it; a cached article
5768    /// missing from the set renders as "not cached", and that row's button
5769    /// deletes the PDS RECORD instead of un-starring the entry. Narrowing this
5770    /// set changes what a click destroys.
5771    #[tokio::test]
5772    async fn starred_identities_span_the_whole_set() -> Result<()> {
5773        let pool = init_url("sqlite::memory:").await?;
5774        let did = "did:plc:ident";
5775        seed_big_entries(&pool, did, 150).await?;
5776        for row in list_entries(&pool, did, ListView::All, None, 1_000, 0).await? {
5777            mark_starred(&pool, did, row.id, true).await?;
5778        }
5779
5780        let identities = match starred_identities(&pool, did, 20_000).await? {
5781            StarredIdentities::All(v) => v,
5782            StarredIdentities::Truncated => panic!("150 rows must not read as truncated"),
5783        };
5784        assert_eq!(
5785            identities.len(),
5786            150,
5787            "the identity set was truncated to a page"
5788        );
5789        assert!(identities
5790            .iter()
5791            .all(|(url, guid)| url.is_some() && !guid.is_empty()));
5792
5793        // **Hitting the cap must be REPORTED, not absorbed.** It used to return
5794        // an arbitrary subset with no way to tell, and every starred article
5795        // outside that subset then rendered an un-save button that deletes the
5796        // PDS record rather than un-starring the entry.
5797        assert!(
5798            matches!(
5799                starred_identities(&pool, did, 10).await?,
5800                StarredIdentities::Truncated
5801            ),
5802            "a truncated identity set reported itself as complete"
5803        );
5804        // Landing EXACTLY on the cap is complete, not truncated — the query asks
5805        // for one extra row precisely so the two are distinguishable.
5806        assert!(
5807            matches!(
5808                starred_identities(&pool, did, 150).await?,
5809                StarredIdentities::All(ref v) if v.len() == 150
5810            ),
5811            "a set exactly at the cap was misreported as truncated"
5812        );
5813        Ok(())
5814    }
5815
5816    /// Prev/next ids are bounded too, and keep the list's ordering.
5817    #[tokio::test]
5818    async fn entry_ids_are_ordered_and_capped() -> Result<()> {
5819        let pool = init_url("sqlite::memory:").await?;
5820        let did = "did:plc:ids";
5821        seed_big_entries(&pool, did, 60).await?;
5822
5823        let capped = list_entry_ids(&pool, did, ListView::All, None, 25).await?;
5824        assert_eq!(capped.len(), 25);
5825
5826        let rows = list_entries(&pool, did, ListView::All, None, 25, 0).await?;
5827        assert_eq!(
5828            capped,
5829            rows.iter().map(|e| e.id).collect::<Vec<_>>(),
5830            "the id list and the row list disagree on ordering"
5831        );
5832        Ok(())
5833    }
5834
5835    // -----------------------------------------------------------------------
5836    // Read-state PDS sync wiring: marking read/unread must project into the
5837    // per-feed `read_cursor` and mark it dirty so the batched flusher pushes it.
5838    // Before this wiring `mark_read` touched only `entry_state`; nothing dirtied
5839    // a cursor, so the flusher never synced read-state to the PDS.
5840    // -----------------------------------------------------------------------
5841
5842    #[tokio::test]
5843    async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
5844        let pool = init_url("sqlite::memory:").await?;
5845        let feed_url = "https://example.com/feed.xml";
5846        let feed_id = upsert_feed(
5847            &pool,
5848            &NewFeed {
5849                url: feed_url.to_string(),
5850                title: Some("Example".to_string()),
5851                ..Default::default()
5852            },
5853        )
5854        .await?;
5855        insert_entries(
5856            &pool,
5857            feed_id,
5858            &[
5859                NewEntry {
5860                    guid: "g1".to_string(),
5861                    published: Some("2026-07-10T00:00:00Z".to_string()),
5862                    ..Default::default()
5863                },
5864                NewEntry {
5865                    guid: "g2".to_string(),
5866                    published: Some("2026-07-11T00:00:00Z".to_string()),
5867                    ..Default::default()
5868                },
5869            ],
5870            0,
5871        )
5872        .await?;
5873        let did = "did:plc:reader";
5874        replace_sub_refs(&pool, did, &[feed_id]).await?;
5875
5876        // No cursor exists yet.
5877        assert!(get_cursor(&pool, did, feed_url).await?.is_none());
5878        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5879
5880        // Mark one entry read → the feed's read_cursor row now exists, dirty=1,
5881        // and dirty_cursors returns it (the exact assertion the fix requires).
5882        let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
5883        assert!(mark_read(&pool, did, e1, true).await?);
5884
5885        let cursor = get_cursor(&pool, did, feed_url)
5886            .await?
5887            .expect("mark_read must create the feed's read_cursor");
5888        assert!(cursor.dirty, "cursor must be dirty after mark_read");
5889        assert!(
5890            cursor.read_ids.contains(&e1.to_string()),
5891            "the read entry id must be in read_ids: {}",
5892            cursor.read_ids
5893        );
5894        let dirty = dirty_cursors(&pool, did).await?;
5895        assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
5896        assert_eq!(dirty[0].feed_url, feed_url);
5897
5898        // Marking it unread again moves the id to unread_ids and keeps it dirty.
5899        assert!(mark_read(&pool, did, e1, false).await?);
5900        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5901        assert!(cursor.dirty);
5902        assert!(
5903            cursor.unread_ids.contains(&e1.to_string()),
5904            "unread id must be in unread_ids: {}",
5905            cursor.unread_ids
5906        );
5907        assert!(
5908            !cursor.read_ids.contains(&e1.to_string()),
5909            "id must have left read_ids: {}",
5910            cursor.read_ids
5911        );
5912
5913        // mark_feed_read dirties the one per-feed cursor too (batched, not
5914        // per-article).
5915        assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
5916        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5917        assert!(cursor.dirty);
5918        assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
5919
5920        // A non-subscriber's mark_read is a no-op and dirties NO cursor.
5921        let outsider = "did:plc:outsider";
5922        assert!(!mark_read(&pool, outsider, e1, true).await?);
5923        assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
5924
5925        // The conditional clear only clears when updated_at matches the snapshot.
5926        let snap = dirty_cursors(&pool, did).await?[0].clone();
5927        // A stale updated_at must NOT clear (models a concurrent re-dirty).
5928        clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
5929        assert_eq!(
5930            dirty_cursors(&pool, did).await?.len(),
5931            1,
5932            "stale-snapshot clear must be a no-op"
5933        );
5934        // The matching updated_at clears it.
5935        clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
5936        assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5937
5938        Ok(())
5939    }
5940
5941    #[test]
5942    fn json_id_set_toggle_is_set_like() {
5943        // Add is idempotent, remove drops, output is a JSON string array.
5944        let s = json_id_set_toggle("[]", 5, true);
5945        assert_eq!(s, r#"["5"]"#);
5946        assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); // no dup
5947        let s = json_id_set_toggle(&s, 7, true);
5948        assert_eq!(s, r#"["5","7"]"#);
5949        let s = json_id_set_toggle(&s, 5, false);
5950        assert_eq!(s, r#"["7"]"#);
5951        // Tolerates numeric-array input and malformed input.
5952        assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
5953        assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
5954    }
5955
5956    // -----------------------------------------------------------------------
5957    // Per-DID isolation: the shared cache is one row per URL, but the READ
5958    // SURFACE (entries/unread/starred) and the read/star MUTATIONS are scoped
5959    // to the caller's own subscriptions (`sub_ref`). User A must never see or
5960    // mutate user B's entries.
5961    // -----------------------------------------------------------------------
5962
5963    #[tokio::test]
5964    async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
5965        let pool = init_url("sqlite::memory:").await?;
5966
5967        // Two feeds in the SHARED cache; A subscribes to feed_a, B to feed_b.
5968        let feed_a = upsert_feed(
5969            &pool,
5970            &NewFeed {
5971                url: "https://a.example/feed.xml".to_string(),
5972                title: Some("A".to_string()),
5973                ..Default::default()
5974            },
5975        )
5976        .await?;
5977        let feed_b = upsert_feed(
5978            &pool,
5979            &NewFeed {
5980                url: "https://b.example/feed.xml".to_string(),
5981                title: Some("B".to_string()),
5982                ..Default::default()
5983            },
5984        )
5985        .await?;
5986
5987        insert_entries(
5988            &pool,
5989            feed_a,
5990            &[NewEntry {
5991                guid: "a-1".to_string(),
5992                url: Some("https://a.example/1".to_string()),
5993                title: Some("A one".to_string()),
5994                published: Some("2026-07-10T00:00:00Z".to_string()),
5995                content_html: Some("<p>secret A body</p>".to_string()),
5996                ..Default::default()
5997            }],
5998            0,
5999        )
6000        .await?;
6001        insert_entries(
6002            &pool,
6003            feed_b,
6004            &[NewEntry {
6005                guid: "b-1".to_string(),
6006                url: Some("https://b.example/1".to_string()),
6007                title: Some("B one".to_string()),
6008                published: Some("2026-07-11T00:00:00Z".to_string()),
6009                content_html: Some("<p>secret B body</p>".to_string()),
6010                ..Default::default()
6011            }],
6012            0,
6013        )
6014        .await?;
6015
6016        let did_a = "did:plc:aaaa";
6017        let did_b = "did:plc:bbbb";
6018        replace_sub_refs(&pool, did_a, &[feed_a]).await?;
6019        replace_sub_refs(&pool, did_b, &[feed_b]).await?;
6020
6021        // The id of B's only entry (the one A must not be able to touch).
6022        let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
6023
6024        // --- entries_for_feed is scoped: A sees A's feed, not B's ------------
6025        assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
6026        assert!(
6027            entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
6028            "A must not read entries of a feed it does not subscribe to"
6029        );
6030
6031        // --- unread list is scoped -------------------------------------------
6032        let unread_a = get_unread_for_did(&pool, did_a).await?;
6033        assert_eq!(unread_a.len(), 1);
6034        assert_eq!(unread_a[0].guid, "a-1");
6035        let unread_b = get_unread_for_did(&pool, did_b).await?;
6036        assert_eq!(unread_b.len(), 1);
6037        assert_eq!(unread_b[0].guid, "b-1");
6038
6039        // --- did_subscribes_to_entry authorizes correctly --------------------
6040        assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
6041        assert!(
6042            !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
6043            "A does not subscribe to B's feed"
6044        );
6045
6046        // --- mark_read is authorized: A CANNOT mark B's entry ----------------
6047        assert!(
6048            !mark_read(&pool, did_a, b_entry_id, true).await?,
6049            "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
6050        );
6051        // B's unread list is untouched by A's attempt.
6052        assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
6053        // A subscriber CAN mark it.
6054        assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
6055        assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
6056
6057        // --- toggle_star is authorized the same way --------------------------
6058        assert!(
6059            !mark_starred(&pool, did_a, b_entry_id, true).await?,
6060            "non-subscriber mark_starred must be a no-op (→ 404)"
6061        );
6062        assert!(
6063            get_starred_for_did(&pool, did_a).await?.is_empty(),
6064            "A's starred list stays empty after the rejected attempt"
6065        );
6066        assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
6067        assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
6068        // B's star never leaks into A's starred list.
6069        assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
6070
6071        // --- feeds_for_did is scoped to the DID's OWN sub_ref ----------------
6072        // This is the PDS-unreachable fallback's projection: it must NEVER
6073        // widen a DID's surface to feeds it does not subscribe to. A sees only
6074        // feed_a; B (still subscribed to feed_b here) sees only feed_b.
6075        let a_feeds = feeds_for_did(&pool, did_a).await?;
6076        assert_eq!(a_feeds.len(), 1);
6077        assert_eq!(a_feeds[0].id, feed_a);
6078        let b_feeds = feeds_for_did(&pool, did_b).await?;
6079        assert_eq!(b_feeds.len(), 1);
6080        assert_eq!(b_feeds[0].id, feed_b);
6081
6082        // --- resync drops a feed from the surface when the sub goes away ------
6083        replace_sub_refs(&pool, did_b, &[]).await?;
6084        assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
6085        assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
6086        assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
6087        // And the fallback projection is empty too — fail CLOSED, not open.
6088        assert!(feeds_for_did(&pool, did_b).await?.is_empty());
6089
6090        Ok(())
6091    }
6092
6093    // -----------------------------------------------------------------------
6094    // PDS-outage authorization (fail CLOSED). REGRESSION GUARD for the past
6095    // FAIL-OPEN bug (fixed in 2e53e0e): `resolve_subscriptions`' PDS/sidecar-
6096    // unreachable fallback used to synthesize a DID's `sub_ref` from EVERY
6097    // cached feed (`due_feeds(.., i64::MAX)`), granting cross-tenant read +
6098    // mutate during any outage. The fix serves the DID's OWN last-known
6099    // `sub_ref` via `feeds_for_did(did)` and NEVER widens it.
6100    //
6101    // This test replays that fixed fallback at the store layer — the seam the
6102    // web handler drives when `list_subscriptions_sorted(did) -> Err`. The
6103    // key adversarial shape is an ORPHAN cached feed (in the shared cache but
6104    // subscribed by NO ONE): the old fail-open code would have folded it into
6105    // the caller's surface. If the fail-open is reintroduced, `feeds_for_did`
6106    // would include that orphan and every assertion below flips — so this is a
6107    // real guard, not a tautology.
6108    // -----------------------------------------------------------------------
6109
6110    #[tokio::test]
6111    async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
6112        let pool = init_url("sqlite::memory:").await?;
6113
6114        let did_a = "did:plc:aaaa";
6115
6116        // feed_a: A's own subscription (its last-known `sub_ref`; the fallback
6117        // may serve this stale but must not widen past it).
6118        let feed_a = upsert_feed(
6119            &pool,
6120            &NewFeed {
6121                url: "https://a.example/feed.xml".to_string(),
6122                title: Some("A".to_string()),
6123                ..Default::default()
6124            },
6125        )
6126        .await?;
6127        // feed_orphan: present in the SHARED cache but subscribed by NO DID.
6128        // This is exactly what the fail-open path would have leaked to A.
6129        let feed_orphan = upsert_feed(
6130            &pool,
6131            &NewFeed {
6132                url: "https://orphan.example/feed.xml".to_string(),
6133                title: Some("Orphan".to_string()),
6134                ..Default::default()
6135            },
6136        )
6137        .await?;
6138
6139        insert_entries(
6140            &pool,
6141            feed_a,
6142            &[NewEntry {
6143                guid: "a-1".to_string(),
6144                url: Some("https://a.example/1".to_string()),
6145                title: Some("A one".to_string()),
6146                published: Some("2026-07-10T00:00:00Z".to_string()),
6147                content_html: Some("<p>A body</p>".to_string()),
6148                ..Default::default()
6149            }],
6150            0,
6151        )
6152        .await?;
6153        insert_entries(
6154            &pool,
6155            feed_orphan,
6156            &[NewEntry {
6157                guid: "orphan-1".to_string(),
6158                url: Some("https://orphan.example/1".to_string()),
6159                title: Some("Orphan one".to_string()),
6160                published: Some("2026-07-11T00:00:00Z".to_string()),
6161                content_html: Some("<p>secret orphan body</p>".to_string()),
6162                ..Default::default()
6163            }],
6164            0,
6165        )
6166        .await?;
6167
6168        // A's last-known subscription set is feed_a ONLY. No `sub_ref` row ever
6169        // points any DID at feed_orphan.
6170        replace_sub_refs(&pool, did_a, &[feed_a]).await?;
6171
6172        // Grab the orphan entry id via a transient sub so we can address it,
6173        // then drop the sub — nobody subscribes to feed_orphan afterwards.
6174        replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
6175        let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
6176        replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
6177
6178        // --- Replay the FIXED fallback projection ----------------------------
6179        // This is what `resolve_subscriptions` serves on the Err (outage) path:
6180        // the caller's OWN feeds, never widened. It must contain feed_a and
6181        // NEVER the orphan. (The old fail-open synthesized from every cached
6182        // feed → this vec would have held feed_orphan too.)
6183        let fallback = feeds_for_did(&pool, did_a).await?;
6184        let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
6185        assert_eq!(
6186            fallback_ids,
6187            vec![feed_a],
6188            "outage fallback must serve ONLY A's own last-known sub_ref, \
6189             never widen to the orphan cached feed"
6190        );
6191        assert!(
6192            !fallback_ids.contains(&feed_orphan),
6193            "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
6194             cached feed into A's surface"
6195        );
6196
6197        // --- With that projection in place, EVERY scoped read denies A -------
6198        assert!(
6199            !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
6200            "A must not be authorized for an orphan feed's entry during an outage"
6201        );
6202        assert!(
6203            entries_for_feed(&pool, did_a, feed_orphan)
6204                .await?
6205                .is_empty(),
6206            "entries_for_feed must not expose the orphan feed to A during an outage"
6207        );
6208        // Neither the unread nor the starred list may surface the orphan entry.
6209        let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
6210            .await?
6211            .into_iter()
6212            .map(|e| e.guid)
6213            .collect();
6214        assert!(
6215            !unread_guids.iter().any(|g| g == "orphan-1"),
6216            "orphan entry leaked into A's unread list during an outage"
6217        );
6218        assert!(
6219            get_starred_for_did(&pool, did_a).await?.is_empty(),
6220            "A has no starred entries; the orphan must not appear"
6221        );
6222
6223        // --- And EVERY scoped mutation is a no-op (→ 404 at the web layer) ---
6224        assert!(
6225            !mark_read(&pool, did_a, orphan_entry_id, true).await?,
6226            "A must not mark an orphan feed's entry read during an outage"
6227        );
6228        assert!(
6229            !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
6230            "A must not star an orphan feed's entry during an outage"
6231        );
6232        assert_eq!(
6233            mark_feed_read(&pool, did_a, feed_orphan, true).await?,
6234            0,
6235            "A must not mark-all-read the orphan feed during an outage"
6236        );
6237
6238        // Nothing was written for A against the orphan entry.
6239        let es_count: i64 =
6240            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6241                .bind(did_a)
6242                .bind(orphan_entry_id)
6243                .fetch_one(&pool)
6244                .await?;
6245        assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
6246
6247        Ok(())
6248    }
6249
6250    // -----------------------------------------------------------------------
6251    // Closed-beta invite gate
6252    // -----------------------------------------------------------------------
6253
6254    #[test]
6255    fn code_gen_shape_and_alphabet() {
6256        for _ in 0..200 {
6257            let code = generate_invite_code().unwrap();
6258            assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
6259            let body = &code["FEATHER-".len()..];
6260            assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
6261            // Every body char must be from the ambiguity-free alphabet — in
6262            // particular NEVER I/O/0/1.
6263            for c in body.chars() {
6264                assert!(
6265                    CODE_ALPHABET.contains(&(c as u8)),
6266                    "char {c:?} not in alphabet ({code})"
6267                );
6268                assert!(
6269                    !matches!(c, 'I' | 'O' | '0' | '1'),
6270                    "ambiguous char {c:?} leaked into {code}"
6271                );
6272            }
6273        }
6274        // Two codes in a row must differ (unguessable / random).
6275        assert_ne!(
6276            generate_invite_code().unwrap(),
6277            generate_invite_code().unwrap()
6278        );
6279    }
6280
6281    #[tokio::test]
6282    async fn busy_timeout_is_applied() -> Result<()> {
6283        // Opening an on-disk DB and reading back the PRAGMA proves the pool
6284        // carries busy_timeout = 5000 ms.
6285        let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
6286        std::fs::create_dir_all(&dir).ok();
6287        let path = dir.join("busy.db");
6288        let url = format!("sqlite://{}", path.display());
6289        let pool = init_url(&url).await?;
6290        let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
6291        let timeout: i64 = row.get(0);
6292        assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
6293        pool.close().await;
6294        std::fs::remove_dir_all(&dir).ok();
6295        Ok(())
6296    }
6297
6298    #[tokio::test]
6299    async fn redeem_valid_grants_seat() -> Result<()> {
6300        let pool = init_url("sqlite::memory:").await?;
6301        let code = mint_code(&pool, "did:plc:creator", 3600).await?;
6302        assert!(!has_beta_access(&pool, "did:plc:new").await?);
6303
6304        let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
6305        assert_eq!(out, Ok(()));
6306        assert!(has_beta_access(&pool, "did:plc:new").await?);
6307        assert_eq!(count_beta_access(&pool).await?, 1);
6308
6309        // The code is now spent — a second redeem is AlreadyRedeemed.
6310        let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
6311        assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
6312        Ok(())
6313    }
6314
6315    #[tokio::test]
6316    async fn redeem_not_found() -> Result<()> {
6317        let pool = init_url("sqlite::memory:").await?;
6318        let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
6319        assert_eq!(out, Err(RedeemError::NotFound));
6320        Ok(())
6321    }
6322
6323    /// Insert an already-expired `active` code directly (mint_code clamps a
6324    /// negative ttl to 0, so the past-expiry case is set up by hand).
6325    async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
6326        let now = now_unix();
6327        sqlx::query(
6328            r#"INSERT INTO invite_codes
6329               (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
6330               VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
6331        )
6332        .bind(code)
6333        .bind(creator)
6334        .bind(now - 100)
6335        .bind(now - 10) // expires_at in the past
6336        .execute(pool)
6337        .await?;
6338        Ok(())
6339    }
6340
6341    #[tokio::test]
6342    async fn redeem_expired() -> Result<()> {
6343        let pool = init_url("sqlite::memory:").await?;
6344        insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
6345        let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
6346        assert_eq!(out, Err(RedeemError::Expired));
6347        // No seat granted.
6348        assert_eq!(count_beta_access(&pool).await?, 0);
6349        Ok(())
6350    }
6351
6352    #[tokio::test]
6353    async fn redeem_capacity_full() -> Result<()> {
6354        let pool = init_url("sqlite::memory:").await?;
6355        // Cap of 1, one seat already taken by an admin seed.
6356        ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
6357        assert_eq!(count_beta_access(&pool).await?, 1);
6358
6359        let code = mint_code(&pool, "did:plc:admin", 3600).await?;
6360        let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
6361        assert_eq!(out, Err(RedeemError::CapacityFull));
6362        // Seat NOT granted and the code NOT consumed (tx rolled back).
6363        assert!(!has_beta_access(&pool, "did:plc:new").await?);
6364        // Raising the cap lets the same code redeem.
6365        let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
6366        assert_eq!(ok, Ok(()));
6367        Ok(())
6368    }
6369
6370    #[tokio::test]
6371    async fn count_active_codes_excludes_expired_and_redeemed() -> Result<()> {
6372        let pool = init_url("sqlite::memory:").await?;
6373        assert_eq!(count_active_codes(&pool).await?, 0);
6374
6375        // Two live codes.
6376        let a = mint_code(&pool, "did:plc:bot", 3600).await?;
6377        let _b = mint_code(&pool, "did:plc:bot", 3600).await?;
6378        assert_eq!(count_active_codes(&pool).await?, 2);
6379
6380        // An expired code doesn't count.
6381        insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:bot").await?;
6382        assert_eq!(count_active_codes(&pool).await?, 2);
6383
6384        // Redeeming one drops the active count.
6385        let out = redeem_code(&pool, &a, "did:plc:new", None, 100).await?;
6386        assert_eq!(out, Ok(()));
6387        assert_eq!(count_active_codes(&pool).await?, 1);
6388        Ok(())
6389    }
6390
6391    #[tokio::test]
6392    async fn expire_and_seed() -> Result<()> {
6393        let pool = init_url("sqlite::memory:").await?;
6394        // An already-expired code is swept to `expired`.
6395        insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
6396        let live = mint_code(&pool, "did:plc:creator", 3600).await?;
6397        let n = expire_old_codes(&pool).await?;
6398        assert_eq!(n, 1, "exactly the past-expiry code should flip");
6399        // The live code still redeems.
6400        assert_eq!(
6401            redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
6402            Ok(())
6403        );
6404
6405        // ensure_seed is idempotent.
6406        let created = ensure_seed(
6407            &pool,
6408            &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
6409        )
6410        .await?;
6411        assert_eq!(created, 2);
6412        let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
6413        assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
6414        assert!(has_beta_access(&pool, "did:plc:seed1").await?);
6415        Ok(())
6416    }
6417
6418    /// **The sweep spares a REDEEMED code that is past its TTL.** The
6419    /// existing sweep test seeds one active past-expiry code and one live
6420    /// one, so the `status = 'active'` guard never excludes anything — with
6421    /// it deleted the suite stayed green. Without it the hourly sweep rewrites
6422    /// redeemed codes to `expired`, destroying the redemption the invite audit
6423    /// trail depends on and inflating the logged sweep count.
6424    #[tokio::test]
6425    async fn the_expiry_sweep_spares_redeemed_codes() -> Result<()> {
6426        let pool = init_url("sqlite::memory:").await?;
6427        let code = mint_code(&pool, "did:plc:creator", 3600).await?;
6428        assert!(redeem_code(&pool, &code, "did:plc:new", None, 100)
6429            .await?
6430            .is_ok());
6431        // Time passes: the redeemed code is now past its TTL.
6432        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6433            .bind(now_unix() - 10)
6434            .bind(&code)
6435            .execute(&pool)
6436            .await?;
6437        insert_expired_code(&pool, "FEATHER-EXPIRED2", "did:plc:creator").await?;
6438
6439        let n = expire_old_codes(&pool).await?;
6440        assert_eq!(n, 1, "the sweep counted the redeemed code");
6441        let status: String = sqlx::query_scalar("SELECT status FROM invite_codes WHERE code = ?1")
6442            .bind(&code)
6443            .fetch_one(&pool)
6444            .await?;
6445        assert_eq!(status, "redeemed", "the sweep rewrote a redemption");
6446        Ok(())
6447    }
6448
6449    // -----------------------------------------------------------------------
6450    // Hardening caps: per-DID sub count, global feed count, per-feed entry trim.
6451    // -----------------------------------------------------------------------
6452
6453    #[tokio::test]
6454    async fn count_helpers_track_feeds_and_subs() -> Result<()> {
6455        let pool = init_url("sqlite::memory:").await?;
6456        assert_eq!(count_feeds(&pool).await?, 0);
6457
6458        let mut ids = Vec::new();
6459        for i in 0..3 {
6460            let id = upsert_feed(
6461                &pool,
6462                &NewFeed {
6463                    url: format!("https://f{i}.example/feed.xml"),
6464                    ..Default::default()
6465                },
6466            )
6467            .await?;
6468            ids.push(id);
6469        }
6470        assert_eq!(count_feeds(&pool).await?, 3);
6471
6472        let did = "did:plc:capcheck";
6473        assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
6474        replace_sub_refs(&pool, did, &ids).await?;
6475        assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
6476        Ok(())
6477    }
6478
6479    #[tokio::test]
6480    async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
6481        let pool = init_url("sqlite::memory:").await?;
6482        let feed_id = upsert_feed(
6483            &pool,
6484            &NewFeed {
6485                url: "https://firehose.example/feed.xml".to_string(),
6486                ..Default::default()
6487            },
6488        )
6489        .await?;
6490
6491        // Insert 5 entries with ascending published dates, cap retained to 2.
6492        let batch: Vec<NewEntry> = (0..5)
6493            .map(|i| NewEntry {
6494                guid: format!("g-{i}"),
6495                title: Some(format!("E{i}")),
6496                published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
6497                ..Default::default()
6498            })
6499            .collect();
6500        insert_entries(&pool, feed_id, &batch, 2).await?;
6501
6502        let did = "did:plc:trim";
6503        replace_sub_refs(&pool, did, &[feed_id]).await?;
6504        let kept = entries_for_feed(&pool, did, feed_id).await?;
6505        assert_eq!(
6506            kept.len(),
6507            2,
6508            "over-cap feed trimmed to the newest 2 entries"
6509        );
6510        // Newest first: g-4 (2026-07-05), g-3 (2026-07-04).
6511        assert_eq!(kept[0].guid, "g-4");
6512        assert_eq!(kept[1].guid, "g-3");
6513        Ok(())
6514    }
6515
6516    /// Regression: an UNDATED entry (NULL `published`) that was fetched most
6517    /// recently must NOT be evicted in favour of an older *dated* entry. The
6518    /// trim orders by `COALESCE(published, fetched_at) DESC`; under the old
6519    /// `ORDER BY published DESC` a NULL-published row sorts LAST and is dropped
6520    /// first even when it is the freshest thing in the feed.
6521    #[tokio::test]
6522    async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
6523        let pool = init_url("sqlite::memory:").await?;
6524        let feed_id = upsert_feed(
6525            &pool,
6526            &NewFeed {
6527                url: "https://undated.example/feed.xml".to_string(),
6528                ..Default::default()
6529            },
6530        )
6531        .await?;
6532
6533        // Two OLD dated entries (fetched long ago), plus one UNDATED entry
6534        // fetched most recently. Cap = 2, so exactly one row must be evicted.
6535        let batch = vec![
6536            NewEntry {
6537                guid: "old-dated-1".to_string(),
6538                title: Some("Old A".to_string()),
6539                published: Some("2026-07-01T00:00:00Z".to_string()),
6540                fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
6541                ..Default::default()
6542            },
6543            NewEntry {
6544                guid: "old-dated-2".to_string(),
6545                title: Some("Old B".to_string()),
6546                published: Some("2026-07-02T00:00:00Z".to_string()),
6547                fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
6548                ..Default::default()
6549            },
6550            NewEntry {
6551                guid: "fresh-undated".to_string(),
6552                title: Some("Fresh undated".to_string()),
6553                published: None,
6554                fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
6555                ..Default::default()
6556            },
6557        ];
6558        insert_entries(&pool, feed_id, &batch, 2).await?;
6559
6560        let did = "did:plc:undated";
6561        replace_sub_refs(&pool, did, &[feed_id]).await?;
6562        let kept = entries_for_feed(&pool, did, feed_id).await?;
6563        assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
6564        let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
6565        assert!(
6566            guids.contains(&"fresh-undated"),
6567            "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
6568        );
6569        assert!(
6570            guids.contains(&"old-dated-2"),
6571            "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
6572        );
6573        assert!(
6574            !guids.contains(&"old-dated-1"),
6575            "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
6576        );
6577        Ok(())
6578    }
6579
6580    /// **It must actually GROW — the name used to be a lie.**
6581    ///
6582    /// The earlier body was three lines asserting only `before > 0`. There was
6583    /// no second measurement, so `db_size_bytes` returning a constant `1` passed.
6584    /// That matters because this number is the poller's disk watermark: a size
6585    /// that never moves means the pause never trips and the volume fills
6586    /// instead.
6587    #[tokio::test]
6588    async fn db_size_is_positive_and_grows() -> Result<()> {
6589        let pool = init_url("sqlite::memory:").await?;
6590        let before = db_size_bytes(&pool).await?;
6591        assert!(before > 0, "a schema-initialised DB has a non-zero size");
6592
6593        // Enough rows that the file must gain pages, not just fill slack.
6594        seed_big_entries(&pool, "did:plc:growth", 400).await?;
6595
6596        let after = db_size_bytes(&pool).await?;
6597        assert!(
6598            after > before,
6599            "the database grew by {} bytes after 400 seeded entries; the size is \
6600             not tracking the data, so the disk watermark can never trip",
6601            after.saturating_sub(before),
6602        );
6603        Ok(())
6604    }
6605
6606    /// `purge_did_data` removes every per-DID row the caller owns (read/star
6607    /// state, cursors, sub_ref projection, beta seat, created invite codes) —
6608    /// and touches no other DID's rows nor the shared feeds/entries cache.
6609    #[tokio::test]
6610    async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
6611        let pool = init_url("sqlite::memory:").await?;
6612
6613        // A shared feed + entry both DIDs can subscribe to.
6614        let feed_id = upsert_feed(
6615            &pool,
6616            &NewFeed {
6617                url: "https://example.com/feed.xml".to_string(),
6618                title: Some("Example".to_string()),
6619                ..Default::default()
6620            },
6621        )
6622        .await?;
6623        insert_entries(
6624            &pool,
6625            feed_id,
6626            &[NewEntry {
6627                guid: "g-1".to_string(),
6628                url: Some("https://example.com/a".to_string()),
6629                title: Some("First".to_string()),
6630                published: Some("2026-07-10T08:00:00Z".to_string()),
6631                ..Default::default()
6632            }],
6633            0,
6634        )
6635        .await?;
6636        let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
6637            .fetch_one(&pool)
6638            .await?;
6639
6640        let victim = "did:plc:victim";
6641        let bystander = "did:plc:bystander";
6642
6643        // Seed BOTH DIDs with a full spread of per-DID rows.
6644        for did in [victim, bystander] {
6645            replace_sub_refs(&pool, did, &[feed_id]).await?;
6646            assert!(mark_read(&pool, did, entry_id, true).await?);
6647            assert!(mark_starred(&pool, did, entry_id, true).await?);
6648            upsert_cursor(
6649                &pool,
6650                &ReadCursor {
6651                    did: did.to_string(),
6652                    feed_url: "https://example.com/feed.xml".to_string(),
6653                    read_through: Some("2026-07-10T08:00:00Z".to_string()),
6654                    read_ids: "[]".to_string(),
6655                    unread_ids: "[]".to_string(),
6656                    dirty: false,
6657                    pds_created: false,
6658                    updated_at: now_rfc3339(),
6659                },
6660            )
6661            .await?;
6662            grant_access(&pool, did, Some("h.example"), "admin", None).await?;
6663            mint_code(&pool, did, 3600).await?;
6664        }
6665
6666        // Purge only the victim.
6667        let counts = purge_did_data(&pool, victim).await?;
6668        assert_eq!(
6669            counts.entry_state, 1,
6670            "one entry_state row (read+star merge)"
6671        );
6672        assert_eq!(counts.read_cursor, 1);
6673        assert_eq!(counts.sub_ref, 1);
6674        assert_eq!(counts.beta_access, 1);
6675        assert_eq!(counts.invite_codes, 1);
6676        assert_eq!(counts.total(), 5);
6677
6678        // The victim has zero rows left in every per-DID table.
6679        let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
6680            .bind(victim)
6681            .fetch_one(&pool)
6682            .await?;
6683        assert_eq!(es, 0, "victim still had entry_state rows");
6684        let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
6685            .bind(victim)
6686            .fetch_one(&pool)
6687            .await?;
6688        assert_eq!(rc, 0, "victim still had read_cursor rows");
6689        let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
6690            .bind(victim)
6691            .fetch_one(&pool)
6692            .await?;
6693        assert_eq!(sr, 0, "victim still had sub_ref rows");
6694        assert!(
6695            !has_beta_access(&pool, victim).await?,
6696            "victim still had a beta seat"
6697        );
6698        let victim_codes: i64 =
6699            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6700                .bind(victim)
6701                .fetch_one(&pool)
6702                .await?;
6703        assert_eq!(victim_codes, 0);
6704
6705        // The bystander is untouched.
6706        assert!(has_beta_access(&pool, bystander).await?);
6707        let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
6708        assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
6709        let bystander_codes: i64 =
6710            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6711                .bind(bystander)
6712                .fetch_one(&pool)
6713                .await?;
6714        assert_eq!(bystander_codes, 1);
6715
6716        // The shared cache is intact.
6717        assert_eq!(count_feeds(&pool).await?, 1);
6718
6719        // Idempotent: purging again removes nothing.
6720        let again = purge_did_data(&pool, victim).await?;
6721        assert_eq!(again.total(), 0);
6722
6723        Ok(())
6724    }
6725
6726    /// A departing DID leaves back-references on rows that belong to OTHER DIDs:
6727    ///   * the invite code it *redeemed* to join (inviter's row: `invitee_did`);
6728    ///   * seats it *granted* to others (`beta_access.granted_by`).
6729    /// `purge_did_data` must scrub both so no per-DID residue survives, while
6730    /// leaving those other DIDs' rows otherwise intact (their access is kept).
6731    #[tokio::test]
6732    async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
6733        let pool = init_url("sqlite::memory:").await?;
6734
6735        let inviter = "did:plc:inviter";
6736        let leaver = "did:plc:leaver";
6737        let friend = "did:plc:friend";
6738
6739        // inviter mints a code; leaver redeems it to join (stamps invitee_did).
6740        let inviter_code = mint_code(&pool, inviter, 3600).await?;
6741        grant_access(&pool, inviter, None, "admin", None).await?;
6742        assert_eq!(
6743            redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
6744            Ok(())
6745        );
6746
6747        // leaver mints a code; friend redeems it (stamps friend's granted_by).
6748        let leaver_code = mint_code(&pool, leaver, 3600).await?;
6749        assert_eq!(
6750            redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
6751            Ok(())
6752        );
6753
6754        // Precondition: the leaver DID is present in both back-reference columns.
6755        let invitee_before: i64 =
6756            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6757                .bind(leaver)
6758                .fetch_one(&pool)
6759                .await?;
6760        assert_eq!(
6761            invitee_before, 1,
6762            "leaver should be an invitee before purge"
6763        );
6764        let granted_before: i64 =
6765            sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6766                .bind(leaver)
6767                .fetch_one(&pool)
6768                .await?;
6769        assert_eq!(granted_before, 1, "leaver should be a granter before purge");
6770
6771        // Purge the leaver.
6772        let counts = purge_did_data(&pool, leaver).await?;
6773        assert_eq!(
6774            counts.invitee_scrubbed, 1,
6775            "the redeemed code's invitee_did"
6776        );
6777        assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
6778
6779        // No residue: the leaver DID appears in NEITHER back-reference column.
6780        let invitee_after: i64 =
6781            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6782                .bind(leaver)
6783                .fetch_one(&pool)
6784                .await?;
6785        assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
6786        let granted_after: i64 =
6787            sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6788                .bind(leaver)
6789                .fetch_one(&pool)
6790                .await?;
6791        assert_eq!(granted_after, 0, "leaver survived in granted_by");
6792
6793        // The other DIDs' rows are kept: the friend still has a seat (redacted
6794        // granter), and the inviter's code row still exists (invitee NULLed).
6795        assert!(
6796            has_beta_access(&pool, friend).await?,
6797            "friend's seat must survive the leaver's scrub"
6798        );
6799        let friend_granted_by: String =
6800            sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
6801                .bind(friend)
6802                .fetch_one(&pool)
6803                .await?;
6804        assert_eq!(friend_granted_by, REDACTED_DID);
6805        let inviter_code_rows: i64 =
6806            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6807                .bind(inviter)
6808                .fetch_one(&pool)
6809                .await?;
6810        assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
6811
6812        Ok(())
6813    }
6814
6815    // -- F2: consecutive-error count drives the poll backoff -----------------
6816
6817    /// **Rows that failed only because we could not poll them are cleared.**
6818    ///
6819    /// Excluding `at://` from `due_feeds` stops NEW failures; it does nothing
6820    /// about the ones already recorded. This instance carries 19 such rows at
6821    /// 35+ consecutive errors each — accumulated entirely by our own refusal to
6822    /// fetch a scheme we had not implemented. Left alone they keep counting
6823    /// toward `in_backoff` and `badly_broken`, so a public page would report
6824    /// unsupported feeds as broken publishers forever, with no poll that could
6825    /// ever clear them since they are no longer selected.
6826    ///
6827    /// Safe to re-run because of WHAT it clears, not because the count cannot
6828    /// grow: only rows never polled successfully (`last_polled IS NULL`) — see
6829    /// `the_at_uri_error_clearing_spares_a_row_that_has_been_polled`.
6830    #[tokio::test]
6831    async fn the_migration_clears_error_counts_on_unpollable_at_uri_rows() -> Result<()> {
6832        let pool = init_url("sqlite::memory:").await?;
6833        for url in [
6834            "https://real.example/feed.xml",
6835            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
6836        ] {
6837            upsert_feed(
6838                &pool,
6839                &NewFeed {
6840                    url: url.to_string(),
6841                    ..Default::default()
6842                },
6843            )
6844            .await?;
6845            sqlx::query(
6846                "UPDATE feeds SET consecutive_errors = 35, last_error_kind = 'fetch', \
6847                 last_error = 'unsupported scheme' WHERE url = ?1",
6848            )
6849            .bind(url)
6850            .execute(&pool)
6851            .await?;
6852        }
6853
6854        apply_migrations(&pool).await?;
6855
6856        let (at_errors, at_kind, at_detail): (i64, Option<String>, Option<String>) =
6857            sqlx::query_as(sqlx::AssertSqlSafe(format!(
6858                "SELECT consecutive_errors, last_error_kind, last_error FROM feeds \
6859                 WHERE kind = '{}'",
6860                crate::feed::FeedKind::Unsupported.as_str()
6861            )))
6862            .fetch_one(&pool)
6863            .await?;
6864        assert_eq!(at_errors, 0, "an unpollable row kept its failure count");
6865        // A row with no errors carries no reason — the invariant
6866        // `reset_feed_errors` upholds, and the migration must too.
6867        assert_eq!(at_kind, None, "an unpollable row kept its failure kind");
6868        assert_eq!(at_detail, None, "an unpollable row kept its failure detail");
6869
6870        // A real feed's failure history is NOT touched — it is still meaningful.
6871        let http_errors: i64 = sqlx::query_scalar(
6872            "SELECT consecutive_errors FROM feeds WHERE url = 'https://real.example/feed.xml'",
6873        )
6874        .fetch_one(&pool)
6875        .await?;
6876        assert_eq!(http_errors, 35, "a real feed's history was discarded");
6877        Ok(())
6878    }
6879
6880    /// **An `at://` feed is never selected for polling.**
6881    ///
6882    /// (Since 0.4.0 these are rows of kind `unsupported`: an at-URI that is not a
6883    /// well-formed publication, which no reader can fetch.) Selecting them does not
6884    /// leave the feature dormant — it manufactures a permanent failure per row,
6885    /// which since the cause histogram is *published* as an unreachable
6886    /// publisher. This instance already carries 19 such rows, subscribed before
6887    /// the scheme was refused.
6888    ///
6889    /// They are skipped rather than failed: unsupported is not broken, and the
6890    /// difference is the whole point of recording a cause at all.
6891    #[tokio::test]
6892    async fn an_at_uri_feed_is_never_due_for_polling() -> Result<()> {
6893        let pool = init_url("sqlite::memory:").await?;
6894        for url in [
6895            "https://example.com/feed.xml",
6896            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
6897            "at://alice.example.com/site.standard.publication/3lab",
6898        ] {
6899            upsert_feed(
6900                &pool,
6901                &NewFeed {
6902                    url: url.to_string(),
6903                    ..Default::default()
6904                },
6905            )
6906            .await?;
6907        }
6908        // All three have a NULL next_poll, which sorts FIRST — so if at:// were
6909        // selectable at all it would be selected before the http feed.
6910        let due = due_feeds(&pool, "2026-09-20T00:00:00Z", 50).await?;
6911        let urls: Vec<&str> = due.iter().map(|f| f.url.as_str()).collect();
6912        assert_eq!(
6913            urls,
6914            ["https://example.com/feed.xml"],
6915            "an at:// feed was handed to the poller"
6916        );
6917        Ok(())
6918    }
6919
6920    #[tokio::test]
6921    async fn feed_error_count_bumps_and_resets() -> Result<()> {
6922        let pool = init_url("sqlite::memory:").await?;
6923        let url = "https://broken.example/feed.xml";
6924        upsert_feed(
6925            &pool,
6926            &NewFeed {
6927                url: url.to_string(),
6928                ..Default::default()
6929            },
6930        )
6931        .await?;
6932
6933        // A fresh feed starts at 0 errors.
6934        let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
6935        assert_eq!(feed.consecutive_errors, 0);
6936
6937        // N consecutive failures grow the count 1,2,3, and — fed through
6938        // `backoff_for` — the backoff grows with it (never latched at the floor).
6939        let mut last = std::time::Duration::ZERO;
6940        for expected in 1..=3 {
6941            let count = bump_feed_errors(
6942                &pool,
6943                url,
6944                crate::feed::FailureKind::Fetch,
6945                "connection refused",
6946            )
6947            .await?;
6948            assert_eq!(count, expected, "bump returns the new count");
6949            let backoff = crate::feed::backoff_for(count as u32);
6950            assert!(
6951                backoff >= last,
6952                "backoff must not shrink as errors accumulate"
6953            );
6954            last = backoff;
6955        }
6956        // Growth actually happened (2 errors backs off longer than 1).
6957        assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
6958        assert_eq!(
6959            get_feed_by_url(&pool, url)
6960                .await?
6961                .unwrap()
6962                .consecutive_errors,
6963            3
6964        );
6965
6966        // A success resets the streak to 0 (back to the normal cadence).
6967        reset_feed_errors(&pool, url).await?;
6968        assert_eq!(
6969            get_feed_by_url(&pool, url)
6970                .await?
6971                .unwrap()
6972                .consecutive_errors,
6973            0
6974        );
6975        Ok(())
6976    }
6977
6978    /// **A recovered feed keeps no reason for having failed.**
6979    ///
6980    /// Added because a mutation found this untested: deleting the
6981    /// `last_error_kind = NULL, last_error = NULL` half of `reset_feed_errors`
6982    /// left the entire suite green. The histogram filters on
6983    /// `consecutive_errors > 0`, so a stale row would not inflate the public
6984    /// count — but anything reading the row directly would be handed a cause
6985    /// that stopped applying, which is the exact failure this column was added
6986    /// to end. A guarantee nothing checks is a comment.
6987    #[tokio::test]
6988    async fn a_successful_poll_clears_the_recorded_failure_reason() -> Result<()> {
6989        let pool = init_url("sqlite::memory:").await?;
6990        let url = "https://recovers.example/feed.xml";
6991        upsert_feed(
6992            &pool,
6993            &NewFeed {
6994                url: url.to_string(),
6995                ..Default::default()
6996            },
6997        )
6998        .await?;
6999
7000        bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "SENTINEL_WHY").await?;
7001        let failing: (Option<String>, Option<String>) =
7002            sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
7003                .bind(url)
7004                .fetch_one(&pool)
7005                .await?;
7006        assert_eq!(
7007            failing.0.as_deref(),
7008            Some("fetch"),
7009            "the kind was not stored"
7010        );
7011        assert_eq!(
7012            failing.1.as_deref(),
7013            Some("SENTINEL_WHY"),
7014            "the detail was not stored"
7015        );
7016
7017        reset_feed_errors(&pool, url).await?;
7018        let recovered: (Option<String>, Option<String>) =
7019            sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
7020                .bind(url)
7021                .fetch_one(&pool)
7022                .await?;
7023        assert_eq!(
7024            recovered.0, None,
7025            "a healthy feed still names a failure kind"
7026        );
7027        assert_eq!(
7028            recovered.1, None,
7029            "a healthy feed still carries error detail"
7030        );
7031        Ok(())
7032    }
7033
7034    /// **The closed vocabulary is closed where it is READ, not only written.**
7035    ///
7036    /// `FailureKind::parse` promises that a kind string from a newer build is
7037    /// not "silently attributed to a cause this one recognises" — and the
7038    /// histogram's comment leaned on it. But review found `parse` had zero
7039    /// production callers: `poll_health` handed the raw column to the public
7040    /// template, so an unrecognised string got its own bucket, rendered
7041    /// verbatim. The protection existed only as a doc comment.
7042    ///
7043    /// A row written by a future build must land in `unknown`.
7044    #[tokio::test]
7045    async fn an_unrecognised_failure_kind_folds_into_unknown() -> Result<()> {
7046        let pool = init_url("sqlite::memory:").await?;
7047        for (url, kind) in [
7048            ("https://a.example/f.xml", Some("fetch")),
7049            ("https://b.example/f.xml", Some("quota")), // a newer build's kind
7050            ("https://c.example/f.xml", None),          // a legacy row
7051        ] {
7052            upsert_feed(
7053                &pool,
7054                &NewFeed {
7055                    url: url.to_string(),
7056                    ..Default::default()
7057                },
7058            )
7059            .await?;
7060            sqlx::query(
7061                "UPDATE feeds SET consecutive_errors = 1, last_error_kind = ?2 WHERE url = ?1",
7062            )
7063            .bind(url)
7064            .bind(kind)
7065            .execute(&pool)
7066            .await?;
7067        }
7068        let now = chrono::Utc::now();
7069        let health = poll_health(
7070            &pool,
7071            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
7072            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
7073        )
7074        .await?;
7075        let mut kinds = health.failure_kinds.clone();
7076        kinds.sort();
7077        assert_eq!(
7078            kinds,
7079            vec![("fetch".to_string(), 1), ("unknown".to_string(), 2)],
7080            "an unrecognised kind reached the public histogram as its own bucket: {:?}",
7081            health.failure_kinds
7082        );
7083        Ok(())
7084    }
7085
7086    /// **The migration is exercised against a table that predates the columns.**
7087    ///
7088    /// Every other test here builds a fresh database, where `CREATE TABLE`
7089    /// already contains `last_error_kind` / `last_error` — so `ensure_column`,
7090    /// the code path that actually runs against the production volume, was
7091    /// never executed by any of them. A bad `ALTER` would have been found at
7092    /// boot, on the one machine, by crash-looping: `apply_migrations` runs
7093    /// inside `init`, and the entrypoint takes the container down when a child
7094    /// dies.
7095    ///
7096    /// Builds the OLD table shape by hand, puts a failing row in it, migrates,
7097    /// and asserts both that the columns arrive and that the pre-existing row
7098    /// survives with NULLs rather than being rewritten or dropped.
7099    #[tokio::test]
7100    async fn the_last_error_columns_migrate_onto_a_table_that_predates_them() -> Result<()> {
7101        let pool = init_url("sqlite::memory:").await?;
7102
7103        // Drop the current shape and rebuild the pre-migration one.
7104        sqlx::query("DROP TABLE feeds").execute(&pool).await?;
7105        sqlx::query(
7106            "CREATE TABLE feeds (
7107                id                 INTEGER PRIMARY KEY AUTOINCREMENT,
7108                url                TEXT NOT NULL UNIQUE,
7109                title              TEXT,
7110                site_url           TEXT,
7111                etag               TEXT,
7112                last_modified      TEXT,
7113                last_polled        TEXT,
7114                next_poll          TEXT,
7115                consecutive_errors INTEGER NOT NULL DEFAULT 0
7116            )",
7117        )
7118        .execute(&pool)
7119        .await?;
7120        sqlx::query("INSERT INTO feeds (url, consecutive_errors) VALUES (?1, 7)")
7121            .bind("https://legacy.example/feed.xml")
7122            .execute(&pool)
7123            .await?;
7124
7125        apply_migrations(&pool).await?;
7126
7127        // The columns exist...
7128        let cols: Vec<String> = sqlx::query("PRAGMA table_info(feeds)")
7129            .fetch_all(&pool)
7130            .await?
7131            .iter()
7132            .map(|r| r.get::<String, _>("name"))
7133            .collect();
7134        assert!(cols.iter().any(|c| c == "last_error_kind"), "{cols:?}");
7135        assert!(cols.iter().any(|c| c == "last_error"), "{cols:?}");
7136
7137        // ...and the pre-existing row is intact, with no invented cause.
7138        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
7139            "SELECT consecutive_errors, last_error_kind, last_error FROM feeds WHERE url = ?1",
7140        )
7141        .bind("https://legacy.example/feed.xml")
7142        .fetch_one(&pool)
7143        .await?;
7144        assert_eq!(row.0, 7, "the migration disturbed an existing error count");
7145        assert_eq!(row.1, None, "a legacy row was given a cause it never had");
7146        assert_eq!(row.2, None);
7147
7148        // And it is idempotent — `init` runs this on every boot.
7149        apply_migrations(&pool).await?;
7150        Ok(())
7151    }
7152
7153    /// The stored detail is bounded — it is a remote server's text on an
7154    /// unattended path.
7155    #[tokio::test]
7156    async fn the_stored_error_detail_is_truncated() -> Result<()> {
7157        let pool = init_url("sqlite::memory:").await?;
7158        let url = "https://verbose.example/feed.xml";
7159        upsert_feed(
7160            &pool,
7161            &NewFeed {
7162                url: url.to_string(),
7163                ..Default::default()
7164            },
7165        )
7166        .await?;
7167        bump_feed_errors(
7168            &pool,
7169            url,
7170            crate::feed::FailureKind::Body,
7171            &"x".repeat(10_000),
7172        )
7173        .await?;
7174        let stored: (Option<String>,) =
7175            sqlx::query_as("SELECT last_error FROM feeds WHERE url = ?1")
7176                .bind(url)
7177                .fetch_one(&pool)
7178                .await?;
7179        assert_eq!(stored.0.unwrap().chars().count(), MAX_ERROR_DETAIL_CHARS);
7180        Ok(())
7181    }
7182
7183    // -- F3: db_size_bytes ignores freed pages and drops after reclaim -------
7184
7185    /// A new on-disk database must be created in INCREMENTAL mode.
7186    ///
7187    /// This is the whole fix for new instances: `auto_vacuum` was read by
7188    /// `reclaim` and set nowhere, so every database ran in NONE and `reclaim`
7189    /// always took its full-`VACUUM` branch — the one that cannot complete on a
7190    /// volume under the pressure that triggered the sweep. The pragma only binds
7191    /// on a database with no tables yet, so "at creation" is the load-bearing
7192    /// part, not "somewhere in init".
7193    #[tokio::test]
7194    async fn a_new_database_is_created_in_incremental_vacuum_mode() -> Result<()> {
7195        let dir = std::env::temp_dir();
7196        let path = dir.join(format!("fr-autovac-{}.db", std::process::id()));
7197        for p in [
7198            path.display().to_string(),
7199            format!("{}-wal", path.display()),
7200            format!("{}-shm", path.display()),
7201        ] {
7202            std::fs::remove_file(&p).ok();
7203        }
7204        let pool = init_url(&format!("sqlite://{}", path.display())).await?;
7205
7206        assert_eq!(
7207            auto_vacuum_mode(&pool).await?,
7208            AutoVacuum::Incremental,
7209            "a fresh database is still in the mode where reclaim needs a full VACUUM"
7210        );
7211        // And the WAL is bounded rather than growing to its high-water mark
7212        // forever.
7213        let limit: i64 = sqlx::query_scalar("PRAGMA journal_size_limit")
7214            .fetch_one(&pool)
7215            .await?;
7216        assert_eq!(
7217            limit, WAL_SIZE_LIMIT_BYTES,
7218            "journal_size_limit not applied"
7219        );
7220
7221        // Being INCREMENTAL, the migration is a no-op — which is what makes the
7222        // flag safe for an operator to run without checking first.
7223        assert_eq!(
7224            migrate_to_incremental_vacuum(&pool, None).await?,
7225            VacuumMigration::NotNeeded(AutoVacuum::Incremental)
7226        );
7227
7228        pool.close().await;
7229        for p in [
7230            path.display().to_string(),
7231            format!("{}-wal", path.display()),
7232            format!("{}-shm", path.display()),
7233        ] {
7234            std::fs::remove_file(&p).ok();
7235        }
7236        Ok(())
7237    }
7238
7239    /// The migration refuses itself when the volume cannot hold the rebuild.
7240    ///
7241    /// A full `VACUUM` writes a complete second copy, so attempting one without
7242    /// headroom burns I/O on a box that has none and finishes nothing. Refusing
7243    /// is the entire reason this is an operator step rather than something
7244    /// `reclaim` does on its own.
7245    #[tokio::test]
7246    async fn the_vacuum_migration_refuses_without_headroom() -> Result<()> {
7247        let dir = std::env::temp_dir();
7248        let path = dir.join(format!("fr-autovac-none-{}.db", std::process::id()));
7249        for p in [
7250            path.display().to_string(),
7251            format!("{}-wal", path.display()),
7252            format!("{}-shm", path.display()),
7253        ] {
7254            std::fs::remove_file(&p).ok();
7255        }
7256        // Build a database the way one that predates this change looks: create
7257        // the file in NONE mode explicitly, then populate it.
7258        let url = format!("sqlite://{}", path.display());
7259        let opts = SqliteConnectOptions::from_str(&url)?
7260            .create_if_missing(true)
7261            .foreign_keys(true)
7262            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7263            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7264        let pool = SqlitePoolOptions::new()
7265            .min_connections(1)
7266            .max_connections(1)
7267            .connect_with(opts)
7268            .await?;
7269        init_schema(&pool).await?;
7270        assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::None);
7271
7272        // Zero free space: refused, and the mode is untouched.
7273        let refused = migrate_to_incremental_vacuum(&pool, Some(0)).await?;
7274        assert!(
7275            matches!(refused, VacuumMigration::RefusedNoHeadroom { .. }),
7276            "expected a refusal, got {refused:?}"
7277        );
7278        assert_eq!(
7279            auto_vacuum_mode(&pool).await?,
7280            AutoVacuum::None,
7281            "a refused migration must not have changed the mode"
7282        );
7283
7284        // With headroom it runs, and the database ends up INCREMENTAL — which is
7285        // what makes `reclaim` cheap from then on.
7286        let done = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
7287        let VacuumMigration::Migrated {
7288            bytes_after,
7289            file_after,
7290            ..
7291        } = done
7292        else {
7293            panic!("expected a migration, got {done:?}");
7294        };
7295        assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::Incremental);
7296        // The reported size must not include the WAL the VACUUM just filled. In
7297        // WAL mode a VACUUM writes the whole rebuilt database through the WAL,
7298        // so without the truncating checkpoint this reads as roughly double —
7299        // "the migration doubled my database", from the one line the command
7300        // prints.
7301        let file_after = file_after.expect("an on-disk database has a file size") as i64;
7302        assert!(
7303            bytes_after <= file_after * 2,
7304            "bytes_after ({bytes_after}) is inflated by an untruncated WAL against a \
7305             {file_after}-byte file"
7306        );
7307
7308        pool.close().await;
7309        for p in [
7310            path.display().to_string(),
7311            format!("{}-wal", path.display()),
7312            format!("{}-shm", path.display()),
7313        ] {
7314            std::fs::remove_file(&p).ok();
7315        }
7316        Ok(())
7317    }
7318
7319    /// **R6 benchmark: what the retention sweep actually costs, and what fixes it.**
7320    ///
7321    /// `#[ignore]` — builds a ~1M-row database once per shape per scale (ten
7322    /// times), so it is a measurement tool rather than a test. Run with:
7323    ///
7324    /// ```text
7325    /// cargo test --lib -- --ignored --nocapture r6_measure_retention_sweep
7326    /// ```
7327    ///
7328    /// It exists because R6 was "every delete batch re-scans `entry_state`" and
7329    /// the honest answer was "measure before changing an index". Kept so the next
7330    /// candidate index can be tried against the same fixture rather than a new
7331    /// one. Findings are recorded in `design/REVIEW-ROUND-2.md`.
7332    #[tokio::test]
7333    #[ignore]
7334    async fn r6_measure_retention_sweep() -> Result<()> {
7335        const FEEDS: i64 = 500;
7336        const PER_FEED: i64 = 2_000; // matches `max_entries_per_feed`
7337        const PINNED: i64 = 50_000; // entry_state rows a reader has touched
7338
7339        /// Build the fixture, apply `extra_indexes`, then plan and time a sweep.
7340        async fn run(
7341            label: &str,
7342            extra_indexes: &[&str],
7343            pinned: i64,
7344            old_list_form: bool,
7345        ) -> Result<()> {
7346            let dir = std::env::temp_dir();
7347            let path = dir.join(format!("fr-r6-{}-{label}.db", std::process::id()));
7348            // RAII, because every `?` between here and the end used to leak a
7349            // 1M-row fixture plus its -wal/-shm into the temp dir — six per run.
7350            struct Fixture(std::path::PathBuf);
7351            impl Fixture {
7352                fn wipe(&self) {
7353                    for p in [
7354                        self.0.display().to_string(),
7355                        format!("{}-wal", self.0.display()),
7356                        format!("{}-shm", self.0.display()),
7357                    ] {
7358                        std::fs::remove_file(&p).ok();
7359                    }
7360                }
7361            }
7362            impl Drop for Fixture {
7363                fn drop(&mut self) {
7364                    self.wipe();
7365                }
7366            }
7367            let fixture = Fixture(path.clone());
7368            fixture.wipe();
7369            let pool = init_url(&format!("sqlite://{}", path.display())).await?;
7370
7371            // Bulk-build with SQL: a million round trips would measure the
7372            // fixture, not the sweep. Recursive CTE because `generate_series` is
7373            // not compiled into the bundled SQLite.
7374            sqlx::query(
7375                "WITH RECURSIVE n(value) AS ( \
7376                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7377                 ) \
7378                 INSERT INTO feeds (url) \
7379                 SELECT 'https://f' || value || '.example/x.xml' FROM n",
7380            )
7381            .bind(FEEDS)
7382            .execute(&pool)
7383            .await
7384            .context("seeding feeds")?;
7385
7386            // Half the entries older than the window, half inside it.
7387            sqlx::query(
7388                "WITH RECURSIVE n(value) AS ( \
7389                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7390                 ) \
7391                 INSERT INTO entries (feed_id, guid, title, published, fetched_at) \
7392                 SELECT f.id, \
7393                        'g' || f.id || '-' || s.value, \
7394                        'Entry ' || s.value, \
7395                        CASE WHEN s.value % 2 = 0 THEN '2020-01-01T00:00:00Z' \
7396                             ELSE '2099-01-01T00:00:00Z' END, \
7397                        '2026-01-01T00:00:00Z' \
7398                 FROM feeds f, n s",
7399            )
7400            .bind(PER_FEED)
7401            .execute(&pool)
7402            .await?;
7403
7404            // **A REALISTIC pin distribution, which the first version did not
7405            // have.** It made every row `read=0,starred=0` or `read=1,starred=1`,
7406            // so 100% of `entry_state` matched `starred = 1 OR read = 0` — there
7407            // were no "read and not starred" rows at all, which is the commonest
7408            // state a reader leaves behind. That mattered: a PARTIAL index on the
7409            // pinned predicate then covers the whole table and cannot be
7410            // selective, so measuring one against that fixture measures nothing.
7411            //
7412            // 90% read-and-unstarred (evictable), 10% pinned, split between
7413            // starred and unread.
7414            sqlx::query(
7415                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
7416                 SELECT 'did:plc:reader', id, \
7417                        CASE WHEN id % 10 <> 0 THEN 1 \
7418                             WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7419                        CASE WHEN id % 10 <> 0 THEN 0 \
7420                             WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7421                        '2026-01-01T00:00:00Z' \
7422                 FROM entries LIMIT ?1",
7423            )
7424            .bind(pinned)
7425            .execute(&pool)
7426            .await?;
7427
7428            // Space is the other half of the trade: this is a 1 GB volume with a
7429            // 768 MiB watermark, so an index that buys time and costs disk can be
7430            // a net loss.
7431            let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7432                .fetch_one(&pool)
7433                .await?;
7434            let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
7435                .fetch_one(&pool)
7436                .await?;
7437            for idx in extra_indexes {
7438                sqlx::query(sqlx::AssertSqlSafe((*idx).to_string()))
7439                    .execute(&pool)
7440                    .await
7441                    .with_context(|| format!("creating {idx}"))?;
7442            }
7443            let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7444                .fetch_one(&pool)
7445                .await?;
7446            let index_bytes = (pages_after - pages_before) * page_size;
7447
7448            // What the index costs on the WRITE path — the poller inserts
7449            // constantly, the sweep runs once a day.
7450            let t_ins = std::time::Instant::now();
7451            sqlx::query(
7452                "WITH RECURSIVE n(value) AS ( \
7453                     SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 10000 \
7454                 ) \
7455                 INSERT INTO entries (feed_id, guid, published, fetched_at) \
7456                 SELECT 1, 'ins-' || value, '2099-06-01T00:00:00Z', '2026-01-01T00:00:00Z' \
7457                 FROM n",
7458            )
7459            .execute(&pool)
7460            .await?;
7461            let insert_10k = t_ins.elapsed();
7462
7463            // Give the planner statistics, as a long-lived instance would have.
7464            sqlx::query("ANALYZE").execute(&pool).await?;
7465
7466            // The plan must describe the query this run actually TIMES. It used
7467            // to be hardcoded to the `NOT IN` form regardless, so four of six
7468            // runs printed a plan for a different query than the one measured —
7469            // in the artifact kept precisely to be the evidence.
7470            let planned = if old_list_form {
7471                "EXPLAIN QUERY PLAN SELECT id FROM entries \
7472                 WHERE COALESCE(published, fetched_at) < '2026-06-01T00:00:00Z' \
7473                   AND id NOT IN (SELECT entry_id FROM entry_state \
7474                                  WHERE starred = 1 OR read = 0) \
7475                 LIMIT 1000"
7476            } else {
7477                "EXPLAIN QUERY PLAN SELECT e.id FROM entries e \
7478                 WHERE COALESCE(e.published, e.fetched_at) < '2026-06-01T00:00:00Z' \
7479                   AND NOT EXISTS (SELECT 1 FROM entry_state s \
7480                                   WHERE s.entry_id = e.id \
7481                                     AND (s.starred = 1 OR s.read = 0)) \
7482                 LIMIT 1000"
7483            };
7484            let plan: Vec<String> = sqlx::query(sqlx::AssertSqlSafe(planned))
7485                .fetch_all(&pool)
7486                .await?
7487                .into_iter()
7488                .map(|r| r.get::<String, _>("detail"))
7489                .collect();
7490
7491            // ONE variant per fixture — running both against the same database
7492            // measured the second against an already-emptied table, which
7493            // reported a 0-row "win" the first time this was written.
7494            let cutoff = (chrono::Utc::now() - chrono::Duration::days(30))
7495                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7496            let t = std::time::Instant::now();
7497            let deleted = if old_list_form {
7498                // The shape `prune_old_entries` used to have: the pinned set as
7499                // an `IN` list, re-materialised on every batch.
7500                let mut n = 0u64;
7501                loop {
7502                    let got = sqlx::query(
7503                        "DELETE FROM entries WHERE id IN ( \
7504                             SELECT id FROM entries \
7505                             WHERE COALESCE(published, fetched_at) < ?1 \
7506                               AND id NOT IN ( \
7507                                   SELECT entry_id FROM entry_state \
7508                                   WHERE starred = 1 OR read = 0 \
7509                               ) \
7510                             LIMIT 1000)",
7511                    )
7512                    .bind(&cutoff)
7513                    .execute(&pool)
7514                    .await?
7515                    .rows_affected();
7516                    n += got;
7517                    if got == 0 {
7518                        break;
7519                    }
7520                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7521                }
7522                n
7523            } else {
7524                // NOTE the arms are not identical work: this one goes through the
7525                // real `prune_old_entries`, which also runs the hard-ceiling pass
7526                // and the cursor scrub. The bias therefore runs AGAINST the
7527                // shipped form, so a win measured here is a lower bound — but the
7528                // two numbers are not a like-for-like microbenchmark.
7529                prune_old_entries(&pool, 30, 3650, 0).await?
7530            };
7531            let elapsed = t.elapsed();
7532
7533            // State the fixture's shape, so a future reader cannot mistake a
7534            // degenerate distribution for a representative one again.
7535            let matching: i64 = sqlx::query_scalar(
7536                "SELECT COUNT(*) FROM entry_state WHERE starred = 1 OR read = 0",
7537            )
7538            .fetch_one(&pool)
7539            .await?;
7540            println!("\n=== {label}  (entry_state = {pinned}, pinned = {matching}) ===");
7541            println!(
7542                "  index cost: {:.1} MiB on disk, 10k inserts in {insert_10k:?}",
7543                index_bytes as f64 / 1024.0 / 1024.0
7544            );
7545            for l in &plan {
7546                println!("  plan: {l}");
7547            }
7548            println!(
7549                "  deleted {deleted} in {elapsed:?}  ({:?}/batch)",
7550                elapsed / (deleted as u32 / PRUNE_BATCH as u32).max(1)
7551            );
7552
7553            pool.close().await;
7554            drop(fixture);
7555            Ok(())
7556        }
7557
7558        // R6's own hypothesis was that the per-batch `entry_state` scan is the
7559        // cost. Both scales are measured because that scan grows with TOTAL
7560        // users, not with the feed being swept — 50k is one active reader,
7561        // 600k is the figure the schema comment cites as realistic.
7562        const AGE_IDX: &str =
7563            "CREATE INDEX idx_entries_age ON entries(COALESCE(published, fetched_at))";
7564        // The index R6 actually asked for. Its row is the one the rejection
7565        // turns on — "changes the plan, changes the time by nothing" — and an
7566        // earlier version of this benchmark dropped it, leaving that claim
7567        // resting on prose while the artifact kept to prove it could not.
7568        const PINNED_IDX: &str = "CREATE INDEX idx_es_pinned ON entry_state(entry_id) \
7569                                  WHERE starred = 1 OR read = 0";
7570        for pinned in [PINNED, 600_000] {
7571            // `false` = the shipped `prune_old_entries`, whatever shape it
7572            // currently uses; `true` = the raw `NOT IN` list form it replaced,
7573            // kept so the regression stays measurable rather than remembered.
7574            run("as shipped (NOT EXISTS)", &[], pinned, false).await?;
7575            run("old NOT IN list form", &[], pinned, true).await?;
7576            run("old NOT IN + pinned index", &[PINNED_IDX], pinned, true).await?;
7577            // The row that was never measured: the pinned index against the
7578            // query that SHIPPED, rather than against the one being deleted.
7579            // Rejecting it on the strength of the latter was the error.
7580            run("as shipped + pinned index", &[PINNED_IDX], pinned, false).await?;
7581            run("as shipped + age index", &[AGE_IDX], pinned, false).await?;
7582        }
7583        Ok(())
7584    }
7585
7586    /// **The migration must not ask the pool for anything while holding a
7587    /// connection.** A single-connection pool is always saturated, so any such
7588    /// call stalls for the full acquire timeout.
7589    ///
7590    /// This has now been introduced twice — once by acquiring a connection for
7591    /// the pragma pair, and once by resolving the temp directory inside that
7592    /// block. The second was worse than a stall: `main_db_path` swallows errors
7593    /// into `None`, so it waited 30 s and then silently skipped the pragma it
7594    /// existed to set. A wall-clock assertion is crude, but it is the only thing
7595    /// that distinguishes "works" from "works after a 30-second timeout".
7596    #[tokio::test]
7597    async fn the_vacuum_migration_never_waits_on_its_own_pool() -> Result<()> {
7598        let dir = std::env::temp_dir();
7599        let path = dir.join(format!("fr-nodeadlock-{}.db", std::process::id()));
7600        for p in [
7601            path.display().to_string(),
7602            format!("{}-wal", path.display()),
7603            format!("{}-shm", path.display()),
7604        ] {
7605            std::fs::remove_file(&p).ok();
7606        }
7607        let url = format!("sqlite://{}", path.display());
7608        let opts = SqliteConnectOptions::from_str(&url)?
7609            .create_if_missing(true)
7610            .foreign_keys(true)
7611            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7612            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7613        // ONE connection: any pool call made while the migration holds it will
7614        // block until the acquire timeout rather than deadlocking forever.
7615        let pool = SqlitePoolOptions::new()
7616            .min_connections(1)
7617            .max_connections(1)
7618            .connect_with(opts)
7619            .await?;
7620        init_schema(&pool).await?;
7621
7622        let t0 = std::time::Instant::now();
7623        let outcome = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
7624        let elapsed = t0.elapsed();
7625
7626        assert!(
7627            matches!(outcome, VacuumMigration::Migrated { .. }),
7628            "expected a migration, got {outcome:?}"
7629        );
7630        assert!(
7631            elapsed < std::time::Duration::from_secs(5),
7632            "the migration took {elapsed:?} on an empty database — it is waiting on \
7633             its own pool while holding a connection"
7634        );
7635
7636        pool.close().await;
7637        for p in [
7638            path.display().to_string(),
7639            format!("{}-wal", path.display()),
7640            format!("{}-shm", path.display()),
7641        ] {
7642            std::fs::remove_file(&p).ok();
7643        }
7644        Ok(())
7645    }
7646
7647    /// `reclaim` must NOT run a full VACUUM in NONE mode — the branch that used
7648    /// to be the only one that ever executed, and the one that cannot finish on
7649    /// a volume under the pressure that triggers a sweep.
7650    ///
7651    /// Observable without timing a VACUUM: a full VACUUM returns freed pages to
7652    /// the OS, so `page_count` falls. Skipping it leaves the allocation in
7653    /// place — while `db_size_bytes`, which subtracts the freelist, still drops.
7654    /// That pairing is the actual claim: the watermark does not latch even
7655    /// though the file does not shrink.
7656    #[tokio::test]
7657    async fn reclaim_does_not_full_vacuum_in_none_mode() -> Result<()> {
7658        let dir = std::env::temp_dir();
7659        let path = dir.join(format!("fr-noneclaim-{}.db", std::process::id()));
7660        for p in [
7661            path.display().to_string(),
7662            format!("{}-wal", path.display()),
7663            format!("{}-shm", path.display()),
7664        ] {
7665            std::fs::remove_file(&p).ok();
7666        }
7667        let url = format!("sqlite://{}", path.display());
7668        let opts = SqliteConnectOptions::from_str(&url)?
7669            .create_if_missing(true)
7670            .foreign_keys(true)
7671            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7672            .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7673        let pool = SqlitePoolOptions::new()
7674            .min_connections(1)
7675            .max_connections(1)
7676            .connect_with(opts)
7677            .await?;
7678        init_schema(&pool).await?;
7679
7680        let feed_id = upsert_feed(
7681            &pool,
7682            &NewFeed {
7683                url: "https://none.example/f.xml".to_string(),
7684                ..Default::default()
7685            },
7686        )
7687        .await?;
7688        let entries: Vec<NewEntry> = (0..1500)
7689            .map(|i| NewEntry {
7690                guid: format!("n-{i}"),
7691                content_html: Some("x".repeat(800)),
7692                ..Default::default()
7693            })
7694            .collect();
7695        insert_entries(&pool, feed_id, &entries, 0).await?;
7696        // Fold the WAL in so the "full" baseline is file pages, not WAL churn.
7697        sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
7698            .execute(&pool)
7699            .await?;
7700        let used_full = db_size_bytes(&pool).await?;
7701
7702        sqlx::query("DELETE FROM entries").execute(&pool).await?;
7703        let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7704            .fetch_one(&pool)
7705            .await?;
7706
7707        reclaim(&pool).await?;
7708
7709        let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7710            .fetch_one(&pool)
7711            .await?;
7712        assert_eq!(
7713            pages_after, pages_before,
7714            "reclaim shrank the file in NONE mode, so it ran the full VACUUM this \
7715             branch exists to avoid"
7716        );
7717        // …and the watermark still falls, which is what makes skipping safe.
7718        // `db_size_bytes` subtracts the freelist, so the delete alone lowers it
7719        // even though the file kept every page it had allocated.
7720        let used_after = db_size_bytes(&pool).await?;
7721        assert!(
7722            used_after < used_full,
7723            "used size did not fall after the delete ({used_after} !< {used_full}); \
7724             without a VACUUM the DB-size watermark would latch the poller off"
7725        );
7726
7727        pool.close().await;
7728        for p in [
7729            path.display().to_string(),
7730            format!("{}-wal", path.display()),
7731            format!("{}-shm", path.display()),
7732        ] {
7733            std::fs::remove_file(&p).ok();
7734        }
7735        Ok(())
7736    }
7737
7738    #[tokio::test]
7739    async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
7740        // On-disk DB so VACUUM has a file to shrink (in-memory has no freelist to
7741        // speak of the same way). Temp path, cleaned up at the end.
7742        let dir = std::env::temp_dir();
7743        let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
7744        let url = format!("sqlite://{}", path.display());
7745        let pool = init_url(&url).await?;
7746
7747        let feed_id = upsert_feed(
7748            &pool,
7749            &NewFeed {
7750                url: "https://bulk.example/feed.xml".to_string(),
7751                ..Default::default()
7752            },
7753        )
7754        .await?;
7755
7756        // Insert a large batch so the file allocates real pages.
7757        let entries: Vec<NewEntry> = (0..2000)
7758            .map(|i| NewEntry {
7759                guid: format!("guid-{i}"),
7760                title: Some(format!("Entry number {i} with some padding text")),
7761                content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
7762                published: Some("2026-01-01T00:00:00Z".to_string()),
7763                ..Default::default()
7764            })
7765            .collect();
7766        insert_entries(&pool, feed_id, &entries, 0).await?;
7767        let full = db_size_bytes(&pool).await?;
7768        assert!(full > 0);
7769
7770        // Prune: delete every entry (the retention sweep's effect). This frees
7771        // pages onto the freelist but does NOT shrink the file yet.
7772        sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
7773            .bind(feed_id)
7774            .execute(&pool)
7775            .await?;
7776
7777        // Because db_size_bytes subtracts freelist pages, the USED size already
7778        // reflects the delete even before the file shrinks.
7779        let after_delete = db_size_bytes(&pool).await?;
7780        assert!(
7781            after_delete < full,
7782            "used size must drop once rows are deleted (freed pages excluded): \
7783             {after_delete} !< {full}"
7784        );
7785
7786        // Reclaim returns the freed pages to the OS; used size stays low (and the
7787        // file itself shrinks). The key property F3 needs: the watermark can now
7788        // fall back below its threshold instead of latching polling off.
7789        reclaim(&pool).await?;
7790        let after_reclaim = db_size_bytes(&pool).await?;
7791        assert!(
7792            after_reclaim <= after_delete,
7793            "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
7794        );
7795        assert!(
7796            after_reclaim < full,
7797            "after prune+reclaim the DB is smaller than when full: \
7798             {after_reclaim} !< {full}"
7799        );
7800
7801        drop(pool);
7802        let _ = std::fs::remove_file(&path);
7803        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
7804        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
7805        Ok(())
7806    }
7807
7808    // -- F4 support: pds_created flag round-trips + flips ---------------------
7809
7810    #[tokio::test]
7811    async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
7812        let pool = init_url("sqlite::memory:").await?;
7813        let did = "did:plc:f4";
7814        let feed_url = "https://example.com/feed.xml";
7815        upsert_cursor(
7816            &pool,
7817            &ReadCursor {
7818                did: did.to_string(),
7819                feed_url: feed_url.to_string(),
7820                read_through: None,
7821                read_ids: r#"["1"]"#.to_string(),
7822                unread_ids: "[]".to_string(),
7823                dirty: true,
7824                pds_created: false,
7825                updated_at: now_rfc3339(),
7826            },
7827        )
7828        .await?;
7829
7830        // A brand-new cursor's PDS record does NOT yet exist.
7831        let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7832        assert!(!c.pds_created, "first flush must emit a create, not update");
7833
7834        // Two bystanders: the same DID on another feed, another DID on the same
7835        // feed. **The UPDATE must be scoped to exactly one row.** With its WHERE
7836        // clause deleted this test still passed — it seeded one cursor, so
7837        // "every row" and "this row" were the same row. Unscoped, every DID's
7838        // every cursor is flagged as created, their readState records are never
7839        // created, and every later flush emits `update` against nothing.
7840        for (d, f) in [
7841            (did, "https://other.example/feed.xml"),
7842            ("did:plc:other", feed_url),
7843        ] {
7844            upsert_cursor(
7845                &pool,
7846                &ReadCursor {
7847                    did: d.to_string(),
7848                    feed_url: f.to_string(),
7849                    read_through: None,
7850                    read_ids: "[]".to_string(),
7851                    unread_ids: "[]".to_string(),
7852                    dirty: false,
7853                    pds_created: false,
7854                    updated_at: now_rfc3339(),
7855                },
7856            )
7857            .await?;
7858        }
7859
7860        // After the create-flush lands, the flag flips so future flushes update.
7861        mark_cursor_pds_created(&pool, did, feed_url).await?;
7862        let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7863        assert!(c.pds_created);
7864        for (d, f) in [
7865            (did, "https://other.example/feed.xml"),
7866            ("did:plc:other", feed_url),
7867        ] {
7868            let bystander = get_cursor(&pool, d, f).await?.unwrap();
7869            assert!(
7870                !bystander.pds_created,
7871                "marking ({did}, {feed_url}) also flagged ({d}, {f})"
7872            );
7873        }
7874        Ok(())
7875    }
7876
7877    // -- STORAGE HYGIENE: retention prune + orphan-id scrub -------------------
7878
7879    /// Count entries currently in the cache.
7880    async fn count_entries(pool: &SqlitePool) -> Result<i64> {
7881        Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
7882            .fetch_one(pool)
7883            .await?)
7884    }
7885
7886    /// **The rolling window and the hard ceiling do not touch a publication, and
7887    /// this is the test that says the feature works at all.**
7888    ///
7889    /// Measured on 2026-09-27 against three real publications: the newest
7890    /// document Standard.site offered was 131 days old, Annotated's 109, minus
7891    /// listens' 241. Under the 14-day window every one of them stored **zero**
7892    /// rows — a successful poll and an empty feed. So age is not the policy here;
7893    /// COUNT is (`max_entries_per_feed`), and the ceiling below is only the
7894    /// not-immortal backstop.
7895    ///
7896    /// Both directions in one test on purpose: the RSS twin must still be
7897    /// deleted, or "nothing is ever swept" would pass.
7898    #[tokio::test]
7899    async fn the_window_and_the_ceiling_spare_a_publication_but_not_an_rss_entry() -> Result<()> {
7900        let pool = init_url("sqlite::memory:").await?;
7901        let rss = upsert_feed(
7902            &pool,
7903            &NewFeed {
7904                url: "https://aged.example/feed.xml".to_string(),
7905                ..Default::default()
7906            },
7907        )
7908        .await?;
7909        let publication = upsert_feed(
7910            &pool,
7911            &NewFeed {
7912                url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
7913                    .to_string(),
7914                ..Default::default()
7915            },
7916        )
7917        .await?;
7918        // The kind column is what the sweep filters on, so assert the fixture
7919        // really produced two different kinds rather than trusting `FeedKind::of`.
7920        let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM feeds ORDER BY id")
7921            .fetch_all(&pool)
7922            .await?;
7923        assert_eq!(kinds, vec!["rss".to_string(), "publication".to_string()]);
7924
7925        // A year old, and READ by somebody — so the window's own sparing rule
7926        // ("starred or unread survives") cannot be what keeps either row.
7927        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7928            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7929        for feed_id in [rss, publication] {
7930            insert_entries(
7931                &pool,
7932                feed_id,
7933                &[NewEntry {
7934                    guid: format!("ancient-{feed_id}"),
7935                    published: Some(ancient.clone()),
7936                    fetched_at: Some(ancient.clone()),
7937                    ..Default::default()
7938                }],
7939                0,
7940            )
7941            .await?;
7942        }
7943        replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
7944        for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
7945            .fetch_all(&pool)
7946            .await?
7947        {
7948            mark_read(&pool, "did:plc:reader", id, true).await?;
7949        }
7950        assert_eq!(count_entries(&pool).await?, 2);
7951
7952        // **The shipped configuration, all three knobs at their defaults.** An
7953        // earlier version of this test passed `0` for the archive ceiling, so the
7954        // combination under test was not the one any instance runs; at 3650 the
7955        // publication's year-old document is inside the ceiling and must still
7956        // survive.
7957        let deleted = prune_old_entries(&pool, 14, 180, 3_650).await?;
7958        assert_eq!(deleted, 1, "exactly one of the two should have gone");
7959        let surviving: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM entries")
7960            .fetch_all(&pool)
7961            .await?;
7962        assert_eq!(
7963            surviving,
7964            vec![publication],
7965            "the publication's year-old document was swept — under the 14-day \
7966             window that is every document a real publication has, so the feed a \
7967             reader subscribed to would be permanently empty",
7968        );
7969        Ok(())
7970    }
7971
7972    /// **"Not aged out" must not mean "immortal".**
7973    ///
7974    /// The per-feed trim is what bounds a publication, and it only runs when a
7975    /// poll stores something — so entries of a feed nobody polls any more have
7976    /// nothing else to reap them. This ceiling is that backstop, and it spares
7977    /// nothing, for the same reason the hard ceiling spares nothing: a saved
7978    /// record whose entry is gone still renders from the PDS record as a link.
7979    #[tokio::test]
7980    async fn the_archive_ceiling_reaps_a_publication_entry_past_it() -> Result<()> {
7981        let pool = init_url("sqlite::memory:").await?;
7982        // An RSS twin, to pin that this pass is SCOPED. Verified needed: dropping
7983        // the `kind NOT IN` clause from it left all 909 tests passing, and that
7984        // mutation quietly re-enables age-based eviction for RSS on an instance
7985        // whose operator set both RSS knobs to zero.
7986        let rss = upsert_feed(
7987            &pool,
7988            &NewFeed {
7989                url: "https://not-swept.example/feed.xml".to_string(),
7990                ..Default::default()
7991            },
7992        )
7993        .await?;
7994        let publication = upsert_feed(
7995            &pool,
7996            &NewFeed {
7997                url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
7998                    .to_string(),
7999                ..Default::default()
8000            },
8001        )
8002        .await?;
8003        let ancient = (chrono::Utc::now() - chrono::Duration::days(400))
8004            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8005        let recent = now_rfc3339();
8006        insert_entries(
8007            &pool,
8008            publication,
8009            &[
8010                NewEntry {
8011                    guid: "past-the-ceiling".into(),
8012                    published: Some(ancient.clone()),
8013                    fetched_at: Some(ancient),
8014                    ..Default::default()
8015                },
8016                NewEntry {
8017                    guid: "inside-the-ceiling".into(),
8018                    published: Some(recent.clone()),
8019                    fetched_at: Some(recent),
8020                    ..Default::default()
8021                },
8022            ],
8023            0,
8024        )
8025        .await?;
8026        let long_ago = (chrono::Utc::now() - chrono::Duration::days(400))
8027            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8028        insert_entries(
8029            &pool,
8030            rss,
8031            &[NewEntry {
8032                guid: "rss-past-the-archive-ceiling".into(),
8033                published: Some(long_ago.clone()),
8034                fetched_at: Some(long_ago),
8035                ..Default::default()
8036            }],
8037            0,
8038        )
8039        .await?;
8040
8041        // STARRED, so this also pins that the ceiling spares nothing.
8042        replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
8043        for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
8044            .fetch_all(&pool)
8045            .await?
8046        {
8047            mark_starred(&pool, "did:plc:reader", id, true).await?;
8048        }
8049
8050        // Rolling window and hard ceiling off: the archive ceiling is the only
8051        // thing that can delete here.
8052        let deleted = prune_old_entries(&pool, 0, 0, 365).await?;
8053        assert_eq!(
8054            deleted, 1,
8055            "the entry past the archive ceiling was not reaped"
8056        );
8057        let mut guids: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
8058            .fetch_all(&pool)
8059            .await?;
8060        guids.sort();
8061        assert_eq!(
8062            guids,
8063            vec![
8064                "inside-the-ceiling".to_string(),
8065                "rss-past-the-archive-ceiling".to_string(),
8066            ],
8067            "the archive ceiling must reap the publication's over-age entry and \
8068             ONLY that — an RSS entry on an instance with both RSS knobs at zero \
8069             is one the operator chose to keep",
8070        );
8071
8072        // And zero disables it, consistently with the other two knobs.
8073        assert_eq!(
8074            prune_old_entries(&pool, 0, 0, 0).await?,
8075            0,
8076            "publication_retention_days = 0 still deleted something",
8077        );
8078        Ok(())
8079    }
8080
8081    /// **A retention window too large to be a date must disable that pass, not
8082    /// kill the sweeper.**
8083    ///
8084    /// Every knob parses from a `u32` with no upper bound, and `Duration::days` /
8085    /// `DateTime - TimeDelta` both panic out of range — measured, anything past
8086    /// roughly 96 million days, and `u32::MAX` is. A unit slip (seconds or
8087    /// milliseconds typed into a days field) reaches it.
8088    ///
8089    /// The old failure was quiet: this runs in a spawned task, so tokio catches
8090    /// the panic and the sweeper stops for the life of the process, taking the
8091    /// release valve for `db_size_watermark_bytes` with it — the one thing that
8092    /// stops polling for every reader on the instance.
8093    ///
8094    /// `standard_site::ingest_floor` already answers the same input with "no
8095    /// floor", and `Config::retention_for` exists to keep the two agreeing, so
8096    /// this is also the end of a disagreement: unrepresentable meant "store
8097    /// everything" on one side and "panic" on the other.
8098    #[tokio::test]
8099    async fn an_unrepresentable_retention_window_disables_the_pass_it_belongs_to() -> Result<()> {
8100        let pool = init_url("sqlite::memory:").await?;
8101        let feed_id = upsert_feed(
8102            &pool,
8103            &NewFeed {
8104                url: "https://absurd.example/feed.xml".to_string(),
8105                ..Default::default()
8106            },
8107        )
8108        .await?;
8109        let ancient = (chrono::Utc::now() - chrono::Duration::days(1_000))
8110            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8111        insert_entries(
8112            &pool,
8113            feed_id,
8114            &[NewEntry {
8115                guid: "ancient".into(),
8116                published: Some(ancient.clone()),
8117                fetched_at: Some(ancient),
8118                ..Default::default()
8119            }],
8120            0,
8121        )
8122        .await?;
8123
8124        // Each knob in turn, since each computes its own cutoff.
8125        let absurd = u32::MAX as i64;
8126        assert_eq!(
8127            prune_old_entries(&pool, absurd, 0, 0).await?,
8128            0,
8129            "an absurd rolling window deleted something",
8130        );
8131        assert_eq!(
8132            prune_old_entries(&pool, 0, absurd, 0).await?,
8133            0,
8134            "an absurd hard ceiling deleted something",
8135        );
8136        assert_eq!(
8137            prune_old_entries(&pool, 0, 0, absurd).await?,
8138            0,
8139            "an absurd archive ceiling deleted something",
8140        );
8141        assert_eq!(
8142            count_entries(&pool).await?,
8143            1,
8144            "the entry went away under a window that cannot even be expressed",
8145        );
8146
8147        // And the sweep still works for the same knobs at a sane value — a
8148        // function that returned early on every input would satisfy the above.
8149        assert_eq!(
8150            prune_old_entries(&pool, 30, 0, 0).await?,
8151            1,
8152            "a 30-day window did not delete a 1000-day-old entry",
8153        );
8154        Ok(())
8155    }
8156
8157    /// The SQL list and the Rust slice are asserted equal, for the same reason
8158    /// [`POLLABLE_KINDS_SQL`] is: a literal here and a slice there is the drift
8159    /// the `kind` column was introduced to end.
8160    #[test]
8161    fn the_sql_aged_kind_list_matches_the_rust_one() {
8162        let expected = crate::feed::FeedKind::AGED
8163            .iter()
8164            .map(|k| format!("'{}'", k.as_str()))
8165            .collect::<Vec<_>>()
8166            .join(", ");
8167        assert_eq!(AGED_KINDS_SQL, expected);
8168    }
8169
8170    #[tokio::test]
8171    async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
8172        let pool = init_url("sqlite::memory:").await?;
8173        let feed_id = upsert_feed(
8174            &pool,
8175            &NewFeed {
8176                url: "https://ret.example/feed.xml".to_string(),
8177                ..Default::default()
8178            },
8179        )
8180        .await?;
8181
8182        let recent = now_rfc3339();
8183        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
8184            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8185
8186        // One fresh (published now), one ancient (published a year ago), and one
8187        // UNDATED-but-freshly-fetched (published NULL, fetched_at now) — the last
8188        // must survive because COALESCE falls back to fetched_at, not to "old".
8189        insert_entries(
8190            &pool,
8191            feed_id,
8192            &[
8193                NewEntry {
8194                    guid: "fresh".into(),
8195                    published: Some(recent.clone()),
8196                    fetched_at: Some(recent.clone()),
8197                    ..Default::default()
8198                },
8199                NewEntry {
8200                    guid: "ancient".into(),
8201                    published: Some(ancient.clone()),
8202                    fetched_at: Some(ancient.clone()),
8203                    ..Default::default()
8204                },
8205                NewEntry {
8206                    guid: "undated-fresh".into(),
8207                    published: None,
8208                    fetched_at: Some(recent.clone()),
8209                    ..Default::default()
8210                },
8211            ],
8212            0,
8213        )
8214        .await?;
8215        assert_eq!(count_entries(&pool).await?, 3);
8216        // Subscribe so mark_read is authorized to write an entry_state row.
8217        replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
8218
8219        // Give the ancient entry an entry_state row so we can prove the FK cascade.
8220        let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
8221            .fetch_one(&pool)
8222            .await?;
8223        let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
8224        assert!(wrote, "mark_read must write with a sub_ref in place");
8225        let state_before: i64 =
8226            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
8227                .bind(ancient_id)
8228                .fetch_one(&pool)
8229                .await?;
8230        assert_eq!(state_before, 1);
8231
8232        // Prune at a 90-day window: only the ancient entry is old.
8233        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
8234        assert_eq!(deleted, 1, "only the year-old entry should be pruned");
8235        assert_eq!(
8236            count_entries(&pool).await?,
8237            2,
8238            "fresh + undated-fresh survive"
8239        );
8240
8241        // The surviving guids are exactly the two fresh ones.
8242        let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
8243            .fetch_all(&pool)
8244            .await?;
8245        assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
8246
8247        // entry_state for the deleted entry cascaded away via the FK.
8248        let state_after: i64 =
8249            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
8250                .bind(ancient_id)
8251                .fetch_one(&pool)
8252                .await?;
8253        assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
8254
8255        // days == 0 disables the rolling WINDOW. The 3650-day ceiling still runs
8256        // (see `a_disabled_window_does_not_disable_the_ceiling`); it deletes
8257        // nothing here because both survivors are fresh.
8258        assert_eq!(prune_old_entries(&pool, 0, 3650, 0).await?, 0);
8259        assert_eq!(count_entries(&pool).await?, 2);
8260        Ok(())
8261    }
8262
8263    #[tokio::test]
8264    async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
8265        let pool = init_url("sqlite::memory:").await?;
8266        let did = "did:plc:reader";
8267        let feed_url = "https://orphan.example/feed.xml";
8268        let feed_id = upsert_feed(
8269            &pool,
8270            &NewFeed {
8271                url: feed_url.to_string(),
8272                ..Default::default()
8273            },
8274        )
8275        .await?;
8276        // Caller subscribes so mark-read is authorized to project into the cursor.
8277        replace_sub_refs(&pool, did, &[feed_id]).await?;
8278
8279        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
8280            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8281        let recent = now_rfc3339();
8282        insert_entries(
8283            &pool,
8284            feed_id,
8285            &[
8286                NewEntry {
8287                    guid: "old".into(),
8288                    published: Some(ancient.clone()),
8289                    fetched_at: Some(ancient.clone()),
8290                    ..Default::default()
8291                },
8292                NewEntry {
8293                    guid: "new".into(),
8294                    published: Some(recent.clone()),
8295                    fetched_at: Some(recent.clone()),
8296                    ..Default::default()
8297                },
8298            ],
8299            0,
8300        )
8301        .await?;
8302        let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
8303            .fetch_one(&pool)
8304            .await?;
8305        let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
8306            .fetch_one(&pool)
8307            .await?;
8308
8309        // Mark BOTH read — the cursor's read_ids now references both entry ids.
8310        mark_read(&pool, did, old_id, true).await?;
8311        mark_read(&pool, did, new_id, true).await?;
8312        let before = get_cursor(&pool, did, feed_url).await?.unwrap();
8313        let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
8314        assert!(ids_before.contains(&old_id.to_string()));
8315        assert!(ids_before.contains(&new_id.to_string()));
8316
8317        // Prune the old entry — its id must be scrubbed from the cursor's id-set.
8318        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
8319        assert_eq!(deleted, 1);
8320        let after = get_cursor(&pool, did, feed_url).await?.unwrap();
8321        let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
8322        assert_eq!(
8323            ids_after,
8324            vec![new_id.to_string()],
8325            "orphaned (deleted) entry id must be removed; live id kept"
8326        );
8327        // The scrub re-dirties the cursor so the flusher resyncs the PDS record.
8328        assert!(
8329            after.dirty,
8330            "cursor must be marked dirty after orphan scrub"
8331        );
8332        Ok(())
8333    }
8334
8335    #[tokio::test]
8336    async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
8337        // The per-feed max_entries trim path must ALSO scrub orphaned cursor ids.
8338        let pool = init_url("sqlite::memory:").await?;
8339        let did = "did:plc:reader";
8340        let feed_url = "https://trim.example/feed.xml";
8341        let feed_id = upsert_feed(
8342            &pool,
8343            &NewFeed {
8344                url: feed_url.to_string(),
8345                ..Default::default()
8346            },
8347        )
8348        .await?;
8349        replace_sub_refs(&pool, did, &[feed_id]).await?;
8350
8351        // Two entries, cap of 2 for now (no trim yet).
8352        insert_entries(
8353            &pool,
8354            feed_id,
8355            &[
8356                NewEntry {
8357                    guid: "a".into(),
8358                    published: Some("2026-01-01T00:00:00Z".into()),
8359                    ..Default::default()
8360                },
8361                NewEntry {
8362                    guid: "b".into(),
8363                    published: Some("2026-01-02T00:00:00Z".into()),
8364                    ..Default::default()
8365                },
8366            ],
8367            2,
8368        )
8369        .await?;
8370        let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
8371            .fetch_one(&pool)
8372            .await?;
8373        mark_read(&pool, did, a_id, true).await?;
8374
8375        // Insert a newer entry with cap=1 → the oldest ('a') is trimmed away.
8376        insert_entries(
8377            &pool,
8378            feed_id,
8379            &[NewEntry {
8380                guid: "c".into(),
8381                published: Some("2026-01-03T00:00:00Z".into()),
8382                ..Default::default()
8383            }],
8384            1,
8385        )
8386        .await?;
8387        // 'a' is gone.
8388        let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
8389            .fetch_one(&pool)
8390            .await?;
8391        assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
8392
8393        // The cursor no longer references the trimmed id.
8394        let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
8395        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
8396        assert!(
8397            !ids.contains(&a_id.to_string()),
8398            "trimmed entry id must be scrubbed from the cursor"
8399        );
8400        Ok(())
8401    }
8402
8403    /// A sweep spanning several batches must still delete everything.
8404    ///
8405    /// The batching exists to make the write-lock hold interruptible, not to
8406    /// make the sweep partial — so the obvious way to get it wrong is an
8407    /// off-by-one that leaves a batch behind, or a loop that exits on the first
8408    /// short batch instead of the first empty one.
8409    #[tokio::test]
8410    async fn a_sweep_larger_than_one_batch_still_drains() -> Result<()> {
8411        let pool = init_url("sqlite::memory:").await?;
8412        let feed_id = upsert_feed(
8413            &pool,
8414            &NewFeed {
8415                url: "https://bulk.example/f.xml".to_string(),
8416                ..Default::default()
8417            },
8418        )
8419        .await?;
8420        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8421            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8422        // Deliberately not a multiple of PRUNE_BATCH, so the final batch is
8423        // short and the loop has to keep going to the empty one.
8424        let count = (PRUNE_BATCH * 2 + 137) as usize;
8425        let entries: Vec<NewEntry> = (0..count)
8426            .map(|i| NewEntry {
8427                guid: format!("bulk-{i}"),
8428                published: Some(old.clone()),
8429                ..Default::default()
8430            })
8431            .collect();
8432        insert_entries(&pool, feed_id, &entries, 0).await?;
8433        assert_eq!(count_entries(&pool).await? as usize, count);
8434
8435        let deleted = prune_old_entries(&pool, 30, 180, 0).await?;
8436        assert_eq!(deleted as usize, count, "the sweep left rows behind");
8437        assert_eq!(count_entries(&pool).await?, 0);
8438        Ok(())
8439    }
8440
8441    /// What a sweep driven by a lock test actually did.
8442    ///
8443    /// `Contended` is NOT a failure. `SQLITE_BUSY` on the pruner is an outcome
8444    /// production expects and handles — `scheduler.rs` logs it and the next tick
8445    /// retries — so a test that treats it as a regression is stricter than the
8446    /// system it guards, and fails for a reason its own assertions are not
8447    /// about. See #146.
8448    enum SweepOutcome {
8449        Completed(u64),
8450        Contended,
8451    }
8452
8453    /// True for the `SQLITE_BUSY` FAMILY anywhere in the chain.
8454    ///
8455    /// Matched on the DRIVER CODE, not on the message text: "database is
8456    /// locked" is a string another error could plausibly carry, and this
8457    /// decides whether a test failure is suppressed.
8458    ///
8459    /// **Masked to the primary code.** sqlx-sqlite's `code()` returns
8460    /// `sqlite3_extended_errcode` verbatim, so comparing it to `"5"` matches
8461    /// only bare `SQLITE_BUSY` and treats the WAL variants as hard failures:
8462    /// `BUSY_RECOVERY` (261), `BUSY_SNAPSHOT` (517), `BUSY_TIMEOUT` (773).
8463    /// This database runs in WAL mode and `store.rs` already documents hitting
8464    /// `SQLITE_BUSY_SNAPSHOT`, so that gap is not hypothetical — the narrowing
8465    /// would have rejected the very class this tolerance exists for.
8466    ///
8467    /// `& 0xFF` is how SQLite defines the relationship: the low byte of an
8468    /// extended code IS the primary code.
8469    fn is_sqlite_busy(err: &anyhow::Error) -> bool {
8470        err.chain().any(|e| {
8471            e.downcast_ref::<sqlx::Error>().is_some_and(|e| match e {
8472                sqlx::Error::Database(db) => db
8473                    .code()
8474                    .and_then(|c| c.parse::<i32>().ok())
8475                    .is_some_and(is_busy_code),
8476                _ => false,
8477            })
8478        })
8479    }
8480
8481    /// The classification, split out so the WAL variants are TESTABLE.
8482    ///
8483    /// A `BUSY_SNAPSHOT` cannot be produced on demand in a test, so without
8484    /// this the claim that 261/517/773 are tolerated would be a comment and
8485    /// nothing else. The wiring — that `is_sqlite_busy` consults this at all —
8486    /// is pinned separately by `a_busy_sweep_is_reported_as_contended_not_as_a_failure`,
8487    /// which drives a real `SQLITE_BUSY` end to end.
8488    fn is_busy_code(code: i32) -> bool {
8489        code & 0xFF == 5
8490    }
8491
8492    /// **The whole `SQLITE_BUSY` family, and nothing else.**
8493    #[test]
8494    fn busy_codes_cover_the_wal_variants() {
8495        for code in [
8496            5,   // SQLITE_BUSY
8497            261, // SQLITE_BUSY_RECOVERY
8498            517, // SQLITE_BUSY_SNAPSHOT
8499            773, // SQLITE_BUSY_TIMEOUT
8500        ] {
8501            assert!(
8502                is_busy_code(code),
8503                "{code} is in the BUSY family but would be treated as a hard failure"
8504            );
8505        }
8506        for code in [
8507            0,   // SQLITE_OK
8508            1,   // SQLITE_ERROR
8509            6,   // SQLITE_LOCKED — adjacent, and deliberately NOT tolerated
8510            262, // SQLITE_LOCKED_SHAREDCACHE
8511            11,  // SQLITE_CORRUPT
8512        ] {
8513            assert!(
8514                !is_busy_code(code),
8515                "{code} is not contention, but would be swallowed as though it were"
8516            );
8517        }
8518    }
8519
8520    /// Run the batched delete, separating "the write lock was contended" from
8521    /// "the loop misbehaved". Only the second is this test's subject.
8522    async fn sweep_tolerating_busy(
8523        pool: &SqlitePool,
8524        select_ids: &str,
8525        cutoff: &str,
8526        label: &str,
8527    ) -> Result<SweepOutcome> {
8528        match delete_in_batches(pool, select_ids, cutoff, label).await {
8529            Ok(n) => Ok(SweepOutcome::Completed(n)),
8530            // Contended, not broken. Narrowed to SQLITE_BUSY on purpose: every
8531            // other error still fails the caller, so this is not a blanket
8532            // `let _ =` that would delete the test while keeping its name.
8533            Err(err) if is_sqlite_busy(&err) => Ok(SweepOutcome::Contended),
8534            Err(err) => Err(err),
8535        }
8536    }
8537
8538    /// **A sweep that loses the write lock is inconclusive, not a failure.**
8539    ///
8540    /// CI hit this on `main` at `d05a716`: the sweeper took `SQLITE_BUSY` and
8541    /// the test reported a regression, on a tree whose only changes were two
8542    /// version strings and a changelog.
8543    ///
8544    /// Forced deterministically rather than waiting for a contended runner — it
8545    /// did not reproduce in 48 local runs — by holding a write transaction open
8546    /// and giving the sweep a `busy_timeout` short enough to give up at once.
8547    #[tokio::test]
8548    async fn a_busy_sweep_is_reported_as_contended_not_as_a_failure() -> Result<()> {
8549        struct TempDb(std::path::PathBuf);
8550        impl Drop for TempDb {
8551            fn drop(&mut self) {
8552                for suffix in ["", "-wal", "-shm"] {
8553                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8554                }
8555            }
8556        }
8557        let path = std::env::temp_dir().join(format!("fr-busysweep-{}.db", std::process::id()));
8558        drop(TempDb(path.clone()));
8559        let _tmp = TempDb(path.clone());
8560        let url = format!("sqlite://{}", path.display());
8561        let pool = init_url(&url).await?;
8562
8563        let feed_id = upsert_feed(
8564            &pool,
8565            &NewFeed {
8566                url: "https://busy.example/f.xml".to_string(),
8567                ..Default::default()
8568            },
8569        )
8570        .await?;
8571        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8572            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8573        let entries: Vec<NewEntry> = (0..4)
8574            .map(|i| NewEntry {
8575                guid: format!("busy-{i}"),
8576                url: Some(format!("https://busy.example/{i}")),
8577                title: Some(format!("e{i}")),
8578                published: Some(old.clone()),
8579                ..Default::default()
8580            })
8581            .collect();
8582        insert_entries(&pool, feed_id, &entries, 1_000).await?;
8583
8584        // A sweep pool that gives up on a contended write immediately.
8585        let sweep_pool = SqlitePoolOptions::new()
8586            .max_connections(1)
8587            .connect_with(
8588                url.parse::<sqlx::sqlite::SqliteConnectOptions>()?
8589                    .busy_timeout(std::time::Duration::from_millis(2)),
8590            )
8591            .await?;
8592
8593        // Hold the write lock for the duration of the sweep below.
8594        let mut blocker = pool.acquire().await?;
8595        sqlx::query("BEGIN IMMEDIATE")
8596            .execute(&mut *blocker)
8597            .await?;
8598
8599        let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8600            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8601        let outcome = sweep_tolerating_busy(
8602            &sweep_pool,
8603            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8604            &cutoff,
8605            "busy-sweep-test",
8606        )
8607        .await;
8608
8609        sqlx::query("ROLLBACK").execute(&mut *blocker).await.ok();
8610
8611        match outcome {
8612            Ok(SweepOutcome::Contended) => Ok(()),
8613            Ok(SweepOutcome::Completed(n)) => panic!(
8614                "the sweep completed ({n} rows) while the write lock was held — \
8615                 the fixture is not actually contending, so this test proves nothing"
8616            ),
8617            Err(err) => panic!(
8618                "a contended sweep was reported as a failure rather than as \
8619                 inconclusive; production logs this and retries on the next \
8620                 tick (scheduler.rs): {err:#}"
8621            ),
8622        }
8623    }
8624
8625    /// **A sweep error that is NOT `SQLITE_BUSY` must still fail.**
8626    ///
8627    /// `sweep_tolerating_busy` claims to narrow its tolerance to contention.
8628    /// Without this, that claim is unenforced: widening the arm to `Err(_) =>
8629    /// Contended` swallows every sweep error — a malformed query, a missing
8630    /// table, a corrupt file — and the whole suite stays green. Measured, not
8631    /// assumed: that mutation passed 733 tests before this test existed.
8632    #[tokio::test]
8633    async fn a_non_busy_sweep_error_still_fails() -> Result<()> {
8634        let pool = init_url("sqlite::memory:").await?;
8635        // A table that does not exist: SQLITE_ERROR (1), not SQLITE_BUSY (5).
8636        let outcome = sweep_tolerating_busy(
8637            &pool,
8638            "SELECT id FROM no_such_table WHERE created < ?1",
8639            "2026-01-01T00:00:00Z",
8640            "bad-query-test",
8641        )
8642        .await;
8643
8644        match outcome {
8645            Err(err) => {
8646                assert!(
8647                    !is_sqlite_busy(&err),
8648                    "fixture drifted: this must be a non-BUSY error, got {err:#}"
8649                );
8650                Ok(())
8651            }
8652            Ok(SweepOutcome::Contended) => panic!(
8653                "a malformed sweep was reported as lock contention — the \
8654                 tolerance is a blanket error swallow, not a narrowing"
8655            ),
8656            Ok(SweepOutcome::Completed(n)) => {
8657                panic!("a sweep over a missing table reported {n} rows deleted")
8658            }
8659        }
8660    }
8661
8662    /// **An UNCONTENDED sweep must report `Completed`.**
8663    ///
8664    /// This exists to stop the `Contended` arm above becoming a way to never
8665    /// run the hand-off assertions. Make `sweep_tolerating_busy` return
8666    /// `Contended` unconditionally and the sweep-lock test still passes — it
8667    /// just silently stops testing anything. This one fails instead.
8668    ///
8669    /// That is the difference between tolerating a real contention loss and
8670    /// deleting a test while keeping its name.
8671    #[tokio::test]
8672    async fn a_sweep_with_no_contention_completes() -> Result<()> {
8673        struct TempDb(std::path::PathBuf);
8674        impl Drop for TempDb {
8675            fn drop(&mut self) {
8676                for suffix in ["", "-wal", "-shm"] {
8677                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8678                }
8679            }
8680        }
8681        let path = std::env::temp_dir().join(format!("fr-calmsweep-{}.db", std::process::id()));
8682        drop(TempDb(path.clone()));
8683        let _tmp = TempDb(path.clone());
8684        let pool = init_url(&format!("sqlite://{}", path.display())).await?;
8685
8686        let feed_id = upsert_feed(
8687            &pool,
8688            &NewFeed {
8689                url: "https://calm.example/f.xml".to_string(),
8690                ..Default::default()
8691            },
8692        )
8693        .await?;
8694        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8695            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8696        let entries: Vec<NewEntry> = (0..3)
8697            .map(|i| NewEntry {
8698                guid: format!("calm-{i}"),
8699                published: Some(old.clone()),
8700                ..Default::default()
8701            })
8702            .collect();
8703        insert_entries(&pool, feed_id, &entries, 1_000).await?;
8704
8705        let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8706            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8707        match sweep_tolerating_busy(
8708            &pool,
8709            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8710            &cutoff,
8711            "calm-sweep-test",
8712        )
8713        .await?
8714        {
8715            SweepOutcome::Completed(n) => {
8716                assert_eq!(n, 3, "the uncontended sweep did not delete the fixture");
8717                Ok(())
8718            }
8719            SweepOutcome::Contended => panic!(
8720                "nothing was holding the write lock, yet the sweep reported \
8721                 contention — every test that skips on `Contended` is now \
8722                 skipping unconditionally"
8723            ),
8724        }
8725    }
8726
8727    /// **The sweep must not lock other writers out for its duration.**
8728    ///
8729    /// The whole sweep used to be one transaction — both deletes plus a global
8730    /// cursor scrub that loads every `read_cursor` row and then issues a
8731    /// per-cursor live-ids query. SQLite is single-writer with a 5 s
8732    /// `busy_timeout`, so every mark-read, login write and cursor flush failed
8733    /// for that whole span.
8734    ///
8735    /// On-disk (WAL) because the in-memory pool is deliberately
8736    /// single-connection, which would make a concurrency test meaningless.
8737    ///
8738    /// **The writer runs on its own pool with a short `busy_timeout`, and the
8739    /// runtime is multi-thread.** Both are load-bearing — a 5 s `busy_timeout`
8740    /// on a shared runtime is what made this test flake on CI. See the comment
8741    /// on the writer pool and the `attempts` assertion.
8742    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8743    async fn a_writer_gets_through_while_the_sweep_runs() -> Result<()> {
8744        // **Cleanup on EVERY exit, including a panicking assertion.**
8745        //
8746        // The three `remove_file` calls used to sit after the assertions, so any
8747        // failure leaked the database and its `-wal`/`-shm` — 2.7–12.8 MB a time,
8748        // and this test is deliberately the one most likely to fail. Worse, setup
8749        // removed only the `.db`, so a recycled PID paired a fresh database with a
8750        // stale WAL. A guard drops on the unwind path too and takes all three.
8751        struct TempDb(std::path::PathBuf);
8752        impl Drop for TempDb {
8753            fn drop(&mut self) {
8754                for suffix in ["", "-wal", "-shm"] {
8755                    std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8756                }
8757            }
8758        }
8759        let dir = std::env::temp_dir();
8760        let path = dir.join(format!("fr-sweeplock-{}.db", std::process::id()));
8761        // Drops the previous run's leftovers, WAL and all, before opening.
8762        drop(TempDb(path.clone()));
8763        let _tmp = TempDb(path.clone());
8764        let url = format!("sqlite://{}", path.display());
8765        let pool = init_url(&url).await?;
8766
8767        let feed_id = upsert_feed(
8768            &pool,
8769            &NewFeed {
8770                url: "https://lock.example/f.xml".to_string(),
8771                ..Default::default()
8772            },
8773        )
8774        .await?;
8775        // **The fixture is DERIVED from the batch count, not described by it.**
8776        //
8777        // Every assertion below reasons about "ten hand-off windows". That was
8778        // prose — a `const BATCHES: u32 = 10` sitting next to a `PRUNE_BATCH *
8779        // 10` fixture with nothing tying them together. Editing the fixture
8780        // alone to `PRUNE_BATCH * 4` left the floor still demanding ten
8781        // hand-offs' worth of time from a four-batch loop, and correct code was
8782        // accused of not handing the lock over at all (1 run in 6). Now the
8783        // compiler carries the coupling.
8784        const BATCHES: i64 = 10;
8785        // **The 50% ceiling below is only safe because BATCHES is large.**
8786        //
8787        // `max_refused_run / attempts` is bounded by roughly `1 / BATCHES` only
8788        // because the fixture opens that many hand-off windows. Shrink it and
8789        // correct code walks into the ceiling: measured with production code
8790        // untouched and the per-batch hold grown 10x, `BATCHES = 4` gives ratios
8791        // of 0.21–0.35 and `BATCHES = 2` gives 0.45–0.56, **failing 3 runs in
8792        // 5**. The comment above invites editing this fixture; this stops that
8793        // edit from silently turning the assertion against the code it guards.
8794        const _: () = assert!(
8795            BATCHES >= 5,
8796            "the 50% ceiling assumes ~1/BATCHES; below 5 batches correct code              false-fails",
8797        );
8798        let old = (chrono::Utc::now() - chrono::Duration::days(400))
8799            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8800        let entries: Vec<NewEntry> = (0..(PRUNE_BATCH * BATCHES) as usize)
8801            .map(|i| NewEntry {
8802                guid: format!("lock-{i}"),
8803                published: Some(old.clone()),
8804                ..Default::default()
8805            })
8806            .collect();
8807        insert_entries(&pool, feed_id, &entries, 0).await?;
8808
8809        // **The writer gets its OWN pool, with a SHORT `busy_timeout`.**
8810        //
8811        // This is the fix for the CI flake described on the `attempts` assertion
8812        // below, and it is two separate changes.
8813        //
8814        // *Its own pool*, so the only thing that can block a write is SQLite's
8815        // write lock — the thing under test. Sharing the 5-connection pool with
8816        // the sweep meant a write could also stall waiting to ACQUIRE a pooled
8817        // connection the sweep was holding, which is a confounder that looks
8818        // identical from the outside.
8819        //
8820        // *A short `busy_timeout`*, so a contended write FAILS FAST and the loop
8821        // takes another shot. At the production 5 s, SQLite's busy handler backs
8822        // off internally — 1, 2, 5, 10, 25, 50, 100 ms and up — all inside a
8823        // single `execute()`. The writer therefore gets ONE attempt per blocked
8824        // write, and once the ladder reaches 100 ms it sleeps straight past the
8825        // `PRUNE_BATCH_HANDOFF` windows `delete_in_batches` opens. Failing fast
8826        // turns one low-probability attempt into hundreds of independent ones:
8827        // measured 54 attempts at 5 ms, 517 at 2 ms, over the same sweep.
8828        const WRITER_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2);
8829        let writer_pool = SqlitePoolOptions::new()
8830            .min_connections(1)
8831            .max_connections(1)
8832            .connect_with(
8833                SqliteConnectOptions::from_str(&url)?
8834                    .foreign_keys(true)
8835                    .busy_timeout(WRITER_BUSY_TIMEOUT)
8836                    .log_statements(tracing::log::LevelFilter::Debug),
8837            )
8838            .await?;
8839
8840        let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
8841        let writer_done = std::sync::Arc::clone(&done);
8842        let writer = tokio::spawn(async move {
8843            // `(when the attempt STARTED, whether it landed)`.
8844            //
8845            // The ORDER is what the assertions read, not the timestamps: they
8846            // count consecutive failures. The instants serve only to select the
8847            // attempts made inside the measured window — the writer is spawned
8848            // before `t0`, so a plain counter would fold in attempts that can
8849            // never appear in `during`.
8850            //
8851            // (An earlier version of this comment, left behind by the switch away
8852            // from elapsed time, said completions were recorded and that "the
8853            // timestamps are the load-bearing part". Neither is true now.)
8854            let mut outcomes: Vec<(std::time::Instant, bool)> = Vec::new();
8855            // Kept for the failure message: if the writes are failing for a
8856            // reason that is NOT lock contention, nothing lands and the test
8857            // fails — this is what says why. Timestamped so the test can drop it
8858            // when it describes an attempt OUTSIDE the measured window; the
8859            // writer starts before `t0`, so the very first error is usually from
8860            // an attempt the assertions never look at.
8861            let mut first_err: Option<(std::time::Instant, String)> = None;
8862            while !writer_done.load(std::sync::atomic::Ordering::Relaxed) {
8863                let started = std::time::Instant::now();
8864                match grant_access(
8865                    &writer_pool,
8866                    &format!("did:plc:writer{}", outcomes.len()),
8867                    None,
8868                    "sweep-test",
8869                    None,
8870                )
8871                .await
8872                {
8873                    Ok(()) => outcomes.push((started, true)),
8874                    // Expected: the sweep holds the write lock right now.
8875                    // Retrying is the entire point, so this is counted, not
8876                    // fatal. A `?` here would abort the writer on the first
8877                    // contended write and destroy the measurement.
8878                    Err(err) => {
8879                        outcomes.push((started, false));
8880                        if first_err.is_none() {
8881                            first_err = Some((started, format!("{err:#}")));
8882                        }
8883                    }
8884                }
8885                tokio::task::yield_now().await;
8886            }
8887            writer_pool.close().await;
8888            (outcomes, first_err)
8889        });
8890
8891        // **Drive `delete_in_batches` directly, not `prune_old_entries`.**
8892        //
8893        // The subject is the batched delete loop and whether it hands the write
8894        // lock over between batches. `prune_old_entries` wraps it in work that
8895        // is not that — two delete passes plus `prune_orphan_cursor_ids` — so
8896        // timing the whole call measures a window in which the lock was never
8897        // meant to be held throughout, and writes landing outside the loop
8898        // count as though the loop had handed the lock over.
8899        //
8900        // A correction to what this comment first claimed. It said the cursor
8901        // scrub was a tail that "grows with the number of rows deleted", and
8902        // that this explained a `42 of 358` measurement. **That is false, and
8903        // measured to be false**: this fixture creates no `read_cursor` rows at
8904        // all, so the scrub does one `SELECT` over an empty table and loops zero
8905        // times — 0.16–2 ms, 0.03–0.5% of the window, at any fixture size. It
8906        // cannot explain anything. Narrowing the window is still right, for the
8907        // reason above; the mechanism originally given for it was not real.
8908        //
8909        // The consequence worth stating: because the fixture has no cursors, the
8910        // old form never covered the scrub's locking either — it only appeared
8911        // to. Nothing here regressed. `prune_orphan_cursor_ids` holding the lock
8912        // across a whole pass is a real production invariant (see its own doc)
8913        // and remains untested; that needs a test with actual cursors, not this
8914        // one.
8915        let hard_cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8916            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8917        let t0 = std::time::Instant::now();
8918        let outcome = sweep_tolerating_busy(
8919            &pool,
8920            "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8921            &hard_cutoff,
8922            "sweep-lock-test",
8923        )
8924        .await?;
8925        let sweep = t0.elapsed();
8926
8927        // **Teardown happens on BOTH paths, before the outcome is inspected.**
8928        //
8929        // The contended arm below used to carry its own copy of these two lines.
8930        // A probe proved that arm is never reached by the suite — a `panic!` in
8931        // it failed nothing — so it was five lines of unexercised teardown that
8932        // would run for the first time on a contended CI runner, which is
8933        // exactly when it has to work. Hoisting leaves the arm with nothing that
8934        // can be wrong.
8935        done.store(true, std::sync::atomic::Ordering::Relaxed);
8936        let (outcomes, first_err) = writer.await?;
8937
8938        let deleted = match outcome {
8939            SweepOutcome::Completed(n) => n,
8940            // **Inconclusive, not a regression.** The sweeper lost the write
8941            // lock, which says nothing about whether it hands the lock over
8942            // between batches — the property below. Production logs this and
8943            // retries on the next tick (`scheduler.rs`), so a test that failed
8944            // here would be stricter than the system it guards. Observed on CI
8945            // at `d05a716`, on a tree with no `.rs` change at all.
8946            //
8947            // `a_sweep_with_no_contention_completes` is what stops this arm
8948            // becoming a way to never run the assertions.
8949            SweepOutcome::Contended => {
8950                eprintln!(
8951                    "sweep-lock test INCONCLUSIVE: the sweeper took SQLITE_BUSY; \
8952                     the hand-off assertions did not run"
8953                );
8954                return Ok(());
8955            }
8956        };
8957        let sweep_end = t0 + sweep;
8958        // Attempts actually made inside the measured window, in order.
8959        let inside: Vec<bool> = outcomes
8960            .iter()
8961            .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8962            .map(|(_, ok)| *ok)
8963            .collect();
8964        let attempts = inside.len();
8965        let during = inside.iter().filter(|ok| **ok).count();
8966        // **The longest unbroken run of REFUSED attempts.**
8967        //
8968        // Counted in attempts, not elapsed time — see the note on the assertion
8969        // for why that distinction is the whole point.
8970        let max_refused_run = {
8971            let (mut worst, mut run) = (0usize, 0usize);
8972            for ok in &inside {
8973                run = if *ok { 0 } else { run + 1 };
8974                worst = worst.max(run);
8975            }
8976            worst
8977        };
8978        let why = first_err
8979            .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8980            .map(|(_, e)| format!(" (first in-window write error: {e})"))
8981            .unwrap_or_default();
8982
8983        assert_eq!(deleted as usize, entries.len());
8984        // **The sweep has to BE batched before anything downstream means
8985        // anything, and this floor is derived, not calibrated.**
8986        //
8987        // The fixture is `PRUNE_BATCH * 10` rows, all older than the hard
8988        // ceiling, so the hard-ceiling delete drains them in ten full batches
8989        // and stands down `PRUNE_BATCH_HANDOFF` after each. A genuinely batched
8990        // sweep therefore cannot finish in under `10 * PRUNE_BATCH_HANDOFF` on
8991        // any machine, however fast its disk — the sleeps are a floor the
8992        // hardware cannot undercut, and the deletes themselves only add to it.
8993        //
8994        // A loop that has LOST its batching is faster, not slower: measured at
8995        // 56 ms with the `LIMIT` dropped, against 305 ms batched. That is why
8996        // this fires before the two assertions below — without it, removing the
8997        // batching starves the writer of attempts and gets reported as "invalid
8998        // measurement", blaming the test for the defect it just detected.
8999        //
9000        // **Partial coverage, measured rather than asserted.** Two mutations
9001        // that keep the loop looking roughly batched are caught only sometimes:
9002        //
9003        //   `LIMIT` dropped (no batching at all)   3-4 runs in 5-6, MOSTLY by
9004        //                                          the refusal assertion below,
9005        //                                          not by this floor
9006        //   `PRUNE_BATCH_HANDOFF` sleep removed    1 run in 5-6, by this floor
9007        //
9008        // (An earlier version attributed both to this floor. Re-measured: of
9009        // four catches of the `LIMIT` mutation in six runs, three panicked at
9010        // the refusal assertion and one here.)
9011        //
9012        // Both were caught more often — 5/5 and 3/5 — by the wall-clock form
9013        // this replaced. That is a real coverage loss and it was taken on
9014        // purpose: the wall-clock form FALSE-FAILED correct code, which is a
9015        // worse defect than missing a deliberate deletion of a commented line.
9016        // See the note on the assertion below for the measurement.
9017        //
9018        // Nothing here is tuned to make those two reliable. Doing so means
9019        // thresholding a rate, which is what this test has now been wrong about
9020        // three separate times.
9021        let handoff_floor = PRUNE_BATCH_HANDOFF * BATCHES as u32;
9022        assert!(
9023            sweep > handoff_floor,
9024            "the delete loop finished in {sweep:?}, under the {handoff_floor:?} that \
9025             {BATCHES} batches of `PRUNE_BATCH_HANDOFF` alone would take — it is not \
9026             handing the write lock over between batches at all"
9027        );
9028        // **Assert a RATIO OF TWO DURATIONS THAT SCALE TOGETHER.**
9029        //
9030        // Three thresholds have now failed here, each for the same reason: they
9031        // compared something machine-scaled against something fixed.
9032        //
9033        //   `worst * 3 < sweep`  — broke when the sweep got FASTER (the
9034        //                          `NOT EXISTS` rewrite, 1.49x) and tightened a
9035        //                          threshold calibrated against the slow version.
9036        //   `wrote >= 10`        — a raw count is writes-per-unit-time, so it
9037        //                          measured the runner. Flaked on CI at 4 writes.
9038        //   `during * 2 >=`      — a success FRACTION, which I claimed was
9039        //   `attempts`             scale-free. It is not, and this is the
9040        //                          important one, because the argument sounds
9041        //                          right. Successes come from the FIXED
9042        //                          `BATCHES * PRUNE_BATCH_HANDOFF` of open
9043        //                          window divided by write latency; failures
9044        //                          come from the machine-scaled lock hold
9045        //                          divided by the FIXED `WRITER_BUSY_TIMEOUT`.
9046        //                          Slow the machine by k and the fraction decays
9047        //                          as roughly 1/(1 + k²c) — quadratically,
9048        //                          toward failure. Measured with production code
9049        //                          fully correct and only the per-batch hold
9050        //                          grown 10x: **188/949 (19.8%) and 383/1028
9051        //                          (37.3%), two false failures in three runs**,
9052        //                          at loop durations of 3.4 s. CPU saturation
9053        //                          cannot find this — it slows writer and
9054        //                          sweeper together, which is the wrong axis.
9055        //
9056        //   `max_gap * 2 <`     — the longest WALL-CLOCK stretch with no write
9057        //   `sweep`               landing, against the loop's duration. Both
9058        //                         sides scale with the machine, which fixed the
9059        //                         fraction's problem and introduced a new one:
9060        //                         a gap opens when the writer is DESCHEDULED
9061        //                         just as surely as when the lock is held.
9062        //                         Observed under 4x CPU saturation, full suite:
9063        //                         `went 319.95ms of 609.83ms` — while **622 of
9064        //                         626 attempts landed**. The lock was fine; the
9065        //                         writer task simply did not run for 320 ms.
9066        //
9067        // So count REFUSALS, not time. The longest unbroken run of `SQLITE_BUSY`
9068        // against the number of attempts made:
9069        //
9070        //   handed over : the lock is free for `PRUNE_BATCH_HANDOFF` after every
9071        //                 batch, so the longest refused run is bounded by about
9072        //                 one batch's worth of attempts.
9073        //   held across : every attempt in the window is refused — 100%.
9074        //
9075        // **The ~10% this comment used to quote for the handed-over case is not
9076        // what the shipped configuration produces.** Measured here: 0.001–0.05,
9077        // and in roughly a quarter of runs the writer is refused ZERO times
9078        // (`max_refused_run == 0`, every attempt landing), so the assertion is
9079        // vacuously true and certifies the hand-off by never observing one. That
9080        // is a weak test, not a wrong one — but it is worth knowing that the
9081        // enormous margin comes from the writer rarely colliding at all, not
9082        // from a measured 10%. 10% is what appears only once the per-batch hold
9083        // dominates the hand-off (`PRUNE_BATCH` x10 gives 0.115–0.143).
9084        //
9085        // This is immune to descheduling in a way no wall-clock measure can be:
9086        // a starved writer makes no attempts, so it contributes to neither side
9087        // of the ratio. Machine speed still cancels, because both sides are
9088        // counts of the same attempts. Re-checked against the failure above:
9089        // 622 of 626 landing means a refused run of at most 4, nowhere near the
9090        // 313 it would take to trip.
9091        //
9092        // **Detection is near all-or-nothing, and that is a known limit rather
9093        // than an oversight.** Holding one transaction across only the FIRST
9094        // HALF of the batches — production code otherwise correct — is not
9095        // caught at all:
9096        //
9097        //   batches held in one tx (of 10)   runs failing
9098        //   5                                0 of 6   (ratios 0.05-0.27)
9099        //   7                                2 of 6
9100        //   9                                5 of 5
9101        //   10                               22 of 22
9102        //
9103        // The ratio systematically UNDERSTATES the wall-clock fraction the lock
9104        // was held, because a refused attempt costs ~2 ms and leaves the sweeper
9105        // running uncontended, while a successful write actively blocks it and
9106        // stretches the loop. So attempts pile up during free time. The
9107        // "10% vs 100%" framing above describes the endpoints, not the curve.
9108        //
9109        // Closing that would mean measuring the wall-clock SPAN of a refusal run
9110        // rather than its length — which is most of the way back to `max_gap`,
9111        // the form that false-failed correct code on a descheduled writer. Given
9112        // this assertion has now been wrong four times in a row, and the current
9113        // one has zero false failures across 134 runs in six environments while
9114        // catching the real defect 22/22, a fifth redesign to catch a
9115        // half-transaction — a mutation no plausible edit produces — is not a
9116        // trade worth making. Stated here so the next reader knows the gap is
9117        // chosen, not missed.
9118        //
9119        // Measured, with the apparatus verified before each run:
9120        //
9121        //   correct, 1x / 10x per-batch hold   passes
9122        //   one tx across batches, 1x          CAUGHT — refused 128 of 129
9123        //   one tx across batches, 10x         CAUGHT — refused 1841 of 1843
9124        //   `LIMIT` dropped                    caught 3 runs in 5 (by the floor)
9125        //   hand-off sleep removed             caught 1 run  in 5 (by the floor)
9126        //
9127        // The last two were 5/5 and 3/5 under the wall-clock form. Losing that
9128        // is the price of not false-failing correct code, and it is the right
9129        // way round: the named defect is now caught by two orders of magnitude,
9130        // and the mutations that got weaker are deliberate deletions of lines
9131        // that carry their own explanation.
9132        assert!(
9133            attempts >= 20,
9134            "the writer only got {attempts} attempts inside a {sweep:?} delete loop \
9135             — too few for the ratio below to mean anything. That is USUALLY an \
9136             invalid measurement rather than a held lock, but note that a loop \
9137             holding the lock throughout is itself one cause of a starved writer, \
9138             so check {during} (landed) before concluding the test is at \
9139             fault{why}"
9140        );
9141        assert!(
9142            max_refused_run * 2 < attempts,
9143            "the delete loop refused {max_refused_run} consecutive write attempts out \
9144             of {attempts} ({during} landed) — a loop that hands the write lock over \
9145             between batches refuses at most about one batch's worth in a row; one \
9146             that holds the lock across them refuses nearly every attempt it sees{why}"
9147        );
9148
9149        pool.close().await;
9150        // `_tmp` removes the database, WAL and shm as it drops — on this path
9151        // and on the unwind from any assertion above.
9152        Ok(())
9153    }
9154
9155    /// **The sparing predicate must quantify over ALL DIDs, not just one.**
9156    ///
9157    /// `entry_state`'s primary key is `(did, entry_id)`, so several readers can
9158    /// hold rows on the same shared entry. The window spares an entry when ANY of
9159    /// them has starred it or left it unread — one person's star protects the
9160    /// cached copy everyone reads.
9161    ///
9162    /// This is the ONLY case where `id NOT IN (…)` and the correlated
9163    /// `NOT EXISTS` that replaced it could diverge, and it had no test. Every
9164    /// other retention test writes one `entry_state` row per entry under a single
9165    /// DID, where the two forms are trivially identical — so the claim that the
9166    /// suite made the equivalence executable was false when it was written. It is
9167    /// true now.
9168    #[tokio::test]
9169    async fn sparing_honours_every_did_not_just_one() -> Result<()> {
9170        let pool = init_url("sqlite::memory:").await?;
9171        let feed_id = upsert_feed(
9172            &pool,
9173            &NewFeed {
9174                url: "https://shared.example/f.xml".to_string(),
9175                ..Default::default()
9176            },
9177        )
9178        .await?;
9179        let old = (chrono::Utc::now() - chrono::Duration::days(400))
9180            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9181        let guids = [
9182            "nobody-touched",      // no state row at all -> evicted
9183            "both-read-unstarred", // two DIDs, both read+unstarred -> evicted
9184            "one-starred",         // A read+unstarred, B starred -> SPARED by B
9185            "one-unread",          // A read+unstarred, B unread   -> SPARED by B
9186        ];
9187        let entries: Vec<NewEntry> = guids
9188            .iter()
9189            .map(|g| NewEntry {
9190                guid: (*g).to_string(),
9191                published: Some(old.clone()),
9192                ..Default::default()
9193            })
9194            .collect();
9195        insert_entries(&pool, feed_id, &entries, 0).await?;
9196
9197        let id_of = |g: &'static str| {
9198            let pool = pool.clone();
9199            async move {
9200                sqlx::query_scalar::<_, i64>("SELECT id FROM entries WHERE guid = ?1")
9201                    .bind(g)
9202                    .fetch_one(&pool)
9203                    .await
9204                    .unwrap()
9205            }
9206        };
9207        // (did, entry, read, starred)
9208        let rows: [(&str, &'static str, i64, i64); 6] = [
9209            ("did:plc:a", "both-read-unstarred", 1, 0),
9210            ("did:plc:b", "both-read-unstarred", 1, 0),
9211            ("did:plc:a", "one-starred", 1, 0),
9212            ("did:plc:b", "one-starred", 1, 1),
9213            ("did:plc:a", "one-unread", 1, 0),
9214            ("did:plc:b", "one-unread", 0, 0),
9215        ];
9216        for (did, guid, read, starred) in rows {
9217            let id = id_of(guid).await;
9218            sqlx::query(
9219                "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
9220                 VALUES (?1, ?2, ?3, ?4, '2026-01-01T00:00:00Z')",
9221            )
9222            .bind(did)
9223            .bind(id)
9224            .bind(read)
9225            .bind(starred)
9226            .execute(&pool)
9227            .await?;
9228        }
9229
9230        // Window only — no ceiling, so nothing is swept for age alone.
9231        let deleted = prune_old_entries(&pool, 30, 0, 0).await?;
9232        assert_eq!(
9233            deleted, 2,
9234            "expected the untouched and the all-read entries to go"
9235        );
9236
9237        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
9238            .fetch_all(&pool)
9239            .await?;
9240        assert_eq!(
9241            left,
9242            vec!["one-starred".to_string(), "one-unread".to_string()],
9243            "a second reader's star or unread mark must spare the SHARED entry"
9244        );
9245        Ok(())
9246    }
9247
9248    /// **A mark-read landing during the scrub must not be overwritten.**
9249    ///
9250    /// Moving the scrub out of the sweep's transaction removed a multi-minute
9251    /// write-lock hold and introduced a lost update in its place: the id-sets
9252    /// were read into a snapshot up front and written back unguarded, so a
9253    /// `mark_read` arriving mid-pass had its id silently dropped — and the
9254    /// rewrite set `dirty = 1`, so the flusher pushed the truncated set to the
9255    /// PDS as authoritative. Local `entry_state` still said read, so the loss was
9256    /// invisible here and visible only in every other atproto client.
9257    ///
9258    /// **⚠️ THIS TEST DOES NOT PROVE THAT, AND THE NAME NO LONGER CLAIMS IT.**
9259    ///
9260    /// The mark-read below lands BEFORE the scrub is called, not during it — so
9261    /// a snapshot-then-write implementation taking its snapshot at the top of
9262    /// `prune_orphan_cursor_ids` would see it too, and pass. The discriminator
9263    /// does not discriminate; what is actually pinned is the ordinary outcome:
9264    /// orphaned ids go, live ids stay.
9265    ///
9266    /// What the lost-update shape is really prevented by is a TYPE fact, not
9267    /// this test: `scrub_one_cursor(pool, did, feed_url)` is handed no id-sets,
9268    /// so it cannot write back anything but what it read itself, and
9269    /// re-introducing the bug means changing its signature.
9270    ///
9271    /// Proving it by test needs a real interleave — hold the write lock on a
9272    /// second connection, let the scrub block on it, commit a `mark_read`, then
9273    /// release — which needs a file-backed database and, without a hook inside
9274    /// the pass, a sleep to be sure the key snapshot has already run. A sleep is
9275    /// how this suite gets flaky in CI, and a flaky test is worse than an honest
9276    /// one, so it is left undone and written down instead.
9277    #[tokio::test]
9278    async fn the_cursor_scrub_drops_orphans_and_keeps_live_ids() -> Result<()> {
9279        let pool = init_url("sqlite::memory:").await?;
9280        let did = "did:plc:race";
9281        let feed_url = "https://race.example/f.xml";
9282        let feed_id = upsert_feed(
9283            &pool,
9284            &NewFeed {
9285                url: feed_url.to_string(),
9286                ..Default::default()
9287            },
9288        )
9289        .await?;
9290        insert_entries(
9291            &pool,
9292            feed_id,
9293            &[
9294                NewEntry {
9295                    guid: "live".to_string(),
9296                    ..Default::default()
9297                },
9298                NewEntry {
9299                    guid: "doomed".to_string(),
9300                    ..Default::default()
9301                },
9302            ],
9303            0,
9304        )
9305        .await?;
9306        replace_sub_refs(&pool, did, &[feed_id]).await?;
9307        let live_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'live'")
9308            .fetch_one(&pool)
9309            .await?;
9310        let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
9311            .fetch_one(&pool)
9312            .await?;
9313
9314        // A cursor holding only the id that is about to be deleted.
9315        upsert_cursor(
9316            &pool,
9317            &ReadCursor {
9318                did: did.to_string(),
9319                feed_url: feed_url.to_string(),
9320                read_through: None,
9321                read_ids: format!("[\"{doomed_id}\"]"),
9322                unread_ids: "[]".to_string(),
9323                dirty: false,
9324                pds_created: false,
9325                updated_at: now_rfc3339(),
9326            },
9327        )
9328        .await?;
9329        sqlx::query("DELETE FROM entries WHERE guid = 'doomed'")
9330            .execute(&pool)
9331            .await?;
9332
9333        // A reader marks the surviving entry read. NOTE this lands before the
9334        // scrub, not during it — see the caveat on this test. It is here because
9335        // the live id must survive the pass, not because it catches the race.
9336        mark_read(&pool, did, live_id, true).await?;
9337
9338        assert_eq!(prune_orphan_cursor_ids(&pool, None).await?, 1);
9339
9340        let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
9341        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
9342        assert_eq!(
9343            ids,
9344            vec![live_id.to_string()],
9345            "the scrub dropped a live id"
9346        );
9347        assert!(
9348            !ids.contains(&doomed_id.to_string()),
9349            "the orphaned id survived the scrub"
9350        );
9351        Ok(())
9352    }
9353
9354    /// The cursor scrub still happens — it just no longer rides inside the
9355    /// delete transaction. Moving it out is only safe because it is idempotent;
9356    /// this pins that it still runs at all, which is the thing a "move it out"
9357    /// refactor can silently drop.
9358    #[tokio::test]
9359    async fn the_sweep_still_scrubs_orphaned_cursor_ids() -> Result<()> {
9360        let pool = init_url("sqlite::memory:").await?;
9361        let did = "did:plc:scrub";
9362        let feed_url = "https://scrub.example/f.xml";
9363        let feed_id = upsert_feed(
9364            &pool,
9365            &NewFeed {
9366                url: feed_url.to_string(),
9367                ..Default::default()
9368            },
9369        )
9370        .await?;
9371        let old = (chrono::Utc::now() - chrono::Duration::days(400))
9372            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9373        insert_entries(
9374            &pool,
9375            feed_id,
9376            &[NewEntry {
9377                guid: "doomed".to_string(),
9378                published: Some(old),
9379                ..Default::default()
9380            }],
9381            0,
9382        )
9383        .await?;
9384        let doomed = entries_for_feed(&pool, did, feed_id).await;
9385        // `entries_for_feed` is sub_ref-scoped; read the id directly instead.
9386        drop(doomed);
9387        let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
9388            .fetch_one(&pool)
9389            .await?;
9390
9391        upsert_cursor(
9392            &pool,
9393            &ReadCursor {
9394                did: did.to_string(),
9395                feed_url: feed_url.to_string(),
9396                read_through: None,
9397                read_ids: format!("[\"{doomed_id}\"]"),
9398                unread_ids: "[]".to_string(),
9399                dirty: false,
9400                pds_created: false,
9401                updated_at: now_rfc3339(),
9402            },
9403        )
9404        .await?;
9405
9406        assert_eq!(prune_old_entries(&pool, 30, 180, 0).await?, 1);
9407
9408        let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
9409        let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
9410        assert!(
9411            ids.is_empty(),
9412            "the deleted entry's id survived in the cursor: {ids:?}"
9413        );
9414        assert!(cursor.dirty, "a rewritten cursor must be re-flushed");
9415        Ok(())
9416    }
9417
9418    #[tokio::test]
9419    async fn prune_and_reclaim_drops_db_size() -> Result<()> {
9420        // On-disk DB so VACUUM has a file to shrink.
9421        let dir = std::env::temp_dir();
9422        let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
9423        let url = format!("sqlite://{}", path.display());
9424        let pool = init_url(&url).await?;
9425
9426        let feed_id = upsert_feed(
9427            &pool,
9428            &NewFeed {
9429                url: "https://bulk.example/feed.xml".to_string(),
9430                ..Default::default()
9431            },
9432        )
9433        .await?;
9434        let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
9435            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9436        let entries: Vec<NewEntry> = (0..2000)
9437            .map(|i| NewEntry {
9438                guid: format!("guid-{i}"),
9439                content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
9440                published: Some(ancient.clone()),
9441                fetched_at: Some(ancient.clone()),
9442                ..Default::default()
9443            })
9444            .collect();
9445        insert_entries(&pool, feed_id, &entries, 0).await?;
9446        let full = db_size_bytes(&pool).await?;
9447        assert!(full > 0);
9448
9449        // A retention sweep prunes every (year-old) entry, then reclaim shrinks.
9450        let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
9451        assert_eq!(deleted, 2000);
9452        reclaim(&pool).await?;
9453        let after = db_size_bytes(&pool).await?;
9454        assert!(
9455            after < full,
9456            "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
9457        );
9458
9459        drop(pool);
9460        let _ = std::fs::remove_file(&path);
9461        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9462        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9463        Ok(())
9464    }
9465
9466    // ---- B1: an existing PRE-0.2.2 invite_codes table (no intended_did) must
9467    // migrate cleanly, not crash-loop boot. ----------------------------------
9468
9469    #[tokio::test]
9470    async fn migrates_pre_intended_did_invite_codes_table() -> Result<()> {
9471        // Build an on-disk DB whose `invite_codes` table has the OLD 0.2.1 shape
9472        // (NO `intended_did` column, and therefore no `intended_did` index), then
9473        // run init_schema/migrations against it — this is exactly the existing-prod
9474        // volume that blocker B1 crash-looped (the SCHEMA's `CREATE INDEX ...
9475        // (intended_did, ...)` fired before the ALTER TABLE added the column).
9476        let dir = std::env::temp_dir();
9477        let path = dir.join(format!("fr-b1-{}.db", std::process::id()));
9478        let url = format!("sqlite://{}", path.display());
9479
9480        // Open a raw pool WITHOUT init_schema and hand-build the old table shape.
9481        let opts = SqliteConnectOptions::from_str(&url)?
9482            .create_if_missing(true)
9483            .foreign_keys(true);
9484        let pool = SqlitePoolOptions::new()
9485            .min_connections(1)
9486            .max_connections(1)
9487            .connect_with(opts)
9488            .await?;
9489        sqlx::query(
9490            r#"CREATE TABLE invite_codes (
9491                code         TEXT PRIMARY KEY,
9492                creator_did  TEXT NOT NULL,
9493                status       TEXT NOT NULL,
9494                invitee_did  TEXT,
9495                created_at   INTEGER NOT NULL,
9496                expires_at   INTEGER NOT NULL,
9497                redeemed_at  INTEGER
9498            );"#,
9499        )
9500        .execute(&pool)
9501        .await?;
9502        // Seed a legacy active code so the migration runs against real data.
9503        sqlx::query(
9504            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9505             VALUES ('FEATHER-LEGACY00', 'did:plc:old', 'active', 1, 9999999999)",
9506        )
9507        .execute(&pool)
9508        .await?;
9509
9510        // The column is genuinely absent to start with (pre-condition of B1).
9511        let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9512            .fetch_all(&pool)
9513            .await?
9514            .iter()
9515            .map(|r| r.get::<String, _>("name"))
9516            .collect();
9517        assert!(
9518            !cols.iter().any(|c| c == "intended_did"),
9519            "pre-condition: legacy table must lack intended_did"
9520        );
9521
9522        // THE FIX: init_schema must succeed (not error with "no such column").
9523        init_schema(&pool)
9524            .await
9525            .expect("init_schema on a pre-0.2.2 invite_codes table must not crash");
9526
9527        // Post-condition: the column now exists, both indexes were created, and the
9528        // legacy row is intact.
9529        let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9530            .fetch_all(&pool)
9531            .await?
9532            .iter()
9533            .map(|r| r.get::<String, _>("name"))
9534            .collect();
9535        assert!(cols.iter().any(|c| c == "intended_did"));
9536        let idx: Vec<String> = sqlx::query(
9537            "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='invite_codes'",
9538        )
9539        .fetch_all(&pool)
9540        .await?
9541        .iter()
9542        .map(|r| r.get::<String, _>("name"))
9543        .collect();
9544        assert!(idx.iter().any(|n| n == "idx_invite_codes_intended"));
9545        assert!(idx.iter().any(|n| n == "idx_invite_codes_intended_active"));
9546
9547        // Idempotent: running it again is a no-op, not an error.
9548        init_schema(&pool)
9549            .await
9550            .expect("re-running init_schema must be idempotent");
9551
9552        // **The OAuth tables must exist too.** They live in this database, and
9553        // creating them only when the Rust backend is selected would make the
9554        // first request after a cutover flip fail with "no such table" -- at the
9555        // one moment nobody wants to find out a migration was missed. They are
9556        // empty and harmless while the sidecar is serving.
9557        let tables: Vec<String> =
9558            sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table'")
9559                .fetch_all(&pool)
9560                .await
9561                .unwrap();
9562        for table in ["oauth_state", "oauth_session", "oauth_nonce"] {
9563            assert!(
9564                tables.iter().any(|t| t == table),
9565                "{table} is missing, so the rust backend would fail on its first request: {tables:?}"
9566            );
9567        }
9568
9569        // The legacy code still redeems (NULL intended_did → open, as before).
9570        let out = redeem_code(&pool, "FEATHER-LEGACY00", "did:plc:new", None, 100).await?;
9571        assert_eq!(out, Ok(()));
9572
9573        drop(pool);
9574        let _ = std::fs::remove_file(&path);
9575        let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9576        let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9577        Ok(())
9578    }
9579
9580    // ---- 0.3.9: the schema a RELEASED binary left behind must upgrade. ------
9581    //
9582    // B1 above hand-built the old shape of ONE table, so it could only catch the
9583    // mistake it was written for. 0.3.9 made the same mistake on `feeds` — an
9584    // index in the base SCHEMA on `kind`, a column only `apply_migrations` adds
9585    // — and crash-looped production on its first boot, while every test here
9586    // passed, because every other test starts from an empty file. These start
9587    // from the schema a released binary actually created (dumped, not
9588    // transcribed), so they cover every table at once: v0.3.8, the release
9589    // before the bug, and v0.2.0, the oldest and furthest-migrated shape.
9590
9591    /// A fresh in-memory pool on ONE connection that never expires. The bug
9592    /// class is DDL order, which does not depend on a file, and a file named by
9593    /// pid leaks on a failed run and then fails the next run whose pid matches,
9594    /// at the fixture's first CREATE TABLE, before it tests anything.
9595    async fn upgrade_test_pool() -> Result<SqlitePool> {
9596        let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true);
9597        Ok(SqlitePoolOptions::new()
9598            .min_connections(1)
9599            .max_connections(1)
9600            .idle_timeout(None)
9601            .max_lifetime(None)
9602            .connect_with(opts)
9603            .await?)
9604    }
9605
9606    /// Every table's columns (with type, NOT NULL, default and pk) and every
9607    /// index (with uniqueness, partiality and its columns in order), as one
9608    /// comparable set. Column ORDER is left out on purpose: `ALTER TABLE ADD
9609    /// COLUMN` appends, so a migrated table legitimately orders differently
9610    /// from a fresh one.
9611    async fn schema_shape(pool: &SqlitePool) -> Result<std::collections::BTreeSet<String>> {
9612        let mut shape = std::collections::BTreeSet::new();
9613        let tables: Vec<String> = sqlx::query_scalar(
9614            "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
9615        )
9616        .fetch_all(pool)
9617        .await?;
9618        for t in tables {
9619            for r in sqlx::query(
9620                r#"SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)"#,
9621            )
9622            .bind(&t)
9623            .fetch_all(pool)
9624            .await?
9625            {
9626                shape.insert(format!(
9627                    "column {t}.{} {} notnull={} default={:?} pk={}",
9628                    r.get::<String, _>("name"),
9629                    r.get::<String, _>("type"),
9630                    r.get::<i64, _>("notnull"),
9631                    r.get::<Option<String>, _>("dflt_value"),
9632                    r.get::<i64, _>("pk"),
9633                ));
9634            }
9635            for r in sqlx::query(r#"SELECT name, "unique", partial FROM pragma_index_list(?)"#)
9636                .bind(&t)
9637                .fetch_all(pool)
9638                .await?
9639            {
9640                let name: String = r.get("name");
9641                let cols: Vec<String> =
9642                    sqlx::query_scalar("SELECT name FROM pragma_index_info(?) ORDER BY seqno")
9643                        .bind(&name)
9644                        .fetch_all(pool)
9645                        .await?;
9646                shape.insert(format!(
9647                    "index {t}.{name} unique={} partial={} ({})",
9648                    r.get::<i64, _>("unique"),
9649                    r.get::<i64, _>("partial"),
9650                    cols.join(", "),
9651                ));
9652            }
9653        }
9654        Ok(shape)
9655    }
9656
9657    /// Load `fixture`, seed rows the way an old binary inserted them, run the
9658    /// current `init_schema`, and require the result to be indistinguishable
9659    /// in shape from a fresh database, with `kind` back-filled correctly.
9660    async fn assert_upgrades_from(version: &str, fixture: &'static str) -> Result<()> {
9661        let pool = upgrade_test_pool().await?;
9662        sqlx::raw_sql(fixture).execute(&pool).await?;
9663
9664        let has_kind = |pool: SqlitePool| async move {
9665            Ok::<_, anyhow::Error>(
9666                sqlx::query_scalar::<_, i64>(
9667                    "SELECT count(*) FROM pragma_table_info('feeds') WHERE name = 'kind'",
9668                )
9669                .fetch_one(&pool)
9670                .await?
9671                    == 1,
9672            )
9673        };
9674        assert!(
9675            !has_kind(pool.clone()).await?,
9676            "pre-condition: a {version} feeds table has no kind column"
9677        );
9678
9679        // One row of each kind, inserted the way the old binary did: without `kind`.
9680        let publication = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
9681        for u in ["https://example.com/feed.xml", publication] {
9682            sqlx::query("INSERT INTO feeds (url) VALUES (?)")
9683                .bind(u)
9684                .execute(&pool)
9685                .await?;
9686        }
9687
9688        init_schema(&pool)
9689            .await
9690            .unwrap_or_else(|e| panic!("init_schema must upgrade a {version} database: {e:#}"));
9691
9692        let kinds: Vec<(String, String)> =
9693            sqlx::query_as("SELECT url, kind FROM feeds ORDER BY id")
9694                .fetch_all(&pool)
9695                .await?;
9696        assert_eq!(
9697            kinds,
9698            vec![
9699                (
9700                    "https://example.com/feed.xml".to_string(),
9701                    "rss".to_string()
9702                ),
9703                (publication.to_string(), "publication".to_string()),
9704            ],
9705            "{version}: existing rows are back-filled from their URL"
9706        );
9707        // What it indexes, not only its name: an `idx_feeds_kind` on the wrong
9708        // column passed a name check. (The shape comparison below also covers
9709        // this; this one names the bug that shipped.)
9710        let indexed: Vec<String> = sqlx::query_scalar(
9711            "SELECT name FROM pragma_index_info('idx_feeds_kind') ORDER BY seqno",
9712        )
9713        .fetch_all(&pool)
9714        .await?;
9715        assert_eq!(
9716            indexed,
9717            vec!["kind".to_string()],
9718            "{version}: idx_feeds_kind exists, on feeds(kind), after the column"
9719        );
9720
9721        // The general check: anything a fresh database has that the upgraded
9722        // one lacks, or the reverse, is a migration gap.
9723        let fresh = upgrade_test_pool().await?;
9724        init_schema(&fresh).await?;
9725        let (want, got) = (schema_shape(&fresh).await?, schema_shape(&pool).await?);
9726        assert!(
9727            want == got,
9728            "{version}: upgraded schema differs from a fresh one\n  missing: {:#?}\n  extra: {:#?}",
9729            want.difference(&got).collect::<Vec<_>>(),
9730            got.difference(&want).collect::<Vec<_>>(),
9731        );
9732
9733        // And a second boot over the upgraded database is a no-op, not an error.
9734        init_schema(&pool)
9735            .await
9736            .unwrap_or_else(|e| panic!("{version}: re-running init_schema failed: {e:#}"));
9737        Ok(())
9738    }
9739
9740    #[tokio::test]
9741    async fn a_v0_3_8_database_upgrades_to_the_current_schema() -> Result<()> {
9742        assert_upgrades_from(
9743            "v0.3.8",
9744            include_str!("../tests/fixtures/schema-v0.3.8.sql"),
9745        )
9746        .await
9747    }
9748
9749    #[tokio::test]
9750    async fn a_v0_2_0_database_upgrades_to_the_current_schema() -> Result<()> {
9751        assert_upgrades_from(
9752            "v0.2.0",
9753            include_str!("../tests/fixtures/schema-v0.2.0.sql"),
9754        )
9755        .await
9756    }
9757
9758    // ---- B2: a code minted FOR a specific DID is redeemable ONLY by that DID. --
9759
9760    #[tokio::test]
9761    async fn redeem_enforces_intended_did_binding() -> Result<()> {
9762        let pool = init_url("sqlite::memory:").await?;
9763        // Mint a claim FOR did:plc:A (the follower the bot posted the link to).
9764        let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:A").await?;
9765
9766        // A DIFFERENT DID (a throwaway that stole the public link) is refused as if
9767        // the code didn't exist — no seat granted, code still active.
9768        let stolen = redeem_code(&pool, &code, "did:plc:B", Some("thief.bsky"), 100).await?;
9769        assert_eq!(stolen, Err(RedeemError::NotFound));
9770        assert!(!has_beta_access(&pool, "did:plc:B").await?);
9771        assert_eq!(count_active_codes(&pool).await?, 1, "code must stay active");
9772
9773        // The INTENDED DID redeems successfully.
9774        let ok = redeem_code(&pool, &code, "did:plc:A", Some("alice.bsky"), 100).await?;
9775        assert_eq!(ok, Ok(()));
9776        assert!(has_beta_access(&pool, "did:plc:A").await?);
9777
9778        // A NULL-intended (admin/browser) code stays open to anyone (unchanged).
9779        let open = mint_code(&pool, "did:plc:admin", 3600).await?;
9780        let anyone = redeem_code(&pool, &open, "did:plc:C", None, 100).await?;
9781        assert_eq!(anyone, Ok(()));
9782        assert!(has_beta_access(&pool, "did:plc:C").await?);
9783        Ok(())
9784    }
9785
9786    // ---- S4: at most one ACTIVE code per intended DID; a concurrent second mint
9787    // hits the partial-unique index, and is_intended_active_conflict recognises it.
9788
9789    #[tokio::test]
9790    async fn intended_active_partial_unique_index_blocks_double_mint() -> Result<()> {
9791        let pool = init_url("sqlite::memory:").await?;
9792        // First mint for the DID succeeds.
9793        mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup").await?;
9794        // A SECOND active mint for the SAME DID violates the partial unique index.
9795        let err = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9796            .await
9797            .expect_err("second active mint for the same DID must fail the unique index");
9798        assert!(
9799            is_intended_active_conflict(&err),
9800            "the conflict must be recognised so the web layer can recover: {err:?}"
9801        );
9802        // Still exactly one active code for the DID.
9803        assert!(find_active_code_for_did(&pool, "did:plc:dup")
9804            .await?
9805            .is_some());
9806
9807        // Once the first code is redeemed (no longer active), a fresh mint for the
9808        // DID is allowed again (partial index only constrains active rows).
9809        let existing = find_active_code_for_did(&pool, "did:plc:dup")
9810            .await?
9811            .unwrap();
9812        redeem_code(&pool, &existing, "did:plc:dup", None, 100).await??;
9813        mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9814            .await
9815            .expect("a new mint is allowed after the prior one is redeemed");
9816
9817        // And the conflict helper does NOT fire on an unrelated error (a PRIMARY KEY
9818        // clash on `code`, i.e. a different constraint).
9819        sqlx::query(
9820            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9821             VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9822        )
9823        .execute(&pool)
9824        .await?;
9825        let pk_err = sqlx::query(
9826            "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9827             VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9828        )
9829        .execute(&pool)
9830        .await
9831        .expect_err("duplicate PRIMARY KEY must error");
9832        let as_anyhow = anyhow::Error::new(pk_err);
9833        assert!(
9834            !is_intended_active_conflict(&as_anyhow),
9835            "a non-intended-index conflict must NOT be mistaken for the recover-able one"
9836        );
9837        Ok(())
9838    }
9839
9840    #[tokio::test]
9841    async fn purge_expires_orphaned_active_intended_code() -> Result<()> {
9842        // Cheap nit: purging a DID that is the TARGET of an active claim must both
9843        // NULL intended_did AND expire the (now orphaned) active code, so it stops
9844        // counting against the mint cap for its full TTL.
9845        let pool = init_url("sqlite::memory:").await?;
9846        let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:leaver").await?;
9847        assert_eq!(count_active_codes(&pool).await?, 1);
9848
9849        purge_did_data(&pool, "did:plc:leaver").await?;
9850
9851        // The code is no longer active (expired), so it no longer counts.
9852        assert_eq!(
9853            count_active_codes(&pool).await?,
9854            0,
9855            "orphaned code must be expired by purge, not left active"
9856        );
9857        // And intended_did was scrubbed.
9858        let intended: Option<String> =
9859            sqlx::query("SELECT intended_did FROM invite_codes WHERE code = ?1")
9860                .bind(&code)
9861                .fetch_one(&pool)
9862                .await?
9863                .get("intended_did");
9864        assert!(intended.is_none(), "intended_did must be NULLed");
9865        Ok(())
9866    }
9867
9868    /// A `(key, source)` observation upserts in place: two writes for the same
9869    /// relay leave ONE row, carrying the newer value.
9870    #[tokio::test]
9871    async fn network_stat_upserts_per_source() -> Result<()> {
9872        let pool = init_url("sqlite::memory:").await?;
9873        let mut stat = NetworkStat {
9874            key: ADOPTION_STAT_KEY.to_string(),
9875            source: "https://relay1.us-west.bsky.network".to_string(),
9876            value: 1,
9877            truncated: false,
9878            observed_at: "2026-08-12T00:00:00Z".to_string(),
9879        };
9880        record_network_stat(&pool, &stat).await?;
9881        stat.value = 4;
9882        stat.observed_at = "2026-08-13T00:00:00Z".to_string();
9883        record_network_stat(&pool, &stat).await?;
9884
9885        let rows: i64 = sqlx::query("SELECT COUNT(*) AS n FROM network_stat")
9886            .fetch_one(&pool)
9887            .await?
9888            .get("n");
9889        assert_eq!(rows, 1, "the same relay must update, not duplicate");
9890        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9891            .await?
9892            .expect("a stat");
9893        assert_eq!(latest.value, 4);
9894        assert_eq!(latest.observed_at, "2026-08-13T00:00:00Z");
9895        Ok(())
9896    }
9897
9898    /// **Regression (v0.2.9 review).** Once a slow walk can return a PARTIAL
9899    /// count, a plain upsert lets it overwrite a complete, larger one — moving
9900    /// the published "at least N" DOWN because a relay was slow, not because
9901    /// adoption fell. A truncated observation may only ever raise the floor.
9902    #[tokio::test]
9903    async fn a_truncated_observation_never_lowers_a_stored_count() -> Result<()> {
9904        let pool = init_url("sqlite::memory:").await?;
9905        let mut stat = NetworkStat {
9906            key: ADOPTION_STAT_KEY.to_string(),
9907            source: "https://relay1.us-west.bsky.network".to_string(),
9908            value: 2000,
9909            truncated: false,
9910            observed_at: "2026-08-13T00:00:00Z".to_string(),
9911        };
9912        record_network_stat(&pool, &stat).await?;
9913
9914        // A budget-truncated walk that only got one page in.
9915        stat.value = 500;
9916        stat.truncated = true;
9917        stat.observed_at = "2026-08-14T00:00:00Z".to_string();
9918        record_network_stat(&pool, &stat).await?;
9919
9920        let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9921            .await?
9922            .expect("a stat");
9923        assert_eq!(kept.value, 2000, "a partial walk must not lower the count");
9924        assert!(!kept.truncated, "and must not mark the kept row truncated");
9925        assert_eq!(kept.observed_at, "2026-08-13T00:00:00Z");
9926
9927        // A truncated observation that RAISES the floor is still accepted...
9928        stat.value = 3000;
9929        record_network_stat(&pool, &stat).await?;
9930        assert_eq!(
9931            latest_network_stat(&pool, ADOPTION_STAT_KEY)
9932                .await?
9933                .expect("a stat")
9934                .value,
9935            3000
9936        );
9937
9938        // ...and a COMPLETE observation wins even when it is smaller, because
9939        // repos genuinely can go away and a full walk is authoritative.
9940        stat.value = 42;
9941        stat.truncated = false;
9942        record_network_stat(&pool, &stat).await?;
9943        assert_eq!(
9944            latest_network_stat(&pool, ADOPTION_STAT_KEY)
9945                .await?
9946                .expect("a stat")
9947                .value,
9948            42,
9949            "a complete walk is authoritative even when it shrinks"
9950        );
9951
9952        // An EQUAL-valued truncated observation must not downgrade the row
9953        // either: it proves nothing the stored complete count did not already
9954        // prove, but flipping `truncated` would silently degrade /about from
9955        // "42" to "at least 42" with no change in actual adoption. The strict
9956        // `<` in the guard let exactly this through — the equal case is the one
9957        // the two assertions above cannot reach, because both move the value.
9958        stat.truncated = true;
9959        stat.observed_at = "2026-08-15T00:00:00Z".to_string();
9960        record_network_stat(&pool, &stat).await?;
9961        let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9962            .await?
9963            .expect("a stat");
9964        assert_eq!(kept.value, 42);
9965        assert!(
9966            !kept.truncated,
9967            "an equal truncated observation must not mark the kept row truncated"
9968        );
9969        assert_eq!(
9970            kept.observed_at, "2026-08-14T00:00:00Z",
9971            "the rejected observation must not have rewritten the row at all"
9972        );
9973        Ok(())
9974    }
9975
9976    /// Relays disagree by design (non-archival indexes); the max is surfaced.
9977    #[tokio::test]
9978    async fn latest_network_stat_picks_the_max_across_sources() -> Result<()> {
9979        let pool = init_url("sqlite::memory:").await?;
9980        for (source, value, truncated) in [
9981            ("https://relay1.us-west.bsky.network", 2i64, false),
9982            ("https://relay1.us-east.bsky.network", 40i64, true),
9983        ] {
9984            record_network_stat(
9985                &pool,
9986                &NetworkStat {
9987                    key: ADOPTION_STAT_KEY.to_string(),
9988                    source: source.to_string(),
9989                    value,
9990                    truncated,
9991                    observed_at: "2026-08-13T00:00:00Z".to_string(),
9992                },
9993            )
9994            .await?;
9995        }
9996        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9997            .await?
9998            .expect("a stat");
9999        assert_eq!(latest.value, 40);
10000        assert_eq!(latest.source, "https://relay1.us-east.bsky.network");
10001        // `truncated` round-trips as a bool.
10002        assert!(latest.truncated);
10003        Ok(())
10004    }
10005
10006    #[tokio::test]
10007    async fn latest_network_stat_is_none_on_an_empty_table() -> Result<()> {
10008        let pool = init_url("sqlite::memory:").await?;
10009        assert!(latest_network_stat(&pool, ADOPTION_STAT_KEY)
10010            .await?
10011            .is_none());
10012        Ok(())
10013    }
10014
10015    /// **The lookup is keyed.** Every existing network-stat test writes only
10016    /// `ADOPTION_STAT_KEY`, so the `WHERE key = ?1` never discriminated; with
10017    /// it widened to `OR 1=1` the suite stayed green. The public `/stats`
10018    /// page asks for the adoption count, and unkeyed it would render the
10019    /// largest value of ANY stat as the network size.
10020    #[tokio::test]
10021    async fn latest_network_stat_ignores_other_keys() -> Result<()> {
10022        let pool = init_url("sqlite::memory:").await?;
10023        for (key, source, value) in [
10024            (ADOPTION_STAT_KEY, "https://relay1.example", 40),
10025            ("some.other.metric", "https://relay1.example", 9_999),
10026        ] {
10027            record_network_stat(
10028                &pool,
10029                &NetworkStat {
10030                    key: key.to_string(),
10031                    source: source.to_string(),
10032                    value,
10033                    truncated: false,
10034                    observed_at: now_rfc3339(),
10035                },
10036            )
10037            .await?;
10038        }
10039        let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
10040            .await?
10041            .expect("the adoption stat was recorded");
10042        assert_eq!(
10043            latest.value, 40,
10044            "another key's value was returned as the adoption count"
10045        );
10046        Ok(())
10047    }
10048
10049    // ── poll health (the public stats page) ─────────────────────────────────
10050
10051    /// Seed a feed row **through the real writer**, so its `kind` is whatever
10052    /// production would store.
10053    ///
10054    /// This used to be a raw `INSERT`, which took the `kind` column's
10055    /// `DEFAULT 'rss'`. That is correct for an http(s) URL and silently wrong
10056    /// for an `at://` one — the helper claimed to seed a row the poller skips
10057    /// while seeding one it selects.
10058    async fn feed_polled(
10059        pool: &SqlitePool,
10060        url: &str,
10061        last_polled: Option<&str>,
10062        next_poll: Option<&str>,
10063    ) {
10064        upsert_feed(
10065            pool,
10066            &NewFeed {
10067                url: url.to_string(),
10068                last_polled: last_polled.map(str::to_string),
10069                next_poll: next_poll.map(str::to_string),
10070                ..Default::default()
10071            },
10072        )
10073        .await
10074        .unwrap();
10075    }
10076
10077    /// The numbers on the public page must describe the poller's actual state.
10078    #[tokio::test]
10079    async fn poll_health_counts_tracked_recent_and_overdue() -> anyhow::Result<()> {
10080        let pool = init_url("sqlite::memory:").await?;
10081        let now = "2026-01-01T12:00:00Z";
10082        let hour_ago = "2026-01-01T11:00:00Z";
10083
10084        // Polled 10 minutes ago, due in 50 minutes: healthy.
10085        feed_polled(
10086            &pool,
10087            "https://a.example/f",
10088            Some("2026-01-01T11:50:00Z"),
10089            Some("2026-01-01T12:50:00Z"),
10090        )
10091        .await;
10092        // Polled 3 hours ago and overdue: the backlog case.
10093        feed_polled(
10094            &pool,
10095            "https://b.example/f",
10096            Some("2026-01-01T09:00:00Z"),
10097            Some("2026-01-01T10:00:00Z"),
10098        )
10099        .await;
10100        // Never polled: counts as overdue (next_poll IS NULL), and must not
10101        // corrupt the "oldest poll" figure with a NULL.
10102        feed_polled(&pool, "https://c.example/f", None, None).await;
10103
10104        let h = poll_health(&pool, now, hour_ago).await?;
10105        assert_eq!(h.feeds_tracked, 3);
10106        assert_eq!(
10107            h.polled_last_hour, 1,
10108            "only the 11:50 poll is within the hour"
10109        );
10110        assert_eq!(h.overdue, 2, "the stale feed and the never-polled one");
10111        assert_eq!(
10112            h.last_poll_secs_ago,
10113            Some(600),
10114            "most recent poll was 10 minutes ago"
10115        );
10116        // **A never-polled feed IS the worst staleness.**
10117        //
10118        // This originally asserted `Some(10_800)` — the oldest FINITE age — and
10119        // in doing so pinned a defect: `MIN` skips NULLs, so the page reported
10120        // "3h ago" while a quarter of the feeds had never been fetched at all.
10121        // The figure read healthiest in the most degraded state, which is the
10122        // opposite of what a health page is for.
10123        assert_eq!(
10124            h.oldest_poll_secs_ago, None,
10125            "a never-polled feed must outrank any finite age"
10126        );
10127        assert_eq!(h.never_polled, 1);
10128
10129        // With every feed polled, the finite worst case is reported again.
10130        sqlx::query("UPDATE feeds SET last_polled = ?1 WHERE last_polled IS NULL")
10131            .bind("2026-01-01T09:00:00Z")
10132            .execute(&pool)
10133            .await?;
10134        let h = poll_health(&pool, now, hour_ago).await?;
10135        assert_eq!(h.never_polled, 0);
10136        assert_eq!(h.oldest_poll_secs_ago, Some(10_800));
10137        Ok(())
10138    }
10139
10140    /// **`/stats` measures the poller, so it counts only what the poller sees.**
10141    ///
10142    /// `due_feeds` skips `at://` rows; nothing ever advances their `next_poll`
10143    /// or sets `last_polled`. Counted, they read as overdue and never-polled
10144    /// forever, and force "oldest poll" to `never` — the same "unsupported
10145    /// shown as broken" the exclusion exists to end, moved to different rows on
10146    /// a public page. The same predicate decides both queries so they cannot
10147    /// drift.
10148    #[tokio::test]
10149    async fn poll_health_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
10150        let pool = init_url("sqlite::memory:").await?;
10151        let now = "2026-01-01T12:00:00Z";
10152        let hour_ago = "2026-01-01T11:00:00Z";
10153        feed_polled(
10154            &pool,
10155            "https://a.example/f",
10156            Some("2026-01-01T11:50:00Z"),
10157            Some("2026-01-01T12:50:00Z"),
10158        )
10159        .await;
10160        // Never polled, never due: the shape every at:// row has.
10161        feed_polled(
10162            &pool,
10163            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10164            None,
10165            None,
10166        )
10167        .await;
10168
10169        let h = poll_health(&pool, now, hour_ago).await?;
10170        assert_eq!(
10171            h.feeds_tracked, 1,
10172            "an unpollable row was counted as tracked"
10173        );
10174        assert_eq!(h.overdue, 0, "an unpollable row was counted as overdue");
10175        assert_eq!(
10176            h.never_polled, 0,
10177            "an unpollable row was counted as never polled"
10178        );
10179        assert_eq!(
10180            h.oldest_poll_secs_ago,
10181            Some(600),
10182            "an unpollable row forced the oldest poll to `never`"
10183        );
10184        assert_eq!(h.polled_last_hour, 1);
10185        Ok(())
10186    }
10187
10188    /// **The admin's failing-feeds list is the poller's too.** `failing_feeds`
10189    /// feeds `/admin/metrics`; it was not given the exclusion both `/stats`
10190    /// queries got. An `at://` row that carries errors — from a rollback to a
10191    /// build that polled them, say — would then sit at the top of the one page
10192    /// an operator uses to diagnose "unsupported shown as broken", with no
10193    /// poll ever coming to clear it and the one-shot migration already spent.
10194    #[tokio::test]
10195    async fn failing_feeds_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
10196        let pool = init_url("sqlite::memory:").await?;
10197        for url in [
10198            "https://broken.example/feed.xml",
10199            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10200        ] {
10201            upsert_feed(
10202                &pool,
10203                &NewFeed {
10204                    url: url.to_string(),
10205                    ..Default::default()
10206                },
10207            )
10208            .await?;
10209            bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
10210        }
10211        let failing = failing_feeds(&pool, 10).await?;
10212        let urls: Vec<&str> = failing.iter().map(|f| f.url.as_str()).collect();
10213        assert_eq!(
10214            urls,
10215            vec!["https://broken.example/feed.xml"],
10216            "an unpollable row was listed as a failing feed"
10217        );
10218        Ok(())
10219    }
10220
10221    /// **The clearing is idempotent by predicate, not by stamp.** It touches
10222    /// only rows that have never been polled successfully: `bump_feed_errors`
10223    /// never sets `last_polled`, both success paths do. So a row a wired
10224    /// reader has fetched once keeps its later failures across restarts, and
10225    /// a row that only ever failed under our own refusal is cleared at every
10226    /// boot — including after a rollback to a build that polled it. No
10227    /// version stamp, nothing for a test to rewind.
10228    #[tokio::test]
10229    async fn the_at_uri_error_clearing_spares_a_row_that_has_been_polled() -> anyhow::Result<()> {
10230        let pool = init_url("sqlite::memory:").await?;
10231        let polled = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/polled";
10232        let never = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/never";
10233        for url in [polled, never] {
10234            upsert_feed(
10235                &pool,
10236                &NewFeed {
10237                    url: url.to_string(),
10238                    ..Default::default()
10239                },
10240            )
10241            .await?;
10242            bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
10243        }
10244        // A wired reader fetched this one once, then it started failing.
10245        sqlx::query("UPDATE feeds SET last_polled = '2026-01-01T00:00:00Z' WHERE url = ?1")
10246            .bind(polled)
10247            .execute(&pool)
10248            .await?;
10249
10250        for boot in 1..=2 {
10251            apply_migrations(&pool).await?;
10252            let mut errors = std::collections::HashMap::new();
10253            for url in [polled, never] {
10254                let n: i64 =
10255                    sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
10256                        .bind(url)
10257                        .fetch_one(&pool)
10258                        .await?;
10259                errors.insert(url, n);
10260            }
10261            assert_eq!(
10262                errors[polled], 1,
10263                "boot {boot} wiped a polled row's failure"
10264            );
10265            assert_eq!(
10266                errors[never], 0,
10267                "boot {boot} left a never-polled row failing"
10268            );
10269        }
10270        Ok(())
10271    }
10272
10273    /// **The SQL kind list and the Rust one are the same list.** A literal in
10274    /// SQL and a slice in Rust is the drift the column exists to end; wiring
10275    /// the standard.site reader changes both, and this is what makes
10276    /// forgetting one a failure rather than a silently dormant feature.
10277    #[test]
10278    fn the_sql_kind_list_matches_the_rust_one() {
10279        let expected = crate::feed::FeedKind::POLLABLE
10280            .iter()
10281            .map(|k| format!("'{}'", k.as_str()))
10282            .collect::<Vec<_>>()
10283            .join(", ");
10284        assert_eq!(POLLABLE_KINDS_SQL, expected);
10285    }
10286
10287    /// **A feed's kind is recorded at insert, not re-derived from its URL.**
10288    ///
10289    /// "Can the poller fetch this?" was a substring predicate spliced into
10290    /// four statements, and a review found a fifth reader that had drifted
10291    /// from it. A column the writers set cannot drift: the Rust side decides
10292    /// once, SQL reads a value.
10293    #[tokio::test]
10294    async fn a_feed_row_records_its_kind_at_insert() -> anyhow::Result<()> {
10295        let pool = init_url("sqlite::memory:").await?;
10296        for (url, want) in [
10297            ("https://real.example/feed.xml", crate::feed::FeedKind::Rss),
10298            ("http://real.example/feed.xml", crate::feed::FeedKind::Rss),
10299            (
10300                "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
10301                crate::feed::FeedKind::Publication,
10302            ),
10303        ] {
10304            upsert_feed(
10305                &pool,
10306                &NewFeed {
10307                    url: url.to_string(),
10308                    ..Default::default()
10309                },
10310            )
10311            .await?;
10312            let got: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10313                .bind(url)
10314                .fetch_one(&pool)
10315                .await?;
10316            assert_eq!(got, want.as_str(), "wrong kind recorded for {url}");
10317        }
10318        Ok(())
10319    }
10320
10321    /// **A row written before the column existed is back-filled from its URL.**
10322    /// That back-fill is the LAST use of the string predicate; every reader
10323    /// keys on `kind` afterwards.
10324    #[tokio::test]
10325    async fn the_migration_backfills_kind_from_the_url() -> anyhow::Result<()> {
10326        let pool = init_url("sqlite::memory:").await?;
10327        // A table that predates the column, with both shapes in it.
10328        sqlx::query("DROP TABLE feeds").execute(&pool).await?;
10329        sqlx::query(
10330            "CREATE TABLE feeds (
10331                 id INTEGER PRIMARY KEY AUTOINCREMENT,
10332                 url TEXT NOT NULL UNIQUE,
10333                 title TEXT, site_url TEXT, etag TEXT, last_modified TEXT,
10334                 last_polled TEXT, next_poll TEXT,
10335                 consecutive_errors INTEGER NOT NULL DEFAULT 0,
10336                 last_error_kind TEXT, last_error TEXT
10337             )",
10338        )
10339        .execute(&pool)
10340        .await?;
10341        for url in [
10342            "https://real.example/feed.xml",
10343            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
10344            "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
10345        ] {
10346            sqlx::query("INSERT INTO feeds (url) VALUES (?1)")
10347                .bind(url)
10348                .execute(&pool)
10349                .await?;
10350        }
10351
10352        apply_migrations(&pool).await?;
10353
10354        let kinds: Vec<(String, String)> =
10355            sqlx::query_as("SELECT url, kind FROM feeds ORDER BY url")
10356                .fetch_all(&pool)
10357                .await?;
10358        let by_url: std::collections::HashMap<_, _> = kinds.into_iter().collect();
10359        assert_eq!(by_url["https://real.example/feed.xml"], "rss");
10360        assert_eq!(
10361            by_url["at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"],
10362            "publication"
10363        );
10364        // Recognised as an at-URI (not `rss`), like every other guard does —
10365        // and, since 0.4.0 polls publications, classed `unsupported`: storage
10366        // refuses this spelling (#183), so polling it would fail every tick.
10367        assert_eq!(
10368            by_url["At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac"],
10369            "unsupported",
10370            "the back-fill must recognise a non-canonical spelling, and not poll it"
10371        );
10372        Ok(())
10373    }
10374
10375    /// **`feeds.kind` is derived from the URL, so it has to be re-derivable.**
10376    ///
10377    /// The back-fill translated one direction only — a row the Rust side would
10378    /// call `rss` was never touched — which is correct for a one-time migration
10379    /// and wrong for a column that has to survive the rule changing. A kind that
10380    /// disagrees with its own URL is currently permanent: nothing re-reads it.
10381    #[tokio::test]
10382    async fn the_back_fill_corrects_a_kind_that_disagrees_with_the_url() -> anyhow::Result<()> {
10383        let pool = init_url("sqlite::memory:").await?;
10384        let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10385        for (url, wrong) in [
10386            ("https://real.example/feed.xml", "publication"),
10387            (at, "rss"),
10388        ] {
10389            sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, ?2)")
10390                .bind(url)
10391                .bind(wrong)
10392                .execute(&pool)
10393                .await?;
10394        }
10395
10396        apply_migrations(&pool).await?;
10397
10398        let by_url: std::collections::HashMap<String, String> =
10399            sqlx::query_as("SELECT url, kind FROM feeds")
10400                .fetch_all(&pool)
10401                .await?
10402                .into_iter()
10403                .collect();
10404        assert_eq!(
10405            by_url["https://real.example/feed.xml"], "rss",
10406            "an http feed marked as a publication stayed one, and nothing polls it"
10407        );
10408        assert_eq!(by_url[at], "publication", "the at:// direction regressed");
10409        Ok(())
10410    }
10411
10412    /// **Taking a row out of the poller orphans its poll state, so clear it.**
10413    ///
10414    /// `last_polled` is set here on purpose: the migration's other cleanup step
10415    /// only clears rows we never polled, so a row that HAS been polled proves
10416    /// this reset is the one doing the work. An error count left on a row the
10417    /// scheduler will never select again is hidden from `/stats`, which filters
10418    /// on kind — and if a later rule change readmits the row, it resumes at a
10419    /// backoff earned under a classification that no longer applies.
10420    #[tokio::test]
10421    async fn a_row_taken_out_of_the_poller_loses_the_poll_state_it_cannot_use() -> anyhow::Result<()>
10422    {
10423        let pool = init_url("sqlite::memory:").await?;
10424        let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab";
10425        sqlx::query(
10426            "INSERT INTO feeds (url, kind, consecutive_errors, last_error_kind, last_error, \
10427             next_poll, last_polled) \
10428             VALUES (?1, 'rss', 7, 'fetch', 'connection refused', ?2, ?3)",
10429        )
10430        .bind(at)
10431        .bind("2026-09-10T00:00:00Z")
10432        .bind("2026-09-01T00:00:00Z")
10433        .execute(&pool)
10434        .await?;
10435
10436        apply_migrations(&pool).await?;
10437
10438        let (kind, errors, error_kind, error, next_poll): (
10439            String,
10440            i64,
10441            Option<String>,
10442            Option<String>,
10443            Option<String>,
10444        ) = sqlx::query_as(
10445            "SELECT kind, consecutive_errors, last_error_kind, last_error, next_poll \
10446             FROM feeds WHERE url = ?1",
10447        )
10448        .bind(at)
10449        .fetch_one(&pool)
10450        .await?;
10451        assert_eq!(kind, "unsupported", "the row was not reclassified at all");
10452        assert_eq!(
10453            (errors, error_kind, error, next_poll),
10454            (0, None, None, None),
10455            "a row the scheduler will never select again kept its backoff and failure history"
10456        );
10457        Ok(())
10458    }
10459
10460    /// **A row we cannot read must not stop the process from starting.**
10461    ///
10462    /// This runs on the boot path. Refusing to start is a strictly worse
10463    /// outcome than declining to have an opinion about one row, and it is a
10464    /// failure mode the SQL predicate this replaced did not have: it evaluated
10465    /// a non-text `url` happily and returned false.
10466    #[tokio::test]
10467    async fn an_unreadable_feeds_row_does_not_stop_the_boot() -> anyhow::Result<()> {
10468        let pool = init_url("sqlite::memory:").await?;
10469        sqlx::query("INSERT INTO feeds (url, kind) VALUES (X'ff41', 'rss')")
10470            .execute(&pool)
10471            .await?;
10472        sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, 'publication')")
10473            .bind("https://real.example/feed.xml")
10474            .execute(&pool)
10475            .await?;
10476
10477        apply_migrations(&pool).await?;
10478
10479        let corrected: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10480            .bind("https://real.example/feed.xml")
10481            .fetch_one(&pool)
10482            .await?;
10483        assert_eq!(
10484            corrected, "rss",
10485            "one unreadable row aborted the pass before the readable ones were corrected"
10486        );
10487        let untouched: String =
10488            sqlx::query_scalar("SELECT kind FROM feeds WHERE typeof(url) = 'blob'")
10489                .fetch_one(&pool)
10490                .await?;
10491        assert_eq!(
10492            untouched, "rss",
10493            "a row we declined to classify was classified anyway"
10494        );
10495        Ok(())
10496    }
10497
10498    /// Re-subscribing must re-derive the kind, not preserve whatever is there.
10499    ///
10500    /// `upsert_feed` binds `FeedKind::of` on the way in, but its conflict clause
10501    /// never carried `kind`, so the value a row was first written with is the
10502    /// value it keeps. Harmless while the rule is fixed; the rule is about to
10503    /// change.
10504    #[tokio::test]
10505    async fn a_re_upsert_re_derives_the_kind() -> anyhow::Result<()> {
10506        let pool = init_url("sqlite::memory:").await?;
10507        let url = "https://real.example/feed.xml";
10508        let feed = NewFeed {
10509            url: url.to_string(),
10510            ..Default::default()
10511        };
10512        upsert_feed(&pool, &feed).await?;
10513        sqlx::query("UPDATE feeds SET kind = 'publication' WHERE url = ?1")
10514            .bind(url)
10515            .execute(&pool)
10516            .await?;
10517
10518        upsert_feed(&pool, &feed).await?;
10519
10520        let kind: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10521            .bind(url)
10522            .fetch_one(&pool)
10523            .await?;
10524        assert_eq!(
10525            kind, "rss",
10526            "a second subscription to the same URL kept the stale classification"
10527        );
10528        Ok(())
10529    }
10530
10531    /// **The readers key on `kind`, not on the URL.** A row whose kind says
10532    /// publication is unpollable even if its URL looks ordinary — which is
10533    /// what makes the column, rather than the string, the source of truth.
10534    #[tokio::test]
10535    async fn the_poller_and_the_pages_key_on_kind() -> anyhow::Result<()> {
10536        let pool = init_url("sqlite::memory:").await?;
10537        upsert_feed(
10538            &pool,
10539            &NewFeed {
10540                url: "https://looks-ordinary.example/feed.xml".to_string(),
10541                ..Default::default()
10542            },
10543        )
10544        .await?;
10545        // Force the kind independently of the URL: only the column should matter.
10546        // `unsupported` because it is the kind no poller reads (publications
10547        // are pollable since 0.4.0).
10548        sqlx::query("UPDATE feeds SET kind = 'unsupported' WHERE url LIKE 'https://looks%'")
10549            .execute(&pool)
10550            .await?;
10551
10552        let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10553        assert!(due.is_empty(), "due_feeds read the URL, not the kind");
10554        assert_eq!(
10555            unpollable_feeds(&pool).await?,
10556            1,
10557            "unpollable_feeds read the URL"
10558        );
10559
10560        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10561        assert_eq!(h.feeds_tracked, 0, "poll_health read the URL, not the kind");
10562        Ok(())
10563    }
10564
10565    /// **SQL and Rust agree on what an at-URI is — case-insensitively.**
10566    ///
10567    /// This test used to pin the opposite, and pinned a bug. It asserted that a
10568    /// mixed-case `At://` row IS handed to the poller, reasoning that the Rust
10569    /// guards use a case-sensitive `strip_prefix` so "every other check treats
10570    /// it as a plain URL". They do not: URL schemes are case-insensitive, so
10571    /// `Url::parse` folds `At://` to scheme `at`, which `net::check_scheme`
10572    /// refuses — and the DID form does not parse at all. Such a row can only
10573    /// fail, every tick, forever, and be published in the `fetch` bucket as an
10574    /// unreachable publisher. That is the exact conflation the exclusion exists
10575    /// to end.
10576    ///
10577    /// Recognition is case-insensitive on both sides now. Storing one is still
10578    /// refused: `feeds.url` is UNIQUE, so two spellings of one publication are
10579    /// two rows — the same rule the canonical-handle check applies.
10580    #[tokio::test]
10581    async fn a_mixed_case_at_uri_is_unpollable_on_both_sides() -> anyhow::Result<()> {
10582        let pool = init_url("sqlite::memory:").await?;
10583        let odd = "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10584        assert!(
10585            !crate::feed::is_storable_feed_url(odd, true),
10586            "a non-canonical spelling must not be storable"
10587        );
10588        upsert_feed(
10589            &pool,
10590            &NewFeed {
10591                url: odd.to_string(),
10592                ..Default::default()
10593            },
10594        )
10595        .await?;
10596        let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10597        assert!(
10598            due.is_empty(),
10599            "a row nothing can fetch was handed to the poller: {:?}",
10600            due.iter().map(|f| &f.url).collect::<Vec<_>>()
10601        );
10602
10603        // And the boot-time clearing reaches it, so a legacy row that already
10604        // accrued errors stops counting as a broken publisher.
10605        bump_feed_errors(&pool, odd, crate::feed::FailureKind::Fetch, "refused").await?;
10606        apply_migrations(&pool).await?;
10607        let n: i64 = sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
10608            .bind(odd)
10609            .fetch_one(&pool)
10610            .await?;
10611        assert_eq!(n, 0, "the clearing skipped a mixed-case at-URI row");
10612        Ok(())
10613    }
10614
10615    /// **The global feeds ceiling counts every row, including unpollable ones
10616    /// — deliberately, and visibly.**
10617    ///
10618    /// `count_feeds` is a fifth reader of "is this an at-URI" that does NOT use
10619    /// the unpollable kinds, and that is the right call: the ceiling bounds
10620    /// STORAGE on a small box, and an unpollable row occupies a row. What was
10621    /// wrong is that the capacity it consumed appeared on no surface — `/stats`
10622    /// measures the poller and excludes them, so an operator could be at the
10623    /// cap while every page said otherwise. `unpollable_feeds` is what
10624    /// `/admin/metrics` renders to close that gap.
10625    #[tokio::test]
10626    async fn the_ceiling_counts_unpollable_rows_and_they_are_countable() -> anyhow::Result<()> {
10627        let pool = init_url("sqlite::memory:").await?;
10628        for url in [
10629            "https://real.example/feed.xml",
10630            "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10631            "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
10632        ] {
10633            upsert_feed(
10634                &pool,
10635                &NewFeed {
10636                    url: url.to_string(),
10637                    ..Default::default()
10638                },
10639            )
10640            .await?;
10641        }
10642        assert_eq!(
10643            count_feeds(&pool).await?,
10644            3,
10645            "the ceiling must bound storage, so every row counts"
10646        );
10647        assert_eq!(
10648            unpollable_feeds(&pool).await?,
10649            2,
10650            "both at-URI spellings are unpollable and must be countable"
10651        );
10652        Ok(())
10653    }
10654
10655    /// A fresh instance has no polls yet. The page must say so rather than
10656    /// rendering a zero that reads as "polled just now".
10657    #[tokio::test]
10658    async fn poll_health_on_an_empty_instance_reports_no_polls() -> anyhow::Result<()> {
10659        let pool = init_url("sqlite::memory:").await?;
10660        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10661        assert_eq!(h.feeds_tracked, 0);
10662        assert_eq!(h.last_poll_secs_ago, None);
10663        assert_eq!(h.oldest_poll_secs_ago, None);
10664        Ok(())
10665    }
10666
10667    /// A poll timestamped in the future — clock skew, or a restored backup —
10668    /// reads as "just now", never as a negative age.
10669    #[tokio::test]
10670    async fn a_future_poll_timestamp_does_not_go_negative() -> anyhow::Result<()> {
10671        let pool = init_url("sqlite::memory:").await?;
10672        feed_polled(
10673            &pool,
10674            "https://a.example/f",
10675            Some("2026-01-01T13:00:00Z"),
10676            None,
10677        )
10678        .await;
10679        let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10680        assert_eq!(h.last_poll_secs_ago, Some(0));
10681        Ok(())
10682    }
10683
10684    // ── retention is a CACHE policy, not a data-retention policy ────────────
10685
10686    async fn aged_entry(pool: &SqlitePool, url: &str, days_old: i64) -> i64 {
10687        let when = (chrono::Utc::now() - chrono::Duration::days(days_old))
10688            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
10689        sqlx::query("INSERT INTO feeds (url) VALUES (?1) ON CONFLICT(url) DO NOTHING")
10690            .bind("https://f.example/feed")
10691            .execute(pool)
10692            .await
10693            .unwrap();
10694        let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds WHERE url = ?1")
10695            .bind("https://f.example/feed")
10696            .fetch_one(pool)
10697            .await
10698            .unwrap();
10699        sqlx::query("INSERT INTO entries (feed_id, guid, url, title, published, fetched_at) VALUES (?1,?2,?3,'t',?4,?4)")
10700            .bind(feed_id).bind(url).bind(url).bind(&when)
10701            .execute(pool).await.unwrap();
10702        sqlx::query_scalar("SELECT id FROM entries WHERE guid = ?1")
10703            .bind(url)
10704            .fetch_one(pool)
10705            .await
10706            .unwrap()
10707    }
10708
10709    async fn mark(pool: &SqlitePool, entry_id: i64, read: i64, starred: i64) {
10710        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')")
10711            .bind(entry_id).bind(read).bind(starred)
10712            .execute(pool).await.unwrap();
10713    }
10714
10715    /// **A STARRED article is never evicted, however old.**
10716    ///
10717    /// The starred view joins `entries`, and `entry_state` cascades on delete,
10718    /// so pruning a starred entry removed it from the starred list entirely —
10719    /// and the content is not recoverable, because a feed serves only its last
10720    /// few dozen items. The PDS keeps the saved RECORD; it has never held the
10721    /// article.
10722    #[tokio::test]
10723    async fn retention_keeps_starred_and_unread_entries() -> anyhow::Result<()> {
10724        let pool = init_url("sqlite::memory:").await?;
10725        let old_read = aged_entry(&pool, "old-read", 30).await;
10726        let old_starred = aged_entry(&pool, "old-starred", 30).await;
10727        let old_unread = aged_entry(&pool, "old-unread", 30).await;
10728        let recent_read = aged_entry(&pool, "recent-read", 1).await;
10729        mark(&pool, old_read, 1, 0).await;
10730        mark(&pool, old_starred, 1, 1).await; // read AND starred
10731        mark(&pool, old_unread, 0, 0).await;
10732        mark(&pool, recent_read, 1, 0).await;
10733
10734        let deleted = prune_old_entries(&pool, 14, 3650, 0).await?;
10735        assert_eq!(deleted, 1, "only the old, read, unstarred entry should go");
10736
10737        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10738            .fetch_all(&pool)
10739            .await?;
10740        assert_eq!(left, vec!["old-starred", "old-unread", "recent-read"]);
10741        Ok(())
10742    }
10743
10744    /// An entry nobody has interacted with at all — no `entry_state` row — is
10745    /// still evicted once it ages out. Otherwise the cache never shrinks, since
10746    /// most entries are never opened.
10747    #[tokio::test]
10748    async fn retention_evicts_entries_with_no_reader_state() -> anyhow::Result<()> {
10749        let pool = init_url("sqlite::memory:").await?;
10750        aged_entry(&pool, "untouched-old", 30).await;
10751        aged_entry(&pool, "untouched-new", 1).await;
10752        assert_eq!(prune_old_entries(&pool, 14, 3650, 0).await?, 1);
10753        Ok(())
10754    }
10755
10756    /// **A recently-polled feed is NOT made due again.**
10757    ///
10758    /// `due_feeds` treats NULL as due immediately, so an unbounded nudge from a
10759    /// page handler turned every reload of the starred view into another poll of
10760    /// those feeds — outbound amplification against third-party origins, and one
10761    /// reader monopolising a poll budget that is shared and already the binding
10762    /// constraint on user count.
10763    #[tokio::test]
10764    async fn a_recently_polled_feed_is_not_nudged_again() -> anyhow::Result<()> {
10765        let pool = init_url("sqlite::memory:").await?;
10766        let recent = "2026-01-01T11:59:00Z";
10767        let stale_before = "2026-01-01T11:00:00Z"; // one hour before "now"
10768
10769        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10770            .bind("https://fresh.example/f")
10771            .bind(recent)
10772            .bind("2026-01-01T12:59:00Z")
10773            .execute(&pool)
10774            .await?;
10775        // Polled long ago: this one SHOULD be nudged.
10776        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10777            .bind("https://stale.example/f")
10778            .bind("2026-01-01T06:00:00Z")
10779            .bind("2026-01-01T07:00:00Z")
10780            .execute(&pool)
10781            .await?;
10782
10783        mark_feed_due(&pool, "https://fresh.example/f", stale_before).await?;
10784        mark_feed_due(&pool, "https://stale.example/f", stale_before).await?;
10785
10786        let fresh: Option<String> =
10787            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://fresh.example/f'")
10788                .fetch_one(&pool)
10789                .await?;
10790        let stale: Option<String> =
10791            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://stale.example/f'")
10792                .fetch_one(&pool)
10793                .await?;
10794
10795        assert!(
10796            fresh.is_some(),
10797            "a feed polled a minute ago was made due again — a reload loop is an \
10798             amplification vector"
10799        );
10800        assert!(stale.is_none(), "a long-unpolled feed should be nudged");
10801        Ok(())
10802    }
10803
10804    /// A feed that has never been polled is always nudgeable — there is no
10805    /// recent fetch to argue it would be wasted.
10806    #[tokio::test]
10807    async fn a_never_polled_feed_is_nudged() -> anyhow::Result<()> {
10808        let pool = init_url("sqlite::memory:").await?;
10809        sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, NULL, ?2)")
10810            .bind("https://new.example/f")
10811            .bind("2026-01-01T12:59:00Z")
10812            .execute(&pool)
10813            .await?;
10814        mark_feed_due(&pool, "https://new.example/f", "2026-01-01T11:00:00Z").await?;
10815        let next: Option<String> =
10816            sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://new.example/f'")
10817                .fetch_one(&pool)
10818                .await?;
10819        assert!(next.is_none());
10820        Ok(())
10821    }
10822
10823    /// **The hard ceiling is the bound that sparing would otherwise remove.**
10824    ///
10825    /// "Mark unread" is a one-click control and `entries` is shared across every
10826    /// reader, so an unbounded `read = 0` exception lets one person pin rows
10827    /// permanently — and since the poller stops entirely above
10828    /// `db_size_watermark_bytes` with this DELETE as its only release valve,
10829    /// those pins could stop polling for everyone.
10830    #[tokio::test]
10831    async fn the_hard_ceiling_evicts_even_starred_and_unread() -> anyhow::Result<()> {
10832        let pool = init_url("sqlite::memory:").await?;
10833        let ancient_starred = aged_entry(&pool, "ancient-starred", 400).await;
10834        let ancient_unread = aged_entry(&pool, "ancient-unread", 400).await;
10835        let recent_starred = aged_entry(&pool, "recent-starred", 30).await;
10836        mark(&pool, ancient_starred, 1, 1).await;
10837        mark(&pool, ancient_unread, 0, 0).await;
10838        mark(&pool, recent_starred, 1, 1).await;
10839
10840        // 14-day soft window, 180-day hard ceiling.
10841        prune_old_entries(&pool, 14, 180, 0).await?;
10842
10843        let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10844            .fetch_all(&pool)
10845            .await?;
10846        assert_eq!(
10847            left,
10848            vec!["recent-starred"],
10849            "past the ceiling nothing is pinned — otherwise one reader can stall the poller \
10850             for every reader"
10851        );
10852        Ok(())
10853    }
10854
10855    /// The per-feed trim spares starred entries too. It was fixed in the
10856    /// retention sweep and NOT here, which left the documented guarantee false —
10857    /// and this path runs on every poll of every feed rather than daily.
10858    #[tokio::test]
10859    async fn the_per_feed_trim_spares_starred_entries() -> anyhow::Result<()> {
10860        let pool = init_url("sqlite::memory:").await?;
10861        let old_starred = aged_entry(&pool, "old-starred", 5).await;
10862        mark(&pool, old_starred, 1, 1).await;
10863        for i in 0..5 {
10864            aged_entry(&pool, &format!("filler-{i}"), 1).await;
10865        }
10866        let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds LIMIT 1")
10867            .fetch_one(&pool)
10868            .await?;
10869
10870        // Trim hard enough that the older starred entry would be cut. The trim
10871        // runs inside `insert_entries`, so drive it the way production does.
10872        insert_entries(&pool, feed_id, &[], 2).await?;
10873
10874        let left: Vec<String> =
10875            sqlx::query_scalar("SELECT guid FROM entries WHERE guid = 'old-starred'")
10876                .fetch_all(&pool)
10877                .await?;
10878        assert_eq!(
10879            left,
10880            vec!["old-starred"],
10881            "the per-feed trim evicted a starred entry"
10882        );
10883        Ok(())
10884    }
10885
10886    /// **When more entries are starred than the cap, the NEWEST starred ones
10887    /// are spared.** The sparing subquery orders by date and takes `cap`; the
10888    /// existing tests seed one starred row (fewer than the cap, so the order
10889    /// never chooses) or assert only a count. With `DESC` flipped to `ASC` the
10890    /// suite stayed green — and in production the trim would spare the OLDEST
10891    /// starred articles and evict the newest, on every poll of every feed.
10892    #[tokio::test]
10893    async fn the_trim_spares_the_newest_starred_entries_when_over_cap() -> anyhow::Result<()> {
10894        let pool = init_url("sqlite::memory:").await?;
10895        // Five starred entries, one per day, cap of two: only the two newest
10896        // may survive.
10897        let mut ids = Vec::new();
10898        for days_old in 1..=5 {
10899            let id = aged_entry(&pool, &format!("starred-{days_old}"), days_old).await;
10900            mark(&pool, id, 1, 1).await;
10901            ids.push((days_old, id));
10902        }
10903        let feed_id: i64 = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
10904            .bind(ids[0].1)
10905            .fetch_one(&pool)
10906            .await?;
10907        insert_entries(&pool, feed_id, &[], 2).await?;
10908
10909        let mut survivors: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
10910            .fetch_all(&pool)
10911            .await?;
10912        survivors.sort();
10913        assert_eq!(
10914            survivors,
10915            vec!["starred-1".to_string(), "starred-2".to_string()],
10916            "the trim spared the wrong starred entries"
10917        );
10918        Ok(())
10919    }
10920}