Skip to main content

feather_reader/
lexicon.rs

1//! Serde types for the `community.lexicon.rss.*` atproto record schemas.
2//!
3//! FeatherReader's defining bet is that a user's feed subscriptions, folders,
4//! saved items, and batched read-state live as records in their own atproto PDS
5//! under an **open, vendor-neutral community lexicon** (`community.lexicon.rss.*`)
6//! rather than in the app's database — portable across any reader that adopts
7//! the standard, not merely across FeatherReader instances.
8//!
9//! These types mirror the `community.lexicon.rss.*` schemas, authored in
10//! the Lexicon Community idiom (`createdAt`/`updatedAt` as ISO-8601 datetimes,
11//! `url`/`siteUrl`/`feedUrl` as URIs, `folder` as an `at://` strong ref). Each
12//! record carries its `$type` NSID so it round-trips against the atproto record
13//! shape returned by `com.atproto.repo.getRecord` / `listRecords`.
14//!
15//! Storage rules (never write these authoritatively to local SQLite):
16//! - [`Subscription`] — one followed feed. `com.atproto.repo.createRecord` on
17//!   subscribe; `deleteRecord` on unsubscribe. Source of truth for the follow list.
18//! - [`Folder`] — a lightweight named grouping (a feed lives in one folder).
19//! - [`Saved`] — a starred / save-for-later entry.
20//! - [`ReadState`] — the **batched** per-feed read cursor (one record per feed,
21//!   at a feed-derived rkey — never one record per article). Written by the
22//!   read-state flusher; see the caveats on that flush path in
23//!   [`crate::atproto`].
24
25use serde::{Deserialize, Serialize};
26
27/// NSID `$type` constants for the `community.lexicon.rss.*` record collections.
28///
29/// These double as the atproto **collection** NSIDs for `listRecords` /
30/// `createRecord` / `putRecord` calls.
31pub mod nsid {
32    /// `community.lexicon.rss.subscription` — one followed feed.
33    pub const SUBSCRIPTION: &str = "community.lexicon.rss.subscription";
34    /// `community.lexicon.rss.folder` — a named grouping of subscriptions.
35    pub const FOLDER: &str = "community.lexicon.rss.folder";
36    /// `community.lexicon.rss.saved` — a starred / save-for-later entry.
37    pub const SAVED: &str = "community.lexicon.rss.saved";
38    /// `community.lexicon.rss.readState` — batched per-feed read cursor.
39    pub const READ_STATE: &str = "community.lexicon.rss.readState";
40}
41
42/// Optional polling-cadence hint on a [`Subscription`]. Readers MAY honor or
43/// ignore it. Mirrors the lexicon's `knownValues` for `fetchHint`.
44///
45/// `knownValues` in atproto is an *open* enum — an unrecognized value MUST NOT
46/// break deserialization — so [`FetchHint::Other`] captures forward-compatible
47/// values a future reader might write.
48#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
49#[serde(rename_all = "lowercase")]
50pub enum FetchHint {
51    /// Poll as close to realtime as the reader supports.
52    Realtime,
53    /// Poll roughly hourly.
54    Hourly,
55    /// Poll roughly daily.
56    Daily,
57    /// Poll roughly weekly.
58    Weekly,
59    /// An unrecognized (forward-compatible) hint value.
60    #[serde(untagged)]
61    Other(String),
62}
63
64/// `community.lexicon.rss.subscription` — a subscription to a syndication feed
65/// (RSS / Atom / JSON Feed). Record key: `tid`.
66///
67/// `url` + `createdAt` are required; everything else is optional.
68///
69/// ## Public feeds only (and the reserved `private` marker)
70///
71/// atproto PDS records are **public**: anyone can read them via unauthenticated
72/// `getRecord` / `listRecords` and off the firehose, and they are retained even
73/// after `deleteRecord`. A **private feed** (a Substack `…/feed/private/<token>`,
74/// a Patreon `?auth=…` feed, a Ghost members `?uuid=` feed, a private-podcast
75/// token feed, or any URL that carries a secret token / key / auth credential)
76/// has its *secret in the URL*, so writing that URL here would leak paid /
77/// members-only access to the whole network.
78///
79/// **Current decision: FeatherReader supports PUBLIC feeds only.** A private
80/// feed is *refused* at the add / import boundary (see
81/// [`crate::feed::classify_feed_privacy`]) — it is never fetched, never stored,
82/// and no record (redacted or otherwise) is ever written. The server therefore
83/// holds NO private secret, which keeps "your data lives in your public PDS"
84/// 100% honest. Consequently every [`Subscription`] record actually written
85/// carries a real, public feed `url`, and [`private`] is **always omitted**.
86///
87/// The [`private`] field is retained ONLY as a documented, forward-compatible
88/// **reserved marker** for the eventual migration once atproto ships
89/// **permissioned data / permission-sets** (early-proposal as of mid-2026,
90/// bluesky-social/proposals#94). At that point a private feed's secret can live
91/// in an owner-scoped, permission-gated collection and this record can reference
92/// it with `private: true`. Until then the field has **no runtime behavior** —
93/// nothing sets it and nothing branches on it.
94#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
95pub struct Subscription {
96    /// The `$type` NSID discriminator; always [`nsid::SUBSCRIPTION`].
97    #[serde(rename = "$type", default = "subscription_type")]
98    pub r#type: String,
99
100    /// Canonical feed URL (the RSS/Atom/JSON Feed document). Required.
101    ///
102    /// Always a real, PUBLIC feed URL: private/secret-bearing feeds are refused
103    /// at the add boundary (see the type-level docs), so no record with a
104    /// withheld or redacted `url` is ever written.
105    pub url: String,
106
107    /// Display title; a reader MAY override from feed metadata.
108    #[serde(skip_serializing_if = "Option::is_none", default)]
109    pub title: Option<String>,
110
111    /// Human-facing site the feed belongs to.
112    #[serde(rename = "siteUrl", skip_serializing_if = "Option::is_none", default)]
113    pub site_url: Option<String>,
114
115    /// Optional `at://` strong ref to a [`Folder`] record.
116    #[serde(skip_serializing_if = "Option::is_none", default)]
117    pub folder: Option<String>,
118
119    /// Optional polling-cadence hint; readers MAY honor or ignore it.
120    #[serde(rename = "fetchHint", skip_serializing_if = "Option::is_none", default)]
121    pub fetch_hint: Option<FetchHint>,
122
123    /// **Reserved** — no runtime behavior today.
124    ///
125    /// FeatherReader currently supports public feeds only (private/secret-bearing
126    /// feeds are refused at the add boundary), so nothing sets this and every
127    /// written record omits it (`None`). It is kept as a documented,
128    /// forward-compatible seam for the eventual migration once atproto ships
129    /// permissioned data: at that point a private feed's secret can live in an
130    /// owner-scoped, permission-gated collection and this record can reference it
131    /// with `private: true`. See the type-level docs.
132    #[serde(skip_serializing_if = "Option::is_none", default)]
133    pub private: Option<bool>,
134
135    /// Record creation time (ISO-8601 datetime). Required.
136    #[serde(rename = "createdAt")]
137    pub created_at: String,
138}
139
140fn subscription_type() -> String {
141    nsid::SUBSCRIPTION.to_string()
142}
143
144impl Subscription {
145    /// Construct a minimal subscription with only the required fields.
146    pub fn new(url: impl Into<String>, created_at: impl Into<String>) -> Self {
147        Self {
148            r#type: nsid::SUBSCRIPTION.to_string(),
149            url: url.into(),
150            title: None,
151            site_url: None,
152            folder: None,
153            fetch_hint: None,
154            private: None,
155            created_at: created_at.into(),
156        }
157    }
158}
159
160/// `community.lexicon.rss.folder` — a named folder/grouping for subscriptions.
161/// Record key: `tid`.
162///
163/// `name` + `createdAt` are required; `position` is an optional sort hint.
164#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
165pub struct Folder {
166    /// The `$type` NSID discriminator; always [`nsid::FOLDER`].
167    #[serde(rename = "$type", default = "folder_type")]
168    pub r#type: String,
169
170    /// Folder display name. Required.
171    pub name: String,
172
173    /// Optional sort hint among sibling folders (>= 0).
174    #[serde(skip_serializing_if = "Option::is_none", default)]
175    pub position: Option<u64>,
176
177    /// Record creation time (ISO-8601 datetime). Required.
178    #[serde(rename = "createdAt")]
179    pub created_at: String,
180}
181
182fn folder_type() -> String {
183    nsid::FOLDER.to_string()
184}
185
186impl Folder {
187    /// Construct a minimal folder with only the required fields.
188    pub fn new(name: impl Into<String>, created_at: impl Into<String>) -> Self {
189        Self {
190            r#type: nsid::FOLDER.to_string(),
191            name: name.into(),
192            position: None,
193            created_at: created_at.into(),
194        }
195    }
196}
197
198/// `community.lexicon.rss.saved` — an article kept for later (the reader's
199/// "star"). Record key: `tid`.
200///
201/// `url` + `createdAt` are required; the rest aid cross-reader dedup.
202#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
203pub struct Saved {
204    /// The `$type` NSID discriminator; always [`nsid::SAVED`].
205    #[serde(rename = "$type", default = "saved_type")]
206    pub r#type: String,
207
208    /// The article/entry permalink. Required.
209    pub url: String,
210
211    /// Display title of the saved entry.
212    #[serde(skip_serializing_if = "Option::is_none", default)]
213    pub title: Option<String>,
214
215    /// Feed the entry came from (soft ref; may outlive the subscription).
216    #[serde(rename = "feedUrl", skip_serializing_if = "Option::is_none", default)]
217    pub feed_url: Option<String>,
218
219    /// Feed-native guid/id when present, for cross-reader dedup.
220    #[serde(rename = "entryId", skip_serializing_if = "Option::is_none", default)]
221    pub entry_id: Option<String>,
222
223    /// Record creation time (ISO-8601 datetime). Required.
224    #[serde(rename = "createdAt")]
225    pub created_at: String,
226}
227
228fn saved_type() -> String {
229    nsid::SAVED.to_string()
230}
231
232impl Saved {
233    /// Construct a minimal saved entry with only the required fields.
234    pub fn new(url: impl Into<String>, created_at: impl Into<String>) -> Self {
235        Self {
236            r#type: nsid::SAVED.to_string(),
237            url: url.into(),
238            title: None,
239            feed_url: None,
240            entry_id: None,
241            created_at: created_at.into(),
242        }
243    }
244}
245
246/// `community.lexicon.rss.readState` — a batched read high-water-mark for a
247/// single feed. Record key: `any`; the rkey is derived deterministically from the
248/// feed (a hash of the feed URL), so there is one record per feed with a stable
249/// key, NOT one record per article.
250///
251/// `feedUrl` + `updatedAt` are required; `readThrough` is OPTIONAL — it is a
252/// water-mark ("every entry seen/published `<=` this is read"), so it is written
253/// only once a real high-water-mark exists. Omitting it (rather than synthesizing
254/// a flush-time value) means a brand-new cursor asserts nothing about the backlog:
255/// only the explicit `readIds` mark entries read. The two capped id-sets carry
256/// out-of-order reads and explicit mark-unread exceptions.
257#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
258pub struct ReadState {
259    /// The `$type` NSID discriminator; always [`nsid::READ_STATE`].
260    #[serde(rename = "$type", default = "read_state_type")]
261    pub r#type: String,
262
263    /// The feed this cursor covers. Required.
264    #[serde(rename = "feedUrl")]
265    pub feed_url: String,
266
267    /// High-water-mark: every entry with seen/published time <= this is READ.
268    /// **Optional** — omitted from the record when no local high-water-mark
269    /// exists yet, so a fresh cursor never implicitly marks the backlog read.
270    #[serde(
271        rename = "readThrough",
272        skip_serializing_if = "Option::is_none",
273        default
274    )]
275    pub read_through: Option<String>,
276
277    /// Entries newer than `readThrough` that are ALSO read (out-of-order reads).
278    /// Capped at 1000 by the lexicon; empty sets are omitted from the record.
279    #[serde(rename = "readIds", skip_serializing_if = "Vec::is_empty", default)]
280    pub read_ids: Vec<String>,
281
282    /// Entries older than `readThrough` explicitly kept UNREAD (mark-unread).
283    /// Capped at 1000 by the lexicon; empty sets are omitted from the record.
284    #[serde(rename = "unreadIds", skip_serializing_if = "Vec::is_empty", default)]
285    pub unread_ids: Vec<String>,
286
287    /// Last time this cursor was flushed (ISO-8601 datetime). Required. Intended
288    /// as the tie-breaker for cross-device merges (newest `updatedAt` wins);
289    /// a login-time reconcile that uses it is not implemented yet.
290    #[serde(rename = "updatedAt")]
291    pub updated_at: String,
292}
293
294fn read_state_type() -> String {
295    nsid::READ_STATE.to_string()
296}
297
298impl ReadState {
299    /// Maximum length of the `readIds` / `unreadIds` exception sets, per the
300    /// lexicon. The flusher enforces this cap before writing (see
301    /// `scheduler::cap`).
302    pub const MAX_IDS: usize = 1000;
303
304    /// Construct a minimal read cursor with only the required fields.
305    ///
306    /// `read_through` is optional: pass `None` for a cursor that has no local
307    /// high-water-mark yet, so the record omits `readThrough` entirely rather than
308    /// synthesizing a flush-time value that would mark the backlog read.
309    pub fn new(
310        feed_url: impl Into<String>,
311        read_through: Option<String>,
312        updated_at: impl Into<String>,
313    ) -> Self {
314        Self {
315            r#type: nsid::READ_STATE.to_string(),
316            feed_url: feed_url.into(),
317            read_through,
318            read_ids: Vec::new(),
319            unread_ids: Vec::new(),
320            updated_at: updated_at.into(),
321        }
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use serde_json::json;
329
330    #[test]
331    fn subscription_round_trips_full_record() {
332        // Matches the atproto record shape returned by getRecord's `value`.
333        let value = json!({
334            "$type": "community.lexicon.rss.subscription",
335            "url": "https://example.com/feed.xml",
336            "title": "Example Blog",
337            "siteUrl": "https://example.com/",
338            "folder": "at://did:plc:abc123/community.lexicon.rss.folder/3kfolderrkey",
339            "fetchHint": "hourly",
340            "createdAt": "2026-07-12T00:00:00.000Z"
341        });
342
343        let sub: Subscription = serde_json::from_value(value.clone()).expect("deserialize");
344        assert_eq!(sub.r#type, nsid::SUBSCRIPTION);
345        assert_eq!(sub.url, "https://example.com/feed.xml");
346        assert_eq!(sub.title.as_deref(), Some("Example Blog"));
347        assert_eq!(sub.site_url.as_deref(), Some("https://example.com/"));
348        assert_eq!(sub.fetch_hint, Some(FetchHint::Hourly));
349
350        let back = serde_json::to_value(&sub).expect("serialize");
351        assert_eq!(back, value);
352    }
353
354    #[test]
355    fn subscription_minimal_omits_optional_fields() {
356        let sub = Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
357        let back = serde_json::to_value(&sub).expect("serialize");
358        assert_eq!(
359            back,
360            json!({
361                "$type": "community.lexicon.rss.subscription",
362                "url": "https://example.com/feed.xml",
363                "createdAt": "2026-07-12T00:00:00.000Z"
364            })
365        );
366    }
367
368    #[test]
369    fn subscription_reserved_private_marker_omitted_by_default_but_round_trips() {
370        // Default construction never sets `private`; a public record omits it
371        // entirely (byte-for-byte unchanged from before the reserved field).
372        let public = Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
373        assert_eq!(public.private, None);
374        let public_body = serde_json::to_value(&public).expect("serialize");
375        assert!(public_body.get("private").is_none());
376
377        // The reserved field is forward-compatible: if a future record ever
378        // carries `private: true`, it (de)serializes cleanly. Nothing in the
379        // current codebase sets it, but the seam must round-trip.
380        let mut future =
381            Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
382        future.private = Some(true);
383        let back = serde_json::to_value(&future).expect("serialize");
384        assert_eq!(back["private"], serde_json::json!(true));
385        let parsed: Subscription = serde_json::from_value(back).expect("deserialize");
386        assert_eq!(parsed.private, Some(true));
387    }
388
389    #[test]
390    fn fetch_hint_open_enum_accepts_unknown() {
391        let sub: Subscription = serde_json::from_value(json!({
392            "url": "https://example.com/feed.xml",
393            "fetchHint": "every-15-min",
394            "createdAt": "2026-07-12T00:00:00.000Z"
395        }))
396        .expect("deserialize");
397        assert_eq!(
398            sub.fetch_hint,
399            Some(FetchHint::Other("every-15-min".to_string()))
400        );
401        // $type defaults in when the record value omits it.
402        assert_eq!(sub.r#type, nsid::SUBSCRIPTION);
403    }
404
405    #[test]
406    fn folder_round_trips() {
407        let value = json!({
408            "$type": "community.lexicon.rss.folder",
409            "name": "Tech",
410            "position": 2,
411            "createdAt": "2026-07-12T00:00:00.000Z"
412        });
413        let folder: Folder = serde_json::from_value(value.clone()).expect("deserialize");
414        assert_eq!(folder.name, "Tech");
415        assert_eq!(folder.position, Some(2));
416        assert_eq!(serde_json::to_value(&folder).expect("serialize"), value);
417    }
418
419    #[test]
420    fn saved_round_trips() {
421        let value = json!({
422            "$type": "community.lexicon.rss.saved",
423            "url": "https://example.com/post/1",
424            "title": "A kept post",
425            "feedUrl": "https://example.com/feed.xml",
426            "entryId": "tag:example.com,2026:1",
427            "createdAt": "2026-07-12T00:00:00.000Z"
428        });
429        let saved: Saved = serde_json::from_value(value.clone()).expect("deserialize");
430        assert_eq!(saved.url, "https://example.com/post/1");
431        assert_eq!(
432            saved.feed_url.as_deref(),
433            Some("https://example.com/feed.xml")
434        );
435        assert_eq!(saved.entry_id.as_deref(), Some("tag:example.com,2026:1"));
436        assert_eq!(serde_json::to_value(&saved).expect("serialize"), value);
437    }
438
439    #[test]
440    fn read_state_round_trips_with_id_sets() {
441        let value = json!({
442            "$type": "community.lexicon.rss.readState",
443            "feedUrl": "https://example.com/feed.xml",
444            "readThrough": "2026-07-12T00:00:00.000Z",
445            "readIds": ["entry-a", "entry-b"],
446            "unreadIds": ["entry-c"],
447            "updatedAt": "2026-07-12T01:00:00.000Z"
448        });
449        let rs: ReadState = serde_json::from_value(value.clone()).expect("deserialize");
450        assert_eq!(rs.feed_url, "https://example.com/feed.xml");
451        assert_eq!(rs.read_through.as_deref(), Some("2026-07-12T00:00:00.000Z"));
452        assert_eq!(rs.read_ids, vec!["entry-a", "entry-b"]);
453        assert_eq!(rs.unread_ids, vec!["entry-c"]);
454        assert_eq!(serde_json::to_value(&rs).expect("serialize"), value);
455    }
456
457    #[test]
458    fn read_state_minimal_omits_empty_id_sets() {
459        let rs = ReadState::new(
460            "https://example.com/feed.xml",
461            Some("2026-07-12T00:00:00.000Z".to_string()),
462            "2026-07-12T01:00:00.000Z",
463        );
464        let back = serde_json::to_value(&rs).expect("serialize");
465        assert_eq!(
466            back,
467            json!({
468                "$type": "community.lexicon.rss.readState",
469                "feedUrl": "https://example.com/feed.xml",
470                "readThrough": "2026-07-12T00:00:00.000Z",
471                "updatedAt": "2026-07-12T01:00:00.000Z"
472            })
473        );
474    }
475
476    #[test]
477    fn read_state_omits_read_through_when_none() {
478        // A brand-new cursor with no high-water-mark must NOT synthesize one:
479        // `readThrough` is absent entirely so the backlog is not implicitly read.
480        let rs = ReadState::new(
481            "https://example.com/feed.xml",
482            None,
483            "2026-07-12T01:00:00.000Z",
484        );
485        let back = serde_json::to_value(&rs).expect("serialize");
486        assert!(back.get("readThrough").is_none());
487        assert_eq!(
488            back,
489            json!({
490                "$type": "community.lexicon.rss.readState",
491                "feedUrl": "https://example.com/feed.xml",
492                "updatedAt": "2026-07-12T01:00:00.000Z"
493            })
494        );
495        // And a record without readThrough round-trips back to None.
496        let parsed: ReadState = serde_json::from_value(back).expect("deserialize");
497        assert_eq!(parsed.read_through, None);
498    }
499}