1use std::path::Path;
2
3use rusqlite::Connection;
4
5use crate::error::{KernelError, Result};
6
7pub type MigrationFn = fn(&Connection, current_version: u32) -> Result<u32>;
10
11pub fn init_schema(path: &Path, schema_ddl: &str, expected_version: u32) -> Result<Connection> {
17 if let Some(parent) = path.parent() {
18 std::fs::create_dir_all(parent)?;
19 }
20
21 let conn = Connection::open(path).map_err(|e| KernelError::Store(e.to_string()))?;
22 apply_pragma(&conn)?;
23
24 let current_version: u32 = conn
26 .query_row(
27 "SELECT value FROM _meta WHERE key = 'schema_version'",
28 [],
29 |row| row.get::<_, String>(0),
30 )
31 .ok()
32 .and_then(|s| s.parse().ok())
33 .unwrap_or(0);
34
35 if current_version != expected_version {
36 conn.execute_batch(schema_ddl)
38 .map_err(|e| KernelError::Store(format!("Schema DDL failed: {}", e)))?;
39
40 conn.execute(
41 "INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?1)",
42 [expected_version.to_string()],
43 )
44 .map_err(|e| KernelError::Store(e.to_string()))?;
45 }
46
47 Ok(conn)
48}
49
50pub fn init_schema_with_migrations(
56 path: &Path,
57 schema_ddl: &str,
58 expected_version: u32,
59 migrate: Option<MigrationFn>,
60) -> Result<Connection> {
61 if let Some(parent) = path.parent() {
62 std::fs::create_dir_all(parent)?;
63 }
64
65 let conn = Connection::open(path).map_err(|e| KernelError::Store(e.to_string()))?;
66 apply_pragma(&conn)?;
67
68 let current_version: u32 = conn
70 .query_row(
71 "SELECT value FROM _meta WHERE key = 'schema_version'",
72 [],
73 |row| row.get::<_, String>(0),
74 )
75 .ok()
76 .and_then(|s| s.parse().ok())
77 .unwrap_or(0);
78
79 if current_version == expected_version {
80 return Ok(conn);
81 }
82
83 if current_version == 0 || migrate.is_none() {
85 conn.execute_batch(schema_ddl)
86 .map_err(|e| KernelError::Store(format!("Schema DDL failed: {}", e)))?;
87
88 conn.execute(
89 "INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?1)",
90 [expected_version.to_string()],
91 )
92 .map_err(|e| KernelError::Store(e.to_string()))?;
93
94 return Ok(conn);
95 }
96
97 let tx = conn
99 .unchecked_transaction()
100 .map_err(|e| KernelError::Store(format!("Transaction begin failed: {}", e)))?;
101 let new_version = migrate.unwrap()(&tx, current_version)?;
102 tx.execute(
103 "INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?1)",
104 [new_version.to_string()],
105 )
106 .map_err(|e| KernelError::Store(e.to_string()))?;
107 tx.commit()
108 .map_err(|e| KernelError::Store(format!("Transaction commit failed: {}", e)))?;
109
110 Ok(conn)
111}
112
113pub fn init_in_memory(schema_ddl: &str) -> Result<Connection> {
116 let conn = Connection::open_in_memory().map_err(|e| KernelError::Store(e.to_string()))?;
117 apply_pragma(&conn)?;
118 conn.execute_batch(schema_ddl)
119 .map_err(|e| KernelError::Store(format!("Schema DDL failed: {}", e)))?;
120 Ok(conn)
121}
122
123fn apply_pragma(conn: &Connection) -> Result<()> {
124 conn.execute_batch(
125 "PRAGMA journal_mode = WAL;
126 PRAGMA foreign_keys = ON;
127 PRAGMA busy_timeout = 5000;
128 PRAGMA synchronous = NORMAL;",
129 )
130 .map_err(|e| KernelError::Store(format!("PRAGMA failed: {}", e)))?;
131 Ok(())
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use tempfile::TempDir;
138
139 #[test]
140 fn test_init_schema_creates_db() {
141 let dir = TempDir::new().unwrap();
142 let path = dir.path().join("test.db");
143 let ddl = "CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
144 INSERT OR IGNORE INTO _meta (key, value) VALUES ('schema_version', '0');
145 CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT NOT NULL);";
146
147 let conn = init_schema(&path, ddl, 1).unwrap();
148 let version: String = conn
149 .query_row(
150 "SELECT value FROM _meta WHERE key = 'schema_version'",
151 [],
152 |row| row.get(0),
153 )
154 .unwrap();
155 assert_eq!(version, "1");
156 }
157
158 #[test]
159 fn test_init_in_memory() {
160 let ddl = "CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
161 INSERT OR IGNORE INTO _meta (key, value) VALUES ('schema_version', '0');
162 CREATE TABLE items (id TEXT PRIMARY KEY);";
163 let conn = init_in_memory(ddl).unwrap();
164 conn.execute("INSERT INTO items (id) VALUES ('a')", [])
165 .unwrap();
166 }
167
168 #[test]
169 fn test_init_schema_idempotent() {
170 let dir = TempDir::new().unwrap();
171 let path = dir.path().join("test.db");
172 let ddl = "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
173 INSERT OR IGNORE INTO _meta (key, value) VALUES ('schema_version', '0');
174 CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY);";
175
176 let conn1 = init_schema(&path, ddl, 1).unwrap();
177 drop(conn1);
178 let conn2 = init_schema(&path, ddl, 1).unwrap();
179
180 let count: u32 = conn2
181 .query_row(
182 "SELECT COUNT(*) FROM _meta WHERE key = 'schema_version'",
183 [],
184 |row| row.get(0),
185 )
186 .unwrap();
187 assert_eq!(count, 1);
188 }
189
190 fn noop_migrate(_conn: &Connection, _current: u32) -> Result<u32> {
191 Ok(2)
192 }
193
194 #[test]
195 fn migration_fresh_db_uses_full_ddl() {
196 let dir = TempDir::new().unwrap();
197 let path = dir.path().join("test.db");
198 let ddl = "CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
199 INSERT OR IGNORE INTO _meta (key, value) VALUES ('schema_version', '0');
200 CREATE TABLE items (id TEXT PRIMARY KEY);";
201
202 let conn = init_schema_with_migrations(&path, ddl, 2, Some(noop_migrate)).unwrap();
203 let version: String = conn
204 .query_row(
205 "SELECT value FROM _meta WHERE key = 'schema_version'",
206 [],
207 |row| row.get(0),
208 )
209 .unwrap();
210 assert_eq!(version, "2");
212 }
213
214 #[test]
215 fn migration_same_version_skips() {
216 let dir = TempDir::new().unwrap();
217 let path = dir.path().join("test.db");
218 let ddl = "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
219 INSERT OR IGNORE INTO _meta (key, value) VALUES ('schema_version', '0');
220 CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY);";
221
222 let conn1 = init_schema(&path, ddl, 2).unwrap();
223 drop(conn1);
224
225 let conn2 = init_schema_with_migrations(&path, ddl, 2, Some(noop_migrate)).unwrap();
226 let version: String = conn2
227 .query_row(
228 "SELECT value FROM _meta WHERE key = 'schema_version'",
229 [],
230 |row| row.get(0),
231 )
232 .unwrap();
233 assert_eq!(version, "2");
234 }
235
236 #[test]
237 fn migration_callback_invoked_on_version_mismatch() {
238 let dir = TempDir::new().unwrap();
239 let path = dir.path().join("test.db");
240 let ddl = "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
241 INSERT OR IGNORE INTO _meta (key, value) VALUES ('schema_version', '0');
242 CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY);";
243
244 let conn1 = init_schema(&path, ddl, 1).unwrap();
246 drop(conn1);
247
248 let migrate = |conn: &Connection, current: u32| -> Result<u32> {
250 assert_eq!(current, 1);
251 conn.execute_batch("ALTER TABLE items ADD COLUMN label TEXT DEFAULT ''")
252 .map_err(|e| KernelError::Store(e.to_string()))?;
253 Ok(3)
254 };
255
256 let conn2 =
257 init_schema_with_migrations(&path, ddl, 3, Some(migrate as MigrationFn)).unwrap();
258 let version: String = conn2
259 .query_row(
260 "SELECT value FROM _meta WHERE key = 'schema_version'",
261 [],
262 |row| row.get(0),
263 )
264 .unwrap();
265 assert_eq!(version, "3");
266
267 let cols: Vec<String> = conn2
269 .prepare("PRAGMA table_info(items)")
270 .unwrap()
271 .query_map([], |r| r.get::<_, String>(1))
272 .unwrap()
273 .flatten()
274 .collect();
275 assert!(cols.contains(&"label".to_string()));
276 }
277
278 #[test]
279 fn migration_none_falls_back_to_full_ddl() {
280 let dir = TempDir::new().unwrap();
281 let path = dir.path().join("test.db");
282 let ddl = "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
283 INSERT OR IGNORE INTO _meta (key, value) VALUES ('schema_version', '0');
284 CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY);";
285
286 let conn1 = init_schema(&path, ddl, 1).unwrap();
287 drop(conn1);
288
289 let conn2 = init_schema_with_migrations(&path, ddl, 2, None).unwrap();
290 let version: String = conn2
291 .query_row(
292 "SELECT value FROM _meta WHERE key = 'schema_version'",
293 [],
294 |row| row.get(0),
295 )
296 .unwrap();
297 assert_eq!(version, "2");
298 }
299
300 #[test]
301 fn migration_failure_rolls_back() {
302 let dir = TempDir::new().unwrap();
303 let path = dir.path().join("test.db");
304 let ddl = "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
305 INSERT OR IGNORE INTO _meta (key, value) VALUES ('schema_version', '0');
306 CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY);";
307
308 let conn1 = init_schema(&path, ddl, 1).unwrap();
310 drop(conn1);
311
312 let fail_migrate = |_conn: &Connection, _current: u32| -> Result<u32> {
314 Err(KernelError::Store("migration failed".into()))
315 };
316
317 let result = init_schema_with_migrations(&path, ddl, 3, Some(fail_migrate as MigrationFn));
318 assert!(result.is_err());
319
320 let conn2 = Connection::open(&path).unwrap();
322 let version: String = conn2
323 .query_row(
324 "SELECT value FROM _meta WHERE key = 'schema_version'",
325 [],
326 |row| row.get(0),
327 )
328 .unwrap();
329 assert_eq!(version, "1");
330 }
331}