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}