Skip to main content

feather_reader/
store.rs

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