Homebrew RSS reader server
1use anyhow::{Context, Result};
2use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
3use sqlx::SqlitePool;
4use std::collections::HashMap;
5use std::str::FromStr;
6
7use crate::config::Config;
8
9pub async fn init_pool(db_path: &str) -> Result<SqlitePool> {
10 let options = SqliteConnectOptions::from_str(&format!("sqlite:{db_path}"))?
11 .create_if_missing(true)
12 .journal_mode(SqliteJournalMode::Wal)
13 .foreign_keys(true);
14
15 let pool = SqlitePoolOptions::new()
16 .max_connections(5)
17 .connect_with(options)
18 .await
19 .context("connecting to database")?;
20
21 sqlx::migrate!("./migrations")
22 .run(&pool)
23 .await
24 .context("running migrations")?;
25
26 Ok(pool)
27}
28
29/// One-time legacy bootstrap: if the DB has zero categories/feeds and the config
30/// contains legacy [[groups]]/[[feeds]] sections, import them. Never deletes.
31pub async fn bootstrap_from_legacy_config_if_empty(pool: &SqlitePool, config: &Config) -> Result<()> {
32 if config.groups.is_empty() && config.feeds.is_empty() {
33 return Ok(());
34 }
35
36 let (group_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM groups")
37 .fetch_one(pool)
38 .await?;
39 let (feed_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM feeds")
40 .fetch_one(pool)
41 .await?;
42
43 if group_count > 0 || feed_count > 0 {
44 tracing::info!("DB already has data, skipping legacy config bootstrap");
45 return Ok(());
46 }
47
48 tracing::info!("DB empty, bootstrapping from legacy config sections");
49
50 for group in &config.groups {
51 sqlx::query("INSERT OR IGNORE INTO groups (name) VALUES (?)")
52 .bind(&group.name)
53 .execute(pool)
54 .await?;
55 }
56
57 for feed in &config.feeds {
58 let group_id: (i64,) = sqlx::query_as("SELECT id FROM groups WHERE name = ?")
59 .bind(&feed.group)
60 .fetch_one(pool)
61 .await?;
62
63 sqlx::query("INSERT OR IGNORE INTO feeds (url, group_id) VALUES (?, ?)")
64 .bind(&feed.url)
65 .bind(group_id.0)
66 .execute(pool)
67 .await?;
68 }
69
70 Ok(())
71}
72
73// ---------------------------------------------------------------------------
74// Categories (groups table)
75// ---------------------------------------------------------------------------
76
77#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
78pub struct Category {
79 pub id: i64,
80 #[sqlx(rename = "name")]
81 pub title: String,
82}
83
84#[derive(Debug, serde::Serialize)]
85pub struct CategoryWithCounts {
86 pub id: i64,
87 pub title: String,
88 pub feed_count: i64,
89 pub total_unread: i64,
90}
91
92pub async fn get_categories(pool: &SqlitePool) -> Result<Vec<Category>> {
93 let rows = sqlx::query_as::<_, Category>("SELECT id, name FROM groups ORDER BY name")
94 .fetch_all(pool)
95 .await?;
96 Ok(rows)
97}
98
99pub async fn get_categories_with_counts(pool: &SqlitePool) -> Result<Vec<CategoryWithCounts>> {
100 let rows: Vec<(i64, String, i64, i64)> = sqlx::query_as(
101 "SELECT g.id, g.name, \
102 (SELECT COUNT(*) FROM feeds WHERE group_id = g.id) AS feed_count, \
103 (SELECT COUNT(*) FROM items i JOIN feeds f ON i.feed_id = f.id WHERE f.group_id = g.id AND i.is_read = 0) AS total_unread \
104 FROM groups g ORDER BY g.name",
105 )
106 .fetch_all(pool)
107 .await?;
108 Ok(rows
109 .into_iter()
110 .map(|(id, title, feed_count, total_unread)| CategoryWithCounts {
111 id,
112 title,
113 feed_count,
114 total_unread,
115 })
116 .collect())
117}
118
119pub async fn get_category(pool: &SqlitePool, id: i64) -> Result<Option<Category>> {
120 let row = sqlx::query_as::<_, Category>("SELECT id, name FROM groups WHERE id = ?")
121 .bind(id)
122 .fetch_optional(pool)
123 .await?;
124 Ok(row)
125}
126
127pub async fn create_category(pool: &SqlitePool, title: &str) -> Result<Category> {
128 let result = sqlx::query("INSERT INTO groups (name) VALUES (?)")
129 .bind(title)
130 .execute(pool)
131 .await?;
132 Ok(Category {
133 id: result.last_insert_rowid(),
134 title: title.to_string(),
135 })
136}
137
138pub async fn update_category(pool: &SqlitePool, id: i64, title: &str) -> Result<bool> {
139 let result = sqlx::query("UPDATE groups SET name = ? WHERE id = ?")
140 .bind(title)
141 .bind(id)
142 .execute(pool)
143 .await?;
144 Ok(result.rows_affected() > 0)
145}
146
147pub async fn delete_category(pool: &SqlitePool, id: i64) -> Result<bool> {
148 let result = sqlx::query("DELETE FROM groups WHERE id = ?")
149 .bind(id)
150 .execute(pool)
151 .await?;
152 Ok(result.rows_affected() > 0)
153}
154
155pub async fn mark_all_category_entries_read(pool: &SqlitePool, category_id: i64) -> Result<()> {
156 sqlx::query(
157 "UPDATE items SET is_read = 1, changed_at = unixepoch() \
158 WHERE is_read = 0 AND feed_id IN (SELECT id FROM feeds WHERE group_id = ?)",
159 )
160 .bind(category_id)
161 .execute(pool)
162 .await?;
163 Ok(())
164}
165
166// ---------------------------------------------------------------------------
167// Feeds
168// ---------------------------------------------------------------------------
169
170#[allow(dead_code)]
171#[derive(Debug, Clone, sqlx::FromRow)]
172pub struct FeedRow {
173 pub id: i64,
174 pub url: String,
175 pub title: String,
176 pub site_url: String,
177 pub favicon_id: Option<i64>,
178 pub group_id: i64,
179 pub last_fetched_at: Option<i64>,
180}
181
182/// Full feed info for API responses.
183#[allow(dead_code)]
184#[derive(Debug, Clone, sqlx::FromRow)]
185pub struct FeedWithCategory {
186 pub id: i64,
187 pub url: String,
188 pub title: String,
189 pub site_url: String,
190 pub favicon_id: Option<i64>,
191 pub group_id: i64,
192 pub last_fetched_at: Option<i64>,
193 pub category_id: i64,
194 pub category_title: String,
195}
196
197pub async fn get_feeds_with_categories(pool: &SqlitePool) -> Result<Vec<FeedWithCategory>> {
198 let rows = sqlx::query_as::<_, FeedWithCategory>(
199 "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \
200 g.id AS category_id, g.name AS category_title \
201 FROM feeds f JOIN groups g ON f.group_id = g.id ORDER BY f.title",
202 )
203 .fetch_all(pool)
204 .await?;
205 Ok(rows)
206}
207
208pub async fn get_feed_with_category(pool: &SqlitePool, id: i64) -> Result<Option<FeedWithCategory>> {
209 let row = sqlx::query_as::<_, FeedWithCategory>(
210 "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \
211 g.id AS category_id, g.name AS category_title \
212 FROM feeds f JOIN groups g ON f.group_id = g.id WHERE f.id = ?",
213 )
214 .bind(id)
215 .fetch_optional(pool)
216 .await?;
217 Ok(row)
218}
219
220pub async fn get_category_feeds(pool: &SqlitePool, category_id: i64) -> Result<Vec<FeedWithCategory>> {
221 let rows = sqlx::query_as::<_, FeedWithCategory>(
222 "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \
223 g.id AS category_id, g.name AS category_title \
224 FROM feeds f JOIN groups g ON f.group_id = g.id WHERE f.group_id = ? ORDER BY f.title",
225 )
226 .bind(category_id)
227 .fetch_all(pool)
228 .await?;
229 Ok(rows)
230}
231
232pub async fn create_feed(pool: &SqlitePool, feed_url: &str, category_id: i64) -> Result<i64> {
233 let result = sqlx::query("INSERT INTO feeds (url, group_id) VALUES (?, ?)")
234 .bind(feed_url)
235 .bind(category_id)
236 .execute(pool)
237 .await?;
238 Ok(result.last_insert_rowid())
239}
240
241pub async fn update_feed(
242 pool: &SqlitePool,
243 id: i64,
244 title: Option<&str>,
245 feed_url: Option<&str>,
246 site_url: Option<&str>,
247 category_id: Option<i64>,
248) -> Result<bool> {
249 // Build SET clauses dynamically
250 let mut sets = Vec::new();
251 if title.is_some() {
252 sets.push("title = ?");
253 }
254 if feed_url.is_some() {
255 sets.push("url = ?");
256 }
257 if site_url.is_some() {
258 sets.push("site_url = ?");
259 }
260 if category_id.is_some() {
261 sets.push("group_id = ?");
262 }
263 if sets.is_empty() {
264 return Ok(false);
265 }
266 let sql = format!("UPDATE feeds SET {} WHERE id = ?", sets.join(", "));
267 let mut query = sqlx::query(&sql);
268 if let Some(v) = title {
269 query = query.bind(v);
270 }
271 if let Some(v) = feed_url {
272 query = query.bind(v);
273 }
274 if let Some(v) = site_url {
275 query = query.bind(v);
276 }
277 if let Some(v) = category_id {
278 query = query.bind(v);
279 }
280 query = query.bind(id);
281 let result = query.execute(pool).await?;
282 Ok(result.rows_affected() > 0)
283}
284
285pub async fn delete_feed(pool: &SqlitePool, id: i64) -> Result<bool> {
286 let result = sqlx::query("DELETE FROM feeds WHERE id = ?")
287 .bind(id)
288 .execute(pool)
289 .await?;
290 Ok(result.rows_affected() > 0)
291}
292
293pub async fn mark_all_feed_entries_read(pool: &SqlitePool, feed_id: i64) -> Result<()> {
294 sqlx::query(
295 "UPDATE items SET is_read = 1, changed_at = unixepoch() WHERE is_read = 0 AND feed_id = ?",
296 )
297 .bind(feed_id)
298 .execute(pool)
299 .await?;
300 Ok(())
301}
302
303pub async fn get_feed_counters(pool: &SqlitePool) -> Result<(HashMap<i64, i64>, HashMap<i64, i64>)> {
304 let rows: Vec<(i64, i64, i64)> = sqlx::query_as(
305 "SELECT f.id, \
306 COALESCE(SUM(CASE WHEN i.is_read = 1 THEN 1 ELSE 0 END), 0), \
307 COALESCE(SUM(CASE WHEN i.is_read = 0 THEN 1 ELSE 0 END), 0) \
308 FROM feeds f LEFT JOIN items i ON i.feed_id = f.id GROUP BY f.id",
309 )
310 .fetch_all(pool)
311 .await?;
312
313 let mut reads = HashMap::new();
314 let mut unreads = HashMap::new();
315 for (feed_id, read_count, unread_count) in rows {
316 reads.insert(feed_id, read_count);
317 unreads.insert(feed_id, unread_count);
318 }
319 Ok((reads, unreads))
320}
321
322// ---------------------------------------------------------------------------
323// Entries (items table)
324// ---------------------------------------------------------------------------
325
326#[derive(Debug, Clone, sqlx::FromRow)]
327pub struct EntryRow {
328 pub id: i64,
329 pub feed_id: i64,
330 pub title: String,
331 pub author: String,
332 pub content: String,
333 pub url: String,
334 pub is_read: i32,
335 pub is_starred: i32,
336 pub published_at: Option<i64>,
337 pub created_at: i64,
338 pub changed_at: i64,
339 // join fields
340 pub feed_title: String,
341 pub feed_url: String,
342 pub feed_site_url: String,
343 pub category_id: i64,
344 pub category_title: String,
345}
346
347/// Filter/pagination parameters for entry queries.
348#[derive(Debug, Default)]
349pub struct EntryFilter {
350 pub status: Option<String>, // "read", "unread", "removed"
351 pub feed_id: Option<i64>,
352 pub category_id: Option<i64>,
353 pub starred: Option<bool>,
354 pub search: Option<String>,
355 pub after: Option<i64>, // unix timestamp (changed_at)
356 pub after_entry_id: Option<i64>,
357 pub before: Option<i64>, // unix timestamp (changed_at)
358 pub before_entry_id: Option<i64>,
359 pub limit: Option<i64>,
360 pub offset: Option<i64>,
361 pub order: Option<String>, // "id", "status", "published_at", "created_at", "category_title", "category_id"
362 pub direction: Option<String>, // "asc", "desc"
363}
364
365pub async fn get_entry(pool: &SqlitePool, id: i64) -> Result<Option<EntryRow>> {
366 let row = sqlx::query_as::<_, EntryRow>(
367 "SELECT i.id, i.feed_id, i.title, i.author, i.content, i.url, i.is_read, i.is_starred, \
368 i.published_at, i.created_at, i.changed_at, \
369 f.title AS feed_title, f.url AS feed_url, f.site_url AS feed_site_url, \
370 g.id AS category_id, g.name AS category_title \
371 FROM items i \
372 JOIN feeds f ON i.feed_id = f.id \
373 JOIN groups g ON f.group_id = g.id \
374 WHERE i.id = ?",
375 )
376 .bind(id)
377 .fetch_optional(pool)
378 .await?;
379 Ok(row)
380}
381
382pub async fn get_entries_filtered(pool: &SqlitePool, filter: &EntryFilter) -> Result<(i64, Vec<EntryRow>)> {
383 let base_from = "FROM items i \
384 JOIN feeds f ON i.feed_id = f.id \
385 JOIN groups g ON f.group_id = g.id";
386
387 let mut where_parts: Vec<String> = Vec::new();
388
389 if let Some(ref status) = filter.status {
390 match status.as_str() {
391 "read" => where_parts.push("i.is_read = 1".to_string()),
392 "unread" => where_parts.push("i.is_read = 0".to_string()),
393 _ => {} // "removed" not supported, ignore
394 }
395 }
396 if let Some(feed_id) = filter.feed_id {
397 where_parts.push(format!("i.feed_id = {feed_id}"));
398 }
399 if let Some(category_id) = filter.category_id {
400 where_parts.push(format!("f.group_id = {category_id}"));
401 }
402 if let Some(starred) = filter.starred {
403 where_parts.push(format!("i.is_starred = {}", if starred { 1 } else { 0 }));
404 }
405 if let Some(after) = filter.after {
406 where_parts.push(format!("i.changed_at > {after}"));
407 }
408 if let Some(id) = filter.after_entry_id {
409 where_parts.push(format!("i.id > {id}"));
410 }
411 if let Some(before) = filter.before {
412 where_parts.push(format!("i.changed_at < {before}"));
413 }
414 if let Some(id) = filter.before_entry_id {
415 where_parts.push(format!("i.id < {id}"));
416 }
417
418 let where_clause = if where_parts.is_empty() {
419 String::new()
420 } else {
421 format!(" WHERE {}", where_parts.join(" AND "))
422 };
423
424 // Count query
425 let count_sql = format!("SELECT COUNT(*) {base_from}{where_clause}");
426 let total: (i64,) = if let Some(ref search) = filter.search {
427 let search_clause = if where_clause.is_empty() {
428 " WHERE (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')"
429 } else {
430 " AND (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')"
431 };
432 let sql = format!("SELECT COUNT(*) {base_from}{where_clause}{search_clause}");
433 sqlx::query_as(&sql)
434 .bind(search)
435 .bind(search)
436 .fetch_one(pool)
437 .await?
438 } else {
439 sqlx::query_as(&count_sql).fetch_one(pool).await?
440 };
441
442 // Order
443 let order_col = match filter.order.as_deref() {
444 Some("status") => "i.is_read",
445 Some("published_at") => "COALESCE(i.published_at, i.created_at)",
446 Some("created_at") => "i.created_at",
447 Some("category_title") => "category_title",
448 Some("category_id") => "g.id",
449 _ => "i.id",
450 };
451 let direction = match filter.direction.as_deref() {
452 Some("asc") => "ASC",
453 _ => "DESC",
454 };
455
456 let limit = filter.limit.unwrap_or(100).min(500).max(1);
457 let offset = filter.offset.unwrap_or(0).max(0);
458
459 let select = "SELECT i.id, i.feed_id, i.title, i.author, i.content, i.url, i.is_read, i.is_starred, \
460 i.published_at, i.created_at, i.changed_at, \
461 f.title AS feed_title, f.url AS feed_url, f.site_url AS feed_site_url, \
462 g.id AS category_id, g.name AS category_title";
463
464 let entries_sql = if let Some(ref _search) = filter.search {
465 let search_clause = if where_clause.is_empty() {
466 " WHERE (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')"
467 } else {
468 " AND (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')"
469 };
470 format!(
471 "{select} {base_from}{where_clause}{search_clause} ORDER BY {order_col} {direction} LIMIT {limit} OFFSET {offset}"
472 )
473 } else {
474 format!(
475 "{select} {base_from}{where_clause} ORDER BY {order_col} {direction} LIMIT {limit} OFFSET {offset}"
476 )
477 };
478
479 let entries: Vec<EntryRow> = if let Some(ref search) = filter.search {
480 sqlx::query_as::<_, EntryRow>(&entries_sql)
481 .bind(search)
482 .bind(search)
483 .fetch_all(pool)
484 .await?
485 } else {
486 sqlx::query_as::<_, EntryRow>(&entries_sql)
487 .fetch_all(pool)
488 .await?
489 };
490
491 Ok((total.0, entries))
492}
493
494pub async fn update_entries_status(pool: &SqlitePool, entry_ids: &[i64], status: &str) -> Result<()> {
495 if entry_ids.is_empty() {
496 return Ok(());
497 }
498 let is_read = match status {
499 "read" => 1,
500 "unread" => 0,
501 _ => return Ok(()),
502 };
503 let placeholders: Vec<String> = entry_ids.iter().map(|_| "?".to_string()).collect();
504 let sql = format!(
505 "UPDATE items SET is_read = {is_read}, changed_at = unixepoch() WHERE id IN ({})",
506 placeholders.join(",")
507 );
508 let mut query = sqlx::query(&sql);
509 for id in entry_ids {
510 query = query.bind(id);
511 }
512 query.execute(pool).await?;
513 Ok(())
514}
515
516pub async fn toggle_entry_starred(pool: &SqlitePool, id: i64) -> Result<bool> {
517 // Returns the new starred state
518 let result = sqlx::query(
519 "UPDATE items SET is_starred = 1 - is_starred, changed_at = unixepoch() WHERE id = ?",
520 )
521 .bind(id)
522 .execute(pool)
523 .await?;
524 if result.rows_affected() == 0 {
525 return Ok(false);
526 }
527 // Fetch new state
528 let row: Option<(i32,)> = sqlx::query_as("SELECT is_starred FROM items WHERE id = ?")
529 .bind(id)
530 .fetch_optional(pool)
531 .await?;
532 Ok(row.map(|r| r.0 == 1).unwrap_or(false))
533}
534
535// ---------------------------------------------------------------------------
536// Icons (favicons table)
537// ---------------------------------------------------------------------------
538
539#[derive(Debug, sqlx::FromRow)]
540pub struct IconRow {
541 pub id: i64,
542 pub data: String,
543}
544
545pub async fn get_icon_by_id(pool: &SqlitePool, id: i64) -> Result<Option<IconRow>> {
546 let row = sqlx::query_as::<_, IconRow>("SELECT id, data FROM favicons WHERE id = ?")
547 .bind(id)
548 .fetch_optional(pool)
549 .await?;
550 Ok(row)
551}
552
553pub async fn get_icon_by_feed_id(pool: &SqlitePool, feed_id: i64) -> Result<Option<IconRow>> {
554 let row = sqlx::query_as::<_, IconRow>(
555 "SELECT fav.id, fav.data FROM favicons fav \
556 JOIN feeds f ON f.favicon_id = fav.id WHERE f.id = ?",
557 )
558 .bind(feed_id)
559 .fetch_optional(pool)
560 .await?;
561 Ok(row)
562}
563
564// ---------------------------------------------------------------------------
565// Fetcher queries (kept from original)
566// ---------------------------------------------------------------------------
567
568#[derive(sqlx::FromRow)]
569pub struct FetcherFeed {
570 pub id: i64,
571 pub url: String,
572 pub favicon_id: Option<i64>,
573}
574
575pub async fn get_all_feeds(pool: &SqlitePool) -> Result<Vec<FetcherFeed>> {
576 let feeds = sqlx::query_as::<_, FetcherFeed>("SELECT id, url, favicon_id FROM feeds")
577 .fetch_all(pool)
578 .await?;
579 Ok(feeds)
580}
581
582pub async fn get_single_feed_for_fetch(pool: &SqlitePool, feed_id: i64) -> Result<Option<FetcherFeed>> {
583 let feed = sqlx::query_as::<_, FetcherFeed>("SELECT id, url, favicon_id FROM feeds WHERE id = ?")
584 .bind(feed_id)
585 .fetch_optional(pool)
586 .await?;
587 Ok(feed)
588}
589
590pub async fn upsert_feed_metadata(
591 pool: &SqlitePool,
592 feed_id: i64,
593 title: &str,
594 site_url: &str,
595) -> Result<()> {
596 sqlx::query(
597 "UPDATE feeds SET title = ?, site_url = ?, last_fetched_at = unixepoch() WHERE id = ?",
598 )
599 .bind(title)
600 .bind(site_url)
601 .bind(feed_id)
602 .execute(pool)
603 .await?;
604 Ok(())
605}
606
607pub async fn insert_favicon(pool: &SqlitePool, data: &str) -> Result<i64> {
608 let result = sqlx::query("INSERT INTO favicons (data) VALUES (?)")
609 .bind(data)
610 .execute(pool)
611 .await?;
612 Ok(result.last_insert_rowid())
613}
614
615pub async fn set_feed_favicon(pool: &SqlitePool, feed_id: i64, favicon_id: i64) -> Result<()> {
616 sqlx::query("UPDATE feeds SET favicon_id = ? WHERE id = ?")
617 .bind(favicon_id)
618 .bind(feed_id)
619 .execute(pool)
620 .await?;
621 Ok(())
622}
623
624#[allow(clippy::too_many_arguments)]
625pub async fn insert_item(
626 pool: &SqlitePool,
627 feed_id: i64,
628 guid: &str,
629 title: &str,
630 author: &str,
631 url: &str,
632 content: &str,
633 published_at: Option<i64>,
634) -> Result<()> {
635 sqlx::query(
636 "INSERT OR IGNORE INTO items (feed_id, guid, title, author, url, content, published_at) \
637 VALUES (?, ?, ?, ?, ?, ?, ?)",
638 )
639 .bind(feed_id)
640 .bind(guid)
641 .bind(title)
642 .bind(author)
643 .bind(url)
644 .bind(content)
645 .bind(published_at)
646 .execute(pool)
647 .await?;
648 Ok(())
649}
650
651// ---------------------------------------------------------------------------
652// OPML export
653// ---------------------------------------------------------------------------
654
655#[derive(Debug, sqlx::FromRow)]
656pub struct FeedExportRow {
657 pub feed_title: String,
658 pub feed_url: String,
659 pub site_url: String,
660 pub category_name: String,
661}
662
663pub async fn get_feeds_for_export(pool: &SqlitePool) -> Result<Vec<FeedExportRow>> {
664 let rows = sqlx::query_as::<_, FeedExportRow>(
665 "SELECT f.title AS feed_title, f.url AS feed_url, f.site_url, g.name AS category_name \
666 FROM feeds f JOIN groups g ON f.group_id = g.id ORDER BY g.name, f.title",
667 )
668 .fetch_all(pool)
669 .await?;
670 Ok(rows)
671}
672
673// ---------------------------------------------------------------------------
674// Feed existence check
675// ---------------------------------------------------------------------------
676
677pub async fn feed_exists(pool: &SqlitePool, id: i64) -> Result<bool> {
678 let row: Option<(i64,)> = sqlx::query_as("SELECT id FROM feeds WHERE id = ?")
679 .bind(id)
680 .fetch_optional(pool)
681 .await?;
682 Ok(row.is_some())
683}