Skip to main content

synd_persistence/sqlite/feed_registry/timeline/
mod.rs

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
74/// Current feed members that may need to be added to one subscriber timeline.
75struct 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        // Every row gets its own seq: change pagination pages by seq alone, so
112        // rows sharing a seq would be lost at page boundaries. Candidates left
113        // untouched leave gaps, which is fine because seq only needs to be
114        // unique and increasing.
115        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        // Insert missing entries and revive tombstoned ones(resubscribe).
122        // Live rows are left untouched so they emit no sync change.
123        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            // order_time is frozen; the seq bump only tells syncing clients
179            // to re-read the entry content or that a tombstone was revived
180            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    // Removal keeps the row as a tombstone so syncing clients observe it
226    // through the seq bump. Every row gets its own seq: change pagination
227    // pages by seq alone, so rows sharing a seq would be lost at page
228    // boundaries
229    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
274/// Overfetched timeline entries paired with the snapshot sequence they represent.
275struct 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
346/// Overfetched timeline change rows paired with their sequence window.
347struct 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    // Tombstoned entries have lost their subscription, so subscription and
403    // snapshot columns are joined optionally and their absence also means
404    // removal
405    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
466/// Takes the next change seq of one timeline.
467async 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
474/// Allocates `count` change seqs of one timeline in one contiguous range.
475/// The allocated seqs are `base + 1 ..= base + count` where `base` is the
476/// returned value.
477async 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
497/// Ensures every subscriber of the feed has a timeline row to allocate
498/// seqs from.
499async 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;