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
235CREATE TABLE IF NOT EXISTS beta_access (
236 did TEXT PRIMARY KEY,
237 handle TEXT,
238 granted_by TEXT NOT NULL,
239 granted_at INTEGER NOT NULL,
240 invite_code_used TEXT
241);
242
243CREATE TABLE IF NOT EXISTS invite_codes (
244 code TEXT PRIMARY KEY,
245 creator_did TEXT NOT NULL,
246 status TEXT NOT NULL,
247 invitee_did TEXT,
248 created_at INTEGER NOT NULL,
249 expires_at INTEGER NOT NULL,
250 redeemed_at INTEGER
251);
252CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
253"#;
254
255fn now_rfc3339() -> String {
259 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
260}
261
262pub async fn init(config: &Config) -> Result<Pool> {
269 let db_url = format!("sqlite://{}", config.db_path.display());
271 init_url(&db_url).await
272}
273
274pub async fn init_url(db_url: &str) -> Result<Pool> {
282 let is_memory = db_url.contains(":memory:");
289 let mut opts = SqliteConnectOptions::from_str(db_url)
290 .with_context(|| format!("invalid sqlite url: {db_url}"))?
291 .create_if_missing(true)
292 .foreign_keys(true);
293 if !is_memory {
295 opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
296 }
297 opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
306 opts = opts.log_statements(tracing::log::LevelFilter::Debug);
308
309 let pool = SqlitePoolOptions::new()
310 .min_connections(1)
313 .max_connections(if is_memory { 1 } else { 5 })
314 .connect_with(opts)
315 .await
316 .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
317
318 init_schema(&pool).await?;
319 Ok(pool)
320}
321
322pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
325 sqlx::query(SCHEMA)
327 .execute(pool)
328 .await
329 .context("failed to create schema")?;
330 apply_migrations(pool).await?;
331 Ok(())
332}
333
334async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
339 ensure_column(
342 pool,
343 "PRAGMA table_info(feeds)",
344 "consecutive_errors",
345 "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
346 )
347 .await?;
348 ensure_column(
352 pool,
353 "PRAGMA table_info(read_cursor)",
354 "pds_created",
355 "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
356 )
357 .await?;
358 Ok(())
359}
360
361async fn ensure_column(
366 pool: &SqlitePool,
367 info_sql: &'static str,
368 column: &str,
369 alter_sql: &'static str,
370) -> Result<()> {
371 let rows = sqlx::query(info_sql)
372 .fetch_all(pool)
373 .await
374 .with_context(|| format!("{info_sql} failed"))?;
375 let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
376 if !present {
377 sqlx::query(alter_sql)
378 .execute(pool)
379 .await
380 .with_context(|| format!("adding column {column} via {alter_sql}"))?;
381 }
382 Ok(())
383}
384
385pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
388 let row = sqlx::query(
389 r#"
390 INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll)
391 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
392 ON CONFLICT (url) DO UPDATE SET
393 title = COALESCE(excluded.title, feeds.title),
394 site_url = COALESCE(excluded.site_url, feeds.site_url),
395 etag = excluded.etag,
396 last_modified = excluded.last_modified,
397 last_polled = COALESCE(excluded.last_polled, feeds.last_polled),
398 next_poll = COALESCE(excluded.next_poll, feeds.next_poll)
399 RETURNING id
400 "#,
401 )
402 .bind(&feed.url)
403 .bind(&feed.title)
404 .bind(&feed.site_url)
405 .bind(&feed.etag)
406 .bind(&feed.last_modified)
407 .bind(&feed.last_polled)
408 .bind(&feed.next_poll)
409 .fetch_one(pool)
410 .await
411 .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
412
413 Ok(row.get::<i64, _>("id"))
414}
415
416pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
418 let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
419 .bind(url)
420 .fetch_optional(pool)
421 .await
422 .with_context(|| format!("get_feed_by_url failed for {url}"))?;
423 Ok(feed)
424}
425
426pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
429 let feeds = sqlx::query_as::<_, Feed>(
430 r#"
431 SELECT * FROM feeds
432 WHERE next_poll IS NULL OR next_poll <= ?1
433 ORDER BY next_poll IS NOT NULL, next_poll ASC
434 LIMIT ?2
435 "#,
436 )
437 .bind(as_of)
438 .bind(limit)
439 .fetch_all(pool)
440 .await
441 .context("due_feeds failed")?;
442 Ok(feeds)
443}
444
445pub async fn bump_feed_errors(pool: &SqlitePool, url: &str) -> Result<i64> {
451 let row = sqlx::query(
452 "UPDATE feeds SET consecutive_errors = consecutive_errors + 1 \
453 WHERE url = ?1 RETURNING consecutive_errors",
454 )
455 .bind(url)
456 .fetch_optional(pool)
457 .await
458 .with_context(|| format!("bump_feed_errors failed for {url}"))?;
459 Ok(row
461 .map(|r| r.get::<i64, _>("consecutive_errors"))
462 .unwrap_or(1))
463}
464
465pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
468 sqlx::query("UPDATE feeds SET consecutive_errors = 0 WHERE url = ?1")
469 .bind(url)
470 .execute(pool)
471 .await
472 .with_context(|| format!("reset_feed_errors failed for {url}"))?;
473 Ok(())
474}
475
476pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
481 let feeds = sqlx::query_as::<_, Feed>(
482 r#"
483 SELECT f.* FROM feeds f
484 JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
485 ORDER BY f.title IS NULL, f.title, f.url
486 "#,
487 )
488 .bind(did)
489 .fetch_all(pool)
490 .await
491 .with_context(|| format!("feeds_for_did failed for {did}"))?;
492 Ok(feeds)
493}
494
495pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
498 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
499 .bind(did)
500 .fetch_one(pool)
501 .await
502 .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
503 Ok(n)
504}
505
506pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
509 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
510 .fetch_one(pool)
511 .await
512 .context("count_feeds failed")?;
513 Ok(n)
514}
515
516pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
528 let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
529 .fetch_one(pool)
530 .await
531 .context("PRAGMA page_count failed")?;
532 let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
533 .fetch_one(pool)
534 .await
535 .context("PRAGMA freelist_count failed")?;
536 let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
537 .fetch_one(pool)
538 .await
539 .context("PRAGMA page_size failed")?;
540 let used_pages = page_count.saturating_sub(freelist_count).max(0);
541 Ok(used_pages.saturating_mul(page_size))
542}
543
544pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
555 let auto_vacuum: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
556 .fetch_one(pool)
557 .await
558 .context("PRAGMA auto_vacuum failed")?;
559 if auto_vacuum == 2 {
563 sqlx::query("PRAGMA incremental_vacuum")
564 .execute(pool)
565 .await
566 .context("PRAGMA incremental_vacuum failed")?;
567 } else {
568 sqlx::query("VACUUM")
569 .execute(pool)
570 .await
571 .context("VACUUM failed")?;
572 }
573 Ok(())
574}
575
576pub async fn insert_entries(
586 pool: &SqlitePool,
587 feed_id: i64,
588 entries: &[NewEntry],
589 max_entries_per_feed: i64,
590) -> Result<u64> {
591 let mut tx = pool.begin().await.context("begin insert_entries tx")?;
592 let mut count: u64 = 0;
593 for e in entries {
594 let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
595 let res = sqlx::query(
596 r#"
597 INSERT INTO entries
598 (feed_id, guid, url, title, author, published, content_html, fetched_at)
599 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
600 ON CONFLICT (feed_id, guid) DO UPDATE SET
601 url = excluded.url,
602 title = excluded.title,
603 author = excluded.author,
604 published = excluded.published,
605 content_html = excluded.content_html
606 "#,
607 )
608 .bind(feed_id)
609 .bind(&e.guid)
610 .bind(&e.url)
611 .bind(&e.title)
612 .bind(&e.author)
613 .bind(&e.published)
614 .bind(&e.content_html)
615 .bind(&fetched_at)
616 .execute(&mut *tx)
617 .await
618 .with_context(|| format!("insert entry {} failed", e.guid))?;
619 count += res.rows_affected();
620 }
621
622 if max_entries_per_feed > 0 {
630 sqlx::query(
631 r#"
632 DELETE FROM entries
633 WHERE feed_id = ?1
634 AND id NOT IN (
635 SELECT id FROM entries
636 WHERE feed_id = ?1
637 ORDER BY COALESCE(published, fetched_at) DESC, id DESC
638 LIMIT ?2
639 )
640 "#,
641 )
642 .bind(feed_id)
643 .bind(max_entries_per_feed)
644 .execute(&mut *tx)
645 .await
646 .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
647 }
648
649 if max_entries_per_feed > 0 {
655 prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
656 }
657
658 tx.commit().await.context("commit insert_entries tx")?;
659 Ok(count)
660}
661
662pub async fn prune_old_entries(pool: &SqlitePool, days: i64) -> Result<u64> {
674 if days <= 0 {
675 return Ok(0);
676 }
677 let cutoff = (chrono::Utc::now() - chrono::Duration::days(days))
678 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
679
680 let mut tx = pool.begin().await.context("begin prune_old_entries tx")?;
681 let res = sqlx::query("DELETE FROM entries WHERE COALESCE(published, fetched_at) < ?1")
682 .bind(&cutoff)
683 .execute(&mut *tx)
684 .await
685 .with_context(|| format!("prune_old_entries delete (cutoff {cutoff})"))?;
686 let deleted = res.rows_affected();
687
688 if deleted > 0 {
690 prune_orphan_cursor_ids_tx(&mut tx, None).await?;
691 }
692
693 tx.commit().await.context("commit prune_old_entries tx")?;
694 Ok(deleted)
695}
696
697async fn prune_orphan_cursor_ids_tx(
710 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
711 feed_id: Option<i64>,
712) -> Result<u64> {
713 let feed_url = match feed_id {
717 Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
718 Some(u) => Some(u),
719 None => return Ok(0), },
721 None => None,
722 };
723
724 let cursors: Vec<(String, String, String, String)> = match &feed_url {
726 Some(url) => sqlx::query(
727 "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
728 )
729 .bind(url)
730 .fetch_all(&mut **tx)
731 .await
732 .context("prune_orphan_cursor_ids: load feed cursors")?,
733 None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
734 .fetch_all(&mut **tx)
735 .await
736 .context("prune_orphan_cursor_ids: load all cursors")?,
737 }
738 .into_iter()
739 .map(|r| {
740 (
741 r.get::<String, _>("did"),
742 r.get::<String, _>("feed_url"),
743 r.get::<String, _>("read_ids"),
744 r.get::<String, _>("unread_ids"),
745 )
746 })
747 .collect();
748
749 if cursors.is_empty() {
750 return Ok(0);
751 }
752
753 let now = now_rfc3339();
754 let mut changed: u64 = 0;
755 for (did, curl, read_ids, unread_ids) in cursors {
756 let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
758 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
759 )
760 .bind(&curl)
761 .fetch_all(&mut **tx)
762 .await
763 .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
764 .into_iter()
765 .collect();
766
767 let new_read = filter_id_set_to_live(&read_ids, &live);
768 let new_unread = filter_id_set_to_live(&unread_ids, &live);
769 if new_read == read_ids && new_unread == unread_ids {
770 continue; }
772 sqlx::query(
773 "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
774 WHERE did = ?1 AND feed_url = ?2",
775 )
776 .bind(&did)
777 .bind(&curl)
778 .bind(&new_read)
779 .bind(&new_unread)
780 .bind(&now)
781 .execute(&mut **tx)
782 .await
783 .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
784 changed += 1;
785 }
786 Ok(changed)
787}
788
789fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
793 let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
794 .ok()
795 .map(|vals| {
796 vals.into_iter()
797 .filter_map(|v| match v {
798 serde_json::Value::Number(n) => n.as_i64(),
799 serde_json::Value::String(s) => s.parse::<i64>().ok(),
800 _ => None,
801 })
802 .filter(|id| live.contains(id))
803 .collect()
804 })
805 .unwrap_or_default();
806 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
807 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
808}
809
810pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
818 let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
819 sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
820 .bind(did)
821 .execute(&mut *tx)
822 .await
823 .with_context(|| format!("clear sub_ref for {did}"))?;
824 for &feed_id in feed_ids {
825 sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
826 .bind(did)
827 .bind(feed_id)
828 .execute(&mut *tx)
829 .await
830 .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
831 }
832 tx.commit().await.context("commit replace_sub_refs tx")?;
833 Ok(())
834}
835
836pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
840 let found: Option<i64> = sqlx::query_scalar(
841 r#"
842 SELECT 1
843 FROM entries e
844 JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
845 WHERE e.id = ?2
846 "#,
847 )
848 .bind(did)
849 .bind(entry_id)
850 .fetch_optional(pool)
851 .await
852 .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
853 Ok(found.is_some())
854}
855
856pub async fn entries_for_feed(pool: &SqlitePool, did: &str, feed_id: i64) -> Result<Vec<Entry>> {
859 let entries = sqlx::query_as::<_, Entry>(
860 r#"
861 SELECT e.* FROM entries e
862 WHERE e.feed_id = ?2
863 AND EXISTS (
864 SELECT 1 FROM sub_ref sr
865 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
866 )
867 ORDER BY e.published DESC, e.id DESC
868 "#,
869 )
870 .bind(did)
871 .bind(feed_id)
872 .fetch_all(pool)
873 .await
874 .context("entries_for_feed failed")?;
875 Ok(entries)
876}
877
878pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
889 let now = now_rfc3339();
890 let mut tx = pool.begin().await.context("begin mark_read tx")?;
891 let res = sqlx::query(
892 r#"
893 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
894 SELECT ?1, e.id, ?3, 0, ?4
895 FROM entries e
896 WHERE e.id = ?2
897 AND EXISTS (
898 SELECT 1 FROM sub_ref sr
899 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
900 )
901 ON CONFLICT (did, entry_id) DO UPDATE SET
902 read = excluded.read,
903 updated_at = excluded.updated_at
904 "#,
905 )
906 .bind(did)
907 .bind(entry_id)
908 .bind(read)
909 .bind(&now)
910 .execute(&mut *tx)
911 .await
912 .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
913
914 if res.rows_affected() == 0 {
915 tx.rollback().await.ok();
917 return Ok(false);
918 }
919
920 project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
924
925 tx.commit().await.context("commit mark_read tx")?;
926 Ok(true)
927}
928
929pub async fn mark_starred(
935 pool: &SqlitePool,
936 did: &str,
937 entry_id: i64,
938 starred: bool,
939) -> Result<bool> {
940 let res = sqlx::query(
941 r#"
942 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
943 SELECT ?1, e.id, 0, ?3, ?4
944 FROM entries e
945 WHERE e.id = ?2
946 AND EXISTS (
947 SELECT 1 FROM sub_ref sr
948 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
949 )
950 ON CONFLICT (did, entry_id) DO UPDATE SET
951 starred = excluded.starred,
952 updated_at = excluded.updated_at
953 "#,
954 )
955 .bind(did)
956 .bind(entry_id)
957 .bind(starred)
958 .bind(now_rfc3339())
959 .execute(pool)
960 .await
961 .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
962 Ok(res.rows_affected() > 0)
963}
964
965pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
970 let now = now_rfc3339();
971 let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
972 let res = sqlx::query(
973 r#"
974 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
975 SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
976 WHERE e.feed_id = ?4
977 AND EXISTS (
978 SELECT 1 FROM sub_ref sr
979 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
980 )
981 ON CONFLICT (did, entry_id) DO UPDATE SET
982 read = excluded.read,
983 updated_at = excluded.updated_at
984 "#,
985 )
986 .bind(did)
987 .bind(read)
988 .bind(&now)
989 .bind(feed_id)
990 .execute(&mut *tx)
991 .await
992 .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
993
994 if res.rows_affected() > 0 {
995 project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
1000 }
1001
1002 tx.commit().await.context("commit mark_feed_read tx")?;
1003 Ok(res.rows_affected())
1004}
1005
1006fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
1016 let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
1017 .ok()
1018 .map(|vals| {
1019 vals.into_iter()
1020 .filter_map(|v| match v {
1021 serde_json::Value::Number(n) => n.as_i64(),
1022 serde_json::Value::String(s) => s.parse::<i64>().ok(),
1023 _ => None,
1024 })
1025 .collect()
1026 })
1027 .unwrap_or_default();
1028 if present {
1029 if !ids.contains(&id) {
1030 ids.push(id);
1031 }
1032 } else {
1033 ids.retain(|&x| x != id);
1034 }
1035 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
1038 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
1039}
1040
1041async fn feed_url_for_id_tx(
1044 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1045 feed_id: i64,
1046) -> Result<Option<String>> {
1047 let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
1048 .bind(feed_id)
1049 .fetch_optional(&mut **tx)
1050 .await
1051 .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
1052 Ok(url)
1053}
1054
1055async fn cursor_sets(
1058 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1059 did: &str,
1060 feed_url: &str,
1061) -> Result<(Option<String>, String, String)> {
1062 let row = sqlx::query(
1063 "SELECT read_through, read_ids, unread_ids FROM read_cursor \
1064 WHERE did = ?1 AND feed_url = ?2",
1065 )
1066 .bind(did)
1067 .bind(feed_url)
1068 .fetch_optional(&mut **tx)
1069 .await
1070 .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
1071 Ok(match row {
1072 Some(r) => (
1073 r.get::<Option<String>, _>("read_through"),
1074 r.get::<String, _>("read_ids"),
1075 r.get::<String, _>("unread_ids"),
1076 ),
1077 None => (None, "[]".to_string(), "[]".to_string()),
1078 })
1079}
1080
1081async fn write_cursor_sets(
1084 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1085 did: &str,
1086 feed_url: &str,
1087 read_through: Option<&str>,
1088 read_ids: &str,
1089 unread_ids: &str,
1090 now: &str,
1091) -> Result<()> {
1092 sqlx::query(
1093 r#"
1094 INSERT INTO read_cursor
1095 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
1096 VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
1097 ON CONFLICT (did, feed_url) DO UPDATE SET
1098 read_through = excluded.read_through,
1099 read_ids = excluded.read_ids,
1100 unread_ids = excluded.unread_ids,
1101 dirty = 1,
1102 updated_at = excluded.updated_at
1103 "#,
1104 )
1105 .bind(did)
1106 .bind(feed_url)
1107 .bind(read_through)
1108 .bind(read_ids)
1109 .bind(unread_ids)
1110 .bind(now)
1111 .execute(&mut **tx)
1112 .await
1113 .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
1114 Ok(())
1115}
1116
1117async fn project_entry_into_cursor(
1127 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1128 did: &str,
1129 entry_id: i64,
1130 read: bool,
1131 now: &str,
1132) -> Result<()> {
1133 let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
1135 .bind(entry_id)
1136 .fetch_optional(&mut **tx)
1137 .await
1138 .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
1139 let feed_id = match feed_id {
1140 Some(f) => f,
1141 None => return Ok(()), };
1143 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
1144 Some(u) => u,
1145 None => return Ok(()),
1146 };
1147
1148 let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
1149 let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
1151 let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
1152 write_cursor_sets(
1153 tx,
1154 did,
1155 &feed_url,
1156 read_through.as_deref(),
1157 &read_ids,
1158 &unread_ids,
1159 now,
1160 )
1161 .await
1162}
1163
1164async fn project_feed_into_cursor(
1171 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1172 did: &str,
1173 feed_id: i64,
1174 read: bool,
1175 now: &str,
1176) -> Result<()> {
1177 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
1178 Some(u) => u,
1179 None => return Ok(()),
1180 };
1181
1182 let ids: Vec<i64> = sqlx::query_scalar(
1184 r#"
1185 SELECT e.id FROM entries e
1186 WHERE e.feed_id = ?2
1187 AND EXISTS (
1188 SELECT 1 FROM sub_ref sr
1189 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1190 )
1191 "#,
1192 )
1193 .bind(did)
1194 .bind(feed_id)
1195 .fetch_all(&mut **tx)
1196 .await
1197 .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
1198
1199 let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
1200 for id in ids {
1201 read_ids = json_id_set_toggle(&read_ids, id, read);
1202 unread_ids = json_id_set_toggle(&unread_ids, id, !read);
1203 }
1204 write_cursor_sets(
1205 tx,
1206 did,
1207 &feed_url,
1208 read_through.as_deref(),
1209 &read_ids,
1210 &unread_ids,
1211 now,
1212 )
1213 .await
1214}
1215
1216pub async fn get_unread_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
1220 let entries = sqlx::query_as::<_, Entry>(
1221 r#"
1222 SELECT e.*
1223 FROM entries e
1224 LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
1225 WHERE COALESCE(s.read, 0) = 0
1226 AND EXISTS (
1227 SELECT 1 FROM sub_ref sr
1228 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1229 )
1230 ORDER BY e.published DESC, e.id DESC
1231 "#,
1232 )
1233 .bind(did)
1234 .fetch_all(pool)
1235 .await
1236 .with_context(|| format!("get_unread_for_did failed for {did}"))?;
1237 Ok(entries)
1238}
1239
1240pub async fn get_starred_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
1242 let entries = sqlx::query_as::<_, Entry>(
1243 r#"
1244 SELECT e.*
1245 FROM entries e
1246 JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
1247 WHERE s.starred = 1
1248 AND EXISTS (
1249 SELECT 1 FROM sub_ref sr
1250 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1251 )
1252 ORDER BY e.published DESC, e.id DESC
1253 "#,
1254 )
1255 .bind(did)
1256 .fetch_all(pool)
1257 .await
1258 .with_context(|| format!("get_starred_for_did failed for {did}"))?;
1259 Ok(entries)
1260}
1261
1262pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
1266 sqlx::query(
1267 r#"
1268 INSERT INTO read_cursor
1269 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
1270 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1271 ON CONFLICT (did, feed_url) DO UPDATE SET
1272 read_through = excluded.read_through,
1273 read_ids = excluded.read_ids,
1274 unread_ids = excluded.unread_ids,
1275 dirty = excluded.dirty,
1276 updated_at = excluded.updated_at
1277 "#,
1278 )
1279 .bind(&cursor.did)
1280 .bind(&cursor.feed_url)
1281 .bind(&cursor.read_through)
1282 .bind(&cursor.read_ids)
1283 .bind(&cursor.unread_ids)
1284 .bind(cursor.dirty)
1285 .bind(&cursor.updated_at)
1286 .execute(pool)
1287 .await
1288 .with_context(|| {
1289 format!(
1290 "upsert_cursor failed for {}/{}",
1291 cursor.did, cursor.feed_url
1292 )
1293 })?;
1294 Ok(())
1295}
1296
1297pub async fn get_cursor(
1299 pool: &SqlitePool,
1300 did: &str,
1301 feed_url: &str,
1302) -> Result<Option<ReadCursor>> {
1303 let cursor = sqlx::query_as::<_, ReadCursor>(
1304 "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
1305 )
1306 .bind(did)
1307 .bind(feed_url)
1308 .fetch_optional(pool)
1309 .await
1310 .context("get_cursor failed")?;
1311 Ok(cursor)
1312}
1313
1314pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
1317 let cursors =
1318 sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
1319 .bind(did)
1320 .fetch_all(pool)
1321 .await
1322 .with_context(|| format!("dirty_cursors failed for {did}"))?;
1323 Ok(cursors)
1324}
1325
1326pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
1330 sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
1331 .bind(did)
1332 .bind(feed_url)
1333 .execute(pool)
1334 .await
1335 .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
1336 Ok(())
1337}
1338
1339pub async fn clear_cursor_dirty(
1350 pool: &SqlitePool,
1351 did: &str,
1352 feed_url: &str,
1353 flushed_updated_at: &str,
1354) -> Result<()> {
1355 sqlx::query(
1356 "UPDATE read_cursor SET dirty = 0 \
1357 WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
1358 )
1359 .bind(did)
1360 .bind(feed_url)
1361 .bind(flushed_updated_at)
1362 .execute(pool)
1363 .await
1364 .context("clear_cursor_dirty failed")?;
1365 Ok(())
1366}
1367
1368fn now_unix() -> i64 {
1381 chrono::Utc::now().timestamp()
1382}
1383
1384const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1388
1389const CODE_PREFIX: &str = "FEATHER-";
1392
1393const CODE_BODY_LEN: usize = 8;
1395
1396pub fn generate_invite_code() -> Result<String> {
1403 let n = CODE_ALPHABET.len() as u16; let limit = 256 / n * n; let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
1408 out.push_str(CODE_PREFIX);
1409 let mut got = 0;
1410 let mut buf = [0u8; 1];
1411 while got < CODE_BODY_LEN {
1412 getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
1413 let b = buf[0] as u16;
1414 if b < limit {
1415 out.push(CODE_ALPHABET[(b % n) as usize] as char);
1416 got += 1;
1417 }
1418 }
1419 Ok(out)
1420}
1421
1422pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
1424 let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
1425 .bind(did)
1426 .fetch_optional(pool)
1427 .await
1428 .with_context(|| format!("has_beta_access failed for {did}"))?;
1429 Ok(row.is_some())
1430}
1431
1432pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
1435 let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
1436 .fetch_one(pool)
1437 .await
1438 .context("count_beta_access failed")?;
1439 Ok(row.get::<i64, _>("n"))
1440}
1441
1442pub async fn grant_access(
1445 pool: &SqlitePool,
1446 did: &str,
1447 handle: Option<&str>,
1448 granted_by: &str,
1449 invite_code_used: Option<&str>,
1450) -> Result<()> {
1451 sqlx::query(
1452 r#"
1453 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1454 VALUES (?1, ?2, ?3, ?4, ?5)
1455 ON CONFLICT (did) DO UPDATE SET
1456 handle = COALESCE(excluded.handle, beta_access.handle),
1457 granted_by = excluded.granted_by,
1458 invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
1459 "#,
1460 )
1461 .bind(did)
1462 .bind(handle)
1463 .bind(granted_by)
1464 .bind(now_unix())
1465 .bind(invite_code_used)
1466 .execute(pool)
1467 .await
1468 .with_context(|| format!("grant_access failed for {did}"))?;
1469 Ok(())
1470}
1471
1472pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
1475 let code = generate_invite_code()?;
1476 let now = now_unix();
1477 let expires_at = now.saturating_add(ttl_secs.max(0));
1478 sqlx::query(
1479 r#"
1480 INSERT INTO invite_codes
1481 (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
1482 VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)
1483 "#,
1484 )
1485 .bind(&code)
1486 .bind(creator_did)
1487 .bind(now)
1488 .bind(expires_at)
1489 .execute(pool)
1490 .await
1491 .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
1492 Ok(code)
1493}
1494
1495pub async fn redeem_code(
1507 pool: &SqlitePool,
1508 code: &str,
1509 did: &str,
1510 handle: Option<&str>,
1511 cap: i64,
1512) -> Result<std::result::Result<(), RedeemError>> {
1513 let now = now_unix();
1514 let mut tx = pool.begin().await.context("begin redeem_code tx")?;
1515
1516 let row = sqlx::query("SELECT status, expires_at FROM invite_codes WHERE code = ?1")
1518 .bind(code)
1519 .fetch_optional(&mut *tx)
1520 .await
1521 .context("redeem_code: lookup")?;
1522 let row = match row {
1523 Some(r) => r,
1524 None => return Ok(Err(RedeemError::NotFound)),
1525 };
1526 let status: String = row.get("status");
1527 let expires_at: i64 = row.get("expires_at");
1528
1529 if status == "expired" || now > expires_at {
1533 return Ok(Err(RedeemError::Expired));
1534 }
1535 if status != "active" {
1536 return Ok(Err(RedeemError::AlreadyRedeemed));
1537 }
1538
1539 let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
1541 .fetch_one(&mut *tx)
1542 .await
1543 .context("redeem_code: count")?
1544 .get("n");
1545 if count >= cap {
1546 return Ok(Err(RedeemError::CapacityFull));
1547 }
1548
1549 let flipped = sqlx::query(
1553 r#"
1554 UPDATE invite_codes
1555 SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
1556 WHERE code = ?1 AND status = 'active'
1557 "#,
1558 )
1559 .bind(code)
1560 .bind(did)
1561 .bind(now)
1562 .execute(&mut *tx)
1563 .await
1564 .context("redeem_code: flip")?;
1565 if flipped.rows_affected() == 0 {
1566 return Ok(Err(RedeemError::AlreadyRedeemed));
1567 }
1568
1569 sqlx::query(
1571 r#"
1572 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1573 VALUES (?1, ?2, ?3, ?4, ?5)
1574 ON CONFLICT (did) DO UPDATE SET
1575 handle = COALESCE(excluded.handle, beta_access.handle),
1576 invite_code_used = excluded.invite_code_used
1577 "#,
1578 )
1579 .bind(did)
1580 .bind(handle)
1581 .bind(
1583 sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
1584 .bind(code)
1585 .fetch_one(&mut *tx)
1586 .await
1587 .context("redeem_code: creator lookup")?
1588 .get::<String, _>("creator_did"),
1589 )
1590 .bind(now)
1591 .bind(code)
1592 .execute(&mut *tx)
1593 .await
1594 .context("redeem_code: grant")?;
1595
1596 tx.commit().await.context("commit redeem_code tx")?;
1597 Ok(Ok(()))
1598}
1599
1600pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
1604 let now = now_unix();
1605 let res = sqlx::query(
1606 "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
1607 )
1608 .bind(now)
1609 .execute(pool)
1610 .await
1611 .context("expire_old_codes failed")?;
1612 Ok(res.rows_affected())
1613}
1614
1615pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
1619 let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
1620 let now = now_unix();
1621 let mut created = 0u64;
1622 for did in dids {
1623 let res = sqlx::query(
1624 r#"
1625 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1626 VALUES (?1, NULL, 'admin', ?2, NULL)
1627 ON CONFLICT (did) DO NOTHING
1628 "#,
1629 )
1630 .bind(did)
1631 .bind(now)
1632 .execute(&mut *tx)
1633 .await
1634 .with_context(|| format!("ensure_seed insert failed for {did}"))?;
1635 created += res.rows_affected();
1636 }
1637 tx.commit().await.context("commit ensure_seed tx")?;
1638 Ok(created)
1639}
1640
1641#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1644pub struct PurgeCounts {
1645 pub entry_state: u64,
1647 pub read_cursor: u64,
1649 pub sub_ref: u64,
1651 pub beta_access: u64,
1653 pub invite_codes: u64,
1655 pub invitee_scrubbed: u64,
1658 pub granted_by_scrubbed: u64,
1661}
1662
1663impl PurgeCounts {
1664 pub fn total(&self) -> u64 {
1668 self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
1669 }
1670}
1671
1672pub const REDACTED_DID: &str = "__redacted__";
1676
1677pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
1689 let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
1690
1691 let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
1692 .bind(did)
1693 .execute(&mut *tx)
1694 .await
1695 .with_context(|| format!("purge entry_state for {did}"))?
1696 .rows_affected();
1697
1698 let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
1699 .bind(did)
1700 .execute(&mut *tx)
1701 .await
1702 .with_context(|| format!("purge read_cursor for {did}"))?
1703 .rows_affected();
1704
1705 let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
1706 .bind(did)
1707 .execute(&mut *tx)
1708 .await
1709 .with_context(|| format!("purge sub_ref for {did}"))?
1710 .rows_affected();
1711
1712 let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
1713 .bind(did)
1714 .execute(&mut *tx)
1715 .await
1716 .with_context(|| format!("purge beta_access for {did}"))?
1717 .rows_affected();
1718
1719 let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
1720 .bind(did)
1721 .execute(&mut *tx)
1722 .await
1723 .with_context(|| format!("purge invite_codes for {did}"))?
1724 .rows_affected();
1725
1726 let invitee_scrubbed =
1734 sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
1735 .bind(did)
1736 .execute(&mut *tx)
1737 .await
1738 .with_context(|| format!("scrub invitee_did for {did}"))?
1739 .rows_affected();
1740
1741 let granted_by_scrubbed =
1742 sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
1743 .bind(did)
1744 .bind(REDACTED_DID)
1745 .execute(&mut *tx)
1746 .await
1747 .with_context(|| format!("scrub granted_by for {did}"))?
1748 .rows_affected();
1749
1750 tx.commit().await.context("commit purge_did_data tx")?;
1751
1752 Ok(PurgeCounts {
1753 entry_state,
1754 read_cursor,
1755 sub_ref,
1756 beta_access,
1757 invite_codes,
1758 invitee_scrubbed,
1759 granted_by_scrubbed,
1760 })
1761}
1762
1763#[cfg(test)]
1764mod tests {
1765 use super::*;
1766
1767 #[tokio::test]
1769 async fn init_insert_readback() -> Result<()> {
1770 let pool = init_url("sqlite::memory:").await?;
1771
1772 let feed_id = upsert_feed(
1774 &pool,
1775 &NewFeed {
1776 url: "https://example.com/feed.xml".to_string(),
1777 title: Some("Example".to_string()),
1778 site_url: Some("https://example.com".to_string()),
1779 next_poll: Some("2026-07-12T00:00:00Z".to_string()),
1780 ..Default::default()
1781 },
1782 )
1783 .await?;
1784 assert!(feed_id > 0);
1785
1786 let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
1788 .await?
1789 .expect("feed should exist");
1790 assert_eq!(feed.id, feed_id);
1791 assert_eq!(feed.title.as_deref(), Some("Example"));
1792 assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
1793
1794 let feed_id2 = upsert_feed(
1796 &pool,
1797 &NewFeed {
1798 url: "https://example.com/feed.xml".to_string(),
1799 title: Some("Example (renamed)".to_string()),
1800 ..Default::default()
1801 },
1802 )
1803 .await?;
1804 assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
1805
1806 let n = insert_entries(
1808 &pool,
1809 feed_id,
1810 &[
1811 NewEntry {
1812 guid: "guid-1".to_string(),
1813 url: Some("https://example.com/a".to_string()),
1814 title: Some("First".to_string()),
1815 published: Some("2026-07-10T08:00:00Z".to_string()),
1816 content_html: Some("<p>hello</p>".to_string()),
1817 ..Default::default()
1818 },
1819 NewEntry {
1820 guid: "guid-2".to_string(),
1821 url: Some("https://example.com/b".to_string()),
1822 title: Some("Second".to_string()),
1823 published: Some("2026-07-11T08:00:00Z".to_string()),
1824 ..Default::default()
1825 },
1826 ],
1827 0, )
1829 .await?;
1830 assert_eq!(n, 2);
1831
1832 let did = "did:plc:abc123";
1835 replace_sub_refs(&pool, did, &[feed_id]).await?;
1836
1837 let entries = entries_for_feed(&pool, did, feed_id).await?;
1839 assert_eq!(entries.len(), 2);
1840 assert_eq!(entries[0].guid, "guid-2");
1841 assert_eq!(entries[1].guid, "guid-1");
1842 assert_eq!(entries[1].content_html.as_deref(), Some("<p>hello</p>"));
1843
1844 let n2 = insert_entries(
1846 &pool,
1847 feed_id,
1848 &[NewEntry {
1849 guid: "guid-1".to_string(),
1850 title: Some("First (edited)".to_string()),
1851 ..Default::default()
1852 }],
1853 0,
1854 )
1855 .await?;
1856 assert_eq!(n2, 1);
1857 assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
1858
1859 let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
1861
1862 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
1864
1865 mark_read(&pool, did, e1, true).await?;
1867 let unread = get_unread_for_did(&pool, did).await?;
1868 assert_eq!(unread.len(), 1);
1869 assert_eq!(unread[0].guid, "guid-2");
1870
1871 mark_starred(&pool, did, e1, true).await?;
1873 let starred = get_starred_for_did(&pool, did).await?;
1874 assert_eq!(starred.len(), 1);
1875 assert_eq!(starred[0].id, e1);
1876
1877 mark_feed_read(&pool, did, feed_id, true).await?;
1879 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
1880
1881 let cursor = ReadCursor {
1883 did: did.to_string(),
1884 feed_url: "https://example.com/feed.xml".to_string(),
1885 read_through: Some("2026-07-11T08:00:00Z".to_string()),
1886 read_ids: "[]".to_string(),
1887 unread_ids: "[]".to_string(),
1888 dirty: true,
1889 pds_created: false,
1890 updated_at: now_rfc3339(),
1891 };
1892 upsert_cursor(&pool, &cursor).await?;
1893
1894 let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
1895 .await?
1896 .expect("cursor should exist");
1897 assert_eq!(
1898 fetched.read_through.as_deref(),
1899 Some("2026-07-11T08:00:00Z")
1900 );
1901 assert!(fetched.dirty);
1902
1903 let dirty = dirty_cursors(&pool, did).await?;
1905 assert_eq!(dirty.len(), 1);
1906 let flushed_at = dirty[0].updated_at.clone();
1907
1908 clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
1911 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
1912
1913 Ok(())
1914 }
1915
1916 #[tokio::test]
1924 async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
1925 let pool = init_url("sqlite::memory:").await?;
1926 let feed_url = "https://example.com/feed.xml";
1927 let feed_id = upsert_feed(
1928 &pool,
1929 &NewFeed {
1930 url: feed_url.to_string(),
1931 title: Some("Example".to_string()),
1932 ..Default::default()
1933 },
1934 )
1935 .await?;
1936 insert_entries(
1937 &pool,
1938 feed_id,
1939 &[
1940 NewEntry {
1941 guid: "g1".to_string(),
1942 published: Some("2026-07-10T00:00:00Z".to_string()),
1943 ..Default::default()
1944 },
1945 NewEntry {
1946 guid: "g2".to_string(),
1947 published: Some("2026-07-11T00:00:00Z".to_string()),
1948 ..Default::default()
1949 },
1950 ],
1951 0,
1952 )
1953 .await?;
1954 let did = "did:plc:reader";
1955 replace_sub_refs(&pool, did, &[feed_id]).await?;
1956
1957 assert!(get_cursor(&pool, did, feed_url).await?.is_none());
1959 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
1960
1961 let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
1964 assert!(mark_read(&pool, did, e1, true).await?);
1965
1966 let cursor = get_cursor(&pool, did, feed_url)
1967 .await?
1968 .expect("mark_read must create the feed's read_cursor");
1969 assert!(cursor.dirty, "cursor must be dirty after mark_read");
1970 assert!(
1971 cursor.read_ids.contains(&e1.to_string()),
1972 "the read entry id must be in read_ids: {}",
1973 cursor.read_ids
1974 );
1975 let dirty = dirty_cursors(&pool, did).await?;
1976 assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
1977 assert_eq!(dirty[0].feed_url, feed_url);
1978
1979 assert!(mark_read(&pool, did, e1, false).await?);
1981 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
1982 assert!(cursor.dirty);
1983 assert!(
1984 cursor.unread_ids.contains(&e1.to_string()),
1985 "unread id must be in unread_ids: {}",
1986 cursor.unread_ids
1987 );
1988 assert!(
1989 !cursor.read_ids.contains(&e1.to_string()),
1990 "id must have left read_ids: {}",
1991 cursor.read_ids
1992 );
1993
1994 assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
1997 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
1998 assert!(cursor.dirty);
1999 assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
2000
2001 let outsider = "did:plc:outsider";
2003 assert!(!mark_read(&pool, outsider, e1, true).await?);
2004 assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
2005
2006 let snap = dirty_cursors(&pool, did).await?[0].clone();
2008 clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
2010 assert_eq!(
2011 dirty_cursors(&pool, did).await?.len(),
2012 1,
2013 "stale-snapshot clear must be a no-op"
2014 );
2015 clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
2017 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
2018
2019 Ok(())
2020 }
2021
2022 #[test]
2023 fn json_id_set_toggle_is_set_like() {
2024 let s = json_id_set_toggle("[]", 5, true);
2026 assert_eq!(s, r#"["5"]"#);
2027 assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); let s = json_id_set_toggle(&s, 7, true);
2029 assert_eq!(s, r#"["5","7"]"#);
2030 let s = json_id_set_toggle(&s, 5, false);
2031 assert_eq!(s, r#"["7"]"#);
2032 assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
2034 assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
2035 }
2036
2037 #[tokio::test]
2045 async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
2046 let pool = init_url("sqlite::memory:").await?;
2047
2048 let feed_a = upsert_feed(
2050 &pool,
2051 &NewFeed {
2052 url: "https://a.example/feed.xml".to_string(),
2053 title: Some("A".to_string()),
2054 ..Default::default()
2055 },
2056 )
2057 .await?;
2058 let feed_b = upsert_feed(
2059 &pool,
2060 &NewFeed {
2061 url: "https://b.example/feed.xml".to_string(),
2062 title: Some("B".to_string()),
2063 ..Default::default()
2064 },
2065 )
2066 .await?;
2067
2068 insert_entries(
2069 &pool,
2070 feed_a,
2071 &[NewEntry {
2072 guid: "a-1".to_string(),
2073 url: Some("https://a.example/1".to_string()),
2074 title: Some("A one".to_string()),
2075 published: Some("2026-07-10T00:00:00Z".to_string()),
2076 content_html: Some("<p>secret A body</p>".to_string()),
2077 ..Default::default()
2078 }],
2079 0,
2080 )
2081 .await?;
2082 insert_entries(
2083 &pool,
2084 feed_b,
2085 &[NewEntry {
2086 guid: "b-1".to_string(),
2087 url: Some("https://b.example/1".to_string()),
2088 title: Some("B one".to_string()),
2089 published: Some("2026-07-11T00:00:00Z".to_string()),
2090 content_html: Some("<p>secret B body</p>".to_string()),
2091 ..Default::default()
2092 }],
2093 0,
2094 )
2095 .await?;
2096
2097 let did_a = "did:plc:aaaa";
2098 let did_b = "did:plc:bbbb";
2099 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
2100 replace_sub_refs(&pool, did_b, &[feed_b]).await?;
2101
2102 let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
2104
2105 assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
2107 assert!(
2108 entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
2109 "A must not read entries of a feed it does not subscribe to"
2110 );
2111
2112 let unread_a = get_unread_for_did(&pool, did_a).await?;
2114 assert_eq!(unread_a.len(), 1);
2115 assert_eq!(unread_a[0].guid, "a-1");
2116 let unread_b = get_unread_for_did(&pool, did_b).await?;
2117 assert_eq!(unread_b.len(), 1);
2118 assert_eq!(unread_b[0].guid, "b-1");
2119
2120 assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
2122 assert!(
2123 !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
2124 "A does not subscribe to B's feed"
2125 );
2126
2127 assert!(
2129 !mark_read(&pool, did_a, b_entry_id, true).await?,
2130 "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
2131 );
2132 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
2134 assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
2136 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
2137
2138 assert!(
2140 !mark_starred(&pool, did_a, b_entry_id, true).await?,
2141 "non-subscriber mark_starred must be a no-op (→ 404)"
2142 );
2143 assert!(
2144 get_starred_for_did(&pool, did_a).await?.is_empty(),
2145 "A's starred list stays empty after the rejected attempt"
2146 );
2147 assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
2148 assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
2149 assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
2151
2152 let a_feeds = feeds_for_did(&pool, did_a).await?;
2157 assert_eq!(a_feeds.len(), 1);
2158 assert_eq!(a_feeds[0].id, feed_a);
2159 let b_feeds = feeds_for_did(&pool, did_b).await?;
2160 assert_eq!(b_feeds.len(), 1);
2161 assert_eq!(b_feeds[0].id, feed_b);
2162
2163 replace_sub_refs(&pool, did_b, &[]).await?;
2165 assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
2166 assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
2167 assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
2168 assert!(feeds_for_did(&pool, did_b).await?.is_empty());
2170
2171 Ok(())
2172 }
2173
2174 #[tokio::test]
2192 async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
2193 let pool = init_url("sqlite::memory:").await?;
2194
2195 let did_a = "did:plc:aaaa";
2196
2197 let feed_a = upsert_feed(
2200 &pool,
2201 &NewFeed {
2202 url: "https://a.example/feed.xml".to_string(),
2203 title: Some("A".to_string()),
2204 ..Default::default()
2205 },
2206 )
2207 .await?;
2208 let feed_orphan = upsert_feed(
2211 &pool,
2212 &NewFeed {
2213 url: "https://orphan.example/feed.xml".to_string(),
2214 title: Some("Orphan".to_string()),
2215 ..Default::default()
2216 },
2217 )
2218 .await?;
2219
2220 insert_entries(
2221 &pool,
2222 feed_a,
2223 &[NewEntry {
2224 guid: "a-1".to_string(),
2225 url: Some("https://a.example/1".to_string()),
2226 title: Some("A one".to_string()),
2227 published: Some("2026-07-10T00:00:00Z".to_string()),
2228 content_html: Some("<p>A body</p>".to_string()),
2229 ..Default::default()
2230 }],
2231 0,
2232 )
2233 .await?;
2234 insert_entries(
2235 &pool,
2236 feed_orphan,
2237 &[NewEntry {
2238 guid: "orphan-1".to_string(),
2239 url: Some("https://orphan.example/1".to_string()),
2240 title: Some("Orphan one".to_string()),
2241 published: Some("2026-07-11T00:00:00Z".to_string()),
2242 content_html: Some("<p>secret orphan body</p>".to_string()),
2243 ..Default::default()
2244 }],
2245 0,
2246 )
2247 .await?;
2248
2249 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
2252
2253 replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
2256 let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
2257 replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
2258
2259 let fallback = feeds_for_did(&pool, did_a).await?;
2265 let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
2266 assert_eq!(
2267 fallback_ids,
2268 vec![feed_a],
2269 "outage fallback must serve ONLY A's own last-known sub_ref, \
2270 never widen to the orphan cached feed"
2271 );
2272 assert!(
2273 !fallback_ids.contains(&feed_orphan),
2274 "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
2275 cached feed into A's surface"
2276 );
2277
2278 assert!(
2280 !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
2281 "A must not be authorized for an orphan feed's entry during an outage"
2282 );
2283 assert!(
2284 entries_for_feed(&pool, did_a, feed_orphan)
2285 .await?
2286 .is_empty(),
2287 "entries_for_feed must not expose the orphan feed to A during an outage"
2288 );
2289 let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
2291 .await?
2292 .into_iter()
2293 .map(|e| e.guid)
2294 .collect();
2295 assert!(
2296 !unread_guids.iter().any(|g| g == "orphan-1"),
2297 "orphan entry leaked into A's unread list during an outage"
2298 );
2299 assert!(
2300 get_starred_for_did(&pool, did_a).await?.is_empty(),
2301 "A has no starred entries; the orphan must not appear"
2302 );
2303
2304 assert!(
2306 !mark_read(&pool, did_a, orphan_entry_id, true).await?,
2307 "A must not mark an orphan feed's entry read during an outage"
2308 );
2309 assert!(
2310 !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
2311 "A must not star an orphan feed's entry during an outage"
2312 );
2313 assert_eq!(
2314 mark_feed_read(&pool, did_a, feed_orphan, true).await?,
2315 0,
2316 "A must not mark-all-read the orphan feed during an outage"
2317 );
2318
2319 let es_count: i64 =
2321 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
2322 .bind(did_a)
2323 .bind(orphan_entry_id)
2324 .fetch_one(&pool)
2325 .await?;
2326 assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
2327
2328 Ok(())
2329 }
2330
2331 #[test]
2336 fn code_gen_shape_and_alphabet() {
2337 for _ in 0..200 {
2338 let code = generate_invite_code().unwrap();
2339 assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
2340 let body = &code["FEATHER-".len()..];
2341 assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
2342 for c in body.chars() {
2345 assert!(
2346 CODE_ALPHABET.contains(&(c as u8)),
2347 "char {c:?} not in alphabet ({code})"
2348 );
2349 assert!(
2350 !matches!(c, 'I' | 'O' | '0' | '1'),
2351 "ambiguous char {c:?} leaked into {code}"
2352 );
2353 }
2354 }
2355 assert_ne!(
2357 generate_invite_code().unwrap(),
2358 generate_invite_code().unwrap()
2359 );
2360 }
2361
2362 #[tokio::test]
2363 async fn busy_timeout_is_applied() -> Result<()> {
2364 let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
2367 std::fs::create_dir_all(&dir).ok();
2368 let path = dir.join("busy.db");
2369 let url = format!("sqlite://{}", path.display());
2370 let pool = init_url(&url).await?;
2371 let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
2372 let timeout: i64 = row.get(0);
2373 assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
2374 pool.close().await;
2375 std::fs::remove_dir_all(&dir).ok();
2376 Ok(())
2377 }
2378
2379 #[tokio::test]
2380 async fn redeem_valid_grants_seat() -> Result<()> {
2381 let pool = init_url("sqlite::memory:").await?;
2382 let code = mint_code(&pool, "did:plc:creator", 3600).await?;
2383 assert!(!has_beta_access(&pool, "did:plc:new").await?);
2384
2385 let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
2386 assert_eq!(out, Ok(()));
2387 assert!(has_beta_access(&pool, "did:plc:new").await?);
2388 assert_eq!(count_beta_access(&pool).await?, 1);
2389
2390 let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
2392 assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
2393 Ok(())
2394 }
2395
2396 #[tokio::test]
2397 async fn redeem_not_found() -> Result<()> {
2398 let pool = init_url("sqlite::memory:").await?;
2399 let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
2400 assert_eq!(out, Err(RedeemError::NotFound));
2401 Ok(())
2402 }
2403
2404 async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
2407 let now = now_unix();
2408 sqlx::query(
2409 r#"INSERT INTO invite_codes
2410 (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
2411 VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
2412 )
2413 .bind(code)
2414 .bind(creator)
2415 .bind(now - 100)
2416 .bind(now - 10) .execute(pool)
2418 .await?;
2419 Ok(())
2420 }
2421
2422 #[tokio::test]
2423 async fn redeem_expired() -> Result<()> {
2424 let pool = init_url("sqlite::memory:").await?;
2425 insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
2426 let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
2427 assert_eq!(out, Err(RedeemError::Expired));
2428 assert_eq!(count_beta_access(&pool).await?, 0);
2430 Ok(())
2431 }
2432
2433 #[tokio::test]
2434 async fn redeem_capacity_full() -> Result<()> {
2435 let pool = init_url("sqlite::memory:").await?;
2436 ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
2438 assert_eq!(count_beta_access(&pool).await?, 1);
2439
2440 let code = mint_code(&pool, "did:plc:admin", 3600).await?;
2441 let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
2442 assert_eq!(out, Err(RedeemError::CapacityFull));
2443 assert!(!has_beta_access(&pool, "did:plc:new").await?);
2445 let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
2447 assert_eq!(ok, Ok(()));
2448 Ok(())
2449 }
2450
2451 #[tokio::test]
2452 async fn expire_and_seed() -> Result<()> {
2453 let pool = init_url("sqlite::memory:").await?;
2454 insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
2456 let live = mint_code(&pool, "did:plc:creator", 3600).await?;
2457 let n = expire_old_codes(&pool).await?;
2458 assert_eq!(n, 1, "exactly the past-expiry code should flip");
2459 assert_eq!(
2461 redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
2462 Ok(())
2463 );
2464
2465 let created = ensure_seed(
2467 &pool,
2468 &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
2469 )
2470 .await?;
2471 assert_eq!(created, 2);
2472 let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
2473 assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
2474 assert!(has_beta_access(&pool, "did:plc:seed1").await?);
2475 Ok(())
2476 }
2477
2478 #[tokio::test]
2483 async fn count_helpers_track_feeds_and_subs() -> Result<()> {
2484 let pool = init_url("sqlite::memory:").await?;
2485 assert_eq!(count_feeds(&pool).await?, 0);
2486
2487 let mut ids = Vec::new();
2488 for i in 0..3 {
2489 let id = upsert_feed(
2490 &pool,
2491 &NewFeed {
2492 url: format!("https://f{i}.example/feed.xml"),
2493 ..Default::default()
2494 },
2495 )
2496 .await?;
2497 ids.push(id);
2498 }
2499 assert_eq!(count_feeds(&pool).await?, 3);
2500
2501 let did = "did:plc:capcheck";
2502 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
2503 replace_sub_refs(&pool, did, &ids).await?;
2504 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
2505 Ok(())
2506 }
2507
2508 #[tokio::test]
2509 async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
2510 let pool = init_url("sqlite::memory:").await?;
2511 let feed_id = upsert_feed(
2512 &pool,
2513 &NewFeed {
2514 url: "https://firehose.example/feed.xml".to_string(),
2515 ..Default::default()
2516 },
2517 )
2518 .await?;
2519
2520 let batch: Vec<NewEntry> = (0..5)
2522 .map(|i| NewEntry {
2523 guid: format!("g-{i}"),
2524 title: Some(format!("E{i}")),
2525 published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
2526 ..Default::default()
2527 })
2528 .collect();
2529 insert_entries(&pool, feed_id, &batch, 2).await?;
2530
2531 let did = "did:plc:trim";
2532 replace_sub_refs(&pool, did, &[feed_id]).await?;
2533 let kept = entries_for_feed(&pool, did, feed_id).await?;
2534 assert_eq!(
2535 kept.len(),
2536 2,
2537 "over-cap feed trimmed to the newest 2 entries"
2538 );
2539 assert_eq!(kept[0].guid, "g-4");
2541 assert_eq!(kept[1].guid, "g-3");
2542 Ok(())
2543 }
2544
2545 #[tokio::test]
2551 async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
2552 let pool = init_url("sqlite::memory:").await?;
2553 let feed_id = upsert_feed(
2554 &pool,
2555 &NewFeed {
2556 url: "https://undated.example/feed.xml".to_string(),
2557 ..Default::default()
2558 },
2559 )
2560 .await?;
2561
2562 let batch = vec![
2565 NewEntry {
2566 guid: "old-dated-1".to_string(),
2567 title: Some("Old A".to_string()),
2568 published: Some("2026-07-01T00:00:00Z".to_string()),
2569 fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
2570 ..Default::default()
2571 },
2572 NewEntry {
2573 guid: "old-dated-2".to_string(),
2574 title: Some("Old B".to_string()),
2575 published: Some("2026-07-02T00:00:00Z".to_string()),
2576 fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
2577 ..Default::default()
2578 },
2579 NewEntry {
2580 guid: "fresh-undated".to_string(),
2581 title: Some("Fresh undated".to_string()),
2582 published: None,
2583 fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
2584 ..Default::default()
2585 },
2586 ];
2587 insert_entries(&pool, feed_id, &batch, 2).await?;
2588
2589 let did = "did:plc:undated";
2590 replace_sub_refs(&pool, did, &[feed_id]).await?;
2591 let kept = entries_for_feed(&pool, did, feed_id).await?;
2592 assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
2593 let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
2594 assert!(
2595 guids.contains(&"fresh-undated"),
2596 "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
2597 );
2598 assert!(
2599 guids.contains(&"old-dated-2"),
2600 "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
2601 );
2602 assert!(
2603 !guids.contains(&"old-dated-1"),
2604 "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
2605 );
2606 Ok(())
2607 }
2608
2609 #[tokio::test]
2610 async fn db_size_is_positive_and_grows() -> Result<()> {
2611 let pool = init_url("sqlite::memory:").await?;
2612 let before = db_size_bytes(&pool).await?;
2613 assert!(before > 0, "a schema-initialised DB has a non-zero size");
2614 Ok(())
2615 }
2616
2617 #[tokio::test]
2621 async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
2622 let pool = init_url("sqlite::memory:").await?;
2623
2624 let feed_id = upsert_feed(
2626 &pool,
2627 &NewFeed {
2628 url: "https://example.com/feed.xml".to_string(),
2629 title: Some("Example".to_string()),
2630 ..Default::default()
2631 },
2632 )
2633 .await?;
2634 insert_entries(
2635 &pool,
2636 feed_id,
2637 &[NewEntry {
2638 guid: "g-1".to_string(),
2639 url: Some("https://example.com/a".to_string()),
2640 title: Some("First".to_string()),
2641 published: Some("2026-07-10T08:00:00Z".to_string()),
2642 ..Default::default()
2643 }],
2644 0,
2645 )
2646 .await?;
2647 let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
2648 .fetch_one(&pool)
2649 .await?;
2650
2651 let victim = "did:plc:victim";
2652 let bystander = "did:plc:bystander";
2653
2654 for did in [victim, bystander] {
2656 replace_sub_refs(&pool, did, &[feed_id]).await?;
2657 assert!(mark_read(&pool, did, entry_id, true).await?);
2658 assert!(mark_starred(&pool, did, entry_id, true).await?);
2659 upsert_cursor(
2660 &pool,
2661 &ReadCursor {
2662 did: did.to_string(),
2663 feed_url: "https://example.com/feed.xml".to_string(),
2664 read_through: Some("2026-07-10T08:00:00Z".to_string()),
2665 read_ids: "[]".to_string(),
2666 unread_ids: "[]".to_string(),
2667 dirty: false,
2668 pds_created: false,
2669 updated_at: now_rfc3339(),
2670 },
2671 )
2672 .await?;
2673 grant_access(&pool, did, Some("h.example"), "admin", None).await?;
2674 mint_code(&pool, did, 3600).await?;
2675 }
2676
2677 let counts = purge_did_data(&pool, victim).await?;
2679 assert_eq!(
2680 counts.entry_state, 1,
2681 "one entry_state row (read+star merge)"
2682 );
2683 assert_eq!(counts.read_cursor, 1);
2684 assert_eq!(counts.sub_ref, 1);
2685 assert_eq!(counts.beta_access, 1);
2686 assert_eq!(counts.invite_codes, 1);
2687 assert_eq!(counts.total(), 5);
2688
2689 let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
2691 .bind(victim)
2692 .fetch_one(&pool)
2693 .await?;
2694 assert_eq!(es, 0, "victim still had entry_state rows");
2695 let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
2696 .bind(victim)
2697 .fetch_one(&pool)
2698 .await?;
2699 assert_eq!(rc, 0, "victim still had read_cursor rows");
2700 let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
2701 .bind(victim)
2702 .fetch_one(&pool)
2703 .await?;
2704 assert_eq!(sr, 0, "victim still had sub_ref rows");
2705 assert!(
2706 !has_beta_access(&pool, victim).await?,
2707 "victim still had a beta seat"
2708 );
2709 let victim_codes: i64 =
2710 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2711 .bind(victim)
2712 .fetch_one(&pool)
2713 .await?;
2714 assert_eq!(victim_codes, 0);
2715
2716 assert!(has_beta_access(&pool, bystander).await?);
2718 let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
2719 assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
2720 let bystander_codes: i64 =
2721 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2722 .bind(bystander)
2723 .fetch_one(&pool)
2724 .await?;
2725 assert_eq!(bystander_codes, 1);
2726
2727 assert_eq!(count_feeds(&pool).await?, 1);
2729
2730 let again = purge_did_data(&pool, victim).await?;
2732 assert_eq!(again.total(), 0);
2733
2734 Ok(())
2735 }
2736
2737 #[tokio::test]
2743 async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
2744 let pool = init_url("sqlite::memory:").await?;
2745
2746 let inviter = "did:plc:inviter";
2747 let leaver = "did:plc:leaver";
2748 let friend = "did:plc:friend";
2749
2750 let inviter_code = mint_code(&pool, inviter, 3600).await?;
2752 grant_access(&pool, inviter, None, "admin", None).await?;
2753 assert_eq!(
2754 redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
2755 Ok(())
2756 );
2757
2758 let leaver_code = mint_code(&pool, leaver, 3600).await?;
2760 assert_eq!(
2761 redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
2762 Ok(())
2763 );
2764
2765 let invitee_before: i64 =
2767 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
2768 .bind(leaver)
2769 .fetch_one(&pool)
2770 .await?;
2771 assert_eq!(
2772 invitee_before, 1,
2773 "leaver should be an invitee before purge"
2774 );
2775 let granted_before: i64 =
2776 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
2777 .bind(leaver)
2778 .fetch_one(&pool)
2779 .await?;
2780 assert_eq!(granted_before, 1, "leaver should be a granter before purge");
2781
2782 let counts = purge_did_data(&pool, leaver).await?;
2784 assert_eq!(
2785 counts.invitee_scrubbed, 1,
2786 "the redeemed code's invitee_did"
2787 );
2788 assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
2789
2790 let invitee_after: i64 =
2792 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
2793 .bind(leaver)
2794 .fetch_one(&pool)
2795 .await?;
2796 assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
2797 let granted_after: i64 =
2798 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
2799 .bind(leaver)
2800 .fetch_one(&pool)
2801 .await?;
2802 assert_eq!(granted_after, 0, "leaver survived in granted_by");
2803
2804 assert!(
2807 has_beta_access(&pool, friend).await?,
2808 "friend's seat must survive the leaver's scrub"
2809 );
2810 let friend_granted_by: String =
2811 sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
2812 .bind(friend)
2813 .fetch_one(&pool)
2814 .await?;
2815 assert_eq!(friend_granted_by, REDACTED_DID);
2816 let inviter_code_rows: i64 =
2817 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2818 .bind(inviter)
2819 .fetch_one(&pool)
2820 .await?;
2821 assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
2822
2823 Ok(())
2824 }
2825
2826 #[tokio::test]
2829 async fn feed_error_count_bumps_and_resets() -> Result<()> {
2830 let pool = init_url("sqlite::memory:").await?;
2831 let url = "https://broken.example/feed.xml";
2832 upsert_feed(
2833 &pool,
2834 &NewFeed {
2835 url: url.to_string(),
2836 ..Default::default()
2837 },
2838 )
2839 .await?;
2840
2841 let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
2843 assert_eq!(feed.consecutive_errors, 0);
2844
2845 let mut last = std::time::Duration::ZERO;
2848 for expected in 1..=3 {
2849 let count = bump_feed_errors(&pool, url).await?;
2850 assert_eq!(count, expected, "bump returns the new count");
2851 let backoff = crate::feed::backoff_for(count as u32);
2852 assert!(
2853 backoff >= last,
2854 "backoff must not shrink as errors accumulate"
2855 );
2856 last = backoff;
2857 }
2858 assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
2860 assert_eq!(
2861 get_feed_by_url(&pool, url)
2862 .await?
2863 .unwrap()
2864 .consecutive_errors,
2865 3
2866 );
2867
2868 reset_feed_errors(&pool, url).await?;
2870 assert_eq!(
2871 get_feed_by_url(&pool, url)
2872 .await?
2873 .unwrap()
2874 .consecutive_errors,
2875 0
2876 );
2877 Ok(())
2878 }
2879
2880 #[tokio::test]
2883 async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
2884 let dir = std::env::temp_dir();
2887 let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
2888 let url = format!("sqlite://{}", path.display());
2889 let pool = init_url(&url).await?;
2890
2891 let feed_id = upsert_feed(
2892 &pool,
2893 &NewFeed {
2894 url: "https://bulk.example/feed.xml".to_string(),
2895 ..Default::default()
2896 },
2897 )
2898 .await?;
2899
2900 let entries: Vec<NewEntry> = (0..2000)
2902 .map(|i| NewEntry {
2903 guid: format!("guid-{i}"),
2904 title: Some(format!("Entry number {i} with some padding text")),
2905 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
2906 published: Some("2026-01-01T00:00:00Z".to_string()),
2907 ..Default::default()
2908 })
2909 .collect();
2910 insert_entries(&pool, feed_id, &entries, 0).await?;
2911 let full = db_size_bytes(&pool).await?;
2912 assert!(full > 0);
2913
2914 sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
2917 .bind(feed_id)
2918 .execute(&pool)
2919 .await?;
2920
2921 let after_delete = db_size_bytes(&pool).await?;
2924 assert!(
2925 after_delete < full,
2926 "used size must drop once rows are deleted (freed pages excluded): \
2927 {after_delete} !< {full}"
2928 );
2929
2930 reclaim(&pool).await?;
2934 let after_reclaim = db_size_bytes(&pool).await?;
2935 assert!(
2936 after_reclaim <= after_delete,
2937 "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
2938 );
2939 assert!(
2940 after_reclaim < full,
2941 "after prune+reclaim the DB is smaller than when full: \
2942 {after_reclaim} !< {full}"
2943 );
2944
2945 drop(pool);
2946 let _ = std::fs::remove_file(&path);
2947 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
2948 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
2949 Ok(())
2950 }
2951
2952 #[tokio::test]
2955 async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
2956 let pool = init_url("sqlite::memory:").await?;
2957 let did = "did:plc:f4";
2958 let feed_url = "https://example.com/feed.xml";
2959 upsert_cursor(
2960 &pool,
2961 &ReadCursor {
2962 did: did.to_string(),
2963 feed_url: feed_url.to_string(),
2964 read_through: None,
2965 read_ids: r#"["1"]"#.to_string(),
2966 unread_ids: "[]".to_string(),
2967 dirty: true,
2968 pds_created: false,
2969 updated_at: now_rfc3339(),
2970 },
2971 )
2972 .await?;
2973
2974 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
2976 assert!(!c.pds_created, "first flush must emit a create, not update");
2977
2978 mark_cursor_pds_created(&pool, did, feed_url).await?;
2980 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
2981 assert!(c.pds_created);
2982 Ok(())
2983 }
2984
2985 async fn count_entries(pool: &SqlitePool) -> Result<i64> {
2989 Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
2990 .fetch_one(pool)
2991 .await?)
2992 }
2993
2994 #[tokio::test]
2995 async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
2996 let pool = init_url("sqlite::memory:").await?;
2997 let feed_id = upsert_feed(
2998 &pool,
2999 &NewFeed {
3000 url: "https://ret.example/feed.xml".to_string(),
3001 ..Default::default()
3002 },
3003 )
3004 .await?;
3005
3006 let recent = now_rfc3339();
3007 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3008 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3009
3010 insert_entries(
3014 &pool,
3015 feed_id,
3016 &[
3017 NewEntry {
3018 guid: "fresh".into(),
3019 published: Some(recent.clone()),
3020 fetched_at: Some(recent.clone()),
3021 ..Default::default()
3022 },
3023 NewEntry {
3024 guid: "ancient".into(),
3025 published: Some(ancient.clone()),
3026 fetched_at: Some(ancient.clone()),
3027 ..Default::default()
3028 },
3029 NewEntry {
3030 guid: "undated-fresh".into(),
3031 published: None,
3032 fetched_at: Some(recent.clone()),
3033 ..Default::default()
3034 },
3035 ],
3036 0,
3037 )
3038 .await?;
3039 assert_eq!(count_entries(&pool).await?, 3);
3040 replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
3042
3043 let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
3045 .fetch_one(&pool)
3046 .await?;
3047 let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
3048 assert!(wrote, "mark_read must write with a sub_ref in place");
3049 let state_before: i64 =
3050 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
3051 .bind(ancient_id)
3052 .fetch_one(&pool)
3053 .await?;
3054 assert_eq!(state_before, 1);
3055
3056 let deleted = prune_old_entries(&pool, 90).await?;
3058 assert_eq!(deleted, 1, "only the year-old entry should be pruned");
3059 assert_eq!(
3060 count_entries(&pool).await?,
3061 2,
3062 "fresh + undated-fresh survive"
3063 );
3064
3065 let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
3067 .fetch_all(&pool)
3068 .await?;
3069 assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
3070
3071 let state_after: i64 =
3073 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
3074 .bind(ancient_id)
3075 .fetch_one(&pool)
3076 .await?;
3077 assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
3078
3079 assert_eq!(prune_old_entries(&pool, 0).await?, 0);
3081 assert_eq!(count_entries(&pool).await?, 2);
3082 Ok(())
3083 }
3084
3085 #[tokio::test]
3086 async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
3087 let pool = init_url("sqlite::memory:").await?;
3088 let did = "did:plc:reader";
3089 let feed_url = "https://orphan.example/feed.xml";
3090 let feed_id = upsert_feed(
3091 &pool,
3092 &NewFeed {
3093 url: feed_url.to_string(),
3094 ..Default::default()
3095 },
3096 )
3097 .await?;
3098 replace_sub_refs(&pool, did, &[feed_id]).await?;
3100
3101 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3102 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3103 let recent = now_rfc3339();
3104 insert_entries(
3105 &pool,
3106 feed_id,
3107 &[
3108 NewEntry {
3109 guid: "old".into(),
3110 published: Some(ancient.clone()),
3111 fetched_at: Some(ancient.clone()),
3112 ..Default::default()
3113 },
3114 NewEntry {
3115 guid: "new".into(),
3116 published: Some(recent.clone()),
3117 fetched_at: Some(recent.clone()),
3118 ..Default::default()
3119 },
3120 ],
3121 0,
3122 )
3123 .await?;
3124 let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
3125 .fetch_one(&pool)
3126 .await?;
3127 let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
3128 .fetch_one(&pool)
3129 .await?;
3130
3131 mark_read(&pool, did, old_id, true).await?;
3133 mark_read(&pool, did, new_id, true).await?;
3134 let before = get_cursor(&pool, did, feed_url).await?.unwrap();
3135 let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
3136 assert!(ids_before.contains(&old_id.to_string()));
3137 assert!(ids_before.contains(&new_id.to_string()));
3138
3139 let deleted = prune_old_entries(&pool, 90).await?;
3141 assert_eq!(deleted, 1);
3142 let after = get_cursor(&pool, did, feed_url).await?.unwrap();
3143 let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
3144 assert_eq!(
3145 ids_after,
3146 vec![new_id.to_string()],
3147 "orphaned (deleted) entry id must be removed; live id kept"
3148 );
3149 assert!(
3151 after.dirty,
3152 "cursor must be marked dirty after orphan scrub"
3153 );
3154 Ok(())
3155 }
3156
3157 #[tokio::test]
3158 async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
3159 let pool = init_url("sqlite::memory:").await?;
3161 let did = "did:plc:reader";
3162 let feed_url = "https://trim.example/feed.xml";
3163 let feed_id = upsert_feed(
3164 &pool,
3165 &NewFeed {
3166 url: feed_url.to_string(),
3167 ..Default::default()
3168 },
3169 )
3170 .await?;
3171 replace_sub_refs(&pool, did, &[feed_id]).await?;
3172
3173 insert_entries(
3175 &pool,
3176 feed_id,
3177 &[
3178 NewEntry {
3179 guid: "a".into(),
3180 published: Some("2026-01-01T00:00:00Z".into()),
3181 ..Default::default()
3182 },
3183 NewEntry {
3184 guid: "b".into(),
3185 published: Some("2026-01-02T00:00:00Z".into()),
3186 ..Default::default()
3187 },
3188 ],
3189 2,
3190 )
3191 .await?;
3192 let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
3193 .fetch_one(&pool)
3194 .await?;
3195 mark_read(&pool, did, a_id, true).await?;
3196
3197 insert_entries(
3199 &pool,
3200 feed_id,
3201 &[NewEntry {
3202 guid: "c".into(),
3203 published: Some("2026-01-03T00:00:00Z".into()),
3204 ..Default::default()
3205 }],
3206 1,
3207 )
3208 .await?;
3209 let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
3211 .fetch_one(&pool)
3212 .await?;
3213 assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
3214
3215 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
3217 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
3218 assert!(
3219 !ids.contains(&a_id.to_string()),
3220 "trimmed entry id must be scrubbed from the cursor"
3221 );
3222 Ok(())
3223 }
3224
3225 #[tokio::test]
3226 async fn prune_and_reclaim_drops_db_size() -> Result<()> {
3227 let dir = std::env::temp_dir();
3229 let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
3230 let url = format!("sqlite://{}", path.display());
3231 let pool = init_url(&url).await?;
3232
3233 let feed_id = upsert_feed(
3234 &pool,
3235 &NewFeed {
3236 url: "https://bulk.example/feed.xml".to_string(),
3237 ..Default::default()
3238 },
3239 )
3240 .await?;
3241 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3242 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3243 let entries: Vec<NewEntry> = (0..2000)
3244 .map(|i| NewEntry {
3245 guid: format!("guid-{i}"),
3246 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
3247 published: Some(ancient.clone()),
3248 fetched_at: Some(ancient.clone()),
3249 ..Default::default()
3250 })
3251 .collect();
3252 insert_entries(&pool, feed_id, &entries, 0).await?;
3253 let full = db_size_bytes(&pool).await?;
3254 assert!(full > 0);
3255
3256 let deleted = prune_old_entries(&pool, 90).await?;
3258 assert_eq!(deleted, 2000);
3259 reclaim(&pool).await?;
3260 let after = db_size_bytes(&pool).await?;
3261 assert!(
3262 after < full,
3263 "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
3264 );
3265
3266 drop(pool);
3267 let _ = std::fs::remove_file(&path);
3268 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
3269 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
3270 Ok(())
3271 }
3272}