use anyhow::{Context, Result};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::{ConnectOptions, FromRow, Row};
use std::str::FromStr;
use crate::config::Config;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum RedeemError {
#[error("invite code not found")]
NotFound,
#[error("invite code expired")]
Expired,
#[error("invite code already redeemed")]
AlreadyRedeemed,
#[error("beta is at capacity")]
CapacityFull,
}
pub type Pool = SqlitePool;
#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
pub struct Feed {
pub id: i64,
pub url: String,
pub title: Option<String>,
pub site_url: Option<String>,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub last_polled: Option<String>,
pub next_poll: Option<String>,
#[sqlx(default)]
pub consecutive_errors: i64,
}
#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
pub struct Entry {
pub id: i64,
pub feed_id: i64,
pub guid: String,
pub url: Option<String>,
pub title: Option<String>,
pub author: Option<String>,
pub published: Option<String>,
pub content_html: Option<String>,
pub fetched_at: String,
}
#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
pub struct EntryState {
pub did: String,
pub entry_id: i64,
pub read: bool,
pub starred: bool,
pub updated_at: String,
}
#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
pub struct ReadCursor {
pub did: String,
pub feed_url: String,
pub read_through: Option<String>,
pub read_ids: String,
pub unread_ids: String,
pub dirty: bool,
#[sqlx(default)]
pub pds_created: bool,
pub updated_at: String,
}
#[derive(Debug, Clone, Default)]
pub struct NewFeed {
pub url: String,
pub title: Option<String>,
pub site_url: Option<String>,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub last_polled: Option<String>,
pub next_poll: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct NewEntry {
pub guid: String,
pub url: Option<String>,
pub title: Option<String>,
pub author: Option<String>,
pub published: Option<String>,
pub content_html: Option<String>,
pub fetched_at: Option<String>,
}
const SCHEMA: &str = r#"
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT,
site_url TEXT,
etag TEXT,
last_modified TEXT,
last_polled TEXT,
next_poll TEXT,
consecutive_errors INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_feeds_next_poll ON feeds (next_poll);
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
guid TEXT NOT NULL,
url TEXT,
title TEXT,
author TEXT,
published TEXT,
content_html TEXT,
fetched_at TEXT NOT NULL,
UNIQUE (feed_id, guid)
);
CREATE INDEX IF NOT EXISTS idx_entries_feed_published ON entries (feed_id, published);
CREATE TABLE IF NOT EXISTS entry_state (
did TEXT NOT NULL,
entry_id INTEGER NOT NULL REFERENCES entries (id) ON DELETE CASCADE,
read INTEGER NOT NULL DEFAULT 0,
starred INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (did, entry_id)
);
CREATE INDEX IF NOT EXISTS idx_entry_state_did_read ON entry_state (did, read);
-- Per-DID subscription projection. The shared `feeds`/`entries` cache is
-- deduped by URL and NOT owned by any single DID; `sub_ref` records which
-- feeds a given DID actually subscribes to (mirrored from the caller's PDS
-- subscription set on every resolve/sync). Every entry/feed READ and every
-- read/star MUTATION is scoped through this table so one user can never read
-- or mutate another user's cached articles. Rows are refreshed by
-- `replace_sub_refs`.
CREATE TABLE IF NOT EXISTS sub_ref (
did TEXT NOT NULL,
feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
PRIMARY KEY (did, feed_id)
);
CREATE INDEX IF NOT EXISTS idx_sub_ref_feed ON sub_ref (feed_id);
CREATE TABLE IF NOT EXISTS read_cursor (
did TEXT NOT NULL,
feed_url TEXT NOT NULL,
read_through TEXT,
read_ids TEXT NOT NULL DEFAULT '[]',
unread_ids TEXT NOT NULL DEFAULT '[]',
dirty INTEGER NOT NULL DEFAULT 0,
pds_created INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (did, feed_url)
);
CREATE INDEX IF NOT EXISTS idx_read_cursor_dirty ON read_cursor (did, dirty);
-- The (did, feed_url) PRIMARY KEY can't serve a feed_url-only lookup (did is the
-- leading column). The retention path's orphan-cursor cleanup filters cursors by
-- feed_url alone, so give it an index.
CREATE INDEX IF NOT EXISTS idx_read_cursor_feed_url ON read_cursor (feed_url);
CREATE TABLE IF NOT EXISTS beta_access (
did TEXT PRIMARY KEY,
handle TEXT,
granted_by TEXT NOT NULL,
granted_at INTEGER NOT NULL,
invite_code_used TEXT
);
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
creator_did TEXT NOT NULL,
status TEXT NOT NULL,
invitee_did TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
redeemed_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
"#;
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub async fn init(config: &Config) -> Result<Pool> {
let db_url = format!("sqlite://{}", config.db_path.display());
init_url(&db_url).await
}
pub async fn init_url(db_url: &str) -> Result<Pool> {
let is_memory = db_url.contains(":memory:");
let mut opts = SqliteConnectOptions::from_str(db_url)
.with_context(|| format!("invalid sqlite url: {db_url}"))?
.create_if_missing(true)
.foreign_keys(true);
if !is_memory {
opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
}
opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
opts = opts.log_statements(tracing::log::LevelFilter::Debug);
let pool = SqlitePoolOptions::new()
.min_connections(1)
.max_connections(if is_memory { 1 } else { 5 })
.connect_with(opts)
.await
.with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
init_schema(&pool).await?;
Ok(pool)
}
pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
sqlx::query(SCHEMA)
.execute(pool)
.await
.context("failed to create schema")?;
apply_migrations(pool).await?;
Ok(())
}
async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
ensure_column(
pool,
"PRAGMA table_info(feeds)",
"consecutive_errors",
"ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
)
.await?;
ensure_column(
pool,
"PRAGMA table_info(read_cursor)",
"pds_created",
"ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
)
.await?;
Ok(())
}
async fn ensure_column(
pool: &SqlitePool,
info_sql: &'static str,
column: &str,
alter_sql: &'static str,
) -> Result<()> {
let rows = sqlx::query(info_sql)
.fetch_all(pool)
.await
.with_context(|| format!("{info_sql} failed"))?;
let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
if !present {
sqlx::query(alter_sql)
.execute(pool)
.await
.with_context(|| format!("adding column {column} via {alter_sql}"))?;
}
Ok(())
}
pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
let row = sqlx::query(
r#"
INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT (url) DO UPDATE SET
title = COALESCE(excluded.title, feeds.title),
site_url = COALESCE(excluded.site_url, feeds.site_url),
etag = excluded.etag,
last_modified = excluded.last_modified,
last_polled = COALESCE(excluded.last_polled, feeds.last_polled),
next_poll = COALESCE(excluded.next_poll, feeds.next_poll)
RETURNING id
"#,
)
.bind(&feed.url)
.bind(&feed.title)
.bind(&feed.site_url)
.bind(&feed.etag)
.bind(&feed.last_modified)
.bind(&feed.last_polled)
.bind(&feed.next_poll)
.fetch_one(pool)
.await
.with_context(|| format!("upsert_feed failed for {}", feed.url))?;
Ok(row.get::<i64, _>("id"))
}
pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
.bind(url)
.fetch_optional(pool)
.await
.with_context(|| format!("get_feed_by_url failed for {url}"))?;
Ok(feed)
}
pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
let feeds = sqlx::query_as::<_, Feed>(
r#"
SELECT * FROM feeds
WHERE next_poll IS NULL OR next_poll <= ?1
ORDER BY next_poll IS NOT NULL, next_poll ASC
LIMIT ?2
"#,
)
.bind(as_of)
.bind(limit)
.fetch_all(pool)
.await
.context("due_feeds failed")?;
Ok(feeds)
}
pub async fn bump_feed_errors(pool: &SqlitePool, url: &str) -> Result<i64> {
let row = sqlx::query(
"UPDATE feeds SET consecutive_errors = consecutive_errors + 1 \
WHERE url = ?1 RETURNING consecutive_errors",
)
.bind(url)
.fetch_optional(pool)
.await
.with_context(|| format!("bump_feed_errors failed for {url}"))?;
Ok(row
.map(|r| r.get::<i64, _>("consecutive_errors"))
.unwrap_or(1))
}
pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
sqlx::query("UPDATE feeds SET consecutive_errors = 0 WHERE url = ?1")
.bind(url)
.execute(pool)
.await
.with_context(|| format!("reset_feed_errors failed for {url}"))?;
Ok(())
}
pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
let feeds = sqlx::query_as::<_, Feed>(
r#"
SELECT f.* FROM feeds f
JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
ORDER BY f.title IS NULL, f.title, f.url
"#,
)
.bind(did)
.fetch_all(pool)
.await
.with_context(|| format!("feeds_for_did failed for {did}"))?;
Ok(feeds)
}
pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
.bind(did)
.fetch_all(pool)
.await
.with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
Ok(ids)
}
pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
.bind(did)
.fetch_one(pool)
.await
.with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
Ok(n)
}
pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
.fetch_one(pool)
.await
.context("count_feeds failed")?;
Ok(n)
}
pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
.fetch_one(pool)
.await
.context("PRAGMA page_count failed")?;
let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
.fetch_one(pool)
.await
.context("PRAGMA freelist_count failed")?;
let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
.fetch_one(pool)
.await
.context("PRAGMA page_size failed")?;
let used_pages = page_count.saturating_sub(freelist_count).max(0);
Ok(used_pages.saturating_mul(page_size))
}
pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
let auto_vacuum: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
.fetch_one(pool)
.await
.context("PRAGMA auto_vacuum failed")?;
if auto_vacuum == 2 {
sqlx::query("PRAGMA incremental_vacuum")
.execute(pool)
.await
.context("PRAGMA incremental_vacuum failed")?;
} else {
sqlx::query("VACUUM")
.execute(pool)
.await
.context("VACUUM failed")?;
}
Ok(())
}
pub async fn insert_entries(
pool: &SqlitePool,
feed_id: i64,
entries: &[NewEntry],
max_entries_per_feed: i64,
) -> Result<u64> {
let mut tx = pool.begin().await.context("begin insert_entries tx")?;
let mut count: u64 = 0;
for e in entries {
let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
let res = sqlx::query(
r#"
INSERT INTO entries
(feed_id, guid, url, title, author, published, content_html, fetched_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT (feed_id, guid) DO UPDATE SET
url = excluded.url,
title = excluded.title,
author = excluded.author,
published = excluded.published,
content_html = excluded.content_html
"#,
)
.bind(feed_id)
.bind(&e.guid)
.bind(&e.url)
.bind(&e.title)
.bind(&e.author)
.bind(&e.published)
.bind(&e.content_html)
.bind(&fetched_at)
.execute(&mut *tx)
.await
.with_context(|| format!("insert entry {} failed", e.guid))?;
count += res.rows_affected();
}
if max_entries_per_feed > 0 {
sqlx::query(
r#"
DELETE FROM entries
WHERE feed_id = ?1
AND id NOT IN (
SELECT id FROM entries
WHERE feed_id = ?1
ORDER BY COALESCE(published, fetched_at) DESC, id DESC
LIMIT ?2
)
"#,
)
.bind(feed_id)
.bind(max_entries_per_feed)
.execute(&mut *tx)
.await
.with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
}
if max_entries_per_feed > 0 {
prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
}
tx.commit().await.context("commit insert_entries tx")?;
Ok(count)
}
pub async fn prune_old_entries(pool: &SqlitePool, days: i64) -> Result<u64> {
if days <= 0 {
return Ok(0);
}
let cutoff = (chrono::Utc::now() - chrono::Duration::days(days))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let mut tx = pool.begin().await.context("begin prune_old_entries tx")?;
let res = sqlx::query("DELETE FROM entries WHERE COALESCE(published, fetched_at) < ?1")
.bind(&cutoff)
.execute(&mut *tx)
.await
.with_context(|| format!("prune_old_entries delete (cutoff {cutoff})"))?;
let deleted = res.rows_affected();
if deleted > 0 {
prune_orphan_cursor_ids_tx(&mut tx, None).await?;
}
tx.commit().await.context("commit prune_old_entries tx")?;
Ok(deleted)
}
async fn prune_orphan_cursor_ids_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
feed_id: Option<i64>,
) -> Result<u64> {
let feed_url = match feed_id {
Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
Some(u) => Some(u),
None => return Ok(0), },
None => None,
};
let cursors: Vec<(String, String, String, String)> = match &feed_url {
Some(url) => sqlx::query(
"SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
)
.bind(url)
.fetch_all(&mut **tx)
.await
.context("prune_orphan_cursor_ids: load feed cursors")?,
None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
.fetch_all(&mut **tx)
.await
.context("prune_orphan_cursor_ids: load all cursors")?,
}
.into_iter()
.map(|r| {
(
r.get::<String, _>("did"),
r.get::<String, _>("feed_url"),
r.get::<String, _>("read_ids"),
r.get::<String, _>("unread_ids"),
)
})
.collect();
if cursors.is_empty() {
return Ok(0);
}
let now = now_rfc3339();
let mut changed: u64 = 0;
for (did, curl, read_ids, unread_ids) in cursors {
let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
"SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
)
.bind(&curl)
.fetch_all(&mut **tx)
.await
.with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
.into_iter()
.collect();
let new_read = filter_id_set_to_live(&read_ids, &live);
let new_unread = filter_id_set_to_live(&unread_ids, &live);
if new_read == read_ids && new_unread == unread_ids {
continue; }
sqlx::query(
"UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
WHERE did = ?1 AND feed_url = ?2",
)
.bind(&did)
.bind(&curl)
.bind(&new_read)
.bind(&new_unread)
.bind(&now)
.execute(&mut **tx)
.await
.with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
changed += 1;
}
Ok(changed)
}
fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
.ok()
.map(|vals| {
vals.into_iter()
.filter_map(|v| match v {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => s.parse::<i64>().ok(),
_ => None,
})
.filter(|id| live.contains(id))
.collect()
})
.unwrap_or_default();
let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
}
pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
.bind(did)
.execute(&mut *tx)
.await
.with_context(|| format!("clear sub_ref for {did}"))?;
for &feed_id in feed_ids {
sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
.bind(did)
.bind(feed_id)
.execute(&mut *tx)
.await
.with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
}
tx.commit().await.context("commit replace_sub_refs tx")?;
Ok(())
}
pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
let found: Option<i64> = sqlx::query_scalar(
r#"
SELECT 1
FROM entries e
JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
WHERE e.id = ?2
"#,
)
.bind(did)
.bind(entry_id)
.fetch_optional(pool)
.await
.with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
Ok(found.is_some())
}
pub async fn entries_for_feed(pool: &SqlitePool, did: &str, feed_id: i64) -> Result<Vec<Entry>> {
let entries = sqlx::query_as::<_, Entry>(
r#"
SELECT e.* FROM entries e
WHERE e.feed_id = ?2
AND EXISTS (
SELECT 1 FROM sub_ref sr
WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
)
ORDER BY e.published DESC, e.id DESC
"#,
)
.bind(did)
.bind(feed_id)
.fetch_all(pool)
.await
.context("entries_for_feed failed")?;
Ok(entries)
}
pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
let now = now_rfc3339();
let mut tx = pool.begin().await.context("begin mark_read tx")?;
let res = sqlx::query(
r#"
INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
SELECT ?1, e.id, ?3, 0, ?4
FROM entries e
WHERE e.id = ?2
AND EXISTS (
SELECT 1 FROM sub_ref sr
WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
)
ON CONFLICT (did, entry_id) DO UPDATE SET
read = excluded.read,
updated_at = excluded.updated_at
"#,
)
.bind(did)
.bind(entry_id)
.bind(read)
.bind(&now)
.execute(&mut *tx)
.await
.with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
if res.rows_affected() == 0 {
tx.rollback().await.ok();
return Ok(false);
}
project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
tx.commit().await.context("commit mark_read tx")?;
Ok(true)
}
pub async fn mark_starred(
pool: &SqlitePool,
did: &str,
entry_id: i64,
starred: bool,
) -> Result<bool> {
let res = sqlx::query(
r#"
INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
SELECT ?1, e.id, 0, ?3, ?4
FROM entries e
WHERE e.id = ?2
AND EXISTS (
SELECT 1 FROM sub_ref sr
WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
)
ON CONFLICT (did, entry_id) DO UPDATE SET
starred = excluded.starred,
updated_at = excluded.updated_at
"#,
)
.bind(did)
.bind(entry_id)
.bind(starred)
.bind(now_rfc3339())
.execute(pool)
.await
.with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
Ok(res.rows_affected() > 0)
}
pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
let now = now_rfc3339();
let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
let res = sqlx::query(
r#"
INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
WHERE e.feed_id = ?4
AND EXISTS (
SELECT 1 FROM sub_ref sr
WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
)
ON CONFLICT (did, entry_id) DO UPDATE SET
read = excluded.read,
updated_at = excluded.updated_at
"#,
)
.bind(did)
.bind(read)
.bind(&now)
.bind(feed_id)
.execute(&mut *tx)
.await
.with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
if res.rows_affected() > 0 {
project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
}
tx.commit().await.context("commit mark_feed_read tx")?;
Ok(res.rows_affected())
}
fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
.ok()
.map(|vals| {
vals.into_iter()
.filter_map(|v| match v {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => s.parse::<i64>().ok(),
_ => None,
})
.collect()
})
.unwrap_or_default();
if present {
if !ids.contains(&id) {
ids.push(id);
}
} else {
ids.retain(|&x| x != id);
}
let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
}
async fn feed_url_for_id_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
feed_id: i64,
) -> Result<Option<String>> {
let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
.bind(feed_id)
.fetch_optional(&mut **tx)
.await
.with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
Ok(url)
}
async fn cursor_sets(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
did: &str,
feed_url: &str,
) -> Result<(Option<String>, String, String)> {
let row = sqlx::query(
"SELECT read_through, read_ids, unread_ids FROM read_cursor \
WHERE did = ?1 AND feed_url = ?2",
)
.bind(did)
.bind(feed_url)
.fetch_optional(&mut **tx)
.await
.with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
Ok(match row {
Some(r) => (
r.get::<Option<String>, _>("read_through"),
r.get::<String, _>("read_ids"),
r.get::<String, _>("unread_ids"),
),
None => (None, "[]".to_string(), "[]".to_string()),
})
}
async fn write_cursor_sets(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
did: &str,
feed_url: &str,
read_through: Option<&str>,
read_ids: &str,
unread_ids: &str,
now: &str,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO read_cursor
(did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
ON CONFLICT (did, feed_url) DO UPDATE SET
read_through = excluded.read_through,
read_ids = excluded.read_ids,
unread_ids = excluded.unread_ids,
dirty = 1,
updated_at = excluded.updated_at
"#,
)
.bind(did)
.bind(feed_url)
.bind(read_through)
.bind(read_ids)
.bind(unread_ids)
.bind(now)
.execute(&mut **tx)
.await
.with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
Ok(())
}
async fn project_entry_into_cursor(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
did: &str,
entry_id: i64,
read: bool,
now: &str,
) -> Result<()> {
let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
.bind(entry_id)
.fetch_optional(&mut **tx)
.await
.with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
let feed_id = match feed_id {
Some(f) => f,
None => return Ok(()), };
let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
Some(u) => u,
None => return Ok(()),
};
let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
write_cursor_sets(
tx,
did,
&feed_url,
read_through.as_deref(),
&read_ids,
&unread_ids,
now,
)
.await
}
async fn project_feed_into_cursor(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
did: &str,
feed_id: i64,
read: bool,
now: &str,
) -> Result<()> {
let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
Some(u) => u,
None => return Ok(()),
};
let ids: Vec<i64> = sqlx::query_scalar(
r#"
SELECT e.id FROM entries e
WHERE e.feed_id = ?2
AND EXISTS (
SELECT 1 FROM sub_ref sr
WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
)
"#,
)
.bind(did)
.bind(feed_id)
.fetch_all(&mut **tx)
.await
.with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
for id in ids {
read_ids = json_id_set_toggle(&read_ids, id, read);
unread_ids = json_id_set_toggle(&unread_ids, id, !read);
}
write_cursor_sets(
tx,
did,
&feed_url,
read_through.as_deref(),
&read_ids,
&unread_ids,
now,
)
.await
}
pub async fn get_unread_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
let entries = sqlx::query_as::<_, Entry>(
r#"
SELECT e.*
FROM entries e
LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
WHERE COALESCE(s.read, 0) = 0
AND EXISTS (
SELECT 1 FROM sub_ref sr
WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
)
ORDER BY e.published DESC, e.id DESC
"#,
)
.bind(did)
.fetch_all(pool)
.await
.with_context(|| format!("get_unread_for_did failed for {did}"))?;
Ok(entries)
}
pub async fn get_starred_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
let entries = sqlx::query_as::<_, Entry>(
r#"
SELECT e.*
FROM entries e
JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
WHERE s.starred = 1
AND EXISTS (
SELECT 1 FROM sub_ref sr
WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
)
ORDER BY e.published DESC, e.id DESC
"#,
)
.bind(did)
.fetch_all(pool)
.await
.with_context(|| format!("get_starred_for_did failed for {did}"))?;
Ok(entries)
}
pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
sqlx::query(
r#"
INSERT INTO read_cursor
(did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT (did, feed_url) DO UPDATE SET
read_through = excluded.read_through,
read_ids = excluded.read_ids,
unread_ids = excluded.unread_ids,
dirty = excluded.dirty,
updated_at = excluded.updated_at
"#,
)
.bind(&cursor.did)
.bind(&cursor.feed_url)
.bind(&cursor.read_through)
.bind(&cursor.read_ids)
.bind(&cursor.unread_ids)
.bind(cursor.dirty)
.bind(&cursor.updated_at)
.execute(pool)
.await
.with_context(|| {
format!(
"upsert_cursor failed for {}/{}",
cursor.did, cursor.feed_url
)
})?;
Ok(())
}
pub async fn get_cursor(
pool: &SqlitePool,
did: &str,
feed_url: &str,
) -> Result<Option<ReadCursor>> {
let cursor = sqlx::query_as::<_, ReadCursor>(
"SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
)
.bind(did)
.bind(feed_url)
.fetch_optional(pool)
.await
.context("get_cursor failed")?;
Ok(cursor)
}
pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
let cursors =
sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
.bind(did)
.fetch_all(pool)
.await
.with_context(|| format!("dirty_cursors failed for {did}"))?;
Ok(cursors)
}
pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
.bind(did)
.bind(feed_url)
.execute(pool)
.await
.with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
Ok(())
}
pub async fn clear_cursor_dirty(
pool: &SqlitePool,
did: &str,
feed_url: &str,
flushed_updated_at: &str,
) -> Result<()> {
sqlx::query(
"UPDATE read_cursor SET dirty = 0 \
WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
)
.bind(did)
.bind(feed_url)
.bind(flushed_updated_at)
.execute(pool)
.await
.context("clear_cursor_dirty failed")?;
Ok(())
}
fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_PREFIX: &str = "FEATHER-";
const CODE_BODY_LEN: usize = 8;
pub fn generate_invite_code() -> Result<String> {
let n = CODE_ALPHABET.len() as u16; let limit = 256 / n * n; let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
out.push_str(CODE_PREFIX);
let mut got = 0;
let mut buf = [0u8; 1];
while got < CODE_BODY_LEN {
getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
let b = buf[0] as u16;
if b < limit {
out.push(CODE_ALPHABET[(b % n) as usize] as char);
got += 1;
}
}
Ok(out)
}
pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
.bind(did)
.fetch_optional(pool)
.await
.with_context(|| format!("has_beta_access failed for {did}"))?;
Ok(row.is_some())
}
pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
.fetch_one(pool)
.await
.context("count_beta_access failed")?;
Ok(row.get::<i64, _>("n"))
}
pub async fn grant_access(
pool: &SqlitePool,
did: &str,
handle: Option<&str>,
granted_by: &str,
invite_code_used: Option<&str>,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (did) DO UPDATE SET
handle = COALESCE(excluded.handle, beta_access.handle),
granted_by = excluded.granted_by,
invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
"#,
)
.bind(did)
.bind(handle)
.bind(granted_by)
.bind(now_unix())
.bind(invite_code_used)
.execute(pool)
.await
.with_context(|| format!("grant_access failed for {did}"))?;
Ok(())
}
pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
let code = generate_invite_code()?;
let now = now_unix();
let expires_at = now.saturating_add(ttl_secs.max(0));
sqlx::query(
r#"
INSERT INTO invite_codes
(code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)
"#,
)
.bind(&code)
.bind(creator_did)
.bind(now)
.bind(expires_at)
.execute(pool)
.await
.with_context(|| format!("mint_code failed for creator {creator_did}"))?;
Ok(code)
}
pub async fn redeem_code(
pool: &SqlitePool,
code: &str,
did: &str,
handle: Option<&str>,
cap: i64,
) -> Result<std::result::Result<(), RedeemError>> {
let now = now_unix();
let mut tx = pool.begin().await.context("begin redeem_code tx")?;
sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
.bind(code)
.execute(&mut *tx)
.await
.context("redeem_code: acquire write lock")?;
let row = sqlx::query("SELECT status, expires_at FROM invite_codes WHERE code = ?1")
.bind(code)
.fetch_optional(&mut *tx)
.await
.context("redeem_code: lookup")?;
let row = match row {
Some(r) => r,
None => return Ok(Err(RedeemError::NotFound)),
};
let status: String = row.get("status");
let expires_at: i64 = row.get("expires_at");
if status == "expired" || now > expires_at {
return Ok(Err(RedeemError::Expired));
}
if status != "active" {
return Ok(Err(RedeemError::AlreadyRedeemed));
}
let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
.fetch_one(&mut *tx)
.await
.context("redeem_code: count")?
.get("n");
if count >= cap {
return Ok(Err(RedeemError::CapacityFull));
}
let flipped = sqlx::query(
r#"
UPDATE invite_codes
SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
WHERE code = ?1 AND status = 'active'
"#,
)
.bind(code)
.bind(did)
.bind(now)
.execute(&mut *tx)
.await
.context("redeem_code: flip")?;
if flipped.rows_affected() == 0 {
return Ok(Err(RedeemError::AlreadyRedeemed));
}
sqlx::query(
r#"
INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (did) DO UPDATE SET
handle = COALESCE(excluded.handle, beta_access.handle),
invite_code_used = excluded.invite_code_used
"#,
)
.bind(did)
.bind(handle)
.bind(
sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
.bind(code)
.fetch_one(&mut *tx)
.await
.context("redeem_code: creator lookup")?
.get::<String, _>("creator_did"),
)
.bind(now)
.bind(code)
.execute(&mut *tx)
.await
.context("redeem_code: grant")?;
tx.commit().await.context("commit redeem_code tx")?;
Ok(Ok(()))
}
pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
let now = now_unix();
let res = sqlx::query(
"UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
)
.bind(now)
.execute(pool)
.await
.context("expire_old_codes failed")?;
Ok(res.rows_affected())
}
pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
let now = now_unix();
let mut created = 0u64;
for did in dids {
let res = sqlx::query(
r#"
INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
VALUES (?1, NULL, 'admin', ?2, NULL)
ON CONFLICT (did) DO NOTHING
"#,
)
.bind(did)
.bind(now)
.execute(&mut *tx)
.await
.with_context(|| format!("ensure_seed insert failed for {did}"))?;
created += res.rows_affected();
}
tx.commit().await.context("commit ensure_seed tx")?;
Ok(created)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PurgeCounts {
pub entry_state: u64,
pub read_cursor: u64,
pub sub_ref: u64,
pub beta_access: u64,
pub invite_codes: u64,
pub invitee_scrubbed: u64,
pub granted_by_scrubbed: u64,
}
impl PurgeCounts {
pub fn total(&self) -> u64 {
self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
}
}
pub const REDACTED_DID: &str = "__redacted__";
pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
.bind(did)
.execute(&mut *tx)
.await
.with_context(|| format!("purge entry_state for {did}"))?
.rows_affected();
let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
.bind(did)
.execute(&mut *tx)
.await
.with_context(|| format!("purge read_cursor for {did}"))?
.rows_affected();
let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
.bind(did)
.execute(&mut *tx)
.await
.with_context(|| format!("purge sub_ref for {did}"))?
.rows_affected();
let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
.bind(did)
.execute(&mut *tx)
.await
.with_context(|| format!("purge beta_access for {did}"))?
.rows_affected();
let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
.bind(did)
.execute(&mut *tx)
.await
.with_context(|| format!("purge invite_codes for {did}"))?
.rows_affected();
let invitee_scrubbed =
sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
.bind(did)
.execute(&mut *tx)
.await
.with_context(|| format!("scrub invitee_did for {did}"))?
.rows_affected();
let granted_by_scrubbed =
sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
.bind(did)
.bind(REDACTED_DID)
.execute(&mut *tx)
.await
.with_context(|| format!("scrub granted_by for {did}"))?
.rows_affected();
tx.commit().await.context("commit purge_did_data tx")?;
Ok(PurgeCounts {
entry_state,
read_cursor,
sub_ref,
beta_access,
invite_codes,
invitee_scrubbed,
granted_by_scrubbed,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn init_insert_readback() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: "https://example.com/feed.xml".to_string(),
title: Some("Example".to_string()),
site_url: Some("https://example.com".to_string()),
next_poll: Some("2026-07-12T00:00:00Z".to_string()),
..Default::default()
},
)
.await?;
assert!(feed_id > 0);
let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
.await?
.expect("feed should exist");
assert_eq!(feed.id, feed_id);
assert_eq!(feed.title.as_deref(), Some("Example"));
assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
let feed_id2 = upsert_feed(
&pool,
&NewFeed {
url: "https://example.com/feed.xml".to_string(),
title: Some("Example (renamed)".to_string()),
..Default::default()
},
)
.await?;
assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
let n = insert_entries(
&pool,
feed_id,
&[
NewEntry {
guid: "guid-1".to_string(),
url: Some("https://example.com/a".to_string()),
title: Some("First".to_string()),
published: Some("2026-07-10T08:00:00Z".to_string()),
content_html: Some("<p>hello</p>".to_string()),
..Default::default()
},
NewEntry {
guid: "guid-2".to_string(),
url: Some("https://example.com/b".to_string()),
title: Some("Second".to_string()),
published: Some("2026-07-11T08:00:00Z".to_string()),
..Default::default()
},
],
0, )
.await?;
assert_eq!(n, 2);
let did = "did:plc:abc123";
replace_sub_refs(&pool, did, &[feed_id]).await?;
let entries = entries_for_feed(&pool, did, feed_id).await?;
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].guid, "guid-2");
assert_eq!(entries[1].guid, "guid-1");
assert_eq!(entries[1].content_html.as_deref(), Some("<p>hello</p>"));
let n2 = insert_entries(
&pool,
feed_id,
&[NewEntry {
guid: "guid-1".to_string(),
title: Some("First (edited)".to_string()),
..Default::default()
}],
0,
)
.await?;
assert_eq!(n2, 1);
assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
mark_read(&pool, did, e1, true).await?;
let unread = get_unread_for_did(&pool, did).await?;
assert_eq!(unread.len(), 1);
assert_eq!(unread[0].guid, "guid-2");
mark_starred(&pool, did, e1, true).await?;
let starred = get_starred_for_did(&pool, did).await?;
assert_eq!(starred.len(), 1);
assert_eq!(starred[0].id, e1);
mark_feed_read(&pool, did, feed_id, true).await?;
assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
let cursor = ReadCursor {
did: did.to_string(),
feed_url: "https://example.com/feed.xml".to_string(),
read_through: Some("2026-07-11T08:00:00Z".to_string()),
read_ids: "[]".to_string(),
unread_ids: "[]".to_string(),
dirty: true,
pds_created: false,
updated_at: now_rfc3339(),
};
upsert_cursor(&pool, &cursor).await?;
let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
.await?
.expect("cursor should exist");
assert_eq!(
fetched.read_through.as_deref(),
Some("2026-07-11T08:00:00Z")
);
assert!(fetched.dirty);
let dirty = dirty_cursors(&pool, did).await?;
assert_eq!(dirty.len(), 1);
let flushed_at = dirty[0].updated_at.clone();
clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
Ok(())
}
#[tokio::test]
async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let feed_url = "https://example.com/feed.xml";
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: feed_url.to_string(),
title: Some("Example".to_string()),
..Default::default()
},
)
.await?;
insert_entries(
&pool,
feed_id,
&[
NewEntry {
guid: "g1".to_string(),
published: Some("2026-07-10T00:00:00Z".to_string()),
..Default::default()
},
NewEntry {
guid: "g2".to_string(),
published: Some("2026-07-11T00:00:00Z".to_string()),
..Default::default()
},
],
0,
)
.await?;
let did = "did:plc:reader";
replace_sub_refs(&pool, did, &[feed_id]).await?;
assert!(get_cursor(&pool, did, feed_url).await?.is_none());
assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
assert!(mark_read(&pool, did, e1, true).await?);
let cursor = get_cursor(&pool, did, feed_url)
.await?
.expect("mark_read must create the feed's read_cursor");
assert!(cursor.dirty, "cursor must be dirty after mark_read");
assert!(
cursor.read_ids.contains(&e1.to_string()),
"the read entry id must be in read_ids: {}",
cursor.read_ids
);
let dirty = dirty_cursors(&pool, did).await?;
assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
assert_eq!(dirty[0].feed_url, feed_url);
assert!(mark_read(&pool, did, e1, false).await?);
let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
assert!(cursor.dirty);
assert!(
cursor.unread_ids.contains(&e1.to_string()),
"unread id must be in unread_ids: {}",
cursor.unread_ids
);
assert!(
!cursor.read_ids.contains(&e1.to_string()),
"id must have left read_ids: {}",
cursor.read_ids
);
assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
assert!(cursor.dirty);
assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
let outsider = "did:plc:outsider";
assert!(!mark_read(&pool, outsider, e1, true).await?);
assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
let snap = dirty_cursors(&pool, did).await?[0].clone();
clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
assert_eq!(
dirty_cursors(&pool, did).await?.len(),
1,
"stale-snapshot clear must be a no-op"
);
clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
Ok(())
}
#[test]
fn json_id_set_toggle_is_set_like() {
let s = json_id_set_toggle("[]", 5, true);
assert_eq!(s, r#"["5"]"#);
assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); let s = json_id_set_toggle(&s, 7, true);
assert_eq!(s, r#"["5","7"]"#);
let s = json_id_set_toggle(&s, 5, false);
assert_eq!(s, r#"["7"]"#);
assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
}
#[tokio::test]
async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let feed_a = upsert_feed(
&pool,
&NewFeed {
url: "https://a.example/feed.xml".to_string(),
title: Some("A".to_string()),
..Default::default()
},
)
.await?;
let feed_b = upsert_feed(
&pool,
&NewFeed {
url: "https://b.example/feed.xml".to_string(),
title: Some("B".to_string()),
..Default::default()
},
)
.await?;
insert_entries(
&pool,
feed_a,
&[NewEntry {
guid: "a-1".to_string(),
url: Some("https://a.example/1".to_string()),
title: Some("A one".to_string()),
published: Some("2026-07-10T00:00:00Z".to_string()),
content_html: Some("<p>secret A body</p>".to_string()),
..Default::default()
}],
0,
)
.await?;
insert_entries(
&pool,
feed_b,
&[NewEntry {
guid: "b-1".to_string(),
url: Some("https://b.example/1".to_string()),
title: Some("B one".to_string()),
published: Some("2026-07-11T00:00:00Z".to_string()),
content_html: Some("<p>secret B body</p>".to_string()),
..Default::default()
}],
0,
)
.await?;
let did_a = "did:plc:aaaa";
let did_b = "did:plc:bbbb";
replace_sub_refs(&pool, did_a, &[feed_a]).await?;
replace_sub_refs(&pool, did_b, &[feed_b]).await?;
let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
assert!(
entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
"A must not read entries of a feed it does not subscribe to"
);
let unread_a = get_unread_for_did(&pool, did_a).await?;
assert_eq!(unread_a.len(), 1);
assert_eq!(unread_a[0].guid, "a-1");
let unread_b = get_unread_for_did(&pool, did_b).await?;
assert_eq!(unread_b.len(), 1);
assert_eq!(unread_b[0].guid, "b-1");
assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
assert!(
!did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
"A does not subscribe to B's feed"
);
assert!(
!mark_read(&pool, did_a, b_entry_id, true).await?,
"non-subscriber mark_read must be a no-op (→ 404), never a mutation"
);
assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
assert!(
!mark_starred(&pool, did_a, b_entry_id, true).await?,
"non-subscriber mark_starred must be a no-op (→ 404)"
);
assert!(
get_starred_for_did(&pool, did_a).await?.is_empty(),
"A's starred list stays empty after the rejected attempt"
);
assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
let a_feeds = feeds_for_did(&pool, did_a).await?;
assert_eq!(a_feeds.len(), 1);
assert_eq!(a_feeds[0].id, feed_a);
let b_feeds = feeds_for_did(&pool, did_b).await?;
assert_eq!(b_feeds.len(), 1);
assert_eq!(b_feeds[0].id, feed_b);
replace_sub_refs(&pool, did_b, &[]).await?;
assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
assert!(feeds_for_did(&pool, did_b).await?.is_empty());
Ok(())
}
#[tokio::test]
async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let did_a = "did:plc:aaaa";
let feed_a = upsert_feed(
&pool,
&NewFeed {
url: "https://a.example/feed.xml".to_string(),
title: Some("A".to_string()),
..Default::default()
},
)
.await?;
let feed_orphan = upsert_feed(
&pool,
&NewFeed {
url: "https://orphan.example/feed.xml".to_string(),
title: Some("Orphan".to_string()),
..Default::default()
},
)
.await?;
insert_entries(
&pool,
feed_a,
&[NewEntry {
guid: "a-1".to_string(),
url: Some("https://a.example/1".to_string()),
title: Some("A one".to_string()),
published: Some("2026-07-10T00:00:00Z".to_string()),
content_html: Some("<p>A body</p>".to_string()),
..Default::default()
}],
0,
)
.await?;
insert_entries(
&pool,
feed_orphan,
&[NewEntry {
guid: "orphan-1".to_string(),
url: Some("https://orphan.example/1".to_string()),
title: Some("Orphan one".to_string()),
published: Some("2026-07-11T00:00:00Z".to_string()),
content_html: Some("<p>secret orphan body</p>".to_string()),
..Default::default()
}],
0,
)
.await?;
replace_sub_refs(&pool, did_a, &[feed_a]).await?;
replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
let fallback = feeds_for_did(&pool, did_a).await?;
let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
assert_eq!(
fallback_ids,
vec![feed_a],
"outage fallback must serve ONLY A's own last-known sub_ref, \
never widen to the orphan cached feed"
);
assert!(
!fallback_ids.contains(&feed_orphan),
"FAIL-OPEN regression: outage fallback leaked an unsubscribed \
cached feed into A's surface"
);
assert!(
!did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
"A must not be authorized for an orphan feed's entry during an outage"
);
assert!(
entries_for_feed(&pool, did_a, feed_orphan)
.await?
.is_empty(),
"entries_for_feed must not expose the orphan feed to A during an outage"
);
let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
.await?
.into_iter()
.map(|e| e.guid)
.collect();
assert!(
!unread_guids.iter().any(|g| g == "orphan-1"),
"orphan entry leaked into A's unread list during an outage"
);
assert!(
get_starred_for_did(&pool, did_a).await?.is_empty(),
"A has no starred entries; the orphan must not appear"
);
assert!(
!mark_read(&pool, did_a, orphan_entry_id, true).await?,
"A must not mark an orphan feed's entry read during an outage"
);
assert!(
!mark_starred(&pool, did_a, orphan_entry_id, true).await?,
"A must not star an orphan feed's entry during an outage"
);
assert_eq!(
mark_feed_read(&pool, did_a, feed_orphan, true).await?,
0,
"A must not mark-all-read the orphan feed during an outage"
);
let es_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
.bind(did_a)
.bind(orphan_entry_id)
.fetch_one(&pool)
.await?;
assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
Ok(())
}
#[test]
fn code_gen_shape_and_alphabet() {
for _ in 0..200 {
let code = generate_invite_code().unwrap();
assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
let body = &code["FEATHER-".len()..];
assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
for c in body.chars() {
assert!(
CODE_ALPHABET.contains(&(c as u8)),
"char {c:?} not in alphabet ({code})"
);
assert!(
!matches!(c, 'I' | 'O' | '0' | '1'),
"ambiguous char {c:?} leaked into {code}"
);
}
}
assert_ne!(
generate_invite_code().unwrap(),
generate_invite_code().unwrap()
);
}
#[tokio::test]
async fn busy_timeout_is_applied() -> Result<()> {
let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
let path = dir.join("busy.db");
let url = format!("sqlite://{}", path.display());
let pool = init_url(&url).await?;
let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
let timeout: i64 = row.get(0);
assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
pool.close().await;
std::fs::remove_dir_all(&dir).ok();
Ok(())
}
#[tokio::test]
async fn redeem_valid_grants_seat() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let code = mint_code(&pool, "did:plc:creator", 3600).await?;
assert!(!has_beta_access(&pool, "did:plc:new").await?);
let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
assert_eq!(out, Ok(()));
assert!(has_beta_access(&pool, "did:plc:new").await?);
assert_eq!(count_beta_access(&pool).await?, 1);
let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
Ok(())
}
#[tokio::test]
async fn redeem_not_found() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
assert_eq!(out, Err(RedeemError::NotFound));
Ok(())
}
async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
let now = now_unix();
sqlx::query(
r#"INSERT INTO invite_codes
(code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
)
.bind(code)
.bind(creator)
.bind(now - 100)
.bind(now - 10) .execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn redeem_expired() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
assert_eq!(out, Err(RedeemError::Expired));
assert_eq!(count_beta_access(&pool).await?, 0);
Ok(())
}
#[tokio::test]
async fn redeem_capacity_full() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
assert_eq!(count_beta_access(&pool).await?, 1);
let code = mint_code(&pool, "did:plc:admin", 3600).await?;
let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
assert_eq!(out, Err(RedeemError::CapacityFull));
assert!(!has_beta_access(&pool, "did:plc:new").await?);
let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
assert_eq!(ok, Ok(()));
Ok(())
}
#[tokio::test]
async fn expire_and_seed() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
let live = mint_code(&pool, "did:plc:creator", 3600).await?;
let n = expire_old_codes(&pool).await?;
assert_eq!(n, 1, "exactly the past-expiry code should flip");
assert_eq!(
redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
Ok(())
);
let created = ensure_seed(
&pool,
&["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
)
.await?;
assert_eq!(created, 2);
let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
assert!(has_beta_access(&pool, "did:plc:seed1").await?);
Ok(())
}
#[tokio::test]
async fn count_helpers_track_feeds_and_subs() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
assert_eq!(count_feeds(&pool).await?, 0);
let mut ids = Vec::new();
for i in 0..3 {
let id = upsert_feed(
&pool,
&NewFeed {
url: format!("https://f{i}.example/feed.xml"),
..Default::default()
},
)
.await?;
ids.push(id);
}
assert_eq!(count_feeds(&pool).await?, 3);
let did = "did:plc:capcheck";
assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
replace_sub_refs(&pool, did, &ids).await?;
assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
Ok(())
}
#[tokio::test]
async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: "https://firehose.example/feed.xml".to_string(),
..Default::default()
},
)
.await?;
let batch: Vec<NewEntry> = (0..5)
.map(|i| NewEntry {
guid: format!("g-{i}"),
title: Some(format!("E{i}")),
published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
..Default::default()
})
.collect();
insert_entries(&pool, feed_id, &batch, 2).await?;
let did = "did:plc:trim";
replace_sub_refs(&pool, did, &[feed_id]).await?;
let kept = entries_for_feed(&pool, did, feed_id).await?;
assert_eq!(
kept.len(),
2,
"over-cap feed trimmed to the newest 2 entries"
);
assert_eq!(kept[0].guid, "g-4");
assert_eq!(kept[1].guid, "g-3");
Ok(())
}
#[tokio::test]
async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: "https://undated.example/feed.xml".to_string(),
..Default::default()
},
)
.await?;
let batch = vec![
NewEntry {
guid: "old-dated-1".to_string(),
title: Some("Old A".to_string()),
published: Some("2026-07-01T00:00:00Z".to_string()),
fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
..Default::default()
},
NewEntry {
guid: "old-dated-2".to_string(),
title: Some("Old B".to_string()),
published: Some("2026-07-02T00:00:00Z".to_string()),
fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
..Default::default()
},
NewEntry {
guid: "fresh-undated".to_string(),
title: Some("Fresh undated".to_string()),
published: None,
fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
..Default::default()
},
];
insert_entries(&pool, feed_id, &batch, 2).await?;
let did = "did:plc:undated";
replace_sub_refs(&pool, did, &[feed_id]).await?;
let kept = entries_for_feed(&pool, did, feed_id).await?;
assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
assert!(
guids.contains(&"fresh-undated"),
"the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
);
assert!(
guids.contains(&"old-dated-2"),
"the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
);
assert!(
!guids.contains(&"old-dated-1"),
"the oldest dated entry is the one that should be evicted, kept: {guids:?}"
);
Ok(())
}
#[tokio::test]
async fn db_size_is_positive_and_grows() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let before = db_size_bytes(&pool).await?;
assert!(before > 0, "a schema-initialised DB has a non-zero size");
Ok(())
}
#[tokio::test]
async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: "https://example.com/feed.xml".to_string(),
title: Some("Example".to_string()),
..Default::default()
},
)
.await?;
insert_entries(
&pool,
feed_id,
&[NewEntry {
guid: "g-1".to_string(),
url: Some("https://example.com/a".to_string()),
title: Some("First".to_string()),
published: Some("2026-07-10T08:00:00Z".to_string()),
..Default::default()
}],
0,
)
.await?;
let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
.fetch_one(&pool)
.await?;
let victim = "did:plc:victim";
let bystander = "did:plc:bystander";
for did in [victim, bystander] {
replace_sub_refs(&pool, did, &[feed_id]).await?;
assert!(mark_read(&pool, did, entry_id, true).await?);
assert!(mark_starred(&pool, did, entry_id, true).await?);
upsert_cursor(
&pool,
&ReadCursor {
did: did.to_string(),
feed_url: "https://example.com/feed.xml".to_string(),
read_through: Some("2026-07-10T08:00:00Z".to_string()),
read_ids: "[]".to_string(),
unread_ids: "[]".to_string(),
dirty: false,
pds_created: false,
updated_at: now_rfc3339(),
},
)
.await?;
grant_access(&pool, did, Some("h.example"), "admin", None).await?;
mint_code(&pool, did, 3600).await?;
}
let counts = purge_did_data(&pool, victim).await?;
assert_eq!(
counts.entry_state, 1,
"one entry_state row (read+star merge)"
);
assert_eq!(counts.read_cursor, 1);
assert_eq!(counts.sub_ref, 1);
assert_eq!(counts.beta_access, 1);
assert_eq!(counts.invite_codes, 1);
assert_eq!(counts.total(), 5);
let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
.bind(victim)
.fetch_one(&pool)
.await?;
assert_eq!(es, 0, "victim still had entry_state rows");
let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
.bind(victim)
.fetch_one(&pool)
.await?;
assert_eq!(rc, 0, "victim still had read_cursor rows");
let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
.bind(victim)
.fetch_one(&pool)
.await?;
assert_eq!(sr, 0, "victim still had sub_ref rows");
assert!(
!has_beta_access(&pool, victim).await?,
"victim still had a beta seat"
);
let victim_codes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
.bind(victim)
.fetch_one(&pool)
.await?;
assert_eq!(victim_codes, 0);
assert!(has_beta_access(&pool, bystander).await?);
let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
let bystander_codes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
.bind(bystander)
.fetch_one(&pool)
.await?;
assert_eq!(bystander_codes, 1);
assert_eq!(count_feeds(&pool).await?, 1);
let again = purge_did_data(&pool, victim).await?;
assert_eq!(again.total(), 0);
Ok(())
}
#[tokio::test]
async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let inviter = "did:plc:inviter";
let leaver = "did:plc:leaver";
let friend = "did:plc:friend";
let inviter_code = mint_code(&pool, inviter, 3600).await?;
grant_access(&pool, inviter, None, "admin", None).await?;
assert_eq!(
redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
Ok(())
);
let leaver_code = mint_code(&pool, leaver, 3600).await?;
assert_eq!(
redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
Ok(())
);
let invitee_before: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
.bind(leaver)
.fetch_one(&pool)
.await?;
assert_eq!(
invitee_before, 1,
"leaver should be an invitee before purge"
);
let granted_before: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
.bind(leaver)
.fetch_one(&pool)
.await?;
assert_eq!(granted_before, 1, "leaver should be a granter before purge");
let counts = purge_did_data(&pool, leaver).await?;
assert_eq!(
counts.invitee_scrubbed, 1,
"the redeemed code's invitee_did"
);
assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
let invitee_after: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
.bind(leaver)
.fetch_one(&pool)
.await?;
assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
let granted_after: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
.bind(leaver)
.fetch_one(&pool)
.await?;
assert_eq!(granted_after, 0, "leaver survived in granted_by");
assert!(
has_beta_access(&pool, friend).await?,
"friend's seat must survive the leaver's scrub"
);
let friend_granted_by: String =
sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
.bind(friend)
.fetch_one(&pool)
.await?;
assert_eq!(friend_granted_by, REDACTED_DID);
let inviter_code_rows: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
.bind(inviter)
.fetch_one(&pool)
.await?;
assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
Ok(())
}
#[tokio::test]
async fn feed_error_count_bumps_and_resets() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let url = "https://broken.example/feed.xml";
upsert_feed(
&pool,
&NewFeed {
url: url.to_string(),
..Default::default()
},
)
.await?;
let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
assert_eq!(feed.consecutive_errors, 0);
let mut last = std::time::Duration::ZERO;
for expected in 1..=3 {
let count = bump_feed_errors(&pool, url).await?;
assert_eq!(count, expected, "bump returns the new count");
let backoff = crate::feed::backoff_for(count as u32);
assert!(
backoff >= last,
"backoff must not shrink as errors accumulate"
);
last = backoff;
}
assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
assert_eq!(
get_feed_by_url(&pool, url)
.await?
.unwrap()
.consecutive_errors,
3
);
reset_feed_errors(&pool, url).await?;
assert_eq!(
get_feed_by_url(&pool, url)
.await?
.unwrap()
.consecutive_errors,
0
);
Ok(())
}
#[tokio::test]
async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
let dir = std::env::temp_dir();
let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
let url = format!("sqlite://{}", path.display());
let pool = init_url(&url).await?;
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: "https://bulk.example/feed.xml".to_string(),
..Default::default()
},
)
.await?;
let entries: Vec<NewEntry> = (0..2000)
.map(|i| NewEntry {
guid: format!("guid-{i}"),
title: Some(format!("Entry number {i} with some padding text")),
content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
published: Some("2026-01-01T00:00:00Z".to_string()),
..Default::default()
})
.collect();
insert_entries(&pool, feed_id, &entries, 0).await?;
let full = db_size_bytes(&pool).await?;
assert!(full > 0);
sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
.bind(feed_id)
.execute(&pool)
.await?;
let after_delete = db_size_bytes(&pool).await?;
assert!(
after_delete < full,
"used size must drop once rows are deleted (freed pages excluded): \
{after_delete} !< {full}"
);
reclaim(&pool).await?;
let after_reclaim = db_size_bytes(&pool).await?;
assert!(
after_reclaim <= after_delete,
"reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
);
assert!(
after_reclaim < full,
"after prune+reclaim the DB is smaller than when full: \
{after_reclaim} !< {full}"
);
drop(pool);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}-wal", path.display()));
let _ = std::fs::remove_file(format!("{}-shm", path.display()));
Ok(())
}
#[tokio::test]
async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let did = "did:plc:f4";
let feed_url = "https://example.com/feed.xml";
upsert_cursor(
&pool,
&ReadCursor {
did: did.to_string(),
feed_url: feed_url.to_string(),
read_through: None,
read_ids: r#"["1"]"#.to_string(),
unread_ids: "[]".to_string(),
dirty: true,
pds_created: false,
updated_at: now_rfc3339(),
},
)
.await?;
let c = get_cursor(&pool, did, feed_url).await?.unwrap();
assert!(!c.pds_created, "first flush must emit a create, not update");
mark_cursor_pds_created(&pool, did, feed_url).await?;
let c = get_cursor(&pool, did, feed_url).await?.unwrap();
assert!(c.pds_created);
Ok(())
}
async fn count_entries(pool: &SqlitePool) -> Result<i64> {
Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
.fetch_one(pool)
.await?)
}
#[tokio::test]
async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: "https://ret.example/feed.xml".to_string(),
..Default::default()
},
)
.await?;
let recent = now_rfc3339();
let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
insert_entries(
&pool,
feed_id,
&[
NewEntry {
guid: "fresh".into(),
published: Some(recent.clone()),
fetched_at: Some(recent.clone()),
..Default::default()
},
NewEntry {
guid: "ancient".into(),
published: Some(ancient.clone()),
fetched_at: Some(ancient.clone()),
..Default::default()
},
NewEntry {
guid: "undated-fresh".into(),
published: None,
fetched_at: Some(recent.clone()),
..Default::default()
},
],
0,
)
.await?;
assert_eq!(count_entries(&pool).await?, 3);
replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
.fetch_one(&pool)
.await?;
let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
assert!(wrote, "mark_read must write with a sub_ref in place");
let state_before: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
.bind(ancient_id)
.fetch_one(&pool)
.await?;
assert_eq!(state_before, 1);
let deleted = prune_old_entries(&pool, 90).await?;
assert_eq!(deleted, 1, "only the year-old entry should be pruned");
assert_eq!(
count_entries(&pool).await?,
2,
"fresh + undated-fresh survive"
);
let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
.fetch_all(&pool)
.await?;
assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
let state_after: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
.bind(ancient_id)
.fetch_one(&pool)
.await?;
assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
assert_eq!(prune_old_entries(&pool, 0).await?, 0);
assert_eq!(count_entries(&pool).await?, 2);
Ok(())
}
#[tokio::test]
async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let did = "did:plc:reader";
let feed_url = "https://orphan.example/feed.xml";
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: feed_url.to_string(),
..Default::default()
},
)
.await?;
replace_sub_refs(&pool, did, &[feed_id]).await?;
let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let recent = now_rfc3339();
insert_entries(
&pool,
feed_id,
&[
NewEntry {
guid: "old".into(),
published: Some(ancient.clone()),
fetched_at: Some(ancient.clone()),
..Default::default()
},
NewEntry {
guid: "new".into(),
published: Some(recent.clone()),
fetched_at: Some(recent.clone()),
..Default::default()
},
],
0,
)
.await?;
let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
.fetch_one(&pool)
.await?;
let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
.fetch_one(&pool)
.await?;
mark_read(&pool, did, old_id, true).await?;
mark_read(&pool, did, new_id, true).await?;
let before = get_cursor(&pool, did, feed_url).await?.unwrap();
let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
assert!(ids_before.contains(&old_id.to_string()));
assert!(ids_before.contains(&new_id.to_string()));
let deleted = prune_old_entries(&pool, 90).await?;
assert_eq!(deleted, 1);
let after = get_cursor(&pool, did, feed_url).await?.unwrap();
let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
assert_eq!(
ids_after,
vec![new_id.to_string()],
"orphaned (deleted) entry id must be removed; live id kept"
);
assert!(
after.dirty,
"cursor must be marked dirty after orphan scrub"
);
Ok(())
}
#[tokio::test]
async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
let pool = init_url("sqlite::memory:").await?;
let did = "did:plc:reader";
let feed_url = "https://trim.example/feed.xml";
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: feed_url.to_string(),
..Default::default()
},
)
.await?;
replace_sub_refs(&pool, did, &[feed_id]).await?;
insert_entries(
&pool,
feed_id,
&[
NewEntry {
guid: "a".into(),
published: Some("2026-01-01T00:00:00Z".into()),
..Default::default()
},
NewEntry {
guid: "b".into(),
published: Some("2026-01-02T00:00:00Z".into()),
..Default::default()
},
],
2,
)
.await?;
let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
.fetch_one(&pool)
.await?;
mark_read(&pool, did, a_id, true).await?;
insert_entries(
&pool,
feed_id,
&[NewEntry {
guid: "c".into(),
published: Some("2026-01-03T00:00:00Z".into()),
..Default::default()
}],
1,
)
.await?;
let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
.fetch_one(&pool)
.await?;
assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
assert!(
!ids.contains(&a_id.to_string()),
"trimmed entry id must be scrubbed from the cursor"
);
Ok(())
}
#[tokio::test]
async fn prune_and_reclaim_drops_db_size() -> Result<()> {
let dir = std::env::temp_dir();
let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
let url = format!("sqlite://{}", path.display());
let pool = init_url(&url).await?;
let feed_id = upsert_feed(
&pool,
&NewFeed {
url: "https://bulk.example/feed.xml".to_string(),
..Default::default()
},
)
.await?;
let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let entries: Vec<NewEntry> = (0..2000)
.map(|i| NewEntry {
guid: format!("guid-{i}"),
content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
published: Some(ancient.clone()),
fetched_at: Some(ancient.clone()),
..Default::default()
})
.collect();
insert_entries(&pool, feed_id, &entries, 0).await?;
let full = db_size_bytes(&pool).await?;
assert!(full > 0);
let deleted = prune_old_entries(&pool, 90).await?;
assert_eq!(deleted, 2000);
reclaim(&pool).await?;
let after = db_size_bytes(&pool).await?;
assert!(
after < full,
"prune + reclaim must shrink db_size_bytes: {after} !< {full}"
);
drop(pool);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}-wal", path.display()));
let _ = std::fs::remove_file(format!("{}-shm", path.display()));
Ok(())
}
}