1use std::str::FromStr;
2
3use chrono::{DateTime, Utc};
4use sqlx::{QueryBuilder, Sqlite, Transaction};
5use synd_feed::{
6 entry::EntryId,
7 types::{Annotated, Category, FeedUrl, Requirement},
8};
9use synd_registry::{
10 RegistryDbResult,
11 db::TimelineDb,
12 query::{
13 TimelineChange, TimelineChangesPage, TimelineChangesQuery, TimelineEntriesPage,
14 TimelineEntriesQuery, TimelineEntry, TimelineEntryCursor,
15 },
16 subscription::{SubscriberId, SubscriptionKey},
17 timeline::TimelineCatchup,
18};
19
20use super::{
21 codec::{decode_stored_entry, decode_stored_feed_meta},
22 error::{DecodeResultExt, IntoDbResult, SqliteError, SqliteResult},
23 pagination::PageLimit,
24};
25
26const TIMELINE_ENTRY_SELECT: &str = r#"
27SELECT
28 te.order_time,
29 te.entry_id,
30 e.entry_json,
31 f.url AS feed_url,
32 fs.meta_json,
33 s.requirement,
34 s.category
35FROM timeline_entry AS te
36INNER JOIN entry AS e
37 ON e.entry_id = te.entry_id
38INNER JOIN feed AS f
39 ON f.pk = e.feed_pk
40INNER JOIN feed_snapshot AS fs
41 ON fs.feed_pk = f.pk
42INNER JOIN feed_subscription AS s
43 ON s.subscriber_id = te.subscriber_id
44 AND s.feed_pk = f.pk
45"#;
46
47async fn ensure_timeline(
48 tx: &mut Transaction<'_, Sqlite>,
49 subscriber_id: &SubscriberId,
50) -> SqliteResult<()> {
51 sqlx::query(
52 r#"
53 INSERT OR IGNORE INTO timeline (subscriber_id)
54 VALUES (?)
55 "#,
56 )
57 .bind(subscriber_id.as_str())
58 .execute(&mut **tx)
59 .await?;
60 Ok(())
61}
62
63async fn catchup_feed(
64 tx: &mut Transaction<'_, Sqlite>,
65 subscriber_id: &SubscriberId,
66 feed_url: &FeedUrl,
67) -> SqliteResult<TimelineCatchup> {
68 FeedCatchupCandidates::load(tx, subscriber_id, feed_url)
69 .await?
70 .apply(tx)
71 .await
72}
73
74struct FeedCatchupCandidates {
76 subscriber_id: SubscriberId,
77 feed_url: FeedUrl,
78 count: i64,
79}
80
81impl FeedCatchupCandidates {
82 async fn load(
83 tx: &mut Transaction<'_, Sqlite>,
84 subscriber_id: &SubscriberId,
85 feed_url: &FeedUrl,
86 ) -> SqliteResult<Self> {
87 let count = sqlx::query_scalar::<_, i64>(
88 r#"
89 SELECT COUNT(*)
90 FROM feed_entry AS fe
91 INNER JOIN feed AS f
92 ON f.pk = fe.feed_pk
93 WHERE f.url = ?
94 "#,
95 )
96 .bind(feed_url.as_str())
97 .fetch_one(&mut **tx)
98 .await?;
99 Ok(Self {
100 subscriber_id: subscriber_id.clone(),
101 feed_url: feed_url.clone(),
102 count,
103 })
104 }
105
106 async fn apply(self, tx: &mut Transaction<'_, Sqlite>) -> SqliteResult<TimelineCatchup> {
107 if self.count == 0 {
108 return Ok(self.outcome(0));
109 }
110
111 let base_seq = alloc_seq_range(tx, &self.subscriber_id, self.count).await?;
116 let inserted = self.insert(tx, base_seq).await?;
117 Ok(self.outcome(inserted))
118 }
119
120 async fn insert(&self, tx: &mut Transaction<'_, Sqlite>, base_seq: i64) -> SqliteResult<u64> {
121 let result = sqlx::query(
124 r#"
125 INSERT INTO timeline_entry (
126 subscriber_id,
127 entry_id,
128 order_time,
129 seq
130 )
131 SELECT
132 ?,
133 e.entry_id,
134 e.order_time,
135 ? + ROW_NUMBER() OVER (ORDER BY e.entry_id)
136 FROM feed_entry AS fe
137 INNER JOIN entry AS e
138 ON e.feed_pk = fe.feed_pk
139 AND e.entry_id = fe.entry_id
140 INNER JOIN feed AS f
141 ON f.pk = fe.feed_pk
142 WHERE f.url = ?
143 ON CONFLICT (subscriber_id, entry_id) DO UPDATE SET
144 seq = excluded.seq,
145 deleted = 0
146 WHERE timeline_entry.deleted != 0
147 "#,
148 )
149 .bind(self.subscriber_id.as_str())
150 .bind(base_seq)
151 .bind(self.feed_url.as_str())
152 .execute(&mut **tx)
153 .await?;
154 Ok(result.rows_affected())
155 }
156
157 fn outcome(&self, added: u64) -> TimelineCatchup {
158 TimelineCatchup::new(self.subscriber_id.clone(), self.feed_url.clone(), added)
159 }
160}
161
162async fn apply_entry_to_timelines(
163 tx: &mut Transaction<'_, Sqlite>,
164 feed_url: &FeedUrl,
165 entry_id: &EntryId,
166 content_changed: bool,
167) -> SqliteResult<Vec<SubscriberId>> {
168 ensure_subscriber_timelines(tx, feed_url).await?;
169
170 let mut affected = Vec::new();
171 for target in load_entry_timeline_targets(tx, feed_url, entry_id).await? {
172 let touched = match &target.existing {
173 None => {
174 let seq = next_seq(tx, &target.subscriber_id).await?;
175 insert_timeline_entry(tx, &target, seq).await?;
176 true
177 }
178 Some(existing) if content_changed || existing.deleted => {
181 let seq = next_seq(tx, &target.subscriber_id).await?;
182 bump_timeline_entry(tx, &target, seq).await?;
183 true
184 }
185 Some(_) => false,
186 };
187 if touched {
188 affected.push(target.subscriber_id);
189 }
190 }
191 Ok(affected)
192}
193
194async fn apply_feed_unsubscribed(
195 tx: &mut Transaction<'_, Sqlite>,
196 subscription: &SubscriptionKey,
197) -> SqliteResult<Option<SubscriberId>> {
198 let subscriber_id = &subscription.subscriber_id;
199 if !timeline_exists(tx, subscriber_id).await? {
200 return Ok(None);
201 }
202 let candidates = sqlx::query_scalar::<_, i64>(
203 r#"
204 SELECT COUNT(*)
205 FROM timeline_entry AS te
206 WHERE te.subscriber_id = ?
207 AND te.deleted = 0
208 AND te.entry_id IN (
209 SELECT e.entry_id
210 FROM entry AS e
211 INNER JOIN feed AS f
212 ON f.pk = e.feed_pk
213 WHERE f.url = ?
214 )
215 "#,
216 )
217 .bind(subscriber_id.as_str())
218 .bind(subscription.feed_url.as_str())
219 .fetch_one(&mut **tx)
220 .await?;
221 if candidates == 0 {
222 return Ok(None);
223 }
224
225 let base_seq = alloc_seq_range(tx, subscriber_id, candidates).await?;
230 sqlx::query(
231 r#"
232 UPDATE timeline_entry
233 SET
234 deleted = 1,
235 seq = ? + ranked.rn
236 FROM (
237 SELECT
238 te.entry_id,
239 ROW_NUMBER() OVER (ORDER BY te.entry_id) AS rn
240 FROM timeline_entry AS te
241 WHERE te.subscriber_id = ?
242 AND te.deleted = 0
243 AND te.entry_id IN (
244 SELECT e.entry_id
245 FROM entry AS e
246 INNER JOIN feed AS f
247 ON f.pk = e.feed_pk
248 WHERE f.url = ?
249 )
250 ) AS ranked
251 WHERE timeline_entry.subscriber_id = ?
252 AND timeline_entry.entry_id = ranked.entry_id
253 "#,
254 )
255 .bind(base_seq)
256 .bind(subscriber_id.as_str())
257 .bind(subscription.feed_url.as_str())
258 .bind(subscriber_id.as_str())
259 .execute(&mut **tx)
260 .await?;
261
262 Ok(Some(subscriber_id.clone()))
263}
264
265async fn list_entries(
266 tx: &mut Transaction<'_, Sqlite>,
267 query: TimelineEntriesQuery,
268) -> SqliteResult<TimelineEntriesPage> {
269 Ok(StoredTimelineEntriesPage::load(tx, query)
270 .await?
271 .into_page())
272}
273
274struct StoredTimelineEntriesPage {
276 nodes: Vec<TimelineEntry>,
277 limit: PageLimit,
278 seq: i64,
279}
280
281impl StoredTimelineEntriesPage {
282 async fn load(
283 tx: &mut Transaction<'_, Sqlite>,
284 query: TimelineEntriesQuery,
285 ) -> SqliteResult<Self> {
286 let limit = PageLimit::new(query.first);
287 let Some(seq) = load_last_seq(tx, &query.subscriber_id).await? else {
288 return Ok(Self {
289 nodes: Vec::new(),
290 limit,
291 seq: 0,
292 });
293 };
294 let nodes = load_timeline_entries(tx, &query, limit).await?;
295 Ok(Self { nodes, limit, seq })
296 }
297
298 fn into_page(mut self) -> TimelineEntriesPage {
299 let has_next_page = self.limit.truncate_overfetch(&mut self.nodes);
300 let end_cursor = self.nodes.last().map(|node| node.cursor.clone());
301 TimelineEntriesPage {
302 nodes: self.nodes,
303 has_next_page,
304 end_cursor,
305 seq: self.seq,
306 }
307 }
308}
309
310async fn load_timeline_entries(
311 tx: &mut Transaction<'_, Sqlite>,
312 query: &TimelineEntriesQuery,
313 limit: PageLimit,
314) -> SqliteResult<Vec<TimelineEntry>> {
315 let mut sql = QueryBuilder::<Sqlite>::new(TIMELINE_ENTRY_SELECT);
316 sql.push(" WHERE te.subscriber_id = ");
317 sql.push_bind(query.subscriber_id.as_str());
318 sql.push(" AND te.deleted = 0");
319 if let Some(after) = query.after.as_ref() {
320 sql.push(" AND (te.order_time, te.entry_id) < (");
321 sql.push_bind(after.order_time());
322 sql.push(", ");
323 sql.push_bind(after.entry_id().as_str());
324 sql.push(")");
325 }
326
327 sql.push(" ORDER BY te.order_time DESC, te.entry_id DESC LIMIT ");
328 sql.push_bind(limit.sql_limit());
329
330 let rows = sql
331 .build_query_as::<TimelineEntryRow>()
332 .fetch_all(&mut **tx)
333 .await?;
334 rows.into_iter().map(TimelineEntry::try_from).collect()
335}
336
337async fn list_changes(
338 tx: &mut Transaction<'_, Sqlite>,
339 query: TimelineChangesQuery,
340) -> SqliteResult<TimelineChangesPage> {
341 StoredTimelineChangesPage::load(tx, query)
342 .await?
343 .into_page()
344}
345
346struct StoredTimelineChangesPage {
348 rows: Vec<TimelineChangeRow>,
349 limit: PageLimit,
350 since: i64,
351 last_seq: i64,
352}
353
354impl StoredTimelineChangesPage {
355 async fn load(
356 tx: &mut Transaction<'_, Sqlite>,
357 query: TimelineChangesQuery,
358 ) -> SqliteResult<Self> {
359 let limit = PageLimit::new(query.limit);
360 let Some(last_seq) = load_last_seq(tx, &query.subscriber_id).await? else {
361 return Ok(Self {
362 rows: Vec::new(),
363 limit,
364 since: query.since,
365 last_seq: 0,
366 });
367 };
368 let rows = load_timeline_change_rows(tx, &query, limit).await?;
369 Ok(Self {
370 rows,
371 limit,
372 since: query.since,
373 last_seq,
374 })
375 }
376
377 fn into_page(mut self) -> SqliteResult<TimelineChangesPage> {
378 let has_more = self.limit.truncate_overfetch(&mut self.rows);
379 let seq = if has_more {
380 self.rows.last().map_or(self.since, |row| row.seq)
381 } else {
382 self.last_seq
383 };
384 let changes = self
385 .rows
386 .into_iter()
387 .map(TimelineChange::try_from)
388 .collect::<SqliteResult<Vec<_>>>()?;
389 Ok(TimelineChangesPage {
390 changes,
391 seq,
392 has_more,
393 })
394 }
395}
396
397async fn load_timeline_change_rows(
398 tx: &mut Transaction<'_, Sqlite>,
399 query: &TimelineChangesQuery,
400 limit: PageLimit,
401) -> SqliteResult<Vec<TimelineChangeRow>> {
402 let rows = sqlx::query_as::<_, TimelineChangeRow>(
406 r#"
407 SELECT
408 te.order_time,
409 te.entry_id,
410 te.seq,
411 (te.deleted != 0 OR s.subscriber_id IS NULL) AS removed,
412 e.entry_json,
413 f.url AS feed_url,
414 fs.meta_json,
415 s.requirement,
416 s.category
417 FROM timeline_entry AS te
418 INNER JOIN entry AS e
419 ON e.entry_id = te.entry_id
420 INNER JOIN feed AS f
421 ON f.pk = e.feed_pk
422 LEFT JOIN feed_snapshot AS fs
423 ON fs.feed_pk = f.pk
424 LEFT JOIN feed_subscription AS s
425 ON s.subscriber_id = te.subscriber_id
426 AND s.feed_pk = f.pk
427 WHERE te.subscriber_id = ?
428 AND te.seq > ?
429 ORDER BY te.seq ASC
430 LIMIT ?
431 "#,
432 )
433 .bind(query.subscriber_id.as_str())
434 .bind(query.since)
435 .bind(limit.sql_limit())
436 .fetch_all(&mut **tx)
437 .await?;
438 Ok(rows)
439}
440
441async fn timeline_exists(
442 tx: &mut Transaction<'_, Sqlite>,
443 subscriber_id: &SubscriberId,
444) -> SqliteResult<bool> {
445 Ok(load_last_seq(tx, subscriber_id).await?.is_some())
446}
447
448async fn load_last_seq(
449 tx: &mut Transaction<'_, Sqlite>,
450 subscriber_id: &SubscriberId,
451) -> SqliteResult<Option<i64>> {
452 let row = sqlx::query_scalar::<_, i64>(
453 r#"
454 SELECT last_seq
455 FROM timeline
456 WHERE subscriber_id = ?
457 "#,
458 )
459 .bind(subscriber_id.as_str())
460 .fetch_optional(&mut **tx)
461 .await?;
462
463 Ok(row)
464}
465
466async fn next_seq(
468 tx: &mut Transaction<'_, Sqlite>,
469 subscriber_id: &SubscriberId,
470) -> SqliteResult<i64> {
471 Ok(alloc_seq_range(tx, subscriber_id, 1).await? + 1)
472}
473
474async fn alloc_seq_range(
478 tx: &mut Transaction<'_, Sqlite>,
479 subscriber_id: &SubscriberId,
480 count: i64,
481) -> SqliteResult<i64> {
482 let last_seq = sqlx::query_scalar::<_, i64>(
483 r#"
484 UPDATE timeline
485 SET last_seq = last_seq + ?
486 WHERE subscriber_id = ?
487 RETURNING last_seq
488 "#,
489 )
490 .bind(count)
491 .bind(subscriber_id.as_str())
492 .fetch_one(&mut **tx)
493 .await?;
494 Ok(last_seq - count)
495}
496
497async fn ensure_subscriber_timelines(
500 tx: &mut Transaction<'_, Sqlite>,
501 feed_url: &FeedUrl,
502) -> SqliteResult<()> {
503 sqlx::query(
504 r#"
505 INSERT OR IGNORE INTO timeline (subscriber_id)
506 SELECT s.subscriber_id
507 FROM feed_subscription AS s
508 INNER JOIN feed AS f
509 ON f.pk = s.feed_pk
510 WHERE f.url = ?
511 "#,
512 )
513 .bind(feed_url.as_str())
514 .execute(&mut **tx)
515 .await?;
516 Ok(())
517}
518
519async fn load_entry_timeline_targets(
520 tx: &mut Transaction<'_, Sqlite>,
521 feed_url: &FeedUrl,
522 entry_id: &EntryId,
523) -> SqliteResult<Vec<TimelineEntryTarget>> {
524 let rows = sqlx::query_as::<_, TimelineEntryTargetRow>(
525 r#"
526 SELECT
527 s.subscriber_id,
528 e.entry_id,
529 e.order_time AS entry_order_time,
530 te.entry_id IS NOT NULL AS entry_exists,
531 COALESCE(te.deleted, 0) != 0 AS entry_deleted
532 FROM feed_subscription AS s
533 INNER JOIN feed AS f
534 ON f.pk = s.feed_pk
535 INNER JOIN feed_entry AS fe
536 ON fe.feed_pk = f.pk
537 INNER JOIN entry AS e
538 ON e.feed_pk = fe.feed_pk
539 AND e.entry_id = fe.entry_id
540 LEFT JOIN timeline_entry AS te
541 ON te.subscriber_id = s.subscriber_id
542 AND te.entry_id = e.entry_id
543 WHERE f.url = ?
544 AND e.entry_id = ?
545 ORDER BY s.subscriber_id
546 "#,
547 )
548 .bind(feed_url.as_str())
549 .bind(entry_id.as_str())
550 .fetch_all(&mut **tx)
551 .await?;
552
553 Ok(rows.into_iter().map(TimelineEntryTarget::from).collect())
554}
555
556async fn insert_timeline_entry(
557 tx: &mut Transaction<'_, Sqlite>,
558 target: &TimelineEntryTarget,
559 seq: i64,
560) -> SqliteResult<()> {
561 sqlx::query(
562 r#"
563 INSERT INTO timeline_entry (
564 subscriber_id,
565 entry_id,
566 order_time,
567 seq
568 )
569 VALUES (?, ?, ?, ?)
570 "#,
571 )
572 .bind(target.subscriber_id.as_str())
573 .bind(&target.entry_id)
574 .bind(target.entry_order_time)
575 .bind(seq)
576 .execute(&mut **tx)
577 .await?;
578 Ok(())
579}
580
581async fn bump_timeline_entry(
582 tx: &mut Transaction<'_, Sqlite>,
583 target: &TimelineEntryTarget,
584 seq: i64,
585) -> SqliteResult<()> {
586 sqlx::query(
587 r#"
588 UPDATE timeline_entry
589 SET
590 seq = ?,
591 deleted = 0
592 WHERE subscriber_id = ?
593 AND entry_id = ?
594 "#,
595 )
596 .bind(seq)
597 .bind(target.subscriber_id.as_str())
598 .bind(&target.entry_id)
599 .execute(&mut **tx)
600 .await?;
601 Ok(())
602}
603
604struct TimelineEntryTarget {
605 subscriber_id: SubscriberId,
606 entry_id: String,
607 entry_order_time: DateTime<Utc>,
608 existing: Option<ExistingTimelineEntry>,
609}
610
611struct ExistingTimelineEntry {
612 deleted: bool,
613}
614
615#[derive(sqlx::FromRow)]
616struct TimelineEntryTargetRow {
617 subscriber_id: String,
618 entry_id: String,
619 entry_order_time: DateTime<Utc>,
620 entry_exists: bool,
621 entry_deleted: bool,
622}
623
624impl From<TimelineEntryTargetRow> for TimelineEntryTarget {
625 fn from(row: TimelineEntryTargetRow) -> Self {
626 Self {
627 subscriber_id: SubscriberId::new(row.subscriber_id),
628 entry_id: row.entry_id,
629 entry_order_time: row.entry_order_time,
630 existing: row.entry_exists.then_some(ExistingTimelineEntry {
631 deleted: row.entry_deleted,
632 }),
633 }
634 }
635}
636
637#[derive(sqlx::FromRow)]
638struct TimelineChangeRow {
639 #[sqlx(flatten)]
640 entry: TimelineEntryColumns,
641 seq: i64,
642 removed: bool,
643 meta_json: Option<String>,
644}
645
646impl TryFrom<TimelineChangeRow> for TimelineChange {
647 type Error = SqliteError;
648
649 fn try_from(row: TimelineChangeRow) -> Result<Self, Self::Error> {
650 if row.removed {
651 let entry_id = EntryId::parse(row.entry.entry_id).decode()?;
652 return Ok(TimelineChange::Remove { entry_id });
653 }
654
655 let meta_json = row.meta_json.ok_or_else(|| {
656 SqliteError::decode_message("timeline change row without feed snapshot")
657 })?;
658 let entry = TimelineEntry::try_from(TimelineEntryRow {
659 entry: row.entry,
660 meta_json,
661 })?;
662 Ok(TimelineChange::Upsert(Box::new(entry)))
663 }
664}
665
666#[derive(sqlx::FromRow)]
667struct TimelineEntryRow {
668 #[sqlx(flatten)]
669 entry: TimelineEntryColumns,
670 meta_json: String,
671}
672
673#[derive(sqlx::FromRow)]
674struct TimelineEntryColumns {
675 order_time: DateTime<Utc>,
676 entry_id: String,
677 entry_json: String,
678 feed_url: String,
679 requirement: Option<String>,
680 category: Option<String>,
681}
682
683impl TryFrom<TimelineEntryRow> for TimelineEntry {
684 type Error = SqliteError;
685
686 fn try_from(row: TimelineEntryRow) -> Result<Self, Self::Error> {
687 let entry = decode_stored_entry(&row.entry.entry_id, &row.entry.entry_json)?;
688 let feed_meta = decode_stored_feed_meta(&row.entry.feed_url, &row.meta_json)?;
689 let requirement = row
690 .entry
691 .requirement
692 .as_deref()
693 .map(Requirement::from_str)
694 .transpose()
695 .decode()?;
696 let category = row.entry.category.map(Category::new).transpose().decode()?;
697 let cursor = TimelineEntryCursor::new(row.entry.order_time, entry.id().clone());
698 let feed_meta = Annotated {
699 feed: feed_meta,
700 requirement,
701 category,
702 };
703
704 Ok(TimelineEntry {
705 entry,
706 feed_meta,
707 cursor,
708 })
709 }
710}
711
712impl TimelineDb for super::SqliteRegistryTx<'_> {
713 async fn list_timeline_entries(
714 &mut self,
715 query: TimelineEntriesQuery,
716 ) -> RegistryDbResult<TimelineEntriesPage> {
717 list_entries(&mut self.tx, query).await.db()
718 }
719
720 async fn list_timeline_changes(
721 &mut self,
722 query: TimelineChangesQuery,
723 ) -> RegistryDbResult<TimelineChangesPage> {
724 list_changes(&mut self.tx, query).await.db()
725 }
726
727 async fn catchup_subscribed_feed(
728 &mut self,
729 subscriber_id: &SubscriberId,
730 feed_url: &FeedUrl,
731 ) -> RegistryDbResult<TimelineCatchup> {
732 ensure_timeline(&mut self.tx, subscriber_id).await.db()?;
733 catchup_feed(&mut self.tx, subscriber_id, feed_url)
734 .await
735 .db()
736 }
737
738 async fn apply_entry_to_timelines(
739 &mut self,
740 feed_url: &FeedUrl,
741 entry_id: &EntryId,
742 content_changed: bool,
743 ) -> RegistryDbResult<Vec<SubscriberId>> {
744 apply_entry_to_timelines(&mut self.tx, feed_url, entry_id, content_changed)
745 .await
746 .db()
747 }
748
749 async fn apply_feed_unsubscribed(
750 &mut self,
751 subscription: &SubscriptionKey,
752 ) -> RegistryDbResult<Option<SubscriberId>> {
753 apply_feed_unsubscribed(&mut self.tx, subscription)
754 .await
755 .db()
756 }
757}
758
759#[cfg(test)]
760mod tests;