Skip to main content

feather_reader/
store.rs

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