linkmarks_core/
migrator.rs1use crate::errors::CoreError;
15use rusqlite::Connection;
16
17#[derive(Debug, Clone)]
21pub struct Migration {
22 pub version: i64,
24 pub description: &'static str,
26 pub sql: &'static str,
29}
30
31pub const MIGRATIONS: &[Migration] = &[Migration {
38 version: 1,
39 description: "initial: bookmarks + tags",
40 sql: MIGRATION_001_SQL,
41}];
42
43pub 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
84pub const MAX_SUPPORTED_VERSION: i64 = 1;
87
88pub 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 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 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
142pub 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
150pub 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 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}