Skip to main content

linkmarks_core/
store.rs

1//! SQLite-backed bookmark store.
2//!
3//! Layered on top of [`crate::storage`] (WAL + contention hardening) and
4//! [`crate::migrator`] (forward-only schema versioning). All public
5//! methods are blocking; callers wrap them in `tokio::task::spawn_blocking`
6//! when they need an async surface.
7//!
8//! Statements are prepared with `prepare_cached` and re-used by the
9//! connection's internal LRU cache. We do **not** hold on to
10//! `Statement<'_>` objects (they borrow from the connection); the SQL is
11//! the single source of truth.
12//!
13//! ## Schema ↔ domain mapping
14//!
15//! `Bookmark.created_at` ↔ `bookmarks.added_at` (epoch seconds).
16//! `Bookmark.updated_at` ↔ `bookmarks.last_seen_at` (epoch seconds).
17//! `Bookmark.source.kind` ↔ `bookmarks.source_kind` (lowercase string).
18//! `Bookmark.source.external_id` ↔ `bookmarks.external_id`.
19//! `Bookmark.source.imported_at` is **not** persisted (kept on the source
20//! only); the import timestamp on the `SourceRef` is reconstructed as
21//! the epoch 0 on read.
22//! `Bookmark.source.raw` ↔ `bookmarks.raw` (JSON string).
23//! `tags` ↔ `tags` table (one row per `(bookmark_id, tag)`).
24//! `content_type` is not persisted today; bridges that set it
25//! currently do so to `None`.
26
27use crate::errors::CoreError;
28use crate::migrator;
29use crate::model::{Bookmark, BookmarkId, SourceKind, SourceRef, Tag};
30use crate::storage;
31use chrono::{DateTime, TimeZone, Utc};
32use rusqlite::{params, Connection, OptionalExtension, Row};
33use std::path::Path;
34
35/// Open (and migrate) a file-backed store.
36///
37/// Creates the parent directory if missing, runs `migrate`, and returns
38/// a `Store` ready for CRUD. Idempotent — safe to call repeatedly on
39/// the same path.
40pub fn open(path: &Path) -> Result<Store, CoreError> {
41    if let Some(parent) = path.parent() {
42        if !parent.as_os_str().is_empty() {
43            std::fs::create_dir_all(parent)?;
44        }
45    }
46    let conn = storage::open(path)?;
47    migrator::migrate(&conn)?;
48    Ok(Store { conn })
49}
50
51/// Open an in-memory store. Used by tests and ephemeral tooling.
52pub fn open_in_memory() -> Result<Store, CoreError> {
53    let conn = storage::open_in_memory()?;
54    migrator::migrate(&conn)?;
55    Ok(Store { conn })
56}
57
58/// The bookmark store. Wraps a single SQLite connection.
59pub struct Store {
60    conn: Connection,
61}
62
63impl std::fmt::Debug for Store {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("Store").finish_non_exhaustive()
66    }
67}
68
69impl Store {
70    /// Borrow the underlying connection. Used by tests that need to
71    /// inspect schema state directly.
72    pub fn connection(&self) -> &Connection {
73        &self.conn
74    }
75
76    /// Insert or update a bookmark. On canonical-URL collision with a
77    /// non-archived row, the existing row's `last_seen_at`, title,
78    /// description, collection, `external_id`, and `raw` are updated and
79    /// tags are replaced by the supplied set.
80    ///
81    /// Returns the persisted `BookmarkId`. The id of the supplied
82    /// bookmark is ignored; the canonical URL drives dedupe.
83    pub fn upsert(&mut self, bookmark: &Bookmark) -> Result<BookmarkId, CoreError> {
84        let canonical = bookmark.canonical_url.clone();
85
86        let existing_id: Option<String> = self
87            .conn
88            .query_row(
89                "SELECT id FROM bookmarks WHERE canonical_url = ?1 AND archived = 0",
90                params![canonical],
91                |row| row.get::<_, String>(0),
92            )
93            .optional()
94            .map_err(|e| CoreError::Storage(format!("lookup canonical: {e}")))?;
95
96        let now = unix_now_secs();
97        let persisted_id = match existing_id {
98            Some(id) => {
99                self.update_existing(&BookmarkId(id), bookmark, now)?;
100                BookmarkId(
101                    // re-fetch the id we just used
102                    self.conn
103                        .query_row(
104                            "SELECT id FROM bookmarks WHERE canonical_url = ?1 AND archived = 0",
105                            params![canonical],
106                            |row| row.get::<_, String>(0),
107                        )
108                        .map_err(|e| CoreError::Storage(format!("refetch id: {e}")))?,
109                )
110            }
111            None => {
112                let id = if bookmark.id.0.is_empty() {
113                    BookmarkId::generate()
114                } else {
115                    bookmark.id.clone()
116                };
117                self.insert_new(&id, bookmark, now)?;
118                id
119            }
120        };
121
122        self.set_tags(&persisted_id, &bookmark.tags)?;
123        Ok(persisted_id)
124    }
125
126    /// Look up a single bookmark by canonical URL. Excludes archived rows.
127    pub fn by_canonical(&self, canonical: &str) -> Result<Option<Bookmark>, CoreError> {
128        let row = self
129            .conn
130            .query_row(
131                "\
132                 SELECT id, original_url, canonical_url, title, description, collection, \
133                  source_kind, source_id, external_id, added_at, last_seen_at, raw, archived \
134                 FROM bookmarks WHERE canonical_url = ?1 AND archived = 0",
135                params![canonical],
136                row_to_bookmark,
137            )
138            .optional()
139            .map_err(|e| CoreError::Storage(format!("by_canonical query: {e}")))?;
140        match row {
141            Some(mut b) => {
142                b.tags = self.tags_for(&b.id)?;
143                Ok(Some(b))
144            }
145            None => Ok(None),
146        }
147    }
148
149    /// List bookmarks paginated, ordered by `last_seen_at DESC, id ASC`.
150    /// Archived rows are excluded.
151    pub fn list(&self, limit: usize, offset: usize) -> Result<Vec<Bookmark>, CoreError> {
152        let limit = limit.min(i64::MAX as usize) as i64;
153        let offset = offset.min(i64::MAX as usize) as i64;
154        let mut stmt = self
155            .conn
156            .prepare_cached(SQL_LIST_ACTIVE)
157            .map_err(|e| CoreError::Storage(format!("prepare list: {e}")))?;
158        let rows = stmt
159            .query_map(params![limit, offset], row_to_bookmark)
160            .map_err(|e| CoreError::Storage(format!("list query: {e}")))?;
161        let mut out = Vec::new();
162        for r in rows {
163            let mut b = r.map_err(|e| CoreError::Storage(format!("list row: {e}")))?;
164            b.tags = self.tags_for(&b.id)?;
165            out.push(b);
166        }
167        Ok(out)
168    }
169
170    /// Total number of **active** bookmarks (excludes archived rows).
171    pub fn count(&self) -> Result<i64, CoreError> {
172        self.conn
173            .query_row(SQL_COUNT_ACTIVE, [], |row| row.get::<_, i64>(0))
174            .map_err(|e| CoreError::Storage(format!("count: {e}")))
175    }
176
177    /// Total number of bookmarks including archived tombstones.
178    pub fn count_all(&self) -> Result<i64, CoreError> {
179        self.conn
180            .query_row(SQL_COUNT_ALL, [], |row| row.get::<_, i64>(0))
181            .map_err(|e| CoreError::Storage(format!("count_all: {e}")))
182    }
183
184    /// Soft-delete a bookmark by id. The row remains with `archived=1`
185    /// so re-inserting the same canonical URL is possible without
186    /// violating the unique index.
187    pub fn delete(&mut self, id: &BookmarkId) -> Result<(), CoreError> {
188        let affected = self
189            .conn
190            .execute(SQL_ARCHIVE_BY_ID, params![id.0])
191            .map_err(|e| CoreError::Storage(format!("delete: {e}")))?;
192        if affected == 0 {
193            return Err(CoreError::Storage(format!(
194                "delete: no active row with id {}",
195                id.0
196            )));
197        }
198        Ok(())
199    }
200
201    /// Replace the tag set for a bookmark. Tags are normalized through
202    /// [`Tag::new`] before insertion; duplicates collapse naturally
203    /// (composite PK on `(bookmark_id, tag)`).
204    pub fn set_tags(&mut self, id: &BookmarkId, tags: &[String]) -> Result<(), CoreError> {
205        let tx = self
206            .conn
207            .unchecked_transaction()
208            .map_err(|e| CoreError::Storage(format!("begin tags tx: {e}")))?;
209        tx.execute(SQL_DELETE_TAGS_FOR, params![id.0])
210            .map_err(|e| CoreError::Storage(format!("delete tags: {e}")))?;
211        for raw in tags {
212            if let Some(tag) = Tag::new(raw) {
213                tx.execute(SQL_INSERT_TAG, params![id.0, tag.0])
214                    .map_err(|e| CoreError::Storage(format!("insert tag: {e}")))?;
215            }
216        }
217        tx.commit()
218            .map_err(|e| CoreError::Storage(format!("commit tags tx: {e}")))?;
219        Ok(())
220    }
221
222    /// Read all tags attached to a bookmark, sorted alphabetically.
223    pub fn tags_for(&self, id: &BookmarkId) -> Result<Vec<String>, CoreError> {
224        let mut stmt = self
225            .conn
226            .prepare_cached(SQL_SELECT_TAGS_FOR)
227            .map_err(|e| CoreError::Storage(format!("prepare tags_for: {e}")))?;
228        let rows = stmt
229            .query_map(params![id.0], |row| row.get::<_, String>(0))
230            .map_err(|e| CoreError::Storage(format!("tags_for query: {e}")))?;
231        let mut out = Vec::new();
232        for r in rows {
233            out.push(r.map_err(|e| CoreError::Storage(format!("tag row: {e}")))?);
234        }
235        Ok(out)
236    }
237
238    // ---- internal helpers ----
239
240    fn insert_new(&self, id: &BookmarkId, b: &Bookmark, now: i64) -> Result<(), CoreError> {
241        let added_at = if b.created_at.timestamp() > 0 {
242            b.created_at.timestamp()
243        } else {
244            now
245        };
246        let last_seen = if b.updated_at.timestamp() > 0 {
247            b.updated_at.timestamp()
248        } else {
249            now
250        };
251        let raw_str = b
252            .source
253            .raw
254            .as_ref()
255            .map(|v| serde_json::to_string(v).unwrap_or_default());
256        self.conn
257            .execute(
258                SQL_INSERT_BOOKMARK,
259                params![
260                    id.0,
261                    b.original_url,
262                    b.canonical_url,
263                    b.title,
264                    b.description,
265                    b.collection,
266                    b.source.kind.as_cli_str(),
267                    Option::<String>::None, // source_id — unused; bridged via SourceRef.external_id
268                    b.source.external_id,
269                    added_at,
270                    last_seen,
271                    raw_str,
272                    if b.archived { 1i64 } else { 0i64 },
273                ],
274            )
275            .map_err(|e| CoreError::Storage(format!("insert bookmark: {e}")))?;
276        Ok(())
277    }
278
279    fn update_existing(&self, id: &BookmarkId, b: &Bookmark, _now: i64) -> Result<(), CoreError> {
280        // Preserve the caller-supplied `updated_at` so import-time
281        // provenance (e.g. Chrome's last-visit timestamp) round-trips.
282        // Fall back to "now" only when the caller left it at epoch 0.
283        let last_seen = if b.updated_at.timestamp() > 0 {
284            b.updated_at.timestamp()
285        } else {
286            _now
287        };
288        let raw_str = b
289            .source
290            .raw
291            .as_ref()
292            .map(|v| serde_json::to_string(v).unwrap_or_default());
293        self.conn
294            .execute(
295                SQL_UPDATE_BOOKMARK,
296                params![
297                    b.original_url,
298                    b.title,
299                    b.description,
300                    b.collection,
301                    b.source.kind.as_cli_str(),
302                    b.source.external_id,
303                    last_seen,
304                    raw_str,
305                    id.0,
306                ],
307            )
308            .map_err(|e| CoreError::Storage(format!("update bookmark: {e}")))?;
309        Ok(())
310    }
311}
312
313// --- SQL constants -----------------------------------------------------
314
315const SQL_LIST_ACTIVE: &str = "\
316    SELECT id, original_url, canonical_url, title, description, collection, \
317     source_kind, source_id, external_id, added_at, last_seen_at, raw, archived \
318    FROM bookmarks WHERE archived = 0 \
319    ORDER BY last_seen_at DESC, id ASC LIMIT ?1 OFFSET ?2";
320
321const SQL_COUNT_ACTIVE: &str = "SELECT COUNT(*) FROM bookmarks WHERE archived = 0";
322const SQL_COUNT_ALL: &str = "SELECT COUNT(*) FROM bookmarks";
323
324const SQL_SELECT_TAGS_FOR: &str = "SELECT tag FROM tags WHERE bookmark_id = ?1 ORDER BY tag ASC";
325const SQL_DELETE_TAGS_FOR: &str = "DELETE FROM tags WHERE bookmark_id = ?1";
326const SQL_INSERT_TAG: &str = "INSERT OR IGNORE INTO tags (bookmark_id, tag) VALUES (?1, ?2)";
327
328const SQL_ARCHIVE_BY_ID: &str = "UPDATE bookmarks SET archived = 1 WHERE id = ?1 AND archived = 0";
329
330const SQL_INSERT_BOOKMARK: &str = "\
331    INSERT INTO bookmarks \
332    (id, original_url, canonical_url, title, description, collection, \
333     source_kind, source_id, external_id, added_at, last_seen_at, raw, archived) \
334    VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)";
335
336const SQL_UPDATE_BOOKMARK: &str = "\
337    UPDATE bookmarks SET \
338        original_url = ?1, title = ?2, description = ?3, collection = ?4, \
339        source_kind = ?5, external_id = ?6, last_seen_at = ?7, raw = ?8 \
340    WHERE id = ?9";
341
342fn row_to_bookmark(row: &Row<'_>) -> rusqlite::Result<Bookmark> {
343    let id: String = row.get(0)?;
344    let original_url: String = row.get(1)?;
345    let canonical_url: String = row.get(2)?;
346    let title: String = row.get(3)?;
347    let description: Option<String> = row.get(4)?;
348    let collection: Option<String> = row.get(5)?;
349    let source_kind_str: String = row.get(6)?;
350    let _source_id: Option<String> = row.get(7)?;
351    let external_id: Option<String> = row.get(8)?;
352    let added_at: i64 = row.get(9)?;
353    let last_seen_at: i64 = row.get(10)?;
354    let raw_str: Option<String> = row.get(11)?;
355    let archived: i64 = row.get(12)?;
356
357    let kind = SourceKind::from_cli_str(&source_kind_str).unwrap_or(SourceKind::Manual);
358    let raw = raw_str.and_then(|s| serde_json::from_str(&s).ok());
359    let source = SourceRef {
360        kind,
361        external_id,
362        imported_at: Utc.timestamp_opt(0, 0).single().unwrap_or_else(Utc::now),
363        raw,
364    };
365
366    Ok(Bookmark {
367        id: BookmarkId(id),
368        original_url,
369        canonical_url,
370        title,
371        description,
372        tags: Vec::new(), // filled by caller via tags_for()
373        collection,
374        created_at: epoch_to_utc(added_at),
375        updated_at: epoch_to_utc(last_seen_at),
376        source,
377        content_type: None, // not persisted in the current schema
378        archived: archived != 0,
379    })
380}
381
382fn epoch_to_utc(secs: i64) -> DateTime<Utc> {
383    Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now)
384}
385
386#[inline]
387fn unix_now_secs() -> i64 {
388    std::time::SystemTime::now()
389        .duration_since(std::time::UNIX_EPOCH)
390        .map(|d| d.as_secs() as i64)
391        .unwrap_or(0)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::model::SourceKind;
398    use chrono::TimeZone;
399
400    fn mk(canonical: &str, title: &str, source: SourceKind, secs: i64) -> Bookmark {
401        Bookmark {
402            id: BookmarkId::generate(),
403            original_url: format!("https://example.com/{title}"),
404            canonical_url: canonical.into(),
405            title: title.into(),
406            description: None,
407            tags: vec!["rust".into(), "cli".into()],
408            collection: None,
409            created_at: Utc.timestamp_opt(secs, 0).unwrap(),
410            updated_at: Utc.timestamp_opt(secs, 0).unwrap(),
411            source: SourceRef {
412                kind: source,
413                external_id: Some(format!("ext-{title}")),
414                imported_at: Utc.timestamp_opt(secs, 0).unwrap(),
415                raw: Some(serde_json::json!({"marker": title})),
416            },
417            content_type: None,
418            archived: false,
419        }
420    }
421
422    #[test]
423    fn insert_new_returns_id() {
424        let mut s = open_in_memory().unwrap();
425        let b = mk("https://example.com/a", "A", SourceKind::Chromium, 1_000);
426        let id = s.upsert(&b).unwrap();
427        assert_eq!(s.count().unwrap(), 1);
428        let fetched = s.by_canonical("https://example.com/a").unwrap().unwrap();
429        assert_eq!(fetched.id, id);
430        assert_eq!(fetched.title, "A");
431    }
432
433    #[test]
434    fn by_canonical_returns_none_when_missing() {
435        let s = open_in_memory().unwrap();
436        assert!(s
437            .by_canonical("https://example.com/missing")
438            .unwrap()
439            .is_none());
440    }
441
442    #[test]
443    fn list_pagination_orders_by_last_seen_then_id() {
444        let mut s = open_in_memory().unwrap();
445        let b1 = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
446        let b2 = mk("https://example.com/b", "B", SourceKind::Chromium, 200);
447        let b3 = mk("https://example.com/c", "C", SourceKind::Chromium, 200);
448        s.upsert(&b1).unwrap();
449        s.upsert(&b2).unwrap();
450        s.upsert(&b3).unwrap();
451
452        let all = s.list(10, 0).unwrap();
453        assert_eq!(all.len(), 3);
454        let last_seens: Vec<i64> = all.iter().map(|b| b.updated_at.timestamp()).collect();
455        assert_eq!(last_seens, vec![200, 200, 100]);
456
457        let page1 = s.list(2, 0).unwrap();
458        let page2 = s.list(2, 2).unwrap();
459        assert_eq!(page1.len(), 2);
460        assert_eq!(page2.len(), 1);
461    }
462
463    #[test]
464    fn count_reflects_upserts_and_deletes() {
465        let mut s = open_in_memory().unwrap();
466        assert_eq!(s.count().unwrap(), 0);
467        let b1 = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
468        let b2 = mk("https://example.com/b", "B", SourceKind::Chromium, 200);
469        let id1 = s.upsert(&b1).unwrap();
470        let _id2 = s.upsert(&b2).unwrap();
471        assert_eq!(s.count().unwrap(), 2);
472        s.delete(&id1).unwrap();
473        assert_eq!(s.count().unwrap(), 1);
474        assert_eq!(s.count_all().unwrap(), 2);
475    }
476
477    #[test]
478    fn tags_set_and_replace() {
479        let mut s = open_in_memory().unwrap();
480        let mut b = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
481        b.tags = vec!["Rust".into(), "  CLI  ".into(), "".into(), "Rust".into()];
482        let id = s.upsert(&b).unwrap();
483        let tags = s.tags_for(&id).unwrap();
484        assert_eq!(tags, vec!["cli".to_string(), "rust".to_string()]);
485
486        s.set_tags(&id, &["new".into(), "another".into()]).unwrap();
487        let tags = s.tags_for(&id).unwrap();
488        assert_eq!(tags, vec!["another".to_string(), "new".to_string()]);
489
490        s.set_tags(&id, &[]).unwrap();
491        let tags = s.tags_for(&id).unwrap();
492        assert!(tags.is_empty());
493    }
494}