Skip to main content

linkmarks_core/
migrator.rs

1//! Forward-only SQLite migrator for the LinkMarks store.
2//!
3//! Each migration is a `Migration { version, description, sql }` entry in
4//! the `MIGRATIONS` slice. `migrate(conn)` runs every un-applied migration
5//! inside a single transaction and stamps `PRAGMA user_version` with the
6//! final version. Re-running `migrate` on an up-to-date DB is a no-op.
7//!
8//! The migrator does **not** support downgrades. Forward-only matches the
9//! LinkMarks operational model (snapshot + restart beats destructive
10//! rollback). Detecting a newer schema than the highest known migration
11//! returns [`CoreError::Storage`] with a `newer schema` prefix so callers
12//! can surface a clear upgrade error.
13
14use crate::errors::CoreError;
15use rusqlite::Connection;
16
17/// One forward migration. `sql` may contain any number of statements
18/// separated by `;`. `version` is monotonically increasing and is
19/// stamped into `PRAGMA user_version` after the migration commits.
20#[derive(Debug, Clone)]
21pub struct Migration {
22    /// Monotonic version (1, 2, 3…). Stored in `schema_migrations.version`.
23    pub version: i64,
24    /// Human-readable description. Stored in `schema_migrations.description`.
25    pub description: &'static str,
26    /// SQL statements (semicolon-separated) to apply. May include
27    /// `CREATE TABLE`, `CREATE INDEX`, etc.
28    pub sql: &'static str,
29}
30
31/// All known migrations. Append-only.
32///
33/// Each migration must use `IF NOT EXISTS` / `CREATE OR REPLACE` where
34/// re-running on a partially-migrated DB is a possibility (e.g. when a
35/// previous run committed the SQL but crashed before stamping
36/// `user_version`).
37pub const MIGRATIONS: &[Migration] = &[Migration {
38    version: 1,
39    description: "initial: bookmarks + tags",
40    sql: MIGRATION_001_SQL,
41}];
42
43/// SQL body for migration version 1. Stored separately so the const
44/// initializers above stay readable.
45pub const MIGRATION_001_SQL: &str = r#"
46CREATE TABLE IF NOT EXISTS schema_migrations (
47    version INTEGER PRIMARY KEY,
48    applied_at INTEGER NOT NULL,
49    description TEXT NOT NULL
50);
51
52CREATE TABLE IF NOT EXISTS bookmarks (
53    id TEXT PRIMARY KEY,
54    original_url TEXT NOT NULL,
55    canonical_url TEXT NOT NULL,
56    title TEXT NOT NULL DEFAULT '',
57    description TEXT,
58    collection TEXT,
59    source_kind TEXT NOT NULL,
60    source_id TEXT,
61    external_id TEXT,
62    added_at INTEGER NOT NULL,
63    last_seen_at INTEGER NOT NULL,
64    raw TEXT,
65    archived INTEGER NOT NULL DEFAULT 0
66);
67
68CREATE UNIQUE INDEX IF NOT EXISTS bookmarks_canonical_url_uidx
69    ON bookmarks (canonical_url) WHERE archived = 0;
70
71CREATE INDEX IF NOT EXISTS bookmarks_last_seen_idx ON bookmarks (last_seen_at DESC);
72CREATE INDEX IF NOT EXISTS bookmarks_collection_idx ON bookmarks (collection);
73
74CREATE TABLE IF NOT EXISTS tags (
75    bookmark_id TEXT NOT NULL,
76    tag TEXT NOT NULL,
77    PRIMARY KEY (bookmark_id, tag),
78    FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE
79);
80
81CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
82"#;
83
84/// Highest migration version known to this binary. The migrator will
85/// reject any DB whose `user_version` exceeds this constant.
86pub const MAX_SUPPORTED_VERSION: i64 = 1;
87
88/// Run every un-applied migration in a single transaction and stamp
89/// `PRAGMA user_version` with the final version.
90///
91/// Returns the number of migrations actually applied (0 when the DB is
92/// already up-to-date).
93pub fn migrate(conn: &Connection) -> Result<usize, CoreError> {
94    let current = current_user_version(conn)?;
95    if current > MAX_SUPPORTED_VERSION {
96        return Err(CoreError::Storage(format!(
97            "newer schema detected: db version {current} > supported {MAX_SUPPORTED_VERSION}. \
98             upgrade LinkMarks before opening this store"
99        )));
100    }
101
102    let mut applied = 0usize;
103    let tx = conn
104        .unchecked_transaction()
105        .map_err(|e| CoreError::Storage(format!("begin tx: {e}")))?;
106
107    for mig in MIGRATIONS {
108        if mig.version <= current {
109            continue;
110        }
111
112        tx.execute_batch(mig.sql)
113            .map_err(|e| CoreError::Storage(format!("migration v{}: {e}", mig.version)))?;
114
115        // Record the application. `INSERT OR IGNORE` keeps us idempotent
116        // if a partial previous run already wrote the row but did not
117        // bump `user_version`.
118        let applied_at = unix_now_secs();
119        tx.execute(
120            "INSERT OR IGNORE INTO schema_migrations (version, applied_at, description) \
121             VALUES (?1, ?2, ?3)",
122            rusqlite::params![mig.version, applied_at, mig.description],
123        )
124        .map_err(|e| CoreError::Storage(format!("record migration v{}: {e}", mig.version)))?;
125
126        applied += 1;
127    }
128
129    if applied > 0 {
130        // Stamp the final version. `user_version` is a SQLite built-in
131        // monotonic counter, suitable as our migration marker.
132        let final_version = MIGRATIONS.last().expect("non-empty MIGRATIONS").version;
133        tx.pragma_update(None, "user_version", final_version)
134            .map_err(|e| CoreError::Storage(format!("set user_version: {e}")))?;
135    }
136
137    tx.commit()
138        .map_err(|e| CoreError::Storage(format!("commit migrations: {e}")))?;
139    Ok(applied)
140}
141
142/// Read the current `PRAGMA user_version` from the connection.
143///
144/// Returns 0 on a fresh DB (before the pragma is set).
145pub fn current_user_version(conn: &Connection) -> Result<i64, CoreError> {
146    conn.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
147        .map_err(|e| CoreError::Storage(format!("read user_version: {e}")))
148}
149
150/// Returns the list of versions currently recorded in `schema_migrations`.
151///
152/// Used by tests; not part of the public migrator contract.
153pub fn applied_versions(conn: &Connection) -> Result<Vec<i64>, CoreError> {
154    let mut stmt = conn
155        .prepare("SELECT version FROM schema_migrations ORDER BY version ASC")
156        .map_err(|e| CoreError::Storage(format!("prepare applied_versions: {e}")))?;
157    let rows = stmt
158        .query_map([], |row| row.get::<_, i64>(0))
159        .map_err(|e| CoreError::Storage(format!("query applied_versions: {e}")))?;
160    let mut out = Vec::new();
161    for r in rows {
162        out.push(r.map_err(|e| CoreError::Storage(format!("decode version: {e}")))?);
163    }
164    Ok(out)
165}
166
167#[inline]
168fn unix_now_secs() -> i64 {
169    std::time::SystemTime::now()
170        .duration_since(std::time::UNIX_EPOCH)
171        .map(|d| d.as_secs() as i64)
172        .unwrap_or(0)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::storage;
179
180    #[test]
181    fn fresh_db_runs_migration_001() {
182        let conn = storage::open_in_memory().unwrap();
183        assert_eq!(current_user_version(&conn).unwrap(), 0);
184        let applied = migrate(&conn).unwrap();
185        assert_eq!(applied, 1);
186        assert_eq!(current_user_version(&conn).unwrap(), 1);
187        assert_eq!(applied_versions(&conn).unwrap(), vec![1]);
188    }
189
190    #[test]
191    fn migrate_is_idempotent_on_up_to_date_db() {
192        let conn = storage::open_in_memory().unwrap();
193        migrate(&conn).unwrap();
194        let applied = migrate(&conn).unwrap();
195        assert_eq!(applied, 0, "second run must be a no-op");
196        assert_eq!(current_user_version(&conn).unwrap(), 1);
197    }
198
199    #[test]
200    fn reject_db_newer_than_supported() {
201        let conn = storage::open_in_memory().unwrap();
202        // Simulate a future migration by directly stamping user_version
203        // above MAX_SUPPORTED_VERSION.
204        conn.pragma_update(None, "user_version", MAX_SUPPORTED_VERSION + 5)
205            .unwrap();
206        let err = migrate(&conn).unwrap_err();
207        match err {
208            CoreError::Storage(msg) => assert!(
209                msg.contains("newer schema"),
210                "expected 'newer schema' error, got: {msg}"
211            ),
212            other => panic!("unexpected error variant: {other:?}"),
213        }
214    }
215}