|
1 | 1 | // SPDX-License-Identifier: MIT |
| 2 | +use std::collections::HashSet; |
2 | 3 | use std::path::Path; |
3 | 4 |
|
4 | 5 | use rusqlite::{params, Connection}; |
@@ -65,6 +66,176 @@ pub fn configure(conn: &Connection) -> rusqlite::Result<()> { |
65 | 66 | Ok(()) |
66 | 67 | } |
67 | 68 |
|
| 69 | +type MigrationDef = (&'static str, &'static str); |
| 70 | + |
| 71 | +const SCHEMA_MIGRATIONS: [MigrationDef; 5] = [ |
| 72 | + ("001_initial_schema", "initial_schema"), |
| 73 | + ("002_aging_columns", "aging_columns"), |
| 74 | + ("003_focus_table", "focus_table"), |
| 75 | + ("004_crystal_tables", "crystal_tables"), |
| 76 | + ("005_quality_dedup_columns", "quality_dedup_columns"), |
| 77 | +]; |
| 78 | + |
| 79 | +/// Return ordered schema migration definitions. |
| 80 | +pub fn migration_definitions() -> &'static [MigrationDef] { |
| 81 | + &SCHEMA_MIGRATIONS |
| 82 | +} |
| 83 | + |
| 84 | +/// Ensure schema migration tracking table exists. |
| 85 | +pub fn ensure_schema_migrations_table(conn: &Connection) -> rusqlite::Result<()> { |
| 86 | + conn.execute_batch( |
| 87 | + r#" |
| 88 | + CREATE TABLE IF NOT EXISTS schema_migrations ( |
| 89 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 90 | + version TEXT NOT NULL UNIQUE, |
| 91 | + name TEXT NOT NULL, |
| 92 | + applied_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 93 | + ); |
| 94 | + "#, |
| 95 | + )?; |
| 96 | + Ok(()) |
| 97 | +} |
| 98 | + |
| 99 | +fn migration_error(msg: impl Into<String>) -> rusqlite::Error { |
| 100 | + rusqlite::Error::InvalidParameterName(msg.into()) |
| 101 | +} |
| 102 | + |
| 103 | +fn apply_migration(conn: &Connection, version: &str) -> rusqlite::Result<()> { |
| 104 | + match version { |
| 105 | + // Baseline marker for pre-versioned schemas. |
| 106 | + "001_initial_schema" => Ok(()), |
| 107 | + "002_aging_columns" => { |
| 108 | + migrate_aging_columns(conn); |
| 109 | + if table_has_column(conn, "memories", "compressed_text") |
| 110 | + && table_has_column(conn, "memories", "age_tier") |
| 111 | + && table_has_column(conn, "decisions", "compressed_text") |
| 112 | + && table_has_column(conn, "decisions", "age_tier") |
| 113 | + { |
| 114 | + Ok(()) |
| 115 | + } else { |
| 116 | + Err(migration_error( |
| 117 | + "aging migration did not create expected columns", |
| 118 | + )) |
| 119 | + } |
| 120 | + } |
| 121 | + "003_focus_table" => { |
| 122 | + migrate_focus_table(conn); |
| 123 | + if table_exists(conn, "focus_sessions") { |
| 124 | + Ok(()) |
| 125 | + } else { |
| 126 | + Err(migration_error("focus table migration did not create focus_sessions")) |
| 127 | + } |
| 128 | + } |
| 129 | + "004_crystal_tables" => { |
| 130 | + crate::crystallize::migrate_crystal_tables(conn); |
| 131 | + if table_exists(conn, "memory_clusters") && table_exists(conn, "cluster_members") { |
| 132 | + Ok(()) |
| 133 | + } else { |
| 134 | + Err(migration_error( |
| 135 | + "crystal migration did not create memory_clusters/cluster_members", |
| 136 | + )) |
| 137 | + } |
| 138 | + } |
| 139 | + "005_quality_dedup_columns" => { |
| 140 | + ensure_column( |
| 141 | + conn, |
| 142 | + "memories", |
| 143 | + "ALTER TABLE memories ADD COLUMN merged_count INTEGER DEFAULT 0", |
| 144 | + )?; |
| 145 | + ensure_column( |
| 146 | + conn, |
| 147 | + "memories", |
| 148 | + "ALTER TABLE memories ADD COLUMN quality INTEGER DEFAULT 50", |
| 149 | + )?; |
| 150 | + ensure_column( |
| 151 | + conn, |
| 152 | + "decisions", |
| 153 | + "ALTER TABLE decisions ADD COLUMN merged_count INTEGER DEFAULT 0", |
| 154 | + )?; |
| 155 | + ensure_column( |
| 156 | + conn, |
| 157 | + "decisions", |
| 158 | + "ALTER TABLE decisions ADD COLUMN quality INTEGER DEFAULT 50", |
| 159 | + )?; |
| 160 | + let _ = conn.execute( |
| 161 | + "UPDATE memories SET merged_count = 0 WHERE merged_count IS NULL", |
| 162 | + [], |
| 163 | + ); |
| 164 | + let _ = conn.execute("UPDATE memories SET quality = 50 WHERE quality IS NULL", []); |
| 165 | + let _ = conn.execute( |
| 166 | + "UPDATE decisions SET merged_count = 0 WHERE merged_count IS NULL", |
| 167 | + [], |
| 168 | + ); |
| 169 | + let _ = conn.execute("UPDATE decisions SET quality = 50 WHERE quality IS NULL", []); |
| 170 | + Ok(()) |
| 171 | + } |
| 172 | + other => Err(migration_error(format!("unknown schema migration: {other}"))), |
| 173 | + } |
| 174 | +} |
| 175 | + |
| 176 | +/// Return already-applied migration versions. |
| 177 | +pub fn applied_migration_versions(conn: &Connection) -> rusqlite::Result<Vec<String>> { |
| 178 | + ensure_schema_migrations_table(conn)?; |
| 179 | + let mut stmt = |
| 180 | + conn.prepare("SELECT version FROM schema_migrations ORDER BY id ASC, version ASC")?; |
| 181 | + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; |
| 182 | + Ok(rows.filter_map(|r| r.ok()).collect()) |
| 183 | +} |
| 184 | + |
| 185 | +/// Return pending migration versions in execution order. |
| 186 | +pub fn pending_migration_versions(conn: &Connection) -> rusqlite::Result<Vec<String>> { |
| 187 | + let applied: HashSet<String> = applied_migration_versions(conn)?.into_iter().collect(); |
| 188 | + let mut pending = Vec::new(); |
| 189 | + for (version, _) in migration_definitions() { |
| 190 | + if !applied.contains(*version) { |
| 191 | + pending.push((*version).to_string()); |
| 192 | + } |
| 193 | + } |
| 194 | + Ok(pending) |
| 195 | +} |
| 196 | + |
| 197 | +/// Execute pending schema migrations in-order and record each in |
| 198 | +/// `schema_migrations`. Returns the number of newly-applied migrations. |
| 199 | +pub fn run_pending_migrations(conn: &Connection) -> usize { |
| 200 | + if let Err(e) = ensure_schema_migrations_table(conn) { |
| 201 | + eprintln!("[db] schema migration setup failed: {e}"); |
| 202 | + return 0; |
| 203 | + } |
| 204 | + |
| 205 | + let mut applied_set: HashSet<String> = match applied_migration_versions(conn) { |
| 206 | + Ok(v) => v.into_iter().collect(), |
| 207 | + Err(e) => { |
| 208 | + eprintln!("[db] failed to read applied migrations: {e}"); |
| 209 | + return 0; |
| 210 | + } |
| 211 | + }; |
| 212 | + |
| 213 | + let mut applied_count = 0usize; |
| 214 | + for (version, name) in migration_definitions() { |
| 215 | + if applied_set.contains(*version) { |
| 216 | + continue; |
| 217 | + } |
| 218 | + |
| 219 | + if let Err(e) = apply_migration(conn, version) { |
| 220 | + eprintln!("[db] migration {version} ({name}) failed: {e}"); |
| 221 | + break; |
| 222 | + } |
| 223 | + |
| 224 | + if let Err(e) = conn.execute( |
| 225 | + "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)", |
| 226 | + params![version, name], |
| 227 | + ) { |
| 228 | + eprintln!("[db] failed to record migration {version} ({name}): {e}"); |
| 229 | + break; |
| 230 | + } |
| 231 | + |
| 232 | + applied_set.insert((*version).to_string()); |
| 233 | + applied_count += 1; |
| 234 | + } |
| 235 | + |
| 236 | + applied_count |
| 237 | +} |
| 238 | + |
68 | 239 | /// Create all 12 application tables and supporting indexes if they do not |
69 | 240 | /// already exist. |
70 | 241 | pub fn initialize_schema(conn: &Connection) -> rusqlite::Result<()> { |
@@ -231,6 +402,13 @@ pub fn initialize_schema(conn: &Connection) -> rusqlite::Result<()> { |
231 | 402 | hits INTEGER DEFAULT 0 |
232 | 403 | ); |
233 | 404 |
|
| 405 | + CREATE TABLE IF NOT EXISTS schema_migrations ( |
| 406 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 407 | + version TEXT NOT NULL UNIQUE, |
| 408 | + name TEXT NOT NULL, |
| 409 | + applied_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 410 | + ); |
| 411 | +
|
234 | 412 | -- FTS5 full-text search indexes (trigram tokenizer for code/identifier matching) |
235 | 413 | CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( |
236 | 414 | text, source, tags, |
@@ -706,7 +884,7 @@ fn ensure_column(conn: &Connection, table: &str, alter_sql: &str) -> rusqlite::R |
706 | 884 | } |
707 | 885 | } |
708 | 886 |
|
709 | | -fn table_exists(conn: &Connection, table: &str) -> bool { |
| 887 | +pub fn table_exists(conn: &Connection, table: &str) -> bool { |
710 | 888 | conn.query_row( |
711 | 889 | "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?1 LIMIT 1", |
712 | 890 | params![table], |
@@ -1216,6 +1394,7 @@ mod tests { |
1216 | 1394 | "feed", |
1217 | 1395 | "feed_acks", |
1218 | 1396 | "context_cache", |
| 1397 | + "schema_migrations", |
1219 | 1398 | "memories_fts", |
1220 | 1399 | "decisions_fts", |
1221 | 1400 | "recall_feedback", |
@@ -1364,6 +1543,38 @@ mod tests { |
1364 | 1543 | } |
1365 | 1544 | } |
1366 | 1545 |
|
| 1546 | + #[test] |
| 1547 | + fn test_run_pending_migrations_applies_all_once() { |
| 1548 | + let conn = Connection::open_in_memory().unwrap(); |
| 1549 | + configure(&conn).unwrap(); |
| 1550 | + initialize_schema(&conn).unwrap(); |
| 1551 | + |
| 1552 | + let first_applied = run_pending_migrations(&conn); |
| 1553 | + assert_eq!(first_applied, migration_definitions().len()); |
| 1554 | + |
| 1555 | + let second_applied = run_pending_migrations(&conn); |
| 1556 | + assert_eq!(second_applied, 0); |
| 1557 | + |
| 1558 | + let pending = pending_migration_versions(&conn).unwrap(); |
| 1559 | + assert!( |
| 1560 | + pending.is_empty(), |
| 1561 | + "no pending migrations expected after first run" |
| 1562 | + ); |
| 1563 | + |
| 1564 | + let recorded: i64 = conn |
| 1565 | + .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0)) |
| 1566 | + .unwrap(); |
| 1567 | + assert_eq!(recorded as usize, migration_definitions().len()); |
| 1568 | + |
| 1569 | + assert!(table_has_column(&conn, "memories", "merged_count")); |
| 1570 | + assert!(table_has_column(&conn, "memories", "quality")); |
| 1571 | + assert!(table_has_column(&conn, "decisions", "merged_count")); |
| 1572 | + assert!(table_has_column(&conn, "decisions", "quality")); |
| 1573 | + assert!(table_exists(&conn, "focus_sessions")); |
| 1574 | + assert!(table_exists(&conn, "memory_clusters")); |
| 1575 | + assert!(table_exists(&conn, "cluster_members")); |
| 1576 | + } |
| 1577 | + |
1367 | 1578 | #[test] |
1368 | 1579 | fn test_team_migration_creates_owner_scoped_schema() { |
1369 | 1580 | let conn = Connection::open_in_memory().unwrap(); |
|
0 commit comments