1use 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#[derive(Debug, thiserror::Error, PartialEq, Eq)]
33pub enum RedeemError {
34 #[error("invite code not found")]
36 NotFound,
37 #[error("invite code expired")]
40 Expired,
41 #[error("invite code already redeemed")]
43 AlreadyRedeemed,
44 #[error("beta is at capacity")]
46 CapacityFull,
47}
48
49pub type Pool = SqlitePool;
54
55#[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 pub etag: Option<String>,
67 pub last_modified: Option<String>,
69 pub last_polled: Option<String>,
71 pub next_poll: Option<String>,
73 #[sqlx(default)]
76 pub consecutive_errors: i64,
77}
78
79#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
81pub struct Entry {
82 pub id: i64,
83 pub feed_id: i64,
84 pub guid: String,
86 pub url: Option<String>,
87 pub title: Option<String>,
88 pub author: Option<String>,
89 pub published: Option<String>,
91 pub content_html: Option<String>,
93 pub fetched_at: String,
95}
96
97#[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#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
115pub struct ReadCursor {
116 pub did: String,
117 pub feed_url: String,
118 pub read_through: Option<String>,
120 pub read_ids: String,
122 pub unread_ids: String,
124 pub dirty: bool,
126 #[sqlx(default)]
132 pub pds_created: bool,
133 pub updated_at: String,
134}
135
136#[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#[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 pub content_html: Option<String>,
159 pub fetched_at: Option<String>,
161}
162
163const 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 created_at INTEGER NOT NULL,
253 expires_at INTEGER NOT NULL,
254 redeemed_at INTEGER
255);
256CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
257"#;
258
259fn now_rfc3339() -> String {
263 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
264}
265
266pub async fn init(config: &Config) -> Result<Pool> {
273 let db_url = format!("sqlite://{}", config.db_path.display());
275 init_url(&db_url).await
276}
277
278pub async fn init_url(db_url: &str) -> Result<Pool> {
286 let is_memory = db_url.contains(":memory:");
293 let mut opts = SqliteConnectOptions::from_str(db_url)
294 .with_context(|| format!("invalid sqlite url: {db_url}"))?
295 .create_if_missing(true)
296 .foreign_keys(true);
297 if !is_memory {
299 opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
300 }
301 opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
310 opts = opts.log_statements(tracing::log::LevelFilter::Debug);
312
313 let pool = SqlitePoolOptions::new()
314 .min_connections(1)
317 .max_connections(if is_memory { 1 } else { 5 })
318 .connect_with(opts)
319 .await
320 .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
321
322 init_schema(&pool).await?;
323 Ok(pool)
324}
325
326pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
329 sqlx::query(SCHEMA)
331 .execute(pool)
332 .await
333 .context("failed to create schema")?;
334 apply_migrations(pool).await?;
335 Ok(())
336}
337
338async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
343 ensure_column(
346 pool,
347 "PRAGMA table_info(feeds)",
348 "consecutive_errors",
349 "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
350 )
351 .await?;
352 ensure_column(
356 pool,
357 "PRAGMA table_info(read_cursor)",
358 "pds_created",
359 "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
360 )
361 .await?;
362 Ok(())
363}
364
365async fn ensure_column(
370 pool: &SqlitePool,
371 info_sql: &'static str,
372 column: &str,
373 alter_sql: &'static str,
374) -> Result<()> {
375 let rows = sqlx::query(info_sql)
376 .fetch_all(pool)
377 .await
378 .with_context(|| format!("{info_sql} failed"))?;
379 let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
380 if !present {
381 sqlx::query(alter_sql)
382 .execute(pool)
383 .await
384 .with_context(|| format!("adding column {column} via {alter_sql}"))?;
385 }
386 Ok(())
387}
388
389pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
392 let row = sqlx::query(
393 r#"
394 INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll)
395 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
396 ON CONFLICT (url) DO UPDATE SET
397 title = COALESCE(excluded.title, feeds.title),
398 site_url = COALESCE(excluded.site_url, feeds.site_url),
399 etag = excluded.etag,
400 last_modified = excluded.last_modified,
401 last_polled = COALESCE(excluded.last_polled, feeds.last_polled),
402 next_poll = COALESCE(excluded.next_poll, feeds.next_poll)
403 RETURNING id
404 "#,
405 )
406 .bind(&feed.url)
407 .bind(&feed.title)
408 .bind(&feed.site_url)
409 .bind(&feed.etag)
410 .bind(&feed.last_modified)
411 .bind(&feed.last_polled)
412 .bind(&feed.next_poll)
413 .fetch_one(pool)
414 .await
415 .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
416
417 Ok(row.get::<i64, _>("id"))
418}
419
420pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
422 let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
423 .bind(url)
424 .fetch_optional(pool)
425 .await
426 .with_context(|| format!("get_feed_by_url failed for {url}"))?;
427 Ok(feed)
428}
429
430pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
433 let feeds = sqlx::query_as::<_, Feed>(
434 r#"
435 SELECT * FROM feeds
436 WHERE next_poll IS NULL OR next_poll <= ?1
437 ORDER BY next_poll IS NOT NULL, next_poll ASC
438 LIMIT ?2
439 "#,
440 )
441 .bind(as_of)
442 .bind(limit)
443 .fetch_all(pool)
444 .await
445 .context("due_feeds failed")?;
446 Ok(feeds)
447}
448
449pub async fn bump_feed_errors(pool: &SqlitePool, url: &str) -> Result<i64> {
455 let row = sqlx::query(
456 "UPDATE feeds SET consecutive_errors = consecutive_errors + 1 \
457 WHERE url = ?1 RETURNING consecutive_errors",
458 )
459 .bind(url)
460 .fetch_optional(pool)
461 .await
462 .with_context(|| format!("bump_feed_errors failed for {url}"))?;
463 Ok(row
465 .map(|r| r.get::<i64, _>("consecutive_errors"))
466 .unwrap_or(1))
467}
468
469pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
472 sqlx::query("UPDATE feeds SET consecutive_errors = 0 WHERE url = ?1")
473 .bind(url)
474 .execute(pool)
475 .await
476 .with_context(|| format!("reset_feed_errors failed for {url}"))?;
477 Ok(())
478}
479
480pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
485 let feeds = sqlx::query_as::<_, Feed>(
486 r#"
487 SELECT f.* FROM feeds f
488 JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
489 ORDER BY f.title IS NULL, f.title, f.url
490 "#,
491 )
492 .bind(did)
493 .fetch_all(pool)
494 .await
495 .with_context(|| format!("feeds_for_did failed for {did}"))?;
496 Ok(feeds)
497}
498
499pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
504 let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
505 .bind(did)
506 .fetch_all(pool)
507 .await
508 .with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
509 Ok(ids)
510}
511
512pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
515 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
516 .bind(did)
517 .fetch_one(pool)
518 .await
519 .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
520 Ok(n)
521}
522
523pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
526 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
527 .fetch_one(pool)
528 .await
529 .context("count_feeds failed")?;
530 Ok(n)
531}
532
533pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
545 let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
546 .fetch_one(pool)
547 .await
548 .context("PRAGMA page_count failed")?;
549 let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
550 .fetch_one(pool)
551 .await
552 .context("PRAGMA freelist_count failed")?;
553 let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
554 .fetch_one(pool)
555 .await
556 .context("PRAGMA page_size failed")?;
557 let used_pages = page_count.saturating_sub(freelist_count).max(0);
558 Ok(used_pages.saturating_mul(page_size))
559}
560
561pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
572 let auto_vacuum: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
573 .fetch_one(pool)
574 .await
575 .context("PRAGMA auto_vacuum failed")?;
576 if auto_vacuum == 2 {
580 sqlx::query("PRAGMA incremental_vacuum")
581 .execute(pool)
582 .await
583 .context("PRAGMA incremental_vacuum failed")?;
584 } else {
585 sqlx::query("VACUUM")
586 .execute(pool)
587 .await
588 .context("VACUUM failed")?;
589 }
590 Ok(())
591}
592
593pub async fn insert_entries(
603 pool: &SqlitePool,
604 feed_id: i64,
605 entries: &[NewEntry],
606 max_entries_per_feed: i64,
607) -> Result<u64> {
608 let mut tx = pool.begin().await.context("begin insert_entries tx")?;
609 let mut count: u64 = 0;
610 for e in entries {
611 let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
612 let res = sqlx::query(
613 r#"
614 INSERT INTO entries
615 (feed_id, guid, url, title, author, published, content_html, fetched_at)
616 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
617 ON CONFLICT (feed_id, guid) DO UPDATE SET
618 url = excluded.url,
619 title = excluded.title,
620 author = excluded.author,
621 published = excluded.published,
622 content_html = excluded.content_html
623 "#,
624 )
625 .bind(feed_id)
626 .bind(&e.guid)
627 .bind(&e.url)
628 .bind(&e.title)
629 .bind(&e.author)
630 .bind(&e.published)
631 .bind(&e.content_html)
632 .bind(&fetched_at)
633 .execute(&mut *tx)
634 .await
635 .with_context(|| format!("insert entry {} failed", e.guid))?;
636 count += res.rows_affected();
637 }
638
639 if max_entries_per_feed > 0 {
647 sqlx::query(
648 r#"
649 DELETE FROM entries
650 WHERE feed_id = ?1
651 AND id NOT IN (
652 SELECT id FROM entries
653 WHERE feed_id = ?1
654 ORDER BY COALESCE(published, fetched_at) DESC, id DESC
655 LIMIT ?2
656 )
657 "#,
658 )
659 .bind(feed_id)
660 .bind(max_entries_per_feed)
661 .execute(&mut *tx)
662 .await
663 .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
664 }
665
666 if max_entries_per_feed > 0 {
672 prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
673 }
674
675 tx.commit().await.context("commit insert_entries tx")?;
676 Ok(count)
677}
678
679pub async fn prune_old_entries(pool: &SqlitePool, days: i64) -> Result<u64> {
691 if days <= 0 {
692 return Ok(0);
693 }
694 let cutoff = (chrono::Utc::now() - chrono::Duration::days(days))
695 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
696
697 let mut tx = pool.begin().await.context("begin prune_old_entries tx")?;
698 let res = sqlx::query("DELETE FROM entries WHERE COALESCE(published, fetched_at) < ?1")
699 .bind(&cutoff)
700 .execute(&mut *tx)
701 .await
702 .with_context(|| format!("prune_old_entries delete (cutoff {cutoff})"))?;
703 let deleted = res.rows_affected();
704
705 if deleted > 0 {
707 prune_orphan_cursor_ids_tx(&mut tx, None).await?;
708 }
709
710 tx.commit().await.context("commit prune_old_entries tx")?;
711 Ok(deleted)
712}
713
714async fn prune_orphan_cursor_ids_tx(
727 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
728 feed_id: Option<i64>,
729) -> Result<u64> {
730 let feed_url = match feed_id {
734 Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
735 Some(u) => Some(u),
736 None => return Ok(0), },
738 None => None,
739 };
740
741 let cursors: Vec<(String, String, String, String)> = match &feed_url {
743 Some(url) => sqlx::query(
744 "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
745 )
746 .bind(url)
747 .fetch_all(&mut **tx)
748 .await
749 .context("prune_orphan_cursor_ids: load feed cursors")?,
750 None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
751 .fetch_all(&mut **tx)
752 .await
753 .context("prune_orphan_cursor_ids: load all cursors")?,
754 }
755 .into_iter()
756 .map(|r| {
757 (
758 r.get::<String, _>("did"),
759 r.get::<String, _>("feed_url"),
760 r.get::<String, _>("read_ids"),
761 r.get::<String, _>("unread_ids"),
762 )
763 })
764 .collect();
765
766 if cursors.is_empty() {
767 return Ok(0);
768 }
769
770 let now = now_rfc3339();
771 let mut changed: u64 = 0;
772 for (did, curl, read_ids, unread_ids) in cursors {
773 let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
775 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
776 )
777 .bind(&curl)
778 .fetch_all(&mut **tx)
779 .await
780 .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
781 .into_iter()
782 .collect();
783
784 let new_read = filter_id_set_to_live(&read_ids, &live);
785 let new_unread = filter_id_set_to_live(&unread_ids, &live);
786 if new_read == read_ids && new_unread == unread_ids {
787 continue; }
789 sqlx::query(
790 "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
791 WHERE did = ?1 AND feed_url = ?2",
792 )
793 .bind(&did)
794 .bind(&curl)
795 .bind(&new_read)
796 .bind(&new_unread)
797 .bind(&now)
798 .execute(&mut **tx)
799 .await
800 .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
801 changed += 1;
802 }
803 Ok(changed)
804}
805
806fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
810 let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
811 .ok()
812 .map(|vals| {
813 vals.into_iter()
814 .filter_map(|v| match v {
815 serde_json::Value::Number(n) => n.as_i64(),
816 serde_json::Value::String(s) => s.parse::<i64>().ok(),
817 _ => None,
818 })
819 .filter(|id| live.contains(id))
820 .collect()
821 })
822 .unwrap_or_default();
823 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
824 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
825}
826
827pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
835 let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
836 sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
837 .bind(did)
838 .execute(&mut *tx)
839 .await
840 .with_context(|| format!("clear sub_ref for {did}"))?;
841 for &feed_id in feed_ids {
842 sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
843 .bind(did)
844 .bind(feed_id)
845 .execute(&mut *tx)
846 .await
847 .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
848 }
849 tx.commit().await.context("commit replace_sub_refs tx")?;
850 Ok(())
851}
852
853pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
857 let found: Option<i64> = sqlx::query_scalar(
858 r#"
859 SELECT 1
860 FROM entries e
861 JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
862 WHERE e.id = ?2
863 "#,
864 )
865 .bind(did)
866 .bind(entry_id)
867 .fetch_optional(pool)
868 .await
869 .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
870 Ok(found.is_some())
871}
872
873pub async fn entries_for_feed(pool: &SqlitePool, did: &str, feed_id: i64) -> Result<Vec<Entry>> {
876 let entries = sqlx::query_as::<_, Entry>(
877 r#"
878 SELECT e.* FROM entries e
879 WHERE e.feed_id = ?2
880 AND EXISTS (
881 SELECT 1 FROM sub_ref sr
882 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
883 )
884 ORDER BY e.published DESC, e.id DESC
885 "#,
886 )
887 .bind(did)
888 .bind(feed_id)
889 .fetch_all(pool)
890 .await
891 .context("entries_for_feed failed")?;
892 Ok(entries)
893}
894
895pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
906 let now = now_rfc3339();
907 let mut tx = pool.begin().await.context("begin mark_read tx")?;
908 let res = sqlx::query(
909 r#"
910 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
911 SELECT ?1, e.id, ?3, 0, ?4
912 FROM entries e
913 WHERE e.id = ?2
914 AND EXISTS (
915 SELECT 1 FROM sub_ref sr
916 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
917 )
918 ON CONFLICT (did, entry_id) DO UPDATE SET
919 read = excluded.read,
920 updated_at = excluded.updated_at
921 "#,
922 )
923 .bind(did)
924 .bind(entry_id)
925 .bind(read)
926 .bind(&now)
927 .execute(&mut *tx)
928 .await
929 .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
930
931 if res.rows_affected() == 0 {
932 tx.rollback().await.ok();
934 return Ok(false);
935 }
936
937 project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
941
942 tx.commit().await.context("commit mark_read tx")?;
943 Ok(true)
944}
945
946pub async fn mark_starred(
952 pool: &SqlitePool,
953 did: &str,
954 entry_id: i64,
955 starred: bool,
956) -> Result<bool> {
957 let res = sqlx::query(
958 r#"
959 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
960 SELECT ?1, e.id, 0, ?3, ?4
961 FROM entries e
962 WHERE e.id = ?2
963 AND EXISTS (
964 SELECT 1 FROM sub_ref sr
965 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
966 )
967 ON CONFLICT (did, entry_id) DO UPDATE SET
968 starred = excluded.starred,
969 updated_at = excluded.updated_at
970 "#,
971 )
972 .bind(did)
973 .bind(entry_id)
974 .bind(starred)
975 .bind(now_rfc3339())
976 .execute(pool)
977 .await
978 .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
979 Ok(res.rows_affected() > 0)
980}
981
982pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
987 let now = now_rfc3339();
988 let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
989 let res = sqlx::query(
990 r#"
991 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
992 SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
993 WHERE e.feed_id = ?4
994 AND EXISTS (
995 SELECT 1 FROM sub_ref sr
996 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
997 )
998 ON CONFLICT (did, entry_id) DO UPDATE SET
999 read = excluded.read,
1000 updated_at = excluded.updated_at
1001 "#,
1002 )
1003 .bind(did)
1004 .bind(read)
1005 .bind(&now)
1006 .bind(feed_id)
1007 .execute(&mut *tx)
1008 .await
1009 .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
1010
1011 if res.rows_affected() > 0 {
1012 project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
1017 }
1018
1019 tx.commit().await.context("commit mark_feed_read tx")?;
1020 Ok(res.rows_affected())
1021}
1022
1023fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
1033 let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
1034 .ok()
1035 .map(|vals| {
1036 vals.into_iter()
1037 .filter_map(|v| match v {
1038 serde_json::Value::Number(n) => n.as_i64(),
1039 serde_json::Value::String(s) => s.parse::<i64>().ok(),
1040 _ => None,
1041 })
1042 .collect()
1043 })
1044 .unwrap_or_default();
1045 if present {
1046 if !ids.contains(&id) {
1047 ids.push(id);
1048 }
1049 } else {
1050 ids.retain(|&x| x != id);
1051 }
1052 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
1055 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
1056}
1057
1058async fn feed_url_for_id_tx(
1061 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1062 feed_id: i64,
1063) -> Result<Option<String>> {
1064 let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
1065 .bind(feed_id)
1066 .fetch_optional(&mut **tx)
1067 .await
1068 .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
1069 Ok(url)
1070}
1071
1072async fn cursor_sets(
1075 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1076 did: &str,
1077 feed_url: &str,
1078) -> Result<(Option<String>, String, String)> {
1079 let row = sqlx::query(
1080 "SELECT read_through, read_ids, unread_ids FROM read_cursor \
1081 WHERE did = ?1 AND feed_url = ?2",
1082 )
1083 .bind(did)
1084 .bind(feed_url)
1085 .fetch_optional(&mut **tx)
1086 .await
1087 .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
1088 Ok(match row {
1089 Some(r) => (
1090 r.get::<Option<String>, _>("read_through"),
1091 r.get::<String, _>("read_ids"),
1092 r.get::<String, _>("unread_ids"),
1093 ),
1094 None => (None, "[]".to_string(), "[]".to_string()),
1095 })
1096}
1097
1098async fn write_cursor_sets(
1101 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1102 did: &str,
1103 feed_url: &str,
1104 read_through: Option<&str>,
1105 read_ids: &str,
1106 unread_ids: &str,
1107 now: &str,
1108) -> Result<()> {
1109 sqlx::query(
1110 r#"
1111 INSERT INTO read_cursor
1112 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
1113 VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
1114 ON CONFLICT (did, feed_url) DO UPDATE SET
1115 read_through = excluded.read_through,
1116 read_ids = excluded.read_ids,
1117 unread_ids = excluded.unread_ids,
1118 dirty = 1,
1119 updated_at = excluded.updated_at
1120 "#,
1121 )
1122 .bind(did)
1123 .bind(feed_url)
1124 .bind(read_through)
1125 .bind(read_ids)
1126 .bind(unread_ids)
1127 .bind(now)
1128 .execute(&mut **tx)
1129 .await
1130 .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
1131 Ok(())
1132}
1133
1134async fn project_entry_into_cursor(
1144 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1145 did: &str,
1146 entry_id: i64,
1147 read: bool,
1148 now: &str,
1149) -> Result<()> {
1150 let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
1152 .bind(entry_id)
1153 .fetch_optional(&mut **tx)
1154 .await
1155 .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
1156 let feed_id = match feed_id {
1157 Some(f) => f,
1158 None => return Ok(()), };
1160 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
1161 Some(u) => u,
1162 None => return Ok(()),
1163 };
1164
1165 let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
1166 let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
1168 let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
1169 write_cursor_sets(
1170 tx,
1171 did,
1172 &feed_url,
1173 read_through.as_deref(),
1174 &read_ids,
1175 &unread_ids,
1176 now,
1177 )
1178 .await
1179}
1180
1181async fn project_feed_into_cursor(
1188 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1189 did: &str,
1190 feed_id: i64,
1191 read: bool,
1192 now: &str,
1193) -> Result<()> {
1194 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
1195 Some(u) => u,
1196 None => return Ok(()),
1197 };
1198
1199 let ids: Vec<i64> = sqlx::query_scalar(
1201 r#"
1202 SELECT e.id FROM entries e
1203 WHERE e.feed_id = ?2
1204 AND EXISTS (
1205 SELECT 1 FROM sub_ref sr
1206 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1207 )
1208 "#,
1209 )
1210 .bind(did)
1211 .bind(feed_id)
1212 .fetch_all(&mut **tx)
1213 .await
1214 .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
1215
1216 let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
1217 for id in ids {
1218 read_ids = json_id_set_toggle(&read_ids, id, read);
1219 unread_ids = json_id_set_toggle(&unread_ids, id, !read);
1220 }
1221 write_cursor_sets(
1222 tx,
1223 did,
1224 &feed_url,
1225 read_through.as_deref(),
1226 &read_ids,
1227 &unread_ids,
1228 now,
1229 )
1230 .await
1231}
1232
1233pub async fn get_unread_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
1237 let entries = sqlx::query_as::<_, Entry>(
1238 r#"
1239 SELECT e.*
1240 FROM entries e
1241 LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
1242 WHERE COALESCE(s.read, 0) = 0
1243 AND EXISTS (
1244 SELECT 1 FROM sub_ref sr
1245 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1246 )
1247 ORDER BY e.published DESC, e.id DESC
1248 "#,
1249 )
1250 .bind(did)
1251 .fetch_all(pool)
1252 .await
1253 .with_context(|| format!("get_unread_for_did failed for {did}"))?;
1254 Ok(entries)
1255}
1256
1257pub async fn get_starred_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
1259 let entries = sqlx::query_as::<_, Entry>(
1260 r#"
1261 SELECT e.*
1262 FROM entries e
1263 JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
1264 WHERE s.starred = 1
1265 AND EXISTS (
1266 SELECT 1 FROM sub_ref sr
1267 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1268 )
1269 ORDER BY e.published DESC, e.id DESC
1270 "#,
1271 )
1272 .bind(did)
1273 .fetch_all(pool)
1274 .await
1275 .with_context(|| format!("get_starred_for_did failed for {did}"))?;
1276 Ok(entries)
1277}
1278
1279pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
1283 sqlx::query(
1284 r#"
1285 INSERT INTO read_cursor
1286 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
1287 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1288 ON CONFLICT (did, feed_url) DO UPDATE SET
1289 read_through = excluded.read_through,
1290 read_ids = excluded.read_ids,
1291 unread_ids = excluded.unread_ids,
1292 dirty = excluded.dirty,
1293 updated_at = excluded.updated_at
1294 "#,
1295 )
1296 .bind(&cursor.did)
1297 .bind(&cursor.feed_url)
1298 .bind(&cursor.read_through)
1299 .bind(&cursor.read_ids)
1300 .bind(&cursor.unread_ids)
1301 .bind(cursor.dirty)
1302 .bind(&cursor.updated_at)
1303 .execute(pool)
1304 .await
1305 .with_context(|| {
1306 format!(
1307 "upsert_cursor failed for {}/{}",
1308 cursor.did, cursor.feed_url
1309 )
1310 })?;
1311 Ok(())
1312}
1313
1314pub async fn get_cursor(
1316 pool: &SqlitePool,
1317 did: &str,
1318 feed_url: &str,
1319) -> Result<Option<ReadCursor>> {
1320 let cursor = sqlx::query_as::<_, ReadCursor>(
1321 "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
1322 )
1323 .bind(did)
1324 .bind(feed_url)
1325 .fetch_optional(pool)
1326 .await
1327 .context("get_cursor failed")?;
1328 Ok(cursor)
1329}
1330
1331pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
1334 let cursors =
1335 sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
1336 .bind(did)
1337 .fetch_all(pool)
1338 .await
1339 .with_context(|| format!("dirty_cursors failed for {did}"))?;
1340 Ok(cursors)
1341}
1342
1343pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
1347 sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
1348 .bind(did)
1349 .bind(feed_url)
1350 .execute(pool)
1351 .await
1352 .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
1353 Ok(())
1354}
1355
1356pub async fn clear_cursor_dirty(
1367 pool: &SqlitePool,
1368 did: &str,
1369 feed_url: &str,
1370 flushed_updated_at: &str,
1371) -> Result<()> {
1372 sqlx::query(
1373 "UPDATE read_cursor SET dirty = 0 \
1374 WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
1375 )
1376 .bind(did)
1377 .bind(feed_url)
1378 .bind(flushed_updated_at)
1379 .execute(pool)
1380 .await
1381 .context("clear_cursor_dirty failed")?;
1382 Ok(())
1383}
1384
1385fn now_unix() -> i64 {
1398 chrono::Utc::now().timestamp()
1399}
1400
1401const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1405
1406const CODE_PREFIX: &str = "FEATHER-";
1409
1410const CODE_BODY_LEN: usize = 8;
1412
1413pub fn generate_invite_code() -> Result<String> {
1420 let n = CODE_ALPHABET.len() as u16; let limit = 256 / n * n; let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
1425 out.push_str(CODE_PREFIX);
1426 let mut got = 0;
1427 let mut buf = [0u8; 1];
1428 while got < CODE_BODY_LEN {
1429 getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
1430 let b = buf[0] as u16;
1431 if b < limit {
1432 out.push(CODE_ALPHABET[(b % n) as usize] as char);
1433 got += 1;
1434 }
1435 }
1436 Ok(out)
1437}
1438
1439pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
1441 let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
1442 .bind(did)
1443 .fetch_optional(pool)
1444 .await
1445 .with_context(|| format!("has_beta_access failed for {did}"))?;
1446 Ok(row.is_some())
1447}
1448
1449pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
1452 let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
1453 .fetch_one(pool)
1454 .await
1455 .context("count_beta_access failed")?;
1456 Ok(row.get::<i64, _>("n"))
1457}
1458
1459pub async fn grant_access(
1462 pool: &SqlitePool,
1463 did: &str,
1464 handle: Option<&str>,
1465 granted_by: &str,
1466 invite_code_used: Option<&str>,
1467) -> Result<()> {
1468 sqlx::query(
1469 r#"
1470 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1471 VALUES (?1, ?2, ?3, ?4, ?5)
1472 ON CONFLICT (did) DO UPDATE SET
1473 handle = COALESCE(excluded.handle, beta_access.handle),
1474 granted_by = excluded.granted_by,
1475 invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
1476 "#,
1477 )
1478 .bind(did)
1479 .bind(handle)
1480 .bind(granted_by)
1481 .bind(now_unix())
1482 .bind(invite_code_used)
1483 .execute(pool)
1484 .await
1485 .with_context(|| format!("grant_access failed for {did}"))?;
1486 Ok(())
1487}
1488
1489pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
1492 let code = generate_invite_code()?;
1493 let now = now_unix();
1494 let expires_at = now.saturating_add(ttl_secs.max(0));
1495 sqlx::query(
1496 r#"
1497 INSERT INTO invite_codes
1498 (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
1499 VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)
1500 "#,
1501 )
1502 .bind(&code)
1503 .bind(creator_did)
1504 .bind(now)
1505 .bind(expires_at)
1506 .execute(pool)
1507 .await
1508 .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
1509 Ok(code)
1510}
1511
1512pub async fn redeem_code(
1524 pool: &SqlitePool,
1525 code: &str,
1526 did: &str,
1527 handle: Option<&str>,
1528 cap: i64,
1529) -> Result<std::result::Result<(), RedeemError>> {
1530 let now = now_unix();
1531 let mut tx = pool.begin().await.context("begin redeem_code tx")?;
1532
1533 sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
1544 .bind(code)
1545 .execute(&mut *tx)
1546 .await
1547 .context("redeem_code: acquire write lock")?;
1548
1549 let row = sqlx::query("SELECT status, expires_at FROM invite_codes WHERE code = ?1")
1551 .bind(code)
1552 .fetch_optional(&mut *tx)
1553 .await
1554 .context("redeem_code: lookup")?;
1555 let row = match row {
1556 Some(r) => r,
1557 None => return Ok(Err(RedeemError::NotFound)),
1558 };
1559 let status: String = row.get("status");
1560 let expires_at: i64 = row.get("expires_at");
1561
1562 if status == "expired" || now > expires_at {
1566 return Ok(Err(RedeemError::Expired));
1567 }
1568 if status != "active" {
1569 return Ok(Err(RedeemError::AlreadyRedeemed));
1570 }
1571
1572 let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
1574 .fetch_one(&mut *tx)
1575 .await
1576 .context("redeem_code: count")?
1577 .get("n");
1578 if count >= cap {
1579 return Ok(Err(RedeemError::CapacityFull));
1580 }
1581
1582 let flipped = sqlx::query(
1586 r#"
1587 UPDATE invite_codes
1588 SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
1589 WHERE code = ?1 AND status = 'active'
1590 "#,
1591 )
1592 .bind(code)
1593 .bind(did)
1594 .bind(now)
1595 .execute(&mut *tx)
1596 .await
1597 .context("redeem_code: flip")?;
1598 if flipped.rows_affected() == 0 {
1599 return Ok(Err(RedeemError::AlreadyRedeemed));
1600 }
1601
1602 sqlx::query(
1604 r#"
1605 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1606 VALUES (?1, ?2, ?3, ?4, ?5)
1607 ON CONFLICT (did) DO UPDATE SET
1608 handle = COALESCE(excluded.handle, beta_access.handle),
1609 invite_code_used = excluded.invite_code_used
1610 "#,
1611 )
1612 .bind(did)
1613 .bind(handle)
1614 .bind(
1616 sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
1617 .bind(code)
1618 .fetch_one(&mut *tx)
1619 .await
1620 .context("redeem_code: creator lookup")?
1621 .get::<String, _>("creator_did"),
1622 )
1623 .bind(now)
1624 .bind(code)
1625 .execute(&mut *tx)
1626 .await
1627 .context("redeem_code: grant")?;
1628
1629 tx.commit().await.context("commit redeem_code tx")?;
1630 Ok(Ok(()))
1631}
1632
1633pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
1637 let now = now_unix();
1638 let res = sqlx::query(
1639 "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
1640 )
1641 .bind(now)
1642 .execute(pool)
1643 .await
1644 .context("expire_old_codes failed")?;
1645 Ok(res.rows_affected())
1646}
1647
1648pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
1652 let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
1653 let now = now_unix();
1654 let mut created = 0u64;
1655 for did in dids {
1656 let res = sqlx::query(
1657 r#"
1658 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1659 VALUES (?1, NULL, 'admin', ?2, NULL)
1660 ON CONFLICT (did) DO NOTHING
1661 "#,
1662 )
1663 .bind(did)
1664 .bind(now)
1665 .execute(&mut *tx)
1666 .await
1667 .with_context(|| format!("ensure_seed insert failed for {did}"))?;
1668 created += res.rows_affected();
1669 }
1670 tx.commit().await.context("commit ensure_seed tx")?;
1671 Ok(created)
1672}
1673
1674#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1677pub struct PurgeCounts {
1678 pub entry_state: u64,
1680 pub read_cursor: u64,
1682 pub sub_ref: u64,
1684 pub beta_access: u64,
1686 pub invite_codes: u64,
1688 pub invitee_scrubbed: u64,
1691 pub granted_by_scrubbed: u64,
1694}
1695
1696impl PurgeCounts {
1697 pub fn total(&self) -> u64 {
1701 self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
1702 }
1703}
1704
1705pub const REDACTED_DID: &str = "__redacted__";
1709
1710pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
1722 let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
1723
1724 let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
1725 .bind(did)
1726 .execute(&mut *tx)
1727 .await
1728 .with_context(|| format!("purge entry_state for {did}"))?
1729 .rows_affected();
1730
1731 let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
1732 .bind(did)
1733 .execute(&mut *tx)
1734 .await
1735 .with_context(|| format!("purge read_cursor for {did}"))?
1736 .rows_affected();
1737
1738 let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
1739 .bind(did)
1740 .execute(&mut *tx)
1741 .await
1742 .with_context(|| format!("purge sub_ref for {did}"))?
1743 .rows_affected();
1744
1745 let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
1746 .bind(did)
1747 .execute(&mut *tx)
1748 .await
1749 .with_context(|| format!("purge beta_access for {did}"))?
1750 .rows_affected();
1751
1752 let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
1753 .bind(did)
1754 .execute(&mut *tx)
1755 .await
1756 .with_context(|| format!("purge invite_codes for {did}"))?
1757 .rows_affected();
1758
1759 let invitee_scrubbed =
1767 sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
1768 .bind(did)
1769 .execute(&mut *tx)
1770 .await
1771 .with_context(|| format!("scrub invitee_did for {did}"))?
1772 .rows_affected();
1773
1774 let granted_by_scrubbed =
1775 sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
1776 .bind(did)
1777 .bind(REDACTED_DID)
1778 .execute(&mut *tx)
1779 .await
1780 .with_context(|| format!("scrub granted_by for {did}"))?
1781 .rows_affected();
1782
1783 tx.commit().await.context("commit purge_did_data tx")?;
1784
1785 Ok(PurgeCounts {
1786 entry_state,
1787 read_cursor,
1788 sub_ref,
1789 beta_access,
1790 invite_codes,
1791 invitee_scrubbed,
1792 granted_by_scrubbed,
1793 })
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798 use super::*;
1799
1800 #[tokio::test]
1802 async fn init_insert_readback() -> Result<()> {
1803 let pool = init_url("sqlite::memory:").await?;
1804
1805 let feed_id = upsert_feed(
1807 &pool,
1808 &NewFeed {
1809 url: "https://example.com/feed.xml".to_string(),
1810 title: Some("Example".to_string()),
1811 site_url: Some("https://example.com".to_string()),
1812 next_poll: Some("2026-07-12T00:00:00Z".to_string()),
1813 ..Default::default()
1814 },
1815 )
1816 .await?;
1817 assert!(feed_id > 0);
1818
1819 let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
1821 .await?
1822 .expect("feed should exist");
1823 assert_eq!(feed.id, feed_id);
1824 assert_eq!(feed.title.as_deref(), Some("Example"));
1825 assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
1826
1827 let feed_id2 = upsert_feed(
1829 &pool,
1830 &NewFeed {
1831 url: "https://example.com/feed.xml".to_string(),
1832 title: Some("Example (renamed)".to_string()),
1833 ..Default::default()
1834 },
1835 )
1836 .await?;
1837 assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
1838
1839 let n = insert_entries(
1841 &pool,
1842 feed_id,
1843 &[
1844 NewEntry {
1845 guid: "guid-1".to_string(),
1846 url: Some("https://example.com/a".to_string()),
1847 title: Some("First".to_string()),
1848 published: Some("2026-07-10T08:00:00Z".to_string()),
1849 content_html: Some("<p>hello</p>".to_string()),
1850 ..Default::default()
1851 },
1852 NewEntry {
1853 guid: "guid-2".to_string(),
1854 url: Some("https://example.com/b".to_string()),
1855 title: Some("Second".to_string()),
1856 published: Some("2026-07-11T08:00:00Z".to_string()),
1857 ..Default::default()
1858 },
1859 ],
1860 0, )
1862 .await?;
1863 assert_eq!(n, 2);
1864
1865 let did = "did:plc:abc123";
1868 replace_sub_refs(&pool, did, &[feed_id]).await?;
1869
1870 let entries = entries_for_feed(&pool, did, feed_id).await?;
1872 assert_eq!(entries.len(), 2);
1873 assert_eq!(entries[0].guid, "guid-2");
1874 assert_eq!(entries[1].guid, "guid-1");
1875 assert_eq!(entries[1].content_html.as_deref(), Some("<p>hello</p>"));
1876
1877 let n2 = insert_entries(
1879 &pool,
1880 feed_id,
1881 &[NewEntry {
1882 guid: "guid-1".to_string(),
1883 title: Some("First (edited)".to_string()),
1884 ..Default::default()
1885 }],
1886 0,
1887 )
1888 .await?;
1889 assert_eq!(n2, 1);
1890 assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
1891
1892 let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
1894
1895 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
1897
1898 mark_read(&pool, did, e1, true).await?;
1900 let unread = get_unread_for_did(&pool, did).await?;
1901 assert_eq!(unread.len(), 1);
1902 assert_eq!(unread[0].guid, "guid-2");
1903
1904 mark_starred(&pool, did, e1, true).await?;
1906 let starred = get_starred_for_did(&pool, did).await?;
1907 assert_eq!(starred.len(), 1);
1908 assert_eq!(starred[0].id, e1);
1909
1910 mark_feed_read(&pool, did, feed_id, true).await?;
1912 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
1913
1914 let cursor = ReadCursor {
1916 did: did.to_string(),
1917 feed_url: "https://example.com/feed.xml".to_string(),
1918 read_through: Some("2026-07-11T08:00:00Z".to_string()),
1919 read_ids: "[]".to_string(),
1920 unread_ids: "[]".to_string(),
1921 dirty: true,
1922 pds_created: false,
1923 updated_at: now_rfc3339(),
1924 };
1925 upsert_cursor(&pool, &cursor).await?;
1926
1927 let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
1928 .await?
1929 .expect("cursor should exist");
1930 assert_eq!(
1931 fetched.read_through.as_deref(),
1932 Some("2026-07-11T08:00:00Z")
1933 );
1934 assert!(fetched.dirty);
1935
1936 let dirty = dirty_cursors(&pool, did).await?;
1938 assert_eq!(dirty.len(), 1);
1939 let flushed_at = dirty[0].updated_at.clone();
1940
1941 clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
1944 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
1945
1946 Ok(())
1947 }
1948
1949 #[tokio::test]
1957 async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
1958 let pool = init_url("sqlite::memory:").await?;
1959 let feed_url = "https://example.com/feed.xml";
1960 let feed_id = upsert_feed(
1961 &pool,
1962 &NewFeed {
1963 url: feed_url.to_string(),
1964 title: Some("Example".to_string()),
1965 ..Default::default()
1966 },
1967 )
1968 .await?;
1969 insert_entries(
1970 &pool,
1971 feed_id,
1972 &[
1973 NewEntry {
1974 guid: "g1".to_string(),
1975 published: Some("2026-07-10T00:00:00Z".to_string()),
1976 ..Default::default()
1977 },
1978 NewEntry {
1979 guid: "g2".to_string(),
1980 published: Some("2026-07-11T00:00:00Z".to_string()),
1981 ..Default::default()
1982 },
1983 ],
1984 0,
1985 )
1986 .await?;
1987 let did = "did:plc:reader";
1988 replace_sub_refs(&pool, did, &[feed_id]).await?;
1989
1990 assert!(get_cursor(&pool, did, feed_url).await?.is_none());
1992 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
1993
1994 let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
1997 assert!(mark_read(&pool, did, e1, true).await?);
1998
1999 let cursor = get_cursor(&pool, did, feed_url)
2000 .await?
2001 .expect("mark_read must create the feed's read_cursor");
2002 assert!(cursor.dirty, "cursor must be dirty after mark_read");
2003 assert!(
2004 cursor.read_ids.contains(&e1.to_string()),
2005 "the read entry id must be in read_ids: {}",
2006 cursor.read_ids
2007 );
2008 let dirty = dirty_cursors(&pool, did).await?;
2009 assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
2010 assert_eq!(dirty[0].feed_url, feed_url);
2011
2012 assert!(mark_read(&pool, did, e1, false).await?);
2014 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
2015 assert!(cursor.dirty);
2016 assert!(
2017 cursor.unread_ids.contains(&e1.to_string()),
2018 "unread id must be in unread_ids: {}",
2019 cursor.unread_ids
2020 );
2021 assert!(
2022 !cursor.read_ids.contains(&e1.to_string()),
2023 "id must have left read_ids: {}",
2024 cursor.read_ids
2025 );
2026
2027 assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
2030 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
2031 assert!(cursor.dirty);
2032 assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
2033
2034 let outsider = "did:plc:outsider";
2036 assert!(!mark_read(&pool, outsider, e1, true).await?);
2037 assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
2038
2039 let snap = dirty_cursors(&pool, did).await?[0].clone();
2041 clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
2043 assert_eq!(
2044 dirty_cursors(&pool, did).await?.len(),
2045 1,
2046 "stale-snapshot clear must be a no-op"
2047 );
2048 clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
2050 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
2051
2052 Ok(())
2053 }
2054
2055 #[test]
2056 fn json_id_set_toggle_is_set_like() {
2057 let s = json_id_set_toggle("[]", 5, true);
2059 assert_eq!(s, r#"["5"]"#);
2060 assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); let s = json_id_set_toggle(&s, 7, true);
2062 assert_eq!(s, r#"["5","7"]"#);
2063 let s = json_id_set_toggle(&s, 5, false);
2064 assert_eq!(s, r#"["7"]"#);
2065 assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
2067 assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
2068 }
2069
2070 #[tokio::test]
2078 async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
2079 let pool = init_url("sqlite::memory:").await?;
2080
2081 let feed_a = upsert_feed(
2083 &pool,
2084 &NewFeed {
2085 url: "https://a.example/feed.xml".to_string(),
2086 title: Some("A".to_string()),
2087 ..Default::default()
2088 },
2089 )
2090 .await?;
2091 let feed_b = upsert_feed(
2092 &pool,
2093 &NewFeed {
2094 url: "https://b.example/feed.xml".to_string(),
2095 title: Some("B".to_string()),
2096 ..Default::default()
2097 },
2098 )
2099 .await?;
2100
2101 insert_entries(
2102 &pool,
2103 feed_a,
2104 &[NewEntry {
2105 guid: "a-1".to_string(),
2106 url: Some("https://a.example/1".to_string()),
2107 title: Some("A one".to_string()),
2108 published: Some("2026-07-10T00:00:00Z".to_string()),
2109 content_html: Some("<p>secret A body</p>".to_string()),
2110 ..Default::default()
2111 }],
2112 0,
2113 )
2114 .await?;
2115 insert_entries(
2116 &pool,
2117 feed_b,
2118 &[NewEntry {
2119 guid: "b-1".to_string(),
2120 url: Some("https://b.example/1".to_string()),
2121 title: Some("B one".to_string()),
2122 published: Some("2026-07-11T00:00:00Z".to_string()),
2123 content_html: Some("<p>secret B body</p>".to_string()),
2124 ..Default::default()
2125 }],
2126 0,
2127 )
2128 .await?;
2129
2130 let did_a = "did:plc:aaaa";
2131 let did_b = "did:plc:bbbb";
2132 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
2133 replace_sub_refs(&pool, did_b, &[feed_b]).await?;
2134
2135 let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
2137
2138 assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
2140 assert!(
2141 entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
2142 "A must not read entries of a feed it does not subscribe to"
2143 );
2144
2145 let unread_a = get_unread_for_did(&pool, did_a).await?;
2147 assert_eq!(unread_a.len(), 1);
2148 assert_eq!(unread_a[0].guid, "a-1");
2149 let unread_b = get_unread_for_did(&pool, did_b).await?;
2150 assert_eq!(unread_b.len(), 1);
2151 assert_eq!(unread_b[0].guid, "b-1");
2152
2153 assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
2155 assert!(
2156 !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
2157 "A does not subscribe to B's feed"
2158 );
2159
2160 assert!(
2162 !mark_read(&pool, did_a, b_entry_id, true).await?,
2163 "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
2164 );
2165 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
2167 assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
2169 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
2170
2171 assert!(
2173 !mark_starred(&pool, did_a, b_entry_id, true).await?,
2174 "non-subscriber mark_starred must be a no-op (→ 404)"
2175 );
2176 assert!(
2177 get_starred_for_did(&pool, did_a).await?.is_empty(),
2178 "A's starred list stays empty after the rejected attempt"
2179 );
2180 assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
2181 assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
2182 assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
2184
2185 let a_feeds = feeds_for_did(&pool, did_a).await?;
2190 assert_eq!(a_feeds.len(), 1);
2191 assert_eq!(a_feeds[0].id, feed_a);
2192 let b_feeds = feeds_for_did(&pool, did_b).await?;
2193 assert_eq!(b_feeds.len(), 1);
2194 assert_eq!(b_feeds[0].id, feed_b);
2195
2196 replace_sub_refs(&pool, did_b, &[]).await?;
2198 assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
2199 assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
2200 assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
2201 assert!(feeds_for_did(&pool, did_b).await?.is_empty());
2203
2204 Ok(())
2205 }
2206
2207 #[tokio::test]
2225 async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
2226 let pool = init_url("sqlite::memory:").await?;
2227
2228 let did_a = "did:plc:aaaa";
2229
2230 let feed_a = upsert_feed(
2233 &pool,
2234 &NewFeed {
2235 url: "https://a.example/feed.xml".to_string(),
2236 title: Some("A".to_string()),
2237 ..Default::default()
2238 },
2239 )
2240 .await?;
2241 let feed_orphan = upsert_feed(
2244 &pool,
2245 &NewFeed {
2246 url: "https://orphan.example/feed.xml".to_string(),
2247 title: Some("Orphan".to_string()),
2248 ..Default::default()
2249 },
2250 )
2251 .await?;
2252
2253 insert_entries(
2254 &pool,
2255 feed_a,
2256 &[NewEntry {
2257 guid: "a-1".to_string(),
2258 url: Some("https://a.example/1".to_string()),
2259 title: Some("A one".to_string()),
2260 published: Some("2026-07-10T00:00:00Z".to_string()),
2261 content_html: Some("<p>A body</p>".to_string()),
2262 ..Default::default()
2263 }],
2264 0,
2265 )
2266 .await?;
2267 insert_entries(
2268 &pool,
2269 feed_orphan,
2270 &[NewEntry {
2271 guid: "orphan-1".to_string(),
2272 url: Some("https://orphan.example/1".to_string()),
2273 title: Some("Orphan one".to_string()),
2274 published: Some("2026-07-11T00:00:00Z".to_string()),
2275 content_html: Some("<p>secret orphan body</p>".to_string()),
2276 ..Default::default()
2277 }],
2278 0,
2279 )
2280 .await?;
2281
2282 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
2285
2286 replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
2289 let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
2290 replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
2291
2292 let fallback = feeds_for_did(&pool, did_a).await?;
2298 let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
2299 assert_eq!(
2300 fallback_ids,
2301 vec![feed_a],
2302 "outage fallback must serve ONLY A's own last-known sub_ref, \
2303 never widen to the orphan cached feed"
2304 );
2305 assert!(
2306 !fallback_ids.contains(&feed_orphan),
2307 "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
2308 cached feed into A's surface"
2309 );
2310
2311 assert!(
2313 !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
2314 "A must not be authorized for an orphan feed's entry during an outage"
2315 );
2316 assert!(
2317 entries_for_feed(&pool, did_a, feed_orphan)
2318 .await?
2319 .is_empty(),
2320 "entries_for_feed must not expose the orphan feed to A during an outage"
2321 );
2322 let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
2324 .await?
2325 .into_iter()
2326 .map(|e| e.guid)
2327 .collect();
2328 assert!(
2329 !unread_guids.iter().any(|g| g == "orphan-1"),
2330 "orphan entry leaked into A's unread list during an outage"
2331 );
2332 assert!(
2333 get_starred_for_did(&pool, did_a).await?.is_empty(),
2334 "A has no starred entries; the orphan must not appear"
2335 );
2336
2337 assert!(
2339 !mark_read(&pool, did_a, orphan_entry_id, true).await?,
2340 "A must not mark an orphan feed's entry read during an outage"
2341 );
2342 assert!(
2343 !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
2344 "A must not star an orphan feed's entry during an outage"
2345 );
2346 assert_eq!(
2347 mark_feed_read(&pool, did_a, feed_orphan, true).await?,
2348 0,
2349 "A must not mark-all-read the orphan feed during an outage"
2350 );
2351
2352 let es_count: i64 =
2354 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
2355 .bind(did_a)
2356 .bind(orphan_entry_id)
2357 .fetch_one(&pool)
2358 .await?;
2359 assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
2360
2361 Ok(())
2362 }
2363
2364 #[test]
2369 fn code_gen_shape_and_alphabet() {
2370 for _ in 0..200 {
2371 let code = generate_invite_code().unwrap();
2372 assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
2373 let body = &code["FEATHER-".len()..];
2374 assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
2375 for c in body.chars() {
2378 assert!(
2379 CODE_ALPHABET.contains(&(c as u8)),
2380 "char {c:?} not in alphabet ({code})"
2381 );
2382 assert!(
2383 !matches!(c, 'I' | 'O' | '0' | '1'),
2384 "ambiguous char {c:?} leaked into {code}"
2385 );
2386 }
2387 }
2388 assert_ne!(
2390 generate_invite_code().unwrap(),
2391 generate_invite_code().unwrap()
2392 );
2393 }
2394
2395 #[tokio::test]
2396 async fn busy_timeout_is_applied() -> Result<()> {
2397 let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
2400 std::fs::create_dir_all(&dir).ok();
2401 let path = dir.join("busy.db");
2402 let url = format!("sqlite://{}", path.display());
2403 let pool = init_url(&url).await?;
2404 let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
2405 let timeout: i64 = row.get(0);
2406 assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
2407 pool.close().await;
2408 std::fs::remove_dir_all(&dir).ok();
2409 Ok(())
2410 }
2411
2412 #[tokio::test]
2413 async fn redeem_valid_grants_seat() -> Result<()> {
2414 let pool = init_url("sqlite::memory:").await?;
2415 let code = mint_code(&pool, "did:plc:creator", 3600).await?;
2416 assert!(!has_beta_access(&pool, "did:plc:new").await?);
2417
2418 let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
2419 assert_eq!(out, Ok(()));
2420 assert!(has_beta_access(&pool, "did:plc:new").await?);
2421 assert_eq!(count_beta_access(&pool).await?, 1);
2422
2423 let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
2425 assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
2426 Ok(())
2427 }
2428
2429 #[tokio::test]
2430 async fn redeem_not_found() -> Result<()> {
2431 let pool = init_url("sqlite::memory:").await?;
2432 let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
2433 assert_eq!(out, Err(RedeemError::NotFound));
2434 Ok(())
2435 }
2436
2437 async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
2440 let now = now_unix();
2441 sqlx::query(
2442 r#"INSERT INTO invite_codes
2443 (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
2444 VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
2445 )
2446 .bind(code)
2447 .bind(creator)
2448 .bind(now - 100)
2449 .bind(now - 10) .execute(pool)
2451 .await?;
2452 Ok(())
2453 }
2454
2455 #[tokio::test]
2456 async fn redeem_expired() -> Result<()> {
2457 let pool = init_url("sqlite::memory:").await?;
2458 insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
2459 let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
2460 assert_eq!(out, Err(RedeemError::Expired));
2461 assert_eq!(count_beta_access(&pool).await?, 0);
2463 Ok(())
2464 }
2465
2466 #[tokio::test]
2467 async fn redeem_capacity_full() -> Result<()> {
2468 let pool = init_url("sqlite::memory:").await?;
2469 ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
2471 assert_eq!(count_beta_access(&pool).await?, 1);
2472
2473 let code = mint_code(&pool, "did:plc:admin", 3600).await?;
2474 let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
2475 assert_eq!(out, Err(RedeemError::CapacityFull));
2476 assert!(!has_beta_access(&pool, "did:plc:new").await?);
2478 let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
2480 assert_eq!(ok, Ok(()));
2481 Ok(())
2482 }
2483
2484 #[tokio::test]
2485 async fn expire_and_seed() -> Result<()> {
2486 let pool = init_url("sqlite::memory:").await?;
2487 insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
2489 let live = mint_code(&pool, "did:plc:creator", 3600).await?;
2490 let n = expire_old_codes(&pool).await?;
2491 assert_eq!(n, 1, "exactly the past-expiry code should flip");
2492 assert_eq!(
2494 redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
2495 Ok(())
2496 );
2497
2498 let created = ensure_seed(
2500 &pool,
2501 &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
2502 )
2503 .await?;
2504 assert_eq!(created, 2);
2505 let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
2506 assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
2507 assert!(has_beta_access(&pool, "did:plc:seed1").await?);
2508 Ok(())
2509 }
2510
2511 #[tokio::test]
2516 async fn count_helpers_track_feeds_and_subs() -> Result<()> {
2517 let pool = init_url("sqlite::memory:").await?;
2518 assert_eq!(count_feeds(&pool).await?, 0);
2519
2520 let mut ids = Vec::new();
2521 for i in 0..3 {
2522 let id = upsert_feed(
2523 &pool,
2524 &NewFeed {
2525 url: format!("https://f{i}.example/feed.xml"),
2526 ..Default::default()
2527 },
2528 )
2529 .await?;
2530 ids.push(id);
2531 }
2532 assert_eq!(count_feeds(&pool).await?, 3);
2533
2534 let did = "did:plc:capcheck";
2535 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
2536 replace_sub_refs(&pool, did, &ids).await?;
2537 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
2538 Ok(())
2539 }
2540
2541 #[tokio::test]
2542 async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
2543 let pool = init_url("sqlite::memory:").await?;
2544 let feed_id = upsert_feed(
2545 &pool,
2546 &NewFeed {
2547 url: "https://firehose.example/feed.xml".to_string(),
2548 ..Default::default()
2549 },
2550 )
2551 .await?;
2552
2553 let batch: Vec<NewEntry> = (0..5)
2555 .map(|i| NewEntry {
2556 guid: format!("g-{i}"),
2557 title: Some(format!("E{i}")),
2558 published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
2559 ..Default::default()
2560 })
2561 .collect();
2562 insert_entries(&pool, feed_id, &batch, 2).await?;
2563
2564 let did = "did:plc:trim";
2565 replace_sub_refs(&pool, did, &[feed_id]).await?;
2566 let kept = entries_for_feed(&pool, did, feed_id).await?;
2567 assert_eq!(
2568 kept.len(),
2569 2,
2570 "over-cap feed trimmed to the newest 2 entries"
2571 );
2572 assert_eq!(kept[0].guid, "g-4");
2574 assert_eq!(kept[1].guid, "g-3");
2575 Ok(())
2576 }
2577
2578 #[tokio::test]
2584 async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
2585 let pool = init_url("sqlite::memory:").await?;
2586 let feed_id = upsert_feed(
2587 &pool,
2588 &NewFeed {
2589 url: "https://undated.example/feed.xml".to_string(),
2590 ..Default::default()
2591 },
2592 )
2593 .await?;
2594
2595 let batch = vec![
2598 NewEntry {
2599 guid: "old-dated-1".to_string(),
2600 title: Some("Old A".to_string()),
2601 published: Some("2026-07-01T00:00:00Z".to_string()),
2602 fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
2603 ..Default::default()
2604 },
2605 NewEntry {
2606 guid: "old-dated-2".to_string(),
2607 title: Some("Old B".to_string()),
2608 published: Some("2026-07-02T00:00:00Z".to_string()),
2609 fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
2610 ..Default::default()
2611 },
2612 NewEntry {
2613 guid: "fresh-undated".to_string(),
2614 title: Some("Fresh undated".to_string()),
2615 published: None,
2616 fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
2617 ..Default::default()
2618 },
2619 ];
2620 insert_entries(&pool, feed_id, &batch, 2).await?;
2621
2622 let did = "did:plc:undated";
2623 replace_sub_refs(&pool, did, &[feed_id]).await?;
2624 let kept = entries_for_feed(&pool, did, feed_id).await?;
2625 assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
2626 let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
2627 assert!(
2628 guids.contains(&"fresh-undated"),
2629 "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
2630 );
2631 assert!(
2632 guids.contains(&"old-dated-2"),
2633 "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
2634 );
2635 assert!(
2636 !guids.contains(&"old-dated-1"),
2637 "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
2638 );
2639 Ok(())
2640 }
2641
2642 #[tokio::test]
2643 async fn db_size_is_positive_and_grows() -> Result<()> {
2644 let pool = init_url("sqlite::memory:").await?;
2645 let before = db_size_bytes(&pool).await?;
2646 assert!(before > 0, "a schema-initialised DB has a non-zero size");
2647 Ok(())
2648 }
2649
2650 #[tokio::test]
2654 async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
2655 let pool = init_url("sqlite::memory:").await?;
2656
2657 let feed_id = upsert_feed(
2659 &pool,
2660 &NewFeed {
2661 url: "https://example.com/feed.xml".to_string(),
2662 title: Some("Example".to_string()),
2663 ..Default::default()
2664 },
2665 )
2666 .await?;
2667 insert_entries(
2668 &pool,
2669 feed_id,
2670 &[NewEntry {
2671 guid: "g-1".to_string(),
2672 url: Some("https://example.com/a".to_string()),
2673 title: Some("First".to_string()),
2674 published: Some("2026-07-10T08:00:00Z".to_string()),
2675 ..Default::default()
2676 }],
2677 0,
2678 )
2679 .await?;
2680 let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
2681 .fetch_one(&pool)
2682 .await?;
2683
2684 let victim = "did:plc:victim";
2685 let bystander = "did:plc:bystander";
2686
2687 for did in [victim, bystander] {
2689 replace_sub_refs(&pool, did, &[feed_id]).await?;
2690 assert!(mark_read(&pool, did, entry_id, true).await?);
2691 assert!(mark_starred(&pool, did, entry_id, true).await?);
2692 upsert_cursor(
2693 &pool,
2694 &ReadCursor {
2695 did: did.to_string(),
2696 feed_url: "https://example.com/feed.xml".to_string(),
2697 read_through: Some("2026-07-10T08:00:00Z".to_string()),
2698 read_ids: "[]".to_string(),
2699 unread_ids: "[]".to_string(),
2700 dirty: false,
2701 pds_created: false,
2702 updated_at: now_rfc3339(),
2703 },
2704 )
2705 .await?;
2706 grant_access(&pool, did, Some("h.example"), "admin", None).await?;
2707 mint_code(&pool, did, 3600).await?;
2708 }
2709
2710 let counts = purge_did_data(&pool, victim).await?;
2712 assert_eq!(
2713 counts.entry_state, 1,
2714 "one entry_state row (read+star merge)"
2715 );
2716 assert_eq!(counts.read_cursor, 1);
2717 assert_eq!(counts.sub_ref, 1);
2718 assert_eq!(counts.beta_access, 1);
2719 assert_eq!(counts.invite_codes, 1);
2720 assert_eq!(counts.total(), 5);
2721
2722 let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
2724 .bind(victim)
2725 .fetch_one(&pool)
2726 .await?;
2727 assert_eq!(es, 0, "victim still had entry_state rows");
2728 let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
2729 .bind(victim)
2730 .fetch_one(&pool)
2731 .await?;
2732 assert_eq!(rc, 0, "victim still had read_cursor rows");
2733 let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
2734 .bind(victim)
2735 .fetch_one(&pool)
2736 .await?;
2737 assert_eq!(sr, 0, "victim still had sub_ref rows");
2738 assert!(
2739 !has_beta_access(&pool, victim).await?,
2740 "victim still had a beta seat"
2741 );
2742 let victim_codes: i64 =
2743 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2744 .bind(victim)
2745 .fetch_one(&pool)
2746 .await?;
2747 assert_eq!(victim_codes, 0);
2748
2749 assert!(has_beta_access(&pool, bystander).await?);
2751 let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
2752 assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
2753 let bystander_codes: i64 =
2754 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2755 .bind(bystander)
2756 .fetch_one(&pool)
2757 .await?;
2758 assert_eq!(bystander_codes, 1);
2759
2760 assert_eq!(count_feeds(&pool).await?, 1);
2762
2763 let again = purge_did_data(&pool, victim).await?;
2765 assert_eq!(again.total(), 0);
2766
2767 Ok(())
2768 }
2769
2770 #[tokio::test]
2776 async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
2777 let pool = init_url("sqlite::memory:").await?;
2778
2779 let inviter = "did:plc:inviter";
2780 let leaver = "did:plc:leaver";
2781 let friend = "did:plc:friend";
2782
2783 let inviter_code = mint_code(&pool, inviter, 3600).await?;
2785 grant_access(&pool, inviter, None, "admin", None).await?;
2786 assert_eq!(
2787 redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
2788 Ok(())
2789 );
2790
2791 let leaver_code = mint_code(&pool, leaver, 3600).await?;
2793 assert_eq!(
2794 redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
2795 Ok(())
2796 );
2797
2798 let invitee_before: i64 =
2800 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
2801 .bind(leaver)
2802 .fetch_one(&pool)
2803 .await?;
2804 assert_eq!(
2805 invitee_before, 1,
2806 "leaver should be an invitee before purge"
2807 );
2808 let granted_before: i64 =
2809 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
2810 .bind(leaver)
2811 .fetch_one(&pool)
2812 .await?;
2813 assert_eq!(granted_before, 1, "leaver should be a granter before purge");
2814
2815 let counts = purge_did_data(&pool, leaver).await?;
2817 assert_eq!(
2818 counts.invitee_scrubbed, 1,
2819 "the redeemed code's invitee_did"
2820 );
2821 assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
2822
2823 let invitee_after: i64 =
2825 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
2826 .bind(leaver)
2827 .fetch_one(&pool)
2828 .await?;
2829 assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
2830 let granted_after: i64 =
2831 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
2832 .bind(leaver)
2833 .fetch_one(&pool)
2834 .await?;
2835 assert_eq!(granted_after, 0, "leaver survived in granted_by");
2836
2837 assert!(
2840 has_beta_access(&pool, friend).await?,
2841 "friend's seat must survive the leaver's scrub"
2842 );
2843 let friend_granted_by: String =
2844 sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
2845 .bind(friend)
2846 .fetch_one(&pool)
2847 .await?;
2848 assert_eq!(friend_granted_by, REDACTED_DID);
2849 let inviter_code_rows: i64 =
2850 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2851 .bind(inviter)
2852 .fetch_one(&pool)
2853 .await?;
2854 assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
2855
2856 Ok(())
2857 }
2858
2859 #[tokio::test]
2862 async fn feed_error_count_bumps_and_resets() -> Result<()> {
2863 let pool = init_url("sqlite::memory:").await?;
2864 let url = "https://broken.example/feed.xml";
2865 upsert_feed(
2866 &pool,
2867 &NewFeed {
2868 url: url.to_string(),
2869 ..Default::default()
2870 },
2871 )
2872 .await?;
2873
2874 let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
2876 assert_eq!(feed.consecutive_errors, 0);
2877
2878 let mut last = std::time::Duration::ZERO;
2881 for expected in 1..=3 {
2882 let count = bump_feed_errors(&pool, url).await?;
2883 assert_eq!(count, expected, "bump returns the new count");
2884 let backoff = crate::feed::backoff_for(count as u32);
2885 assert!(
2886 backoff >= last,
2887 "backoff must not shrink as errors accumulate"
2888 );
2889 last = backoff;
2890 }
2891 assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
2893 assert_eq!(
2894 get_feed_by_url(&pool, url)
2895 .await?
2896 .unwrap()
2897 .consecutive_errors,
2898 3
2899 );
2900
2901 reset_feed_errors(&pool, url).await?;
2903 assert_eq!(
2904 get_feed_by_url(&pool, url)
2905 .await?
2906 .unwrap()
2907 .consecutive_errors,
2908 0
2909 );
2910 Ok(())
2911 }
2912
2913 #[tokio::test]
2916 async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
2917 let dir = std::env::temp_dir();
2920 let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
2921 let url = format!("sqlite://{}", path.display());
2922 let pool = init_url(&url).await?;
2923
2924 let feed_id = upsert_feed(
2925 &pool,
2926 &NewFeed {
2927 url: "https://bulk.example/feed.xml".to_string(),
2928 ..Default::default()
2929 },
2930 )
2931 .await?;
2932
2933 let entries: Vec<NewEntry> = (0..2000)
2935 .map(|i| NewEntry {
2936 guid: format!("guid-{i}"),
2937 title: Some(format!("Entry number {i} with some padding text")),
2938 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
2939 published: Some("2026-01-01T00:00:00Z".to_string()),
2940 ..Default::default()
2941 })
2942 .collect();
2943 insert_entries(&pool, feed_id, &entries, 0).await?;
2944 let full = db_size_bytes(&pool).await?;
2945 assert!(full > 0);
2946
2947 sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
2950 .bind(feed_id)
2951 .execute(&pool)
2952 .await?;
2953
2954 let after_delete = db_size_bytes(&pool).await?;
2957 assert!(
2958 after_delete < full,
2959 "used size must drop once rows are deleted (freed pages excluded): \
2960 {after_delete} !< {full}"
2961 );
2962
2963 reclaim(&pool).await?;
2967 let after_reclaim = db_size_bytes(&pool).await?;
2968 assert!(
2969 after_reclaim <= after_delete,
2970 "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
2971 );
2972 assert!(
2973 after_reclaim < full,
2974 "after prune+reclaim the DB is smaller than when full: \
2975 {after_reclaim} !< {full}"
2976 );
2977
2978 drop(pool);
2979 let _ = std::fs::remove_file(&path);
2980 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
2981 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
2982 Ok(())
2983 }
2984
2985 #[tokio::test]
2988 async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
2989 let pool = init_url("sqlite::memory:").await?;
2990 let did = "did:plc:f4";
2991 let feed_url = "https://example.com/feed.xml";
2992 upsert_cursor(
2993 &pool,
2994 &ReadCursor {
2995 did: did.to_string(),
2996 feed_url: feed_url.to_string(),
2997 read_through: None,
2998 read_ids: r#"["1"]"#.to_string(),
2999 unread_ids: "[]".to_string(),
3000 dirty: true,
3001 pds_created: false,
3002 updated_at: now_rfc3339(),
3003 },
3004 )
3005 .await?;
3006
3007 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
3009 assert!(!c.pds_created, "first flush must emit a create, not update");
3010
3011 mark_cursor_pds_created(&pool, did, feed_url).await?;
3013 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
3014 assert!(c.pds_created);
3015 Ok(())
3016 }
3017
3018 async fn count_entries(pool: &SqlitePool) -> Result<i64> {
3022 Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
3023 .fetch_one(pool)
3024 .await?)
3025 }
3026
3027 #[tokio::test]
3028 async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
3029 let pool = init_url("sqlite::memory:").await?;
3030 let feed_id = upsert_feed(
3031 &pool,
3032 &NewFeed {
3033 url: "https://ret.example/feed.xml".to_string(),
3034 ..Default::default()
3035 },
3036 )
3037 .await?;
3038
3039 let recent = now_rfc3339();
3040 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3041 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3042
3043 insert_entries(
3047 &pool,
3048 feed_id,
3049 &[
3050 NewEntry {
3051 guid: "fresh".into(),
3052 published: Some(recent.clone()),
3053 fetched_at: Some(recent.clone()),
3054 ..Default::default()
3055 },
3056 NewEntry {
3057 guid: "ancient".into(),
3058 published: Some(ancient.clone()),
3059 fetched_at: Some(ancient.clone()),
3060 ..Default::default()
3061 },
3062 NewEntry {
3063 guid: "undated-fresh".into(),
3064 published: None,
3065 fetched_at: Some(recent.clone()),
3066 ..Default::default()
3067 },
3068 ],
3069 0,
3070 )
3071 .await?;
3072 assert_eq!(count_entries(&pool).await?, 3);
3073 replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
3075
3076 let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
3078 .fetch_one(&pool)
3079 .await?;
3080 let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
3081 assert!(wrote, "mark_read must write with a sub_ref in place");
3082 let state_before: i64 =
3083 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
3084 .bind(ancient_id)
3085 .fetch_one(&pool)
3086 .await?;
3087 assert_eq!(state_before, 1);
3088
3089 let deleted = prune_old_entries(&pool, 90).await?;
3091 assert_eq!(deleted, 1, "only the year-old entry should be pruned");
3092 assert_eq!(
3093 count_entries(&pool).await?,
3094 2,
3095 "fresh + undated-fresh survive"
3096 );
3097
3098 let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
3100 .fetch_all(&pool)
3101 .await?;
3102 assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
3103
3104 let state_after: i64 =
3106 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
3107 .bind(ancient_id)
3108 .fetch_one(&pool)
3109 .await?;
3110 assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
3111
3112 assert_eq!(prune_old_entries(&pool, 0).await?, 0);
3114 assert_eq!(count_entries(&pool).await?, 2);
3115 Ok(())
3116 }
3117
3118 #[tokio::test]
3119 async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
3120 let pool = init_url("sqlite::memory:").await?;
3121 let did = "did:plc:reader";
3122 let feed_url = "https://orphan.example/feed.xml";
3123 let feed_id = upsert_feed(
3124 &pool,
3125 &NewFeed {
3126 url: feed_url.to_string(),
3127 ..Default::default()
3128 },
3129 )
3130 .await?;
3131 replace_sub_refs(&pool, did, &[feed_id]).await?;
3133
3134 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3135 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3136 let recent = now_rfc3339();
3137 insert_entries(
3138 &pool,
3139 feed_id,
3140 &[
3141 NewEntry {
3142 guid: "old".into(),
3143 published: Some(ancient.clone()),
3144 fetched_at: Some(ancient.clone()),
3145 ..Default::default()
3146 },
3147 NewEntry {
3148 guid: "new".into(),
3149 published: Some(recent.clone()),
3150 fetched_at: Some(recent.clone()),
3151 ..Default::default()
3152 },
3153 ],
3154 0,
3155 )
3156 .await?;
3157 let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
3158 .fetch_one(&pool)
3159 .await?;
3160 let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
3161 .fetch_one(&pool)
3162 .await?;
3163
3164 mark_read(&pool, did, old_id, true).await?;
3166 mark_read(&pool, did, new_id, true).await?;
3167 let before = get_cursor(&pool, did, feed_url).await?.unwrap();
3168 let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
3169 assert!(ids_before.contains(&old_id.to_string()));
3170 assert!(ids_before.contains(&new_id.to_string()));
3171
3172 let deleted = prune_old_entries(&pool, 90).await?;
3174 assert_eq!(deleted, 1);
3175 let after = get_cursor(&pool, did, feed_url).await?.unwrap();
3176 let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
3177 assert_eq!(
3178 ids_after,
3179 vec![new_id.to_string()],
3180 "orphaned (deleted) entry id must be removed; live id kept"
3181 );
3182 assert!(
3184 after.dirty,
3185 "cursor must be marked dirty after orphan scrub"
3186 );
3187 Ok(())
3188 }
3189
3190 #[tokio::test]
3191 async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
3192 let pool = init_url("sqlite::memory:").await?;
3194 let did = "did:plc:reader";
3195 let feed_url = "https://trim.example/feed.xml";
3196 let feed_id = upsert_feed(
3197 &pool,
3198 &NewFeed {
3199 url: feed_url.to_string(),
3200 ..Default::default()
3201 },
3202 )
3203 .await?;
3204 replace_sub_refs(&pool, did, &[feed_id]).await?;
3205
3206 insert_entries(
3208 &pool,
3209 feed_id,
3210 &[
3211 NewEntry {
3212 guid: "a".into(),
3213 published: Some("2026-01-01T00:00:00Z".into()),
3214 ..Default::default()
3215 },
3216 NewEntry {
3217 guid: "b".into(),
3218 published: Some("2026-01-02T00:00:00Z".into()),
3219 ..Default::default()
3220 },
3221 ],
3222 2,
3223 )
3224 .await?;
3225 let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
3226 .fetch_one(&pool)
3227 .await?;
3228 mark_read(&pool, did, a_id, true).await?;
3229
3230 insert_entries(
3232 &pool,
3233 feed_id,
3234 &[NewEntry {
3235 guid: "c".into(),
3236 published: Some("2026-01-03T00:00:00Z".into()),
3237 ..Default::default()
3238 }],
3239 1,
3240 )
3241 .await?;
3242 let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
3244 .fetch_one(&pool)
3245 .await?;
3246 assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
3247
3248 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
3250 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
3251 assert!(
3252 !ids.contains(&a_id.to_string()),
3253 "trimmed entry id must be scrubbed from the cursor"
3254 );
3255 Ok(())
3256 }
3257
3258 #[tokio::test]
3259 async fn prune_and_reclaim_drops_db_size() -> Result<()> {
3260 let dir = std::env::temp_dir();
3262 let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
3263 let url = format!("sqlite://{}", path.display());
3264 let pool = init_url(&url).await?;
3265
3266 let feed_id = upsert_feed(
3267 &pool,
3268 &NewFeed {
3269 url: "https://bulk.example/feed.xml".to_string(),
3270 ..Default::default()
3271 },
3272 )
3273 .await?;
3274 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3275 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3276 let entries: Vec<NewEntry> = (0..2000)
3277 .map(|i| NewEntry {
3278 guid: format!("guid-{i}"),
3279 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
3280 published: Some(ancient.clone()),
3281 fetched_at: Some(ancient.clone()),
3282 ..Default::default()
3283 })
3284 .collect();
3285 insert_entries(&pool, feed_id, &entries, 0).await?;
3286 let full = db_size_bytes(&pool).await?;
3287 assert!(full > 0);
3288
3289 let deleted = prune_old_entries(&pool, 90).await?;
3291 assert_eq!(deleted, 2000);
3292 reclaim(&pool).await?;
3293 let after = db_size_bytes(&pool).await?;
3294 assert!(
3295 after < full,
3296 "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
3297 );
3298
3299 drop(pool);
3300 let _ = std::fs::remove_file(&path);
3301 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
3302 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
3303 Ok(())
3304 }
3305}