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 -- The follower DID a bot-minted claim was minted FOR (recorded at mint time,
253 -- distinct from `invitee_did` which is stamped at redeem). This is the
254 -- server-side idempotency key: a second `POST /bot/claims` for a DID that
255 -- already holds an outstanding active code returns the SAME code instead of
256 -- minting a duplicate, so a bot-host state loss cannot re-mint per follower.
257 intended_did TEXT,
258 created_at INTEGER NOT NULL,
259 expires_at INTEGER NOT NULL,
260 redeemed_at INTEGER
261);
262CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
263-- NOTE: the `intended_did` indexes are created in `apply_migrations`, AFTER the
264-- `intended_did` column is ensured. They MUST NOT live in this base SCHEMA batch:
265-- on an existing pre-0.2.2 volume the `CREATE TABLE IF NOT EXISTS invite_codes`
266-- above is a no-op (the table already exists without `intended_did`), so a
267-- `CREATE INDEX ... (intended_did, ...)` here would fail with "no such column"
268-- and crash-loop the boot before migrations ever run.
269"#;
270
271fn now_rfc3339() -> String {
275 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
276}
277
278pub async fn init(config: &Config) -> Result<Pool> {
285 let db_url = format!("sqlite://{}", config.db_path.display());
287 init_url(&db_url).await
288}
289
290pub async fn init_url(db_url: &str) -> Result<Pool> {
298 let is_memory = db_url.contains(":memory:");
305 let mut opts = SqliteConnectOptions::from_str(db_url)
306 .with_context(|| format!("invalid sqlite url: {db_url}"))?
307 .create_if_missing(true)
308 .foreign_keys(true);
309 if !is_memory {
311 opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
312 }
313 opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
322 opts = opts.log_statements(tracing::log::LevelFilter::Debug);
324
325 let pool = SqlitePoolOptions::new()
326 .min_connections(1)
329 .max_connections(if is_memory { 1 } else { 5 })
330 .connect_with(opts)
331 .await
332 .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
333
334 init_schema(&pool).await?;
335 Ok(pool)
336}
337
338pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
341 sqlx::query(SCHEMA)
343 .execute(pool)
344 .await
345 .context("failed to create schema")?;
346 apply_migrations(pool).await?;
347 Ok(())
348}
349
350async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
355 ensure_column(
358 pool,
359 "PRAGMA table_info(feeds)",
360 "consecutive_errors",
361 "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
362 )
363 .await?;
364 ensure_column(
368 pool,
369 "PRAGMA table_info(read_cursor)",
370 "pds_created",
371 "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
372 )
373 .await?;
374 ensure_column(
379 pool,
380 "PRAGMA table_info(invite_codes)",
381 "intended_did",
382 "ALTER TABLE invite_codes ADD COLUMN intended_did TEXT",
383 )
384 .await?;
385 sqlx::query(
393 "CREATE INDEX IF NOT EXISTS idx_invite_codes_intended \
394 ON invite_codes (intended_did, status)",
395 )
396 .execute(pool)
397 .await
398 .context("creating idx_invite_codes_intended")?;
399 sqlx::query(
406 "CREATE UNIQUE INDEX IF NOT EXISTS idx_invite_codes_intended_active \
407 ON invite_codes (intended_did) \
408 WHERE intended_did IS NOT NULL AND status = 'active'",
409 )
410 .execute(pool)
411 .await
412 .context("creating idx_invite_codes_intended_active")?;
413 Ok(())
414}
415
416async fn ensure_column(
421 pool: &SqlitePool,
422 info_sql: &'static str,
423 column: &str,
424 alter_sql: &'static str,
425) -> Result<()> {
426 let rows = sqlx::query(info_sql)
427 .fetch_all(pool)
428 .await
429 .with_context(|| format!("{info_sql} failed"))?;
430 let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
431 if !present {
432 sqlx::query(alter_sql)
433 .execute(pool)
434 .await
435 .with_context(|| format!("adding column {column} via {alter_sql}"))?;
436 }
437 Ok(())
438}
439
440pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
443 let row = sqlx::query(
444 r#"
445 INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll)
446 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
447 ON CONFLICT (url) DO UPDATE SET
448 title = COALESCE(excluded.title, feeds.title),
449 site_url = COALESCE(excluded.site_url, feeds.site_url),
450 etag = excluded.etag,
451 last_modified = excluded.last_modified,
452 last_polled = COALESCE(excluded.last_polled, feeds.last_polled),
453 next_poll = COALESCE(excluded.next_poll, feeds.next_poll)
454 RETURNING id
455 "#,
456 )
457 .bind(&feed.url)
458 .bind(&feed.title)
459 .bind(&feed.site_url)
460 .bind(&feed.etag)
461 .bind(&feed.last_modified)
462 .bind(&feed.last_polled)
463 .bind(&feed.next_poll)
464 .fetch_one(pool)
465 .await
466 .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
467
468 Ok(row.get::<i64, _>("id"))
469}
470
471pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
473 let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
474 .bind(url)
475 .fetch_optional(pool)
476 .await
477 .with_context(|| format!("get_feed_by_url failed for {url}"))?;
478 Ok(feed)
479}
480
481pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
484 let feeds = sqlx::query_as::<_, Feed>(
485 r#"
486 SELECT * FROM feeds
487 WHERE next_poll IS NULL OR next_poll <= ?1
488 ORDER BY next_poll IS NOT NULL, next_poll ASC
489 LIMIT ?2
490 "#,
491 )
492 .bind(as_of)
493 .bind(limit)
494 .fetch_all(pool)
495 .await
496 .context("due_feeds failed")?;
497 Ok(feeds)
498}
499
500pub async fn bump_feed_errors(pool: &SqlitePool, url: &str) -> Result<i64> {
506 let row = sqlx::query(
507 "UPDATE feeds SET consecutive_errors = consecutive_errors + 1 \
508 WHERE url = ?1 RETURNING consecutive_errors",
509 )
510 .bind(url)
511 .fetch_optional(pool)
512 .await
513 .with_context(|| format!("bump_feed_errors failed for {url}"))?;
514 Ok(row
516 .map(|r| r.get::<i64, _>("consecutive_errors"))
517 .unwrap_or(1))
518}
519
520pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
523 sqlx::query("UPDATE feeds SET consecutive_errors = 0 WHERE url = ?1")
524 .bind(url)
525 .execute(pool)
526 .await
527 .with_context(|| format!("reset_feed_errors failed for {url}"))?;
528 Ok(())
529}
530
531pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
536 let feeds = sqlx::query_as::<_, Feed>(
537 r#"
538 SELECT f.* FROM feeds f
539 JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
540 ORDER BY f.title IS NULL, f.title, f.url
541 "#,
542 )
543 .bind(did)
544 .fetch_all(pool)
545 .await
546 .with_context(|| format!("feeds_for_did failed for {did}"))?;
547 Ok(feeds)
548}
549
550pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
555 let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
556 .bind(did)
557 .fetch_all(pool)
558 .await
559 .with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
560 Ok(ids)
561}
562
563pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
566 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
567 .bind(did)
568 .fetch_one(pool)
569 .await
570 .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
571 Ok(n)
572}
573
574pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
577 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
578 .fetch_one(pool)
579 .await
580 .context("count_feeds failed")?;
581 Ok(n)
582}
583
584pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
596 let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
597 .fetch_one(pool)
598 .await
599 .context("PRAGMA page_count failed")?;
600 let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
601 .fetch_one(pool)
602 .await
603 .context("PRAGMA freelist_count failed")?;
604 let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
605 .fetch_one(pool)
606 .await
607 .context("PRAGMA page_size failed")?;
608 let used_pages = page_count.saturating_sub(freelist_count).max(0);
609 Ok(used_pages.saturating_mul(page_size))
610}
611
612pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
623 let auto_vacuum: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
624 .fetch_one(pool)
625 .await
626 .context("PRAGMA auto_vacuum failed")?;
627 if auto_vacuum == 2 {
631 sqlx::query("PRAGMA incremental_vacuum")
632 .execute(pool)
633 .await
634 .context("PRAGMA incremental_vacuum failed")?;
635 } else {
636 sqlx::query("VACUUM")
637 .execute(pool)
638 .await
639 .context("VACUUM failed")?;
640 }
641 Ok(())
642}
643
644pub async fn insert_entries(
654 pool: &SqlitePool,
655 feed_id: i64,
656 entries: &[NewEntry],
657 max_entries_per_feed: i64,
658) -> Result<u64> {
659 let mut tx = pool.begin().await.context("begin insert_entries tx")?;
660 let mut count: u64 = 0;
661 for e in entries {
662 let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
663 let res = sqlx::query(
664 r#"
665 INSERT INTO entries
666 (feed_id, guid, url, title, author, published, content_html, fetched_at)
667 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
668 ON CONFLICT (feed_id, guid) DO UPDATE SET
669 url = excluded.url,
670 title = excluded.title,
671 author = excluded.author,
672 published = excluded.published,
673 content_html = excluded.content_html
674 "#,
675 )
676 .bind(feed_id)
677 .bind(&e.guid)
678 .bind(&e.url)
679 .bind(&e.title)
680 .bind(&e.author)
681 .bind(&e.published)
682 .bind(&e.content_html)
683 .bind(&fetched_at)
684 .execute(&mut *tx)
685 .await
686 .with_context(|| format!("insert entry {} failed", e.guid))?;
687 count += res.rows_affected();
688 }
689
690 if max_entries_per_feed > 0 {
698 sqlx::query(
699 r#"
700 DELETE FROM entries
701 WHERE feed_id = ?1
702 AND id NOT IN (
703 SELECT id FROM entries
704 WHERE feed_id = ?1
705 ORDER BY COALESCE(published, fetched_at) DESC, id DESC
706 LIMIT ?2
707 )
708 "#,
709 )
710 .bind(feed_id)
711 .bind(max_entries_per_feed)
712 .execute(&mut *tx)
713 .await
714 .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
715 }
716
717 if max_entries_per_feed > 0 {
723 prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
724 }
725
726 tx.commit().await.context("commit insert_entries tx")?;
727 Ok(count)
728}
729
730pub async fn prune_old_entries(pool: &SqlitePool, days: i64) -> Result<u64> {
742 if days <= 0 {
743 return Ok(0);
744 }
745 let cutoff = (chrono::Utc::now() - chrono::Duration::days(days))
746 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
747
748 let mut tx = pool.begin().await.context("begin prune_old_entries tx")?;
749 let res = sqlx::query("DELETE FROM entries WHERE COALESCE(published, fetched_at) < ?1")
750 .bind(&cutoff)
751 .execute(&mut *tx)
752 .await
753 .with_context(|| format!("prune_old_entries delete (cutoff {cutoff})"))?;
754 let deleted = res.rows_affected();
755
756 if deleted > 0 {
758 prune_orphan_cursor_ids_tx(&mut tx, None).await?;
759 }
760
761 tx.commit().await.context("commit prune_old_entries tx")?;
762 Ok(deleted)
763}
764
765async fn prune_orphan_cursor_ids_tx(
778 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
779 feed_id: Option<i64>,
780) -> Result<u64> {
781 let feed_url = match feed_id {
785 Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
786 Some(u) => Some(u),
787 None => return Ok(0), },
789 None => None,
790 };
791
792 let cursors: Vec<(String, String, String, String)> = match &feed_url {
794 Some(url) => sqlx::query(
795 "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
796 )
797 .bind(url)
798 .fetch_all(&mut **tx)
799 .await
800 .context("prune_orphan_cursor_ids: load feed cursors")?,
801 None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
802 .fetch_all(&mut **tx)
803 .await
804 .context("prune_orphan_cursor_ids: load all cursors")?,
805 }
806 .into_iter()
807 .map(|r| {
808 (
809 r.get::<String, _>("did"),
810 r.get::<String, _>("feed_url"),
811 r.get::<String, _>("read_ids"),
812 r.get::<String, _>("unread_ids"),
813 )
814 })
815 .collect();
816
817 if cursors.is_empty() {
818 return Ok(0);
819 }
820
821 let now = now_rfc3339();
822 let mut changed: u64 = 0;
823 for (did, curl, read_ids, unread_ids) in cursors {
824 let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
826 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
827 )
828 .bind(&curl)
829 .fetch_all(&mut **tx)
830 .await
831 .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
832 .into_iter()
833 .collect();
834
835 let new_read = filter_id_set_to_live(&read_ids, &live);
836 let new_unread = filter_id_set_to_live(&unread_ids, &live);
837 if new_read == read_ids && new_unread == unread_ids {
838 continue; }
840 sqlx::query(
841 "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
842 WHERE did = ?1 AND feed_url = ?2",
843 )
844 .bind(&did)
845 .bind(&curl)
846 .bind(&new_read)
847 .bind(&new_unread)
848 .bind(&now)
849 .execute(&mut **tx)
850 .await
851 .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
852 changed += 1;
853 }
854 Ok(changed)
855}
856
857fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
861 let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
862 .ok()
863 .map(|vals| {
864 vals.into_iter()
865 .filter_map(|v| match v {
866 serde_json::Value::Number(n) => n.as_i64(),
867 serde_json::Value::String(s) => s.parse::<i64>().ok(),
868 _ => None,
869 })
870 .filter(|id| live.contains(id))
871 .collect()
872 })
873 .unwrap_or_default();
874 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
875 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
876}
877
878pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
886 let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
887 sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
888 .bind(did)
889 .execute(&mut *tx)
890 .await
891 .with_context(|| format!("clear sub_ref for {did}"))?;
892 for &feed_id in feed_ids {
893 sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
894 .bind(did)
895 .bind(feed_id)
896 .execute(&mut *tx)
897 .await
898 .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
899 }
900 tx.commit().await.context("commit replace_sub_refs tx")?;
901 Ok(())
902}
903
904pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
908 let found: Option<i64> = sqlx::query_scalar(
909 r#"
910 SELECT 1
911 FROM entries e
912 JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
913 WHERE e.id = ?2
914 "#,
915 )
916 .bind(did)
917 .bind(entry_id)
918 .fetch_optional(pool)
919 .await
920 .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
921 Ok(found.is_some())
922}
923
924pub async fn entries_for_feed(pool: &SqlitePool, did: &str, feed_id: i64) -> Result<Vec<Entry>> {
927 let entries = sqlx::query_as::<_, Entry>(
928 r#"
929 SELECT e.* FROM entries e
930 WHERE e.feed_id = ?2
931 AND EXISTS (
932 SELECT 1 FROM sub_ref sr
933 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
934 )
935 ORDER BY e.published DESC, e.id DESC
936 "#,
937 )
938 .bind(did)
939 .bind(feed_id)
940 .fetch_all(pool)
941 .await
942 .context("entries_for_feed failed")?;
943 Ok(entries)
944}
945
946pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
957 let now = now_rfc3339();
958 let mut tx = pool.begin().await.context("begin mark_read tx")?;
959 let res = sqlx::query(
960 r#"
961 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
962 SELECT ?1, e.id, ?3, 0, ?4
963 FROM entries e
964 WHERE e.id = ?2
965 AND EXISTS (
966 SELECT 1 FROM sub_ref sr
967 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
968 )
969 ON CONFLICT (did, entry_id) DO UPDATE SET
970 read = excluded.read,
971 updated_at = excluded.updated_at
972 "#,
973 )
974 .bind(did)
975 .bind(entry_id)
976 .bind(read)
977 .bind(&now)
978 .execute(&mut *tx)
979 .await
980 .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
981
982 if res.rows_affected() == 0 {
983 tx.rollback().await.ok();
985 return Ok(false);
986 }
987
988 project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
992
993 tx.commit().await.context("commit mark_read tx")?;
994 Ok(true)
995}
996
997pub async fn mark_starred(
1003 pool: &SqlitePool,
1004 did: &str,
1005 entry_id: i64,
1006 starred: bool,
1007) -> Result<bool> {
1008 let res = sqlx::query(
1009 r#"
1010 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
1011 SELECT ?1, e.id, 0, ?3, ?4
1012 FROM entries e
1013 WHERE e.id = ?2
1014 AND EXISTS (
1015 SELECT 1 FROM sub_ref sr
1016 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1017 )
1018 ON CONFLICT (did, entry_id) DO UPDATE SET
1019 starred = excluded.starred,
1020 updated_at = excluded.updated_at
1021 "#,
1022 )
1023 .bind(did)
1024 .bind(entry_id)
1025 .bind(starred)
1026 .bind(now_rfc3339())
1027 .execute(pool)
1028 .await
1029 .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
1030 Ok(res.rows_affected() > 0)
1031}
1032
1033pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
1038 let now = now_rfc3339();
1039 let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
1040 let res = sqlx::query(
1041 r#"
1042 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
1043 SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
1044 WHERE e.feed_id = ?4
1045 AND EXISTS (
1046 SELECT 1 FROM sub_ref sr
1047 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1048 )
1049 ON CONFLICT (did, entry_id) DO UPDATE SET
1050 read = excluded.read,
1051 updated_at = excluded.updated_at
1052 "#,
1053 )
1054 .bind(did)
1055 .bind(read)
1056 .bind(&now)
1057 .bind(feed_id)
1058 .execute(&mut *tx)
1059 .await
1060 .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
1061
1062 if res.rows_affected() > 0 {
1063 project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
1068 }
1069
1070 tx.commit().await.context("commit mark_feed_read tx")?;
1071 Ok(res.rows_affected())
1072}
1073
1074fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
1084 let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
1085 .ok()
1086 .map(|vals| {
1087 vals.into_iter()
1088 .filter_map(|v| match v {
1089 serde_json::Value::Number(n) => n.as_i64(),
1090 serde_json::Value::String(s) => s.parse::<i64>().ok(),
1091 _ => None,
1092 })
1093 .collect()
1094 })
1095 .unwrap_or_default();
1096 if present {
1097 if !ids.contains(&id) {
1098 ids.push(id);
1099 }
1100 } else {
1101 ids.retain(|&x| x != id);
1102 }
1103 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
1106 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
1107}
1108
1109async fn feed_url_for_id_tx(
1112 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1113 feed_id: i64,
1114) -> Result<Option<String>> {
1115 let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
1116 .bind(feed_id)
1117 .fetch_optional(&mut **tx)
1118 .await
1119 .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
1120 Ok(url)
1121}
1122
1123async fn cursor_sets(
1126 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1127 did: &str,
1128 feed_url: &str,
1129) -> Result<(Option<String>, String, String)> {
1130 let row = sqlx::query(
1131 "SELECT read_through, read_ids, unread_ids FROM read_cursor \
1132 WHERE did = ?1 AND feed_url = ?2",
1133 )
1134 .bind(did)
1135 .bind(feed_url)
1136 .fetch_optional(&mut **tx)
1137 .await
1138 .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
1139 Ok(match row {
1140 Some(r) => (
1141 r.get::<Option<String>, _>("read_through"),
1142 r.get::<String, _>("read_ids"),
1143 r.get::<String, _>("unread_ids"),
1144 ),
1145 None => (None, "[]".to_string(), "[]".to_string()),
1146 })
1147}
1148
1149async fn write_cursor_sets(
1152 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1153 did: &str,
1154 feed_url: &str,
1155 read_through: Option<&str>,
1156 read_ids: &str,
1157 unread_ids: &str,
1158 now: &str,
1159) -> Result<()> {
1160 sqlx::query(
1161 r#"
1162 INSERT INTO read_cursor
1163 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
1164 VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
1165 ON CONFLICT (did, feed_url) DO UPDATE SET
1166 read_through = excluded.read_through,
1167 read_ids = excluded.read_ids,
1168 unread_ids = excluded.unread_ids,
1169 dirty = 1,
1170 updated_at = excluded.updated_at
1171 "#,
1172 )
1173 .bind(did)
1174 .bind(feed_url)
1175 .bind(read_through)
1176 .bind(read_ids)
1177 .bind(unread_ids)
1178 .bind(now)
1179 .execute(&mut **tx)
1180 .await
1181 .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
1182 Ok(())
1183}
1184
1185async fn project_entry_into_cursor(
1195 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1196 did: &str,
1197 entry_id: i64,
1198 read: bool,
1199 now: &str,
1200) -> Result<()> {
1201 let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
1203 .bind(entry_id)
1204 .fetch_optional(&mut **tx)
1205 .await
1206 .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
1207 let feed_id = match feed_id {
1208 Some(f) => f,
1209 None => return Ok(()), };
1211 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
1212 Some(u) => u,
1213 None => return Ok(()),
1214 };
1215
1216 let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
1217 let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
1219 let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
1220 write_cursor_sets(
1221 tx,
1222 did,
1223 &feed_url,
1224 read_through.as_deref(),
1225 &read_ids,
1226 &unread_ids,
1227 now,
1228 )
1229 .await
1230}
1231
1232async fn project_feed_into_cursor(
1239 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1240 did: &str,
1241 feed_id: i64,
1242 read: bool,
1243 now: &str,
1244) -> Result<()> {
1245 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
1246 Some(u) => u,
1247 None => return Ok(()),
1248 };
1249
1250 let ids: Vec<i64> = sqlx::query_scalar(
1252 r#"
1253 SELECT e.id FROM entries e
1254 WHERE e.feed_id = ?2
1255 AND EXISTS (
1256 SELECT 1 FROM sub_ref sr
1257 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1258 )
1259 "#,
1260 )
1261 .bind(did)
1262 .bind(feed_id)
1263 .fetch_all(&mut **tx)
1264 .await
1265 .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
1266
1267 let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
1268 for id in ids {
1269 read_ids = json_id_set_toggle(&read_ids, id, read);
1270 unread_ids = json_id_set_toggle(&unread_ids, id, !read);
1271 }
1272 write_cursor_sets(
1273 tx,
1274 did,
1275 &feed_url,
1276 read_through.as_deref(),
1277 &read_ids,
1278 &unread_ids,
1279 now,
1280 )
1281 .await
1282}
1283
1284pub async fn get_unread_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
1288 let entries = sqlx::query_as::<_, Entry>(
1289 r#"
1290 SELECT e.*
1291 FROM entries e
1292 LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
1293 WHERE COALESCE(s.read, 0) = 0
1294 AND EXISTS (
1295 SELECT 1 FROM sub_ref sr
1296 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1297 )
1298 ORDER BY e.published DESC, e.id DESC
1299 "#,
1300 )
1301 .bind(did)
1302 .fetch_all(pool)
1303 .await
1304 .with_context(|| format!("get_unread_for_did failed for {did}"))?;
1305 Ok(entries)
1306}
1307
1308pub async fn get_starred_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Entry>> {
1310 let entries = sqlx::query_as::<_, Entry>(
1311 r#"
1312 SELECT e.*
1313 FROM entries e
1314 JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
1315 WHERE s.starred = 1
1316 AND EXISTS (
1317 SELECT 1 FROM sub_ref sr
1318 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
1319 )
1320 ORDER BY e.published DESC, e.id DESC
1321 "#,
1322 )
1323 .bind(did)
1324 .fetch_all(pool)
1325 .await
1326 .with_context(|| format!("get_starred_for_did failed for {did}"))?;
1327 Ok(entries)
1328}
1329
1330pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
1334 sqlx::query(
1335 r#"
1336 INSERT INTO read_cursor
1337 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
1338 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1339 ON CONFLICT (did, feed_url) DO UPDATE SET
1340 read_through = excluded.read_through,
1341 read_ids = excluded.read_ids,
1342 unread_ids = excluded.unread_ids,
1343 dirty = excluded.dirty,
1344 updated_at = excluded.updated_at
1345 "#,
1346 )
1347 .bind(&cursor.did)
1348 .bind(&cursor.feed_url)
1349 .bind(&cursor.read_through)
1350 .bind(&cursor.read_ids)
1351 .bind(&cursor.unread_ids)
1352 .bind(cursor.dirty)
1353 .bind(&cursor.updated_at)
1354 .execute(pool)
1355 .await
1356 .with_context(|| {
1357 format!(
1358 "upsert_cursor failed for {}/{}",
1359 cursor.did, cursor.feed_url
1360 )
1361 })?;
1362 Ok(())
1363}
1364
1365pub async fn get_cursor(
1367 pool: &SqlitePool,
1368 did: &str,
1369 feed_url: &str,
1370) -> Result<Option<ReadCursor>> {
1371 let cursor = sqlx::query_as::<_, ReadCursor>(
1372 "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
1373 )
1374 .bind(did)
1375 .bind(feed_url)
1376 .fetch_optional(pool)
1377 .await
1378 .context("get_cursor failed")?;
1379 Ok(cursor)
1380}
1381
1382pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
1385 let cursors =
1386 sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
1387 .bind(did)
1388 .fetch_all(pool)
1389 .await
1390 .with_context(|| format!("dirty_cursors failed for {did}"))?;
1391 Ok(cursors)
1392}
1393
1394pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
1398 sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
1399 .bind(did)
1400 .bind(feed_url)
1401 .execute(pool)
1402 .await
1403 .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
1404 Ok(())
1405}
1406
1407pub async fn clear_cursor_dirty(
1418 pool: &SqlitePool,
1419 did: &str,
1420 feed_url: &str,
1421 flushed_updated_at: &str,
1422) -> Result<()> {
1423 sqlx::query(
1424 "UPDATE read_cursor SET dirty = 0 \
1425 WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
1426 )
1427 .bind(did)
1428 .bind(feed_url)
1429 .bind(flushed_updated_at)
1430 .execute(pool)
1431 .await
1432 .context("clear_cursor_dirty failed")?;
1433 Ok(())
1434}
1435
1436fn now_unix() -> i64 {
1449 chrono::Utc::now().timestamp()
1450}
1451
1452const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1456
1457const CODE_PREFIX: &str = "FEATHER-";
1460
1461const CODE_BODY_LEN: usize = 8;
1463
1464pub fn generate_invite_code() -> Result<String> {
1471 let n = CODE_ALPHABET.len() as u16; let limit = 256 / n * n; let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
1476 out.push_str(CODE_PREFIX);
1477 let mut got = 0;
1478 let mut buf = [0u8; 1];
1479 while got < CODE_BODY_LEN {
1480 getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
1481 let b = buf[0] as u16;
1482 if b < limit {
1483 out.push(CODE_ALPHABET[(b % n) as usize] as char);
1484 got += 1;
1485 }
1486 }
1487 Ok(out)
1488}
1489
1490pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
1492 let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
1493 .bind(did)
1494 .fetch_optional(pool)
1495 .await
1496 .with_context(|| format!("has_beta_access failed for {did}"))?;
1497 Ok(row.is_some())
1498}
1499
1500pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
1503 let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
1504 .fetch_one(pool)
1505 .await
1506 .context("count_beta_access failed")?;
1507 Ok(row.get::<i64, _>("n"))
1508}
1509
1510pub async fn count_active_codes(pool: &SqlitePool) -> Result<i64> {
1517 let now = now_unix();
1518 let row = sqlx::query(
1519 "SELECT COUNT(*) AS n FROM invite_codes WHERE status = 'active' AND expires_at >= ?1",
1520 )
1521 .bind(now)
1522 .fetch_one(pool)
1523 .await
1524 .context("count_active_codes failed")?;
1525 Ok(row.get::<i64, _>("n"))
1526}
1527
1528pub async fn grant_access(
1531 pool: &SqlitePool,
1532 did: &str,
1533 handle: Option<&str>,
1534 granted_by: &str,
1535 invite_code_used: Option<&str>,
1536) -> Result<()> {
1537 sqlx::query(
1538 r#"
1539 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1540 VALUES (?1, ?2, ?3, ?4, ?5)
1541 ON CONFLICT (did) DO UPDATE SET
1542 handle = COALESCE(excluded.handle, beta_access.handle),
1543 granted_by = excluded.granted_by,
1544 invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
1545 "#,
1546 )
1547 .bind(did)
1548 .bind(handle)
1549 .bind(granted_by)
1550 .bind(now_unix())
1551 .bind(invite_code_used)
1552 .execute(pool)
1553 .await
1554 .with_context(|| format!("grant_access failed for {did}"))?;
1555 Ok(())
1556}
1557
1558pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
1563 mint_code_inner(pool, creator_did, ttl_secs, None).await
1564}
1565
1566pub async fn mint_code_for_did(
1571 pool: &SqlitePool,
1572 creator_did: &str,
1573 ttl_secs: i64,
1574 intended_did: &str,
1575) -> Result<String> {
1576 mint_code_inner(pool, creator_did, ttl_secs, Some(intended_did)).await
1577}
1578
1579async fn mint_code_inner(
1580 pool: &SqlitePool,
1581 creator_did: &str,
1582 ttl_secs: i64,
1583 intended_did: Option<&str>,
1584) -> Result<String> {
1585 let code = generate_invite_code()?;
1586 let now = now_unix();
1587 let expires_at = now.saturating_add(ttl_secs.max(0));
1588 sqlx::query(
1589 r#"
1590 INSERT INTO invite_codes
1591 (code, creator_did, status, invitee_did, intended_did, created_at, expires_at, redeemed_at)
1592 VALUES (?1, ?2, 'active', NULL, ?3, ?4, ?5, NULL)
1593 "#,
1594 )
1595 .bind(&code)
1596 .bind(creator_did)
1597 .bind(intended_did)
1598 .bind(now)
1599 .bind(expires_at)
1600 .execute(pool)
1601 .await
1602 .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
1603 Ok(code)
1604}
1605
1606pub fn is_intended_active_conflict(err: &anyhow::Error) -> bool {
1615 for cause in err.chain() {
1616 if let Some(sqlx::Error::Database(db)) = cause.downcast_ref::<sqlx::Error>() {
1617 let msg = db.message();
1618 let is_unique = db.code().as_deref() == Some("2067")
1622 || db.code().as_deref() == Some("19")
1623 || msg.contains("UNIQUE constraint failed");
1624 if is_unique && msg.contains("invite_codes.intended_did") {
1629 return true;
1630 }
1631 }
1632 }
1633 false
1634}
1635
1636pub async fn find_active_code_for_did(
1654 pool: &SqlitePool,
1655 intended_did: &str,
1656) -> Result<Option<String>> {
1657 let now = now_unix();
1658 let row = sqlx::query(
1659 "SELECT code FROM invite_codes
1660 WHERE intended_did = ?1 AND status = 'active' AND expires_at >= ?2
1661 ORDER BY expires_at ASC
1662 LIMIT 1",
1663 )
1664 .bind(intended_did)
1665 .bind(now)
1666 .fetch_optional(pool)
1667 .await
1668 .with_context(|| format!("find_active_code_for_did failed for {intended_did}"))?;
1669 Ok(row.map(|r| r.get::<String, _>("code")))
1670}
1671
1672pub async fn redeem_code(
1684 pool: &SqlitePool,
1685 code: &str,
1686 did: &str,
1687 handle: Option<&str>,
1688 cap: i64,
1689) -> Result<std::result::Result<(), RedeemError>> {
1690 let now = now_unix();
1691 let mut tx = pool.begin().await.context("begin redeem_code tx")?;
1692
1693 sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
1704 .bind(code)
1705 .execute(&mut *tx)
1706 .await
1707 .context("redeem_code: acquire write lock")?;
1708
1709 let row =
1711 sqlx::query("SELECT status, expires_at, intended_did FROM invite_codes WHERE code = ?1")
1712 .bind(code)
1713 .fetch_optional(&mut *tx)
1714 .await
1715 .context("redeem_code: lookup")?;
1716 let row = match row {
1717 Some(r) => r,
1718 None => return Ok(Err(RedeemError::NotFound)),
1719 };
1720 let status: String = row.get("status");
1721 let expires_at: i64 = row.get("expires_at");
1722 let intended_did: Option<String> = row.get("intended_did");
1723
1724 if let Some(bound) = intended_did.as_deref() {
1733 if bound != did {
1734 return Ok(Err(RedeemError::NotFound));
1735 }
1736 }
1737
1738 if status == "expired" || now > expires_at {
1742 return Ok(Err(RedeemError::Expired));
1743 }
1744 if status != "active" {
1745 return Ok(Err(RedeemError::AlreadyRedeemed));
1746 }
1747
1748 let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
1750 .fetch_one(&mut *tx)
1751 .await
1752 .context("redeem_code: count")?
1753 .get("n");
1754 if count >= cap {
1755 return Ok(Err(RedeemError::CapacityFull));
1756 }
1757
1758 let flipped = sqlx::query(
1762 r#"
1763 UPDATE invite_codes
1764 SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
1765 WHERE code = ?1 AND status = 'active'
1766 "#,
1767 )
1768 .bind(code)
1769 .bind(did)
1770 .bind(now)
1771 .execute(&mut *tx)
1772 .await
1773 .context("redeem_code: flip")?;
1774 if flipped.rows_affected() == 0 {
1775 return Ok(Err(RedeemError::AlreadyRedeemed));
1776 }
1777
1778 sqlx::query(
1780 r#"
1781 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1782 VALUES (?1, ?2, ?3, ?4, ?5)
1783 ON CONFLICT (did) DO UPDATE SET
1784 handle = COALESCE(excluded.handle, beta_access.handle),
1785 invite_code_used = excluded.invite_code_used
1786 "#,
1787 )
1788 .bind(did)
1789 .bind(handle)
1790 .bind(
1792 sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
1793 .bind(code)
1794 .fetch_one(&mut *tx)
1795 .await
1796 .context("redeem_code: creator lookup")?
1797 .get::<String, _>("creator_did"),
1798 )
1799 .bind(now)
1800 .bind(code)
1801 .execute(&mut *tx)
1802 .await
1803 .context("redeem_code: grant")?;
1804
1805 tx.commit().await.context("commit redeem_code tx")?;
1806 Ok(Ok(()))
1807}
1808
1809pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
1813 let now = now_unix();
1814 let res = sqlx::query(
1815 "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
1816 )
1817 .bind(now)
1818 .execute(pool)
1819 .await
1820 .context("expire_old_codes failed")?;
1821 Ok(res.rows_affected())
1822}
1823
1824pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
1828 let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
1829 let now = now_unix();
1830 let mut created = 0u64;
1831 for did in dids {
1832 let res = sqlx::query(
1833 r#"
1834 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
1835 VALUES (?1, NULL, 'admin', ?2, NULL)
1836 ON CONFLICT (did) DO NOTHING
1837 "#,
1838 )
1839 .bind(did)
1840 .bind(now)
1841 .execute(&mut *tx)
1842 .await
1843 .with_context(|| format!("ensure_seed insert failed for {did}"))?;
1844 created += res.rows_affected();
1845 }
1846 tx.commit().await.context("commit ensure_seed tx")?;
1847 Ok(created)
1848}
1849
1850#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1853pub struct PurgeCounts {
1854 pub entry_state: u64,
1856 pub read_cursor: u64,
1858 pub sub_ref: u64,
1860 pub beta_access: u64,
1862 pub invite_codes: u64,
1864 pub invitee_scrubbed: u64,
1867 pub granted_by_scrubbed: u64,
1870}
1871
1872impl PurgeCounts {
1873 pub fn total(&self) -> u64 {
1877 self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
1878 }
1879}
1880
1881pub const REDACTED_DID: &str = "__redacted__";
1885
1886pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
1898 let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
1899
1900 let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
1901 .bind(did)
1902 .execute(&mut *tx)
1903 .await
1904 .with_context(|| format!("purge entry_state for {did}"))?
1905 .rows_affected();
1906
1907 let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
1908 .bind(did)
1909 .execute(&mut *tx)
1910 .await
1911 .with_context(|| format!("purge read_cursor for {did}"))?
1912 .rows_affected();
1913
1914 let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
1915 .bind(did)
1916 .execute(&mut *tx)
1917 .await
1918 .with_context(|| format!("purge sub_ref for {did}"))?
1919 .rows_affected();
1920
1921 let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
1922 .bind(did)
1923 .execute(&mut *tx)
1924 .await
1925 .with_context(|| format!("purge beta_access for {did}"))?
1926 .rows_affected();
1927
1928 let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
1929 .bind(did)
1930 .execute(&mut *tx)
1931 .await
1932 .with_context(|| format!("purge invite_codes for {did}"))?
1933 .rows_affected();
1934
1935 let invitee_scrubbed =
1943 sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
1944 .bind(did)
1945 .execute(&mut *tx)
1946 .await
1947 .with_context(|| format!("scrub invitee_did for {did}"))?
1948 .rows_affected();
1949
1950 sqlx::query(
1958 "UPDATE invite_codes \
1959 SET intended_did = NULL, \
1960 status = CASE WHEN status = 'active' THEN 'expired' ELSE status END \
1961 WHERE intended_did = ?1",
1962 )
1963 .bind(did)
1964 .execute(&mut *tx)
1965 .await
1966 .with_context(|| format!("scrub intended_did for {did}"))?;
1967
1968 let granted_by_scrubbed =
1969 sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
1970 .bind(did)
1971 .bind(REDACTED_DID)
1972 .execute(&mut *tx)
1973 .await
1974 .with_context(|| format!("scrub granted_by for {did}"))?
1975 .rows_affected();
1976
1977 tx.commit().await.context("commit purge_did_data tx")?;
1978
1979 Ok(PurgeCounts {
1980 entry_state,
1981 read_cursor,
1982 sub_ref,
1983 beta_access,
1984 invite_codes,
1985 invitee_scrubbed,
1986 granted_by_scrubbed,
1987 })
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992 use super::*;
1993
1994 #[tokio::test]
1996 async fn init_insert_readback() -> Result<()> {
1997 let pool = init_url("sqlite::memory:").await?;
1998
1999 let feed_id = upsert_feed(
2001 &pool,
2002 &NewFeed {
2003 url: "https://example.com/feed.xml".to_string(),
2004 title: Some("Example".to_string()),
2005 site_url: Some("https://example.com".to_string()),
2006 next_poll: Some("2026-07-12T00:00:00Z".to_string()),
2007 ..Default::default()
2008 },
2009 )
2010 .await?;
2011 assert!(feed_id > 0);
2012
2013 let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
2015 .await?
2016 .expect("feed should exist");
2017 assert_eq!(feed.id, feed_id);
2018 assert_eq!(feed.title.as_deref(), Some("Example"));
2019 assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
2020
2021 let feed_id2 = upsert_feed(
2023 &pool,
2024 &NewFeed {
2025 url: "https://example.com/feed.xml".to_string(),
2026 title: Some("Example (renamed)".to_string()),
2027 ..Default::default()
2028 },
2029 )
2030 .await?;
2031 assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
2032
2033 let n = insert_entries(
2035 &pool,
2036 feed_id,
2037 &[
2038 NewEntry {
2039 guid: "guid-1".to_string(),
2040 url: Some("https://example.com/a".to_string()),
2041 title: Some("First".to_string()),
2042 published: Some("2026-07-10T08:00:00Z".to_string()),
2043 content_html: Some("<p>hello</p>".to_string()),
2044 ..Default::default()
2045 },
2046 NewEntry {
2047 guid: "guid-2".to_string(),
2048 url: Some("https://example.com/b".to_string()),
2049 title: Some("Second".to_string()),
2050 published: Some("2026-07-11T08:00:00Z".to_string()),
2051 ..Default::default()
2052 },
2053 ],
2054 0, )
2056 .await?;
2057 assert_eq!(n, 2);
2058
2059 let did = "did:plc:abc123";
2062 replace_sub_refs(&pool, did, &[feed_id]).await?;
2063
2064 let entries = entries_for_feed(&pool, did, feed_id).await?;
2066 assert_eq!(entries.len(), 2);
2067 assert_eq!(entries[0].guid, "guid-2");
2068 assert_eq!(entries[1].guid, "guid-1");
2069 assert_eq!(entries[1].content_html.as_deref(), Some("<p>hello</p>"));
2070
2071 let n2 = insert_entries(
2073 &pool,
2074 feed_id,
2075 &[NewEntry {
2076 guid: "guid-1".to_string(),
2077 title: Some("First (edited)".to_string()),
2078 ..Default::default()
2079 }],
2080 0,
2081 )
2082 .await?;
2083 assert_eq!(n2, 1);
2084 assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
2085
2086 let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
2088
2089 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
2091
2092 mark_read(&pool, did, e1, true).await?;
2094 let unread = get_unread_for_did(&pool, did).await?;
2095 assert_eq!(unread.len(), 1);
2096 assert_eq!(unread[0].guid, "guid-2");
2097
2098 mark_starred(&pool, did, e1, true).await?;
2100 let starred = get_starred_for_did(&pool, did).await?;
2101 assert_eq!(starred.len(), 1);
2102 assert_eq!(starred[0].id, e1);
2103
2104 mark_feed_read(&pool, did, feed_id, true).await?;
2106 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
2107
2108 let cursor = ReadCursor {
2110 did: did.to_string(),
2111 feed_url: "https://example.com/feed.xml".to_string(),
2112 read_through: Some("2026-07-11T08:00:00Z".to_string()),
2113 read_ids: "[]".to_string(),
2114 unread_ids: "[]".to_string(),
2115 dirty: true,
2116 pds_created: false,
2117 updated_at: now_rfc3339(),
2118 };
2119 upsert_cursor(&pool, &cursor).await?;
2120
2121 let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
2122 .await?
2123 .expect("cursor should exist");
2124 assert_eq!(
2125 fetched.read_through.as_deref(),
2126 Some("2026-07-11T08:00:00Z")
2127 );
2128 assert!(fetched.dirty);
2129
2130 let dirty = dirty_cursors(&pool, did).await?;
2132 assert_eq!(dirty.len(), 1);
2133 let flushed_at = dirty[0].updated_at.clone();
2134
2135 clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
2138 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
2139
2140 Ok(())
2141 }
2142
2143 #[tokio::test]
2151 async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
2152 let pool = init_url("sqlite::memory:").await?;
2153 let feed_url = "https://example.com/feed.xml";
2154 let feed_id = upsert_feed(
2155 &pool,
2156 &NewFeed {
2157 url: feed_url.to_string(),
2158 title: Some("Example".to_string()),
2159 ..Default::default()
2160 },
2161 )
2162 .await?;
2163 insert_entries(
2164 &pool,
2165 feed_id,
2166 &[
2167 NewEntry {
2168 guid: "g1".to_string(),
2169 published: Some("2026-07-10T00:00:00Z".to_string()),
2170 ..Default::default()
2171 },
2172 NewEntry {
2173 guid: "g2".to_string(),
2174 published: Some("2026-07-11T00:00:00Z".to_string()),
2175 ..Default::default()
2176 },
2177 ],
2178 0,
2179 )
2180 .await?;
2181 let did = "did:plc:reader";
2182 replace_sub_refs(&pool, did, &[feed_id]).await?;
2183
2184 assert!(get_cursor(&pool, did, feed_url).await?.is_none());
2186 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
2187
2188 let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
2191 assert!(mark_read(&pool, did, e1, true).await?);
2192
2193 let cursor = get_cursor(&pool, did, feed_url)
2194 .await?
2195 .expect("mark_read must create the feed's read_cursor");
2196 assert!(cursor.dirty, "cursor must be dirty after mark_read");
2197 assert!(
2198 cursor.read_ids.contains(&e1.to_string()),
2199 "the read entry id must be in read_ids: {}",
2200 cursor.read_ids
2201 );
2202 let dirty = dirty_cursors(&pool, did).await?;
2203 assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
2204 assert_eq!(dirty[0].feed_url, feed_url);
2205
2206 assert!(mark_read(&pool, did, e1, false).await?);
2208 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
2209 assert!(cursor.dirty);
2210 assert!(
2211 cursor.unread_ids.contains(&e1.to_string()),
2212 "unread id must be in unread_ids: {}",
2213 cursor.unread_ids
2214 );
2215 assert!(
2216 !cursor.read_ids.contains(&e1.to_string()),
2217 "id must have left read_ids: {}",
2218 cursor.read_ids
2219 );
2220
2221 assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
2224 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
2225 assert!(cursor.dirty);
2226 assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
2227
2228 let outsider = "did:plc:outsider";
2230 assert!(!mark_read(&pool, outsider, e1, true).await?);
2231 assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
2232
2233 let snap = dirty_cursors(&pool, did).await?[0].clone();
2235 clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
2237 assert_eq!(
2238 dirty_cursors(&pool, did).await?.len(),
2239 1,
2240 "stale-snapshot clear must be a no-op"
2241 );
2242 clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
2244 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
2245
2246 Ok(())
2247 }
2248
2249 #[test]
2250 fn json_id_set_toggle_is_set_like() {
2251 let s = json_id_set_toggle("[]", 5, true);
2253 assert_eq!(s, r#"["5"]"#);
2254 assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); let s = json_id_set_toggle(&s, 7, true);
2256 assert_eq!(s, r#"["5","7"]"#);
2257 let s = json_id_set_toggle(&s, 5, false);
2258 assert_eq!(s, r#"["7"]"#);
2259 assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
2261 assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
2262 }
2263
2264 #[tokio::test]
2272 async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
2273 let pool = init_url("sqlite::memory:").await?;
2274
2275 let feed_a = upsert_feed(
2277 &pool,
2278 &NewFeed {
2279 url: "https://a.example/feed.xml".to_string(),
2280 title: Some("A".to_string()),
2281 ..Default::default()
2282 },
2283 )
2284 .await?;
2285 let feed_b = upsert_feed(
2286 &pool,
2287 &NewFeed {
2288 url: "https://b.example/feed.xml".to_string(),
2289 title: Some("B".to_string()),
2290 ..Default::default()
2291 },
2292 )
2293 .await?;
2294
2295 insert_entries(
2296 &pool,
2297 feed_a,
2298 &[NewEntry {
2299 guid: "a-1".to_string(),
2300 url: Some("https://a.example/1".to_string()),
2301 title: Some("A one".to_string()),
2302 published: Some("2026-07-10T00:00:00Z".to_string()),
2303 content_html: Some("<p>secret A body</p>".to_string()),
2304 ..Default::default()
2305 }],
2306 0,
2307 )
2308 .await?;
2309 insert_entries(
2310 &pool,
2311 feed_b,
2312 &[NewEntry {
2313 guid: "b-1".to_string(),
2314 url: Some("https://b.example/1".to_string()),
2315 title: Some("B one".to_string()),
2316 published: Some("2026-07-11T00:00:00Z".to_string()),
2317 content_html: Some("<p>secret B body</p>".to_string()),
2318 ..Default::default()
2319 }],
2320 0,
2321 )
2322 .await?;
2323
2324 let did_a = "did:plc:aaaa";
2325 let did_b = "did:plc:bbbb";
2326 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
2327 replace_sub_refs(&pool, did_b, &[feed_b]).await?;
2328
2329 let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
2331
2332 assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
2334 assert!(
2335 entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
2336 "A must not read entries of a feed it does not subscribe to"
2337 );
2338
2339 let unread_a = get_unread_for_did(&pool, did_a).await?;
2341 assert_eq!(unread_a.len(), 1);
2342 assert_eq!(unread_a[0].guid, "a-1");
2343 let unread_b = get_unread_for_did(&pool, did_b).await?;
2344 assert_eq!(unread_b.len(), 1);
2345 assert_eq!(unread_b[0].guid, "b-1");
2346
2347 assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
2349 assert!(
2350 !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
2351 "A does not subscribe to B's feed"
2352 );
2353
2354 assert!(
2356 !mark_read(&pool, did_a, b_entry_id, true).await?,
2357 "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
2358 );
2359 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
2361 assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
2363 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
2364
2365 assert!(
2367 !mark_starred(&pool, did_a, b_entry_id, true).await?,
2368 "non-subscriber mark_starred must be a no-op (→ 404)"
2369 );
2370 assert!(
2371 get_starred_for_did(&pool, did_a).await?.is_empty(),
2372 "A's starred list stays empty after the rejected attempt"
2373 );
2374 assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
2375 assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
2376 assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
2378
2379 let a_feeds = feeds_for_did(&pool, did_a).await?;
2384 assert_eq!(a_feeds.len(), 1);
2385 assert_eq!(a_feeds[0].id, feed_a);
2386 let b_feeds = feeds_for_did(&pool, did_b).await?;
2387 assert_eq!(b_feeds.len(), 1);
2388 assert_eq!(b_feeds[0].id, feed_b);
2389
2390 replace_sub_refs(&pool, did_b, &[]).await?;
2392 assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
2393 assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
2394 assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
2395 assert!(feeds_for_did(&pool, did_b).await?.is_empty());
2397
2398 Ok(())
2399 }
2400
2401 #[tokio::test]
2419 async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
2420 let pool = init_url("sqlite::memory:").await?;
2421
2422 let did_a = "did:plc:aaaa";
2423
2424 let feed_a = upsert_feed(
2427 &pool,
2428 &NewFeed {
2429 url: "https://a.example/feed.xml".to_string(),
2430 title: Some("A".to_string()),
2431 ..Default::default()
2432 },
2433 )
2434 .await?;
2435 let feed_orphan = upsert_feed(
2438 &pool,
2439 &NewFeed {
2440 url: "https://orphan.example/feed.xml".to_string(),
2441 title: Some("Orphan".to_string()),
2442 ..Default::default()
2443 },
2444 )
2445 .await?;
2446
2447 insert_entries(
2448 &pool,
2449 feed_a,
2450 &[NewEntry {
2451 guid: "a-1".to_string(),
2452 url: Some("https://a.example/1".to_string()),
2453 title: Some("A one".to_string()),
2454 published: Some("2026-07-10T00:00:00Z".to_string()),
2455 content_html: Some("<p>A body</p>".to_string()),
2456 ..Default::default()
2457 }],
2458 0,
2459 )
2460 .await?;
2461 insert_entries(
2462 &pool,
2463 feed_orphan,
2464 &[NewEntry {
2465 guid: "orphan-1".to_string(),
2466 url: Some("https://orphan.example/1".to_string()),
2467 title: Some("Orphan one".to_string()),
2468 published: Some("2026-07-11T00:00:00Z".to_string()),
2469 content_html: Some("<p>secret orphan body</p>".to_string()),
2470 ..Default::default()
2471 }],
2472 0,
2473 )
2474 .await?;
2475
2476 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
2479
2480 replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
2483 let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
2484 replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
2485
2486 let fallback = feeds_for_did(&pool, did_a).await?;
2492 let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
2493 assert_eq!(
2494 fallback_ids,
2495 vec![feed_a],
2496 "outage fallback must serve ONLY A's own last-known sub_ref, \
2497 never widen to the orphan cached feed"
2498 );
2499 assert!(
2500 !fallback_ids.contains(&feed_orphan),
2501 "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
2502 cached feed into A's surface"
2503 );
2504
2505 assert!(
2507 !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
2508 "A must not be authorized for an orphan feed's entry during an outage"
2509 );
2510 assert!(
2511 entries_for_feed(&pool, did_a, feed_orphan)
2512 .await?
2513 .is_empty(),
2514 "entries_for_feed must not expose the orphan feed to A during an outage"
2515 );
2516 let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
2518 .await?
2519 .into_iter()
2520 .map(|e| e.guid)
2521 .collect();
2522 assert!(
2523 !unread_guids.iter().any(|g| g == "orphan-1"),
2524 "orphan entry leaked into A's unread list during an outage"
2525 );
2526 assert!(
2527 get_starred_for_did(&pool, did_a).await?.is_empty(),
2528 "A has no starred entries; the orphan must not appear"
2529 );
2530
2531 assert!(
2533 !mark_read(&pool, did_a, orphan_entry_id, true).await?,
2534 "A must not mark an orphan feed's entry read during an outage"
2535 );
2536 assert!(
2537 !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
2538 "A must not star an orphan feed's entry during an outage"
2539 );
2540 assert_eq!(
2541 mark_feed_read(&pool, did_a, feed_orphan, true).await?,
2542 0,
2543 "A must not mark-all-read the orphan feed during an outage"
2544 );
2545
2546 let es_count: i64 =
2548 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
2549 .bind(did_a)
2550 .bind(orphan_entry_id)
2551 .fetch_one(&pool)
2552 .await?;
2553 assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
2554
2555 Ok(())
2556 }
2557
2558 #[test]
2563 fn code_gen_shape_and_alphabet() {
2564 for _ in 0..200 {
2565 let code = generate_invite_code().unwrap();
2566 assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
2567 let body = &code["FEATHER-".len()..];
2568 assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
2569 for c in body.chars() {
2572 assert!(
2573 CODE_ALPHABET.contains(&(c as u8)),
2574 "char {c:?} not in alphabet ({code})"
2575 );
2576 assert!(
2577 !matches!(c, 'I' | 'O' | '0' | '1'),
2578 "ambiguous char {c:?} leaked into {code}"
2579 );
2580 }
2581 }
2582 assert_ne!(
2584 generate_invite_code().unwrap(),
2585 generate_invite_code().unwrap()
2586 );
2587 }
2588
2589 #[tokio::test]
2590 async fn busy_timeout_is_applied() -> Result<()> {
2591 let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
2594 std::fs::create_dir_all(&dir).ok();
2595 let path = dir.join("busy.db");
2596 let url = format!("sqlite://{}", path.display());
2597 let pool = init_url(&url).await?;
2598 let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
2599 let timeout: i64 = row.get(0);
2600 assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
2601 pool.close().await;
2602 std::fs::remove_dir_all(&dir).ok();
2603 Ok(())
2604 }
2605
2606 #[tokio::test]
2607 async fn redeem_valid_grants_seat() -> Result<()> {
2608 let pool = init_url("sqlite::memory:").await?;
2609 let code = mint_code(&pool, "did:plc:creator", 3600).await?;
2610 assert!(!has_beta_access(&pool, "did:plc:new").await?);
2611
2612 let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
2613 assert_eq!(out, Ok(()));
2614 assert!(has_beta_access(&pool, "did:plc:new").await?);
2615 assert_eq!(count_beta_access(&pool).await?, 1);
2616
2617 let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
2619 assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
2620 Ok(())
2621 }
2622
2623 #[tokio::test]
2624 async fn redeem_not_found() -> Result<()> {
2625 let pool = init_url("sqlite::memory:").await?;
2626 let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
2627 assert_eq!(out, Err(RedeemError::NotFound));
2628 Ok(())
2629 }
2630
2631 async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
2634 let now = now_unix();
2635 sqlx::query(
2636 r#"INSERT INTO invite_codes
2637 (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
2638 VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
2639 )
2640 .bind(code)
2641 .bind(creator)
2642 .bind(now - 100)
2643 .bind(now - 10) .execute(pool)
2645 .await?;
2646 Ok(())
2647 }
2648
2649 #[tokio::test]
2650 async fn redeem_expired() -> Result<()> {
2651 let pool = init_url("sqlite::memory:").await?;
2652 insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
2653 let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
2654 assert_eq!(out, Err(RedeemError::Expired));
2655 assert_eq!(count_beta_access(&pool).await?, 0);
2657 Ok(())
2658 }
2659
2660 #[tokio::test]
2661 async fn redeem_capacity_full() -> Result<()> {
2662 let pool = init_url("sqlite::memory:").await?;
2663 ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
2665 assert_eq!(count_beta_access(&pool).await?, 1);
2666
2667 let code = mint_code(&pool, "did:plc:admin", 3600).await?;
2668 let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
2669 assert_eq!(out, Err(RedeemError::CapacityFull));
2670 assert!(!has_beta_access(&pool, "did:plc:new").await?);
2672 let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
2674 assert_eq!(ok, Ok(()));
2675 Ok(())
2676 }
2677
2678 #[tokio::test]
2679 async fn count_active_codes_excludes_expired_and_redeemed() -> Result<()> {
2680 let pool = init_url("sqlite::memory:").await?;
2681 assert_eq!(count_active_codes(&pool).await?, 0);
2682
2683 let a = mint_code(&pool, "did:plc:bot", 3600).await?;
2685 let _b = mint_code(&pool, "did:plc:bot", 3600).await?;
2686 assert_eq!(count_active_codes(&pool).await?, 2);
2687
2688 insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:bot").await?;
2690 assert_eq!(count_active_codes(&pool).await?, 2);
2691
2692 let out = redeem_code(&pool, &a, "did:plc:new", None, 100).await?;
2694 assert_eq!(out, Ok(()));
2695 assert_eq!(count_active_codes(&pool).await?, 1);
2696 Ok(())
2697 }
2698
2699 #[tokio::test]
2700 async fn expire_and_seed() -> Result<()> {
2701 let pool = init_url("sqlite::memory:").await?;
2702 insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
2704 let live = mint_code(&pool, "did:plc:creator", 3600).await?;
2705 let n = expire_old_codes(&pool).await?;
2706 assert_eq!(n, 1, "exactly the past-expiry code should flip");
2707 assert_eq!(
2709 redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
2710 Ok(())
2711 );
2712
2713 let created = ensure_seed(
2715 &pool,
2716 &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
2717 )
2718 .await?;
2719 assert_eq!(created, 2);
2720 let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
2721 assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
2722 assert!(has_beta_access(&pool, "did:plc:seed1").await?);
2723 Ok(())
2724 }
2725
2726 #[tokio::test]
2731 async fn count_helpers_track_feeds_and_subs() -> Result<()> {
2732 let pool = init_url("sqlite::memory:").await?;
2733 assert_eq!(count_feeds(&pool).await?, 0);
2734
2735 let mut ids = Vec::new();
2736 for i in 0..3 {
2737 let id = upsert_feed(
2738 &pool,
2739 &NewFeed {
2740 url: format!("https://f{i}.example/feed.xml"),
2741 ..Default::default()
2742 },
2743 )
2744 .await?;
2745 ids.push(id);
2746 }
2747 assert_eq!(count_feeds(&pool).await?, 3);
2748
2749 let did = "did:plc:capcheck";
2750 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
2751 replace_sub_refs(&pool, did, &ids).await?;
2752 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
2753 Ok(())
2754 }
2755
2756 #[tokio::test]
2757 async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
2758 let pool = init_url("sqlite::memory:").await?;
2759 let feed_id = upsert_feed(
2760 &pool,
2761 &NewFeed {
2762 url: "https://firehose.example/feed.xml".to_string(),
2763 ..Default::default()
2764 },
2765 )
2766 .await?;
2767
2768 let batch: Vec<NewEntry> = (0..5)
2770 .map(|i| NewEntry {
2771 guid: format!("g-{i}"),
2772 title: Some(format!("E{i}")),
2773 published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
2774 ..Default::default()
2775 })
2776 .collect();
2777 insert_entries(&pool, feed_id, &batch, 2).await?;
2778
2779 let did = "did:plc:trim";
2780 replace_sub_refs(&pool, did, &[feed_id]).await?;
2781 let kept = entries_for_feed(&pool, did, feed_id).await?;
2782 assert_eq!(
2783 kept.len(),
2784 2,
2785 "over-cap feed trimmed to the newest 2 entries"
2786 );
2787 assert_eq!(kept[0].guid, "g-4");
2789 assert_eq!(kept[1].guid, "g-3");
2790 Ok(())
2791 }
2792
2793 #[tokio::test]
2799 async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
2800 let pool = init_url("sqlite::memory:").await?;
2801 let feed_id = upsert_feed(
2802 &pool,
2803 &NewFeed {
2804 url: "https://undated.example/feed.xml".to_string(),
2805 ..Default::default()
2806 },
2807 )
2808 .await?;
2809
2810 let batch = vec![
2813 NewEntry {
2814 guid: "old-dated-1".to_string(),
2815 title: Some("Old A".to_string()),
2816 published: Some("2026-07-01T00:00:00Z".to_string()),
2817 fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
2818 ..Default::default()
2819 },
2820 NewEntry {
2821 guid: "old-dated-2".to_string(),
2822 title: Some("Old B".to_string()),
2823 published: Some("2026-07-02T00:00:00Z".to_string()),
2824 fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
2825 ..Default::default()
2826 },
2827 NewEntry {
2828 guid: "fresh-undated".to_string(),
2829 title: Some("Fresh undated".to_string()),
2830 published: None,
2831 fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
2832 ..Default::default()
2833 },
2834 ];
2835 insert_entries(&pool, feed_id, &batch, 2).await?;
2836
2837 let did = "did:plc:undated";
2838 replace_sub_refs(&pool, did, &[feed_id]).await?;
2839 let kept = entries_for_feed(&pool, did, feed_id).await?;
2840 assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
2841 let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
2842 assert!(
2843 guids.contains(&"fresh-undated"),
2844 "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
2845 );
2846 assert!(
2847 guids.contains(&"old-dated-2"),
2848 "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
2849 );
2850 assert!(
2851 !guids.contains(&"old-dated-1"),
2852 "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
2853 );
2854 Ok(())
2855 }
2856
2857 #[tokio::test]
2858 async fn db_size_is_positive_and_grows() -> Result<()> {
2859 let pool = init_url("sqlite::memory:").await?;
2860 let before = db_size_bytes(&pool).await?;
2861 assert!(before > 0, "a schema-initialised DB has a non-zero size");
2862 Ok(())
2863 }
2864
2865 #[tokio::test]
2869 async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
2870 let pool = init_url("sqlite::memory:").await?;
2871
2872 let feed_id = upsert_feed(
2874 &pool,
2875 &NewFeed {
2876 url: "https://example.com/feed.xml".to_string(),
2877 title: Some("Example".to_string()),
2878 ..Default::default()
2879 },
2880 )
2881 .await?;
2882 insert_entries(
2883 &pool,
2884 feed_id,
2885 &[NewEntry {
2886 guid: "g-1".to_string(),
2887 url: Some("https://example.com/a".to_string()),
2888 title: Some("First".to_string()),
2889 published: Some("2026-07-10T08:00:00Z".to_string()),
2890 ..Default::default()
2891 }],
2892 0,
2893 )
2894 .await?;
2895 let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
2896 .fetch_one(&pool)
2897 .await?;
2898
2899 let victim = "did:plc:victim";
2900 let bystander = "did:plc:bystander";
2901
2902 for did in [victim, bystander] {
2904 replace_sub_refs(&pool, did, &[feed_id]).await?;
2905 assert!(mark_read(&pool, did, entry_id, true).await?);
2906 assert!(mark_starred(&pool, did, entry_id, true).await?);
2907 upsert_cursor(
2908 &pool,
2909 &ReadCursor {
2910 did: did.to_string(),
2911 feed_url: "https://example.com/feed.xml".to_string(),
2912 read_through: Some("2026-07-10T08:00:00Z".to_string()),
2913 read_ids: "[]".to_string(),
2914 unread_ids: "[]".to_string(),
2915 dirty: false,
2916 pds_created: false,
2917 updated_at: now_rfc3339(),
2918 },
2919 )
2920 .await?;
2921 grant_access(&pool, did, Some("h.example"), "admin", None).await?;
2922 mint_code(&pool, did, 3600).await?;
2923 }
2924
2925 let counts = purge_did_data(&pool, victim).await?;
2927 assert_eq!(
2928 counts.entry_state, 1,
2929 "one entry_state row (read+star merge)"
2930 );
2931 assert_eq!(counts.read_cursor, 1);
2932 assert_eq!(counts.sub_ref, 1);
2933 assert_eq!(counts.beta_access, 1);
2934 assert_eq!(counts.invite_codes, 1);
2935 assert_eq!(counts.total(), 5);
2936
2937 let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
2939 .bind(victim)
2940 .fetch_one(&pool)
2941 .await?;
2942 assert_eq!(es, 0, "victim still had entry_state rows");
2943 let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
2944 .bind(victim)
2945 .fetch_one(&pool)
2946 .await?;
2947 assert_eq!(rc, 0, "victim still had read_cursor rows");
2948 let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
2949 .bind(victim)
2950 .fetch_one(&pool)
2951 .await?;
2952 assert_eq!(sr, 0, "victim still had sub_ref rows");
2953 assert!(
2954 !has_beta_access(&pool, victim).await?,
2955 "victim still had a beta seat"
2956 );
2957 let victim_codes: i64 =
2958 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2959 .bind(victim)
2960 .fetch_one(&pool)
2961 .await?;
2962 assert_eq!(victim_codes, 0);
2963
2964 assert!(has_beta_access(&pool, bystander).await?);
2966 let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
2967 assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
2968 let bystander_codes: i64 =
2969 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
2970 .bind(bystander)
2971 .fetch_one(&pool)
2972 .await?;
2973 assert_eq!(bystander_codes, 1);
2974
2975 assert_eq!(count_feeds(&pool).await?, 1);
2977
2978 let again = purge_did_data(&pool, victim).await?;
2980 assert_eq!(again.total(), 0);
2981
2982 Ok(())
2983 }
2984
2985 #[tokio::test]
2991 async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
2992 let pool = init_url("sqlite::memory:").await?;
2993
2994 let inviter = "did:plc:inviter";
2995 let leaver = "did:plc:leaver";
2996 let friend = "did:plc:friend";
2997
2998 let inviter_code = mint_code(&pool, inviter, 3600).await?;
3000 grant_access(&pool, inviter, None, "admin", None).await?;
3001 assert_eq!(
3002 redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
3003 Ok(())
3004 );
3005
3006 let leaver_code = mint_code(&pool, leaver, 3600).await?;
3008 assert_eq!(
3009 redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
3010 Ok(())
3011 );
3012
3013 let invitee_before: i64 =
3015 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
3016 .bind(leaver)
3017 .fetch_one(&pool)
3018 .await?;
3019 assert_eq!(
3020 invitee_before, 1,
3021 "leaver should be an invitee before purge"
3022 );
3023 let granted_before: i64 =
3024 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
3025 .bind(leaver)
3026 .fetch_one(&pool)
3027 .await?;
3028 assert_eq!(granted_before, 1, "leaver should be a granter before purge");
3029
3030 let counts = purge_did_data(&pool, leaver).await?;
3032 assert_eq!(
3033 counts.invitee_scrubbed, 1,
3034 "the redeemed code's invitee_did"
3035 );
3036 assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
3037
3038 let invitee_after: i64 =
3040 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
3041 .bind(leaver)
3042 .fetch_one(&pool)
3043 .await?;
3044 assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
3045 let granted_after: i64 =
3046 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
3047 .bind(leaver)
3048 .fetch_one(&pool)
3049 .await?;
3050 assert_eq!(granted_after, 0, "leaver survived in granted_by");
3051
3052 assert!(
3055 has_beta_access(&pool, friend).await?,
3056 "friend's seat must survive the leaver's scrub"
3057 );
3058 let friend_granted_by: String =
3059 sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
3060 .bind(friend)
3061 .fetch_one(&pool)
3062 .await?;
3063 assert_eq!(friend_granted_by, REDACTED_DID);
3064 let inviter_code_rows: i64 =
3065 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
3066 .bind(inviter)
3067 .fetch_one(&pool)
3068 .await?;
3069 assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
3070
3071 Ok(())
3072 }
3073
3074 #[tokio::test]
3077 async fn feed_error_count_bumps_and_resets() -> Result<()> {
3078 let pool = init_url("sqlite::memory:").await?;
3079 let url = "https://broken.example/feed.xml";
3080 upsert_feed(
3081 &pool,
3082 &NewFeed {
3083 url: url.to_string(),
3084 ..Default::default()
3085 },
3086 )
3087 .await?;
3088
3089 let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
3091 assert_eq!(feed.consecutive_errors, 0);
3092
3093 let mut last = std::time::Duration::ZERO;
3096 for expected in 1..=3 {
3097 let count = bump_feed_errors(&pool, url).await?;
3098 assert_eq!(count, expected, "bump returns the new count");
3099 let backoff = crate::feed::backoff_for(count as u32);
3100 assert!(
3101 backoff >= last,
3102 "backoff must not shrink as errors accumulate"
3103 );
3104 last = backoff;
3105 }
3106 assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
3108 assert_eq!(
3109 get_feed_by_url(&pool, url)
3110 .await?
3111 .unwrap()
3112 .consecutive_errors,
3113 3
3114 );
3115
3116 reset_feed_errors(&pool, url).await?;
3118 assert_eq!(
3119 get_feed_by_url(&pool, url)
3120 .await?
3121 .unwrap()
3122 .consecutive_errors,
3123 0
3124 );
3125 Ok(())
3126 }
3127
3128 #[tokio::test]
3131 async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
3132 let dir = std::env::temp_dir();
3135 let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
3136 let url = format!("sqlite://{}", path.display());
3137 let pool = init_url(&url).await?;
3138
3139 let feed_id = upsert_feed(
3140 &pool,
3141 &NewFeed {
3142 url: "https://bulk.example/feed.xml".to_string(),
3143 ..Default::default()
3144 },
3145 )
3146 .await?;
3147
3148 let entries: Vec<NewEntry> = (0..2000)
3150 .map(|i| NewEntry {
3151 guid: format!("guid-{i}"),
3152 title: Some(format!("Entry number {i} with some padding text")),
3153 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
3154 published: Some("2026-01-01T00:00:00Z".to_string()),
3155 ..Default::default()
3156 })
3157 .collect();
3158 insert_entries(&pool, feed_id, &entries, 0).await?;
3159 let full = db_size_bytes(&pool).await?;
3160 assert!(full > 0);
3161
3162 sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
3165 .bind(feed_id)
3166 .execute(&pool)
3167 .await?;
3168
3169 let after_delete = db_size_bytes(&pool).await?;
3172 assert!(
3173 after_delete < full,
3174 "used size must drop once rows are deleted (freed pages excluded): \
3175 {after_delete} !< {full}"
3176 );
3177
3178 reclaim(&pool).await?;
3182 let after_reclaim = db_size_bytes(&pool).await?;
3183 assert!(
3184 after_reclaim <= after_delete,
3185 "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
3186 );
3187 assert!(
3188 after_reclaim < full,
3189 "after prune+reclaim the DB is smaller than when full: \
3190 {after_reclaim} !< {full}"
3191 );
3192
3193 drop(pool);
3194 let _ = std::fs::remove_file(&path);
3195 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
3196 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
3197 Ok(())
3198 }
3199
3200 #[tokio::test]
3203 async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
3204 let pool = init_url("sqlite::memory:").await?;
3205 let did = "did:plc:f4";
3206 let feed_url = "https://example.com/feed.xml";
3207 upsert_cursor(
3208 &pool,
3209 &ReadCursor {
3210 did: did.to_string(),
3211 feed_url: feed_url.to_string(),
3212 read_through: None,
3213 read_ids: r#"["1"]"#.to_string(),
3214 unread_ids: "[]".to_string(),
3215 dirty: true,
3216 pds_created: false,
3217 updated_at: now_rfc3339(),
3218 },
3219 )
3220 .await?;
3221
3222 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
3224 assert!(!c.pds_created, "first flush must emit a create, not update");
3225
3226 mark_cursor_pds_created(&pool, did, feed_url).await?;
3228 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
3229 assert!(c.pds_created);
3230 Ok(())
3231 }
3232
3233 async fn count_entries(pool: &SqlitePool) -> Result<i64> {
3237 Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
3238 .fetch_one(pool)
3239 .await?)
3240 }
3241
3242 #[tokio::test]
3243 async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
3244 let pool = init_url("sqlite::memory:").await?;
3245 let feed_id = upsert_feed(
3246 &pool,
3247 &NewFeed {
3248 url: "https://ret.example/feed.xml".to_string(),
3249 ..Default::default()
3250 },
3251 )
3252 .await?;
3253
3254 let recent = now_rfc3339();
3255 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3256 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3257
3258 insert_entries(
3262 &pool,
3263 feed_id,
3264 &[
3265 NewEntry {
3266 guid: "fresh".into(),
3267 published: Some(recent.clone()),
3268 fetched_at: Some(recent.clone()),
3269 ..Default::default()
3270 },
3271 NewEntry {
3272 guid: "ancient".into(),
3273 published: Some(ancient.clone()),
3274 fetched_at: Some(ancient.clone()),
3275 ..Default::default()
3276 },
3277 NewEntry {
3278 guid: "undated-fresh".into(),
3279 published: None,
3280 fetched_at: Some(recent.clone()),
3281 ..Default::default()
3282 },
3283 ],
3284 0,
3285 )
3286 .await?;
3287 assert_eq!(count_entries(&pool).await?, 3);
3288 replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
3290
3291 let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
3293 .fetch_one(&pool)
3294 .await?;
3295 let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
3296 assert!(wrote, "mark_read must write with a sub_ref in place");
3297 let state_before: i64 =
3298 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
3299 .bind(ancient_id)
3300 .fetch_one(&pool)
3301 .await?;
3302 assert_eq!(state_before, 1);
3303
3304 let deleted = prune_old_entries(&pool, 90).await?;
3306 assert_eq!(deleted, 1, "only the year-old entry should be pruned");
3307 assert_eq!(
3308 count_entries(&pool).await?,
3309 2,
3310 "fresh + undated-fresh survive"
3311 );
3312
3313 let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
3315 .fetch_all(&pool)
3316 .await?;
3317 assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
3318
3319 let state_after: i64 =
3321 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
3322 .bind(ancient_id)
3323 .fetch_one(&pool)
3324 .await?;
3325 assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
3326
3327 assert_eq!(prune_old_entries(&pool, 0).await?, 0);
3329 assert_eq!(count_entries(&pool).await?, 2);
3330 Ok(())
3331 }
3332
3333 #[tokio::test]
3334 async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
3335 let pool = init_url("sqlite::memory:").await?;
3336 let did = "did:plc:reader";
3337 let feed_url = "https://orphan.example/feed.xml";
3338 let feed_id = upsert_feed(
3339 &pool,
3340 &NewFeed {
3341 url: feed_url.to_string(),
3342 ..Default::default()
3343 },
3344 )
3345 .await?;
3346 replace_sub_refs(&pool, did, &[feed_id]).await?;
3348
3349 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3350 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3351 let recent = now_rfc3339();
3352 insert_entries(
3353 &pool,
3354 feed_id,
3355 &[
3356 NewEntry {
3357 guid: "old".into(),
3358 published: Some(ancient.clone()),
3359 fetched_at: Some(ancient.clone()),
3360 ..Default::default()
3361 },
3362 NewEntry {
3363 guid: "new".into(),
3364 published: Some(recent.clone()),
3365 fetched_at: Some(recent.clone()),
3366 ..Default::default()
3367 },
3368 ],
3369 0,
3370 )
3371 .await?;
3372 let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
3373 .fetch_one(&pool)
3374 .await?;
3375 let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
3376 .fetch_one(&pool)
3377 .await?;
3378
3379 mark_read(&pool, did, old_id, true).await?;
3381 mark_read(&pool, did, new_id, true).await?;
3382 let before = get_cursor(&pool, did, feed_url).await?.unwrap();
3383 let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
3384 assert!(ids_before.contains(&old_id.to_string()));
3385 assert!(ids_before.contains(&new_id.to_string()));
3386
3387 let deleted = prune_old_entries(&pool, 90).await?;
3389 assert_eq!(deleted, 1);
3390 let after = get_cursor(&pool, did, feed_url).await?.unwrap();
3391 let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
3392 assert_eq!(
3393 ids_after,
3394 vec![new_id.to_string()],
3395 "orphaned (deleted) entry id must be removed; live id kept"
3396 );
3397 assert!(
3399 after.dirty,
3400 "cursor must be marked dirty after orphan scrub"
3401 );
3402 Ok(())
3403 }
3404
3405 #[tokio::test]
3406 async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
3407 let pool = init_url("sqlite::memory:").await?;
3409 let did = "did:plc:reader";
3410 let feed_url = "https://trim.example/feed.xml";
3411 let feed_id = upsert_feed(
3412 &pool,
3413 &NewFeed {
3414 url: feed_url.to_string(),
3415 ..Default::default()
3416 },
3417 )
3418 .await?;
3419 replace_sub_refs(&pool, did, &[feed_id]).await?;
3420
3421 insert_entries(
3423 &pool,
3424 feed_id,
3425 &[
3426 NewEntry {
3427 guid: "a".into(),
3428 published: Some("2026-01-01T00:00:00Z".into()),
3429 ..Default::default()
3430 },
3431 NewEntry {
3432 guid: "b".into(),
3433 published: Some("2026-01-02T00:00:00Z".into()),
3434 ..Default::default()
3435 },
3436 ],
3437 2,
3438 )
3439 .await?;
3440 let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
3441 .fetch_one(&pool)
3442 .await?;
3443 mark_read(&pool, did, a_id, true).await?;
3444
3445 insert_entries(
3447 &pool,
3448 feed_id,
3449 &[NewEntry {
3450 guid: "c".into(),
3451 published: Some("2026-01-03T00:00:00Z".into()),
3452 ..Default::default()
3453 }],
3454 1,
3455 )
3456 .await?;
3457 let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
3459 .fetch_one(&pool)
3460 .await?;
3461 assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
3462
3463 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
3465 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
3466 assert!(
3467 !ids.contains(&a_id.to_string()),
3468 "trimmed entry id must be scrubbed from the cursor"
3469 );
3470 Ok(())
3471 }
3472
3473 #[tokio::test]
3474 async fn prune_and_reclaim_drops_db_size() -> Result<()> {
3475 let dir = std::env::temp_dir();
3477 let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
3478 let url = format!("sqlite://{}", path.display());
3479 let pool = init_url(&url).await?;
3480
3481 let feed_id = upsert_feed(
3482 &pool,
3483 &NewFeed {
3484 url: "https://bulk.example/feed.xml".to_string(),
3485 ..Default::default()
3486 },
3487 )
3488 .await?;
3489 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
3490 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
3491 let entries: Vec<NewEntry> = (0..2000)
3492 .map(|i| NewEntry {
3493 guid: format!("guid-{i}"),
3494 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
3495 published: Some(ancient.clone()),
3496 fetched_at: Some(ancient.clone()),
3497 ..Default::default()
3498 })
3499 .collect();
3500 insert_entries(&pool, feed_id, &entries, 0).await?;
3501 let full = db_size_bytes(&pool).await?;
3502 assert!(full > 0);
3503
3504 let deleted = prune_old_entries(&pool, 90).await?;
3506 assert_eq!(deleted, 2000);
3507 reclaim(&pool).await?;
3508 let after = db_size_bytes(&pool).await?;
3509 assert!(
3510 after < full,
3511 "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
3512 );
3513
3514 drop(pool);
3515 let _ = std::fs::remove_file(&path);
3516 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
3517 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
3518 Ok(())
3519 }
3520
3521 #[tokio::test]
3525 async fn migrates_pre_intended_did_invite_codes_table() -> Result<()> {
3526 let dir = std::env::temp_dir();
3532 let path = dir.join(format!("fr-b1-{}.db", std::process::id()));
3533 let url = format!("sqlite://{}", path.display());
3534
3535 let opts = SqliteConnectOptions::from_str(&url)?
3537 .create_if_missing(true)
3538 .foreign_keys(true);
3539 let pool = SqlitePoolOptions::new()
3540 .min_connections(1)
3541 .max_connections(1)
3542 .connect_with(opts)
3543 .await?;
3544 sqlx::query(
3545 r#"CREATE TABLE invite_codes (
3546 code TEXT PRIMARY KEY,
3547 creator_did TEXT NOT NULL,
3548 status TEXT NOT NULL,
3549 invitee_did TEXT,
3550 created_at INTEGER NOT NULL,
3551 expires_at INTEGER NOT NULL,
3552 redeemed_at INTEGER
3553 );"#,
3554 )
3555 .execute(&pool)
3556 .await?;
3557 sqlx::query(
3559 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
3560 VALUES ('FEATHER-LEGACY00', 'did:plc:old', 'active', 1, 9999999999)",
3561 )
3562 .execute(&pool)
3563 .await?;
3564
3565 let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
3567 .fetch_all(&pool)
3568 .await?
3569 .iter()
3570 .map(|r| r.get::<String, _>("name"))
3571 .collect();
3572 assert!(
3573 !cols.iter().any(|c| c == "intended_did"),
3574 "pre-condition: legacy table must lack intended_did"
3575 );
3576
3577 init_schema(&pool)
3579 .await
3580 .expect("init_schema on a pre-0.2.2 invite_codes table must not crash");
3581
3582 let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
3585 .fetch_all(&pool)
3586 .await?
3587 .iter()
3588 .map(|r| r.get::<String, _>("name"))
3589 .collect();
3590 assert!(cols.iter().any(|c| c == "intended_did"));
3591 let idx: Vec<String> = sqlx::query(
3592 "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='invite_codes'",
3593 )
3594 .fetch_all(&pool)
3595 .await?
3596 .iter()
3597 .map(|r| r.get::<String, _>("name"))
3598 .collect();
3599 assert!(idx.iter().any(|n| n == "idx_invite_codes_intended"));
3600 assert!(idx.iter().any(|n| n == "idx_invite_codes_intended_active"));
3601
3602 init_schema(&pool)
3604 .await
3605 .expect("re-running init_schema must be idempotent");
3606
3607 let out = redeem_code(&pool, "FEATHER-LEGACY00", "did:plc:new", None, 100).await?;
3609 assert_eq!(out, Ok(()));
3610
3611 drop(pool);
3612 let _ = std::fs::remove_file(&path);
3613 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
3614 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
3615 Ok(())
3616 }
3617
3618 #[tokio::test]
3621 async fn redeem_enforces_intended_did_binding() -> Result<()> {
3622 let pool = init_url("sqlite::memory:").await?;
3623 let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:A").await?;
3625
3626 let stolen = redeem_code(&pool, &code, "did:plc:B", Some("thief.bsky"), 100).await?;
3629 assert_eq!(stolen, Err(RedeemError::NotFound));
3630 assert!(!has_beta_access(&pool, "did:plc:B").await?);
3631 assert_eq!(count_active_codes(&pool).await?, 1, "code must stay active");
3632
3633 let ok = redeem_code(&pool, &code, "did:plc:A", Some("alice.bsky"), 100).await?;
3635 assert_eq!(ok, Ok(()));
3636 assert!(has_beta_access(&pool, "did:plc:A").await?);
3637
3638 let open = mint_code(&pool, "did:plc:admin", 3600).await?;
3640 let anyone = redeem_code(&pool, &open, "did:plc:C", None, 100).await?;
3641 assert_eq!(anyone, Ok(()));
3642 assert!(has_beta_access(&pool, "did:plc:C").await?);
3643 Ok(())
3644 }
3645
3646 #[tokio::test]
3650 async fn intended_active_partial_unique_index_blocks_double_mint() -> Result<()> {
3651 let pool = init_url("sqlite::memory:").await?;
3652 mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup").await?;
3654 let err = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
3656 .await
3657 .expect_err("second active mint for the same DID must fail the unique index");
3658 assert!(
3659 is_intended_active_conflict(&err),
3660 "the conflict must be recognised so the web layer can recover: {err:?}"
3661 );
3662 assert!(find_active_code_for_did(&pool, "did:plc:dup")
3664 .await?
3665 .is_some());
3666
3667 let existing = find_active_code_for_did(&pool, "did:plc:dup")
3670 .await?
3671 .unwrap();
3672 redeem_code(&pool, &existing, "did:plc:dup", None, 100).await??;
3673 mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
3674 .await
3675 .expect("a new mint is allowed after the prior one is redeemed");
3676
3677 sqlx::query(
3680 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
3681 VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
3682 )
3683 .execute(&pool)
3684 .await?;
3685 let pk_err = sqlx::query(
3686 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
3687 VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
3688 )
3689 .execute(&pool)
3690 .await
3691 .expect_err("duplicate PRIMARY KEY must error");
3692 let as_anyhow = anyhow::Error::new(pk_err);
3693 assert!(
3694 !is_intended_active_conflict(&as_anyhow),
3695 "a non-intended-index conflict must NOT be mistaken for the recover-able one"
3696 );
3697 Ok(())
3698 }
3699
3700 #[tokio::test]
3701 async fn purge_expires_orphaned_active_intended_code() -> Result<()> {
3702 let pool = init_url("sqlite::memory:").await?;
3706 let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:leaver").await?;
3707 assert_eq!(count_active_codes(&pool).await?, 1);
3708
3709 purge_did_data(&pool, "did:plc:leaver").await?;
3710
3711 assert_eq!(
3713 count_active_codes(&pool).await?,
3714 0,
3715 "orphaned code must be expired by purge, not left active"
3716 );
3717 let intended: Option<String> =
3719 sqlx::query("SELECT intended_did FROM invite_codes WHERE code = ?1")
3720 .bind(&code)
3721 .fetch_one(&pool)
3722 .await?
3723 .get("intended_did");
3724 assert!(intended.is_none(), "intended_did must be NULLed");
3725 Ok(())
3726 }
3727}