Skip to main content

max / balanced_breakfast

18.0 KB · 545 lines History Blame Raw
1 //! Database models for the SQLite persistence layer.
2 //!
3 //! Each struct maps 1:1 to a table. Helper methods handle serialization
4 //! quirks (JSON columns, timestamp formats).
5
6 use chrono::{DateTime, Utc};
7 use serde::{Deserialize, Serialize};
8 use sqlx::FromRow;
9
10 use crate::id_types::{BookmarkId, BusserId, BusserStateId, FeedId, ItemId, QueryFeedId};
11 use crate::TIMESTAMP_FMT;
12
13 /// Parse a value or log a warning and return the default.
14 /// Used for data that we wrote ourselves (JSON) — parse failures
15 /// indicate DB corruption or a write bug, both extremely rare.
16 fn parse_or_default<T: Default>(result: Result<T, impl std::fmt::Display>, context: &str) -> T {
17 match result {
18 Ok(v) => v,
19 Err(e) => {
20 tracing::warn!(error = %e, context, "Parse failed, using default");
21 T::default()
22 }
23 }
24 }
25
26 #[tracing::instrument(skip_all)]
27 /// Parse a timestamp string from SQLite, falling back to UNIX_EPOCH on failure
28 pub fn parse_timestamp(s: &str) -> DateTime<Utc> {
29 s.parse::<DateTime<Utc>>()
30 .or_else(|_| {
31 chrono::NaiveDateTime::parse_from_str(s, TIMESTAMP_FMT).map(|ndt| ndt.and_utc())
32 })
33 .unwrap_or(DateTime::UNIX_EPOCH)
34 }
35
36 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
37 /// Registered feed/busser source stored in the `feeds` table
38 pub struct DbFeed {
39 /// Internal UUID.
40 pub id: FeedId,
41 /// Plugin/busser identifier this feed belongs to.
42 pub busser_id: BusserId,
43 /// Human-readable feed name.
44 pub name: String,
45 /// JSON-encoded plugin configuration for this feed.
46 pub config: String,
47 /// Whether auto-fetching is enabled for this feed.
48 pub enabled: bool,
49 /// Timestamp of the last successful fetch, if any.
50 pub last_fetch: Option<String>,
51 /// Number of consecutive fetch failures (0 = healthy).
52 pub consecutive_failures: i64,
53 /// Error message from the last failed fetch, if any.
54 pub last_error: Option<String>,
55 /// Timestamp of the last successful fetch, if any.
56 pub last_success_at: Option<String>,
57 /// Whether this feed has been auto-disabled by the circuit breaker.
58 pub circuit_broken: bool,
59 /// Row creation timestamp.
60 pub created_at: String,
61 /// Row last-modified timestamp.
62 pub updated_at: String,
63 }
64
65 impl DbFeed {
66 /// Deserialize the JSON config column into a `serde_json::Value`.
67 #[tracing::instrument(skip_all)]
68 pub fn config_json(&self) -> serde_json::Value {
69 parse_or_default(
70 serde_json::from_str(&self.config),
71 "Failed to parse feed config JSON",
72 )
73 }
74 }
75
76 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
77 /// Feed item stored in the `feed_items` table
78 pub struct DbFeedItem {
79 /// Internal UUID.
80 pub id: ItemId,
81 /// Busser-provided unique key (format: `busser_id:item_id`).
82 pub external_id: String,
83 /// Foreign key to the parent `feeds` row.
84 pub feed_id: FeedId,
85 /// Plugin/busser that produced this item.
86 pub busser_id: BusserId,
87
88 /// Author attribution for the compact bite view.
89 pub bite_author: String,
90 /// Primary text shown in the bite view.
91 pub bite_text: String,
92 /// Secondary info (score, retweet count, etc.) for the bite view.
93 pub bite_secondary: Option<String>,
94 /// Type indicator (emoji/icon) for the bite view.
95 pub bite_indicator: Option<String>,
96
97 /// Full article/post title.
98 pub title: Option<String>,
99 /// Full body/content text.
100 pub body: Option<String>,
101 /// URL to the original content.
102 pub url: Option<String>,
103 /// JSON-encoded list of media attachment URLs.
104 pub media: String,
105 /// JSON-encoded list of plugin-declared custom actions.
106 pub actions: String,
107
108 /// When the item was originally published (SQLite timestamp text).
109 pub published_at: String,
110 /// When we fetched this item (SQLite timestamp text).
111 pub fetched_at: String,
112 /// Human-readable source name for display.
113 pub source_name: String,
114 /// Engagement score (likes, upvotes, etc.).
115 pub score: Option<i64>,
116 /// JSON-encoded list of tags/categories.
117 pub tags: String,
118
119 /// Whether the user has read this item.
120 pub is_read: bool,
121 /// Whether the user has starred this item.
122 pub is_starred: bool,
123
124 /// Row creation timestamp.
125 pub created_at: String,
126 /// Row last-modified timestamp.
127 pub updated_at: String,
128 }
129
130 impl DbFeedItem {
131 /// Parse `published_at` into a `DateTime<Utc>`.
132 #[tracing::instrument(skip_all)]
133 pub fn published_at_dt(&self) -> DateTime<Utc> {
134 parse_timestamp(&self.published_at)
135 }
136
137 /// Parse `fetched_at` into a `DateTime<Utc>`.
138 #[tracing::instrument(skip_all)]
139 pub fn fetched_at_dt(&self) -> DateTime<Utc> {
140 parse_timestamp(&self.fetched_at)
141 }
142
143 /// Deserialize the JSON `media` column into a `Vec<String>`.
144 #[tracing::instrument(skip_all)]
145 pub fn media_vec(&self) -> Vec<String> {
146 parse_or_default(
147 serde_json::from_str(&self.media),
148 "Failed to parse feed item media JSON",
149 )
150 }
151
152 /// Deserialize the JSON `tags` column into a `Vec<String>`.
153 #[tracing::instrument(skip_all)]
154 pub fn tags_vec(&self) -> Vec<String> {
155 parse_or_default(
156 serde_json::from_str(&self.tags),
157 "Failed to parse feed item tags JSON",
158 )
159 }
160
161 /// Deserialize the JSON `actions` column into a `Vec<ItemAction>`.
162 #[tracing::instrument(skip_all)]
163 pub fn actions_vec(&self) -> Vec<bb_interface::ItemAction> {
164 parse_or_default(
165 serde_json::from_str(&self.actions),
166 "Failed to parse feed item actions JSON",
167 )
168 }
169
170 /// Reconstruct a full [`FeedItem`](bb_interface::FeedItem) from the flat DB row.
171 ///
172 /// The interface type splits an item into three parts:
173 /// 1. **Bite** — compact list-view display (author, text, secondary, indicator)
174 /// 2. **Content** — full article (title, body, url, media)
175 /// 3. **Meta** — metadata (source name, timestamps, score, tags)
176 ///
177 /// This method maps the flat `DbFeedItem` columns back into those three
178 /// structs, deserializing JSON columns (media, tags) along the way.
179 #[tracing::instrument(skip_all)]
180 pub fn to_feed_item(&self) -> bb_interface::FeedItem {
181 use bb_interface::{BiteDisplay, FeedItem, FeedItemContent, FeedItemId, FeedItemMeta};
182
183 let id = FeedItemId::new(&*self.busser_id, &self.external_id);
184
185 // 1. Bite display — compact list row
186 let mut bite = BiteDisplay::new(&self.bite_author, &self.bite_text);
187 bite.secondary = self.bite_secondary.clone();
188 bite.indicator = self.bite_indicator.clone();
189
190 // 2. Content — full article view
191 let mut content = FeedItemContent::new();
192 content.title = self.title.clone();
193 content.body = self.body.clone();
194 content.url = self.url.clone();
195 content.media = self.media_vec();
196 content.actions = self.actions_vec();
197
198 // 3. Meta — timestamps, score, tags
199 let published_at = self.published_at_dt();
200 let mut meta = FeedItemMeta::new(&self.source_name, published_at.timestamp());
201 meta.fetched_at = self.fetched_at_dt().timestamp();
202 meta.score = self.score;
203 meta.tags = self.tags_vec();
204
205 let mut item = FeedItem::new(id, bite, content, meta);
206 item.db_id = Some(self.id.to_string());
207 item.is_read = self.is_read;
208 item.is_starred = self.is_starred;
209 item
210 }
211 }
212
213 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
214 /// Key-value state storage for bussers (e.g. cursors, tokens)
215 pub struct DbBusserState {
216 /// Internal UUID.
217 pub id: BusserStateId,
218 /// Plugin/busser this state belongs to.
219 pub busser_id: BusserId,
220 /// State key (unique per busser).
221 pub key: String,
222 /// State value (opaque string).
223 pub value: String,
224 /// Row creation timestamp.
225 pub created_at: String,
226 /// Row last-modified timestamp.
227 pub updated_at: String,
228 }
229
230 #[derive(Debug, Clone, Serialize, Deserialize)]
231 /// Input for creating a new feed row
232 pub struct CreateFeed {
233 /// Plugin/busser identifier.
234 pub busser_id: BusserId,
235 /// Human-readable name.
236 pub name: String,
237 /// Plugin-specific configuration (stored as JSON text).
238 pub config: serde_json::Value,
239 }
240
241 #[derive(Debug, Clone, Serialize, Deserialize)]
242 /// Input for inserting or upserting a feed item
243 pub struct CreateFeedItem {
244 /// Busser-provided unique key (format: `busser_id:item_id`).
245 pub external_id: String,
246 /// Parent feed ID.
247 pub feed_id: FeedId,
248 /// Plugin/busser identifier.
249 pub busser_id: BusserId,
250 /// Author attribution for bite display.
251 pub bite_author: String,
252 /// Primary text for bite display.
253 pub bite_text: String,
254 /// Optional secondary info for bite display.
255 pub bite_secondary: Option<String>,
256 /// Optional type indicator for bite display.
257 pub bite_indicator: Option<String>,
258 /// Full article/post title.
259 pub title: Option<String>,
260 /// Full body/content text.
261 pub body: Option<String>,
262 /// URL to the original content.
263 pub url: Option<String>,
264 /// Media attachment URLs.
265 pub media: Vec<String>,
266 /// Plugin-declared custom actions.
267 pub actions: Vec<bb_interface::ItemAction>,
268 /// When the item was originally published.
269 pub published_at: DateTime<Utc>,
270 /// Human-readable source name.
271 pub source_name: String,
272 /// Engagement score.
273 pub score: Option<i64>,
274 /// Tags/categories.
275 pub tags: Vec<String>,
276 }
277
278 impl CreateFeedItem {
279 /// Convert an interface [`FeedItem`](bb_interface::FeedItem) into a database insert input.
280 #[tracing::instrument(skip_all)]
281 pub fn from_feed_item(item: &bb_interface::FeedItem, feed_id: FeedId) -> Self {
282 Self {
283 external_id: item.id.to_combined(),
284 feed_id,
285 busser_id: BusserId::new(&item.id.source),
286 bite_author: item.bite.author.clone(),
287 bite_text: item.bite.text.clone(),
288 bite_secondary: item.bite.secondary.clone(),
289 bite_indicator: item.bite.indicator.clone(),
290 title: item.content.title.clone(),
291 body: item.content.body.clone(),
292 url: item.content.url.clone(),
293 media: item.content.media.clone(),
294 actions: item.content.actions.clone(),
295 published_at: DateTime::from_timestamp(item.meta.published_at, 0)
296 .unwrap_or_else(Utc::now),
297 source_name: item.meta.source_name.clone(),
298 score: item.meta.score,
299 tags: item.meta.tags.clone(),
300 }
301 }
302 }
303
304 #[derive(Debug, Clone, Serialize, Deserialize)]
305 /// A single condition in a query feed's rules array
306 pub struct QueryCondition {
307 /// The item field to match against.
308 ///
309 /// Valid values:
310 /// - `"title"` — full article/post title (in-memory filter)
311 /// - `"author"` — bite display author (in-memory filter)
312 /// - `"body"` — full body/content text (in-memory filter)
313 /// - `"source"` — busser source ID (fast-path SQL)
314 /// - `"tag"` — item-level tag (fast-path SQL)
315 /// - `"starred"` — starred boolean state (fast-path SQL)
316 /// - `"unread"` — unread boolean state (fast-path SQL)
317 pub field: String,
318 /// The comparison operator to apply.
319 ///
320 /// For text fields (title, author, body):
321 /// - `"contains"` — case-insensitive substring match
322 /// - `"not_contains"` — negated case-insensitive substring match
323 /// - `"equals"` — case-insensitive exact match
324 /// - `"matches_regex"` — Rust `regex` crate pattern match
325 ///
326 /// For fast-path fields:
327 /// - `"is"` — boolean check, used with starred/unread
328 /// - `"equals"` — exact match, used with source/tag
329 pub operator: String,
330 /// The comparison value.
331 ///
332 /// For boolean operators (`"is"`), use `"true"` or `"false"`.
333 /// For text operators, the value is matched case-insensitively against the
334 /// field content (substring for contains, exact for equals, regex pattern
335 /// for matches_regex).
336 pub value: String,
337 }
338
339 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
340 /// Saved filter ("query feed") stored in the `query_feeds` table
341 pub struct DbQueryFeed {
342 pub id: QueryFeedId,
343 pub name: String,
344 /// JSON array of [`QueryCondition`].
345 pub rules: String,
346 pub created_at: String,
347 pub updated_at: String,
348 }
349
350 impl DbQueryFeed {
351 /// Deserialize the JSON `rules` column into a `Vec<QueryCondition>`.
352 #[tracing::instrument(skip_all)]
353 pub fn rules_vec(&self) -> Vec<QueryCondition> {
354 parse_or_default(
355 serde_json::from_str(&self.rules),
356 "Failed to parse query feed rules JSON",
357 )
358 }
359 }
360
361 #[derive(Debug, Clone, Serialize, Deserialize)]
362 /// Input for creating a new query feed
363 pub struct CreateQueryFeed {
364 /// Human-readable name for this query feed, displayed in the feed list.
365 pub name: String,
366 /// Filter rules applied with AND logic. See [`QueryCondition`] for valid
367 /// field/operator/value combinations.
368 pub rules: Vec<QueryCondition>,
369 }
370
371 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
372 /// Bookmark stored in the `bookmarks` table
373 pub struct DbBookmark {
374 /// Internal UUID.
375 pub id: BookmarkId,
376 /// Bookmarked URL.
377 pub url: String,
378 /// Display title.
379 pub title: String,
380 /// Short description or excerpt.
381 pub description: String,
382 /// Author attribution.
383 pub author: String,
384 /// Source name for display.
385 pub source_name: String,
386 /// Optional link to the originating feed item (SET NULL on item deletion).
387 pub feed_item_id: Option<String>,
388 /// User's personal notes.
389 pub notes: String,
390 /// Whether this bookmark is pinned to the top.
391 pub is_pinned: bool,
392 /// Row creation timestamp.
393 pub created_at: String,
394 /// Row last-modified timestamp.
395 pub updated_at: String,
396 }
397
398 #[derive(Debug, Clone, Serialize, Deserialize)]
399 /// Input for creating a new bookmark
400 pub struct CreateBookmark {
401 /// URL to bookmark.
402 pub url: String,
403 /// Display title.
404 pub title: String,
405 /// Short description or excerpt.
406 pub description: String,
407 /// Author attribution.
408 pub author: String,
409 /// Source name for display.
410 pub source_name: String,
411 /// Optional link to an existing feed item.
412 pub feed_item_id: Option<String>,
413 /// User's personal notes.
414 pub notes: String,
415 /// Tags to assign.
416 pub tags: Vec<String>,
417 }
418
419 #[derive(Debug, Clone, Serialize, Deserialize)]
420 /// Input for updating an existing bookmark
421 pub struct UpdateBookmark {
422 /// Updated title.
423 pub title: Option<String>,
424 /// Updated description.
425 pub description: Option<String>,
426 /// Updated notes.
427 pub notes: Option<String>,
428 /// Updated pin state.
429 pub is_pinned: Option<bool>,
430 }
431
432 #[cfg(test)]
433 mod tests {
434 use super::*;
435
436 #[test]
437 fn parse_timestamp_rfc3339() {
438 let ts = "2024-01-15T10:30:00Z";
439 let dt = parse_timestamp(ts);
440 assert_eq!(dt.year(), 2024);
441 assert_eq!(dt.month(), 1);
442 }
443
444 #[test]
445 fn parse_timestamp_sqlite_format() {
446 let ts = "2024-06-01 12:00:00";
447 let dt = parse_timestamp(ts);
448 assert_eq!(dt.year(), 2024);
449 assert_eq!(dt.month(), 6);
450 }
451
452 #[test]
453 fn parse_timestamp_garbage_returns_epoch() {
454 let dt = parse_timestamp("not a date");
455 assert_eq!(dt, DateTime::UNIX_EPOCH);
456 }
457
458 #[test]
459 fn parse_timestamp_empty_returns_epoch() {
460 let dt = parse_timestamp("");
461 assert_eq!(dt, DateTime::UNIX_EPOCH);
462 }
463
464 #[test]
465 fn parse_timestamp_valid_rfc3339() {
466 let dt = parse_timestamp("2025-06-15T10:30:00Z");
467 assert_eq!(dt.year(), 2025);
468 assert_eq!(dt.month(), 6);
469 assert_eq!(dt.day(), 15);
470 }
471
472 #[test]
473 fn parse_timestamp_valid_sqlite_format() {
474 let dt = parse_timestamp("2024-03-20 08:45:00");
475 assert_eq!(dt.year(), 2024);
476 assert_eq!(dt.month(), 3);
477 }
478
479 #[test]
480 fn db_feed_id_roundtrip() {
481 let expected: FeedId = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
482 let feed = DbFeed {
483 id: expected,
484 busser_id: BusserId::new("rss"),
485 name: "Test".to_string(),
486 config: "{}".to_string(),
487 enabled: true,
488 last_fetch: None,
489 consecutive_failures: 0,
490 last_error: None,
491 last_success_at: None,
492 circuit_broken: false,
493 created_at: "2024-01-01 00:00:00".to_string(),
494 updated_at: "2024-01-01 00:00:00".to_string(),
495 };
496 assert_eq!(feed.id, expected);
497 assert_eq!(
498 feed.id.to_string(),
499 "550e8400-e29b-41d4-a716-446655440000"
500 );
501 }
502
503 #[test]
504 fn db_feed_config_json_valid() {
505 let feed = DbFeed {
506 id: FeedId::new(),
507 busser_id: BusserId::new("rss"),
508 name: "Test".to_string(),
509 config: r#"{"url":"https://example.com"}"#.to_string(),
510 enabled: true,
511 last_fetch: None,
512 consecutive_failures: 0,
513 last_error: None,
514 last_success_at: None,
515 circuit_broken: false,
516 created_at: "2024-01-01 00:00:00".to_string(),
517 updated_at: "2024-01-01 00:00:00".to_string(),
518 };
519 let json = feed.config_json();
520 assert_eq!(json["url"], "https://example.com");
521 }
522
523 #[test]
524 fn db_feed_config_json_invalid_returns_default() {
525 let feed = DbFeed {
526 id: FeedId::new(),
527 busser_id: BusserId::new("rss"),
528 name: "Test".to_string(),
529 config: "not json".to_string(),
530 enabled: true,
531 last_fetch: None,
532 consecutive_failures: 0,
533 last_error: None,
534 last_success_at: None,
535 circuit_broken: false,
536 created_at: "2024-01-01 00:00:00".to_string(),
537 updated_at: "2024-01-01 00:00:00".to_string(),
538 };
539 let json = feed.config_json();
540 assert!(json.is_null());
541 }
542
543 use chrono::Datelike;
544 }
545