use sqlx::SqlitePool; use kon_core::error::{KonError, Result}; /// Each migration is a (version, description, sql) tuple. /// Migrations MUST be append-only — never modify an existing migration. /// Column defaults and NOT NULL constraints must exactly match the existing schema. const MIGRATIONS: &[(i64, &str, &str)] = &[ (1, "initial schema", r#" CREATE TABLE IF NOT EXISTS transcripts ( id TEXT PRIMARY KEY, text TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'microphone', title TEXT, audio_path TEXT, duration REAL NOT NULL DEFAULT 0.0, engine TEXT, model_id TEXT, inference_ms INTEGER, sample_rate INTEGER, audio_channels INTEGER, format_mode TEXT, remove_fillers INTEGER NOT NULL DEFAULT 0, british_english INTEGER NOT NULL DEFAULT 0, anti_hallucination INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS segments ( id INTEGER PRIMARY KEY AUTOINCREMENT, transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE, start_time REAL NOT NULL, end_time REAL NOT NULL, text TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, text TEXT NOT NULL, bucket TEXT NOT NULL DEFAULT 'inbox', list_id TEXT, effort TEXT, done INTEGER NOT NULL DEFAULT 0, done_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), source_transcript_id TEXT ); CREATE TABLE IF NOT EXISTS task_lists ( id TEXT PRIMARY KEY, name TEXT NOT NULL, built_in INTEGER NOT NULL DEFAULT 0, profile_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS error_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL DEFAULT (datetime('now')), context TEXT NOT NULL, error_code TEXT, message TEXT NOT NULL, metadata TEXT ); CREATE INDEX IF NOT EXISTS idx_transcripts_created ON transcripts(created_at); CREATE INDEX IF NOT EXISTS idx_segments_transcript ON segments(transcript_id); CREATE INDEX IF NOT EXISTS idx_tasks_bucket ON tasks(bucket); CREATE INDEX IF NOT EXISTS idx_tasks_transcript ON tasks(source_transcript_id); CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context) "#), (2, "transcripts FTS5 + dictionary table", r#" CREATE VIRTUAL TABLE IF NOT EXISTS transcripts_fts USING fts5( text, title, content='transcripts', content_rowid='rowid', tokenize='porter unicode61 remove_diacritics 2' ); CREATE TRIGGER IF NOT EXISTS transcripts_ai AFTER INSERT ON transcripts BEGIN INSERT INTO transcripts_fts(rowid, text, title) VALUES (new.rowid, new.text, COALESCE(new.title, '')); END; CREATE TRIGGER IF NOT EXISTS transcripts_ad AFTER DELETE ON transcripts BEGIN INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title) VALUES ('delete', old.rowid, old.text, COALESCE(old.title, '')); END; CREATE TRIGGER IF NOT EXISTS transcripts_au AFTER UPDATE ON transcripts BEGIN INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title) VALUES ('delete', old.rowid, old.text, COALESCE(old.title, '')); INSERT INTO transcripts_fts(rowid, text, title) VALUES (new.rowid, new.text, COALESCE(new.title, '')); END; CREATE TABLE IF NOT EXISTS dictionary ( id INTEGER PRIMARY KEY AUTOINCREMENT, term TEXT NOT NULL UNIQUE, note TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_dictionary_term ON dictionary(term) "#), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. fn split_statements(sql: &str) -> Vec { let mut result = Vec::new(); let mut current = String::new(); let mut depth: i32 = 0; let upper = sql.to_ascii_uppercase(); let ub = upper.as_bytes(); let ob = sql.as_bytes(); let n = ob.len(); let mut i = 0; while i < n { let prev_alpha = i > 0 && ob[i - 1].is_ascii_alphanumeric(); if !prev_alpha && ub[i..].starts_with(b"BEGIN") { let next_alpha = i + 5 < n && ob[i + 5].is_ascii_alphanumeric(); if !next_alpha { depth += 1; } } if !prev_alpha && ub[i..].starts_with(b"END") { let next_alpha = i + 3 < n && ob[i + 3].is_ascii_alphanumeric(); if !next_alpha && depth > 0 { depth -= 1; } } if ob[i] == b';' && depth == 0 { let stmt = current.trim().to_string(); if !stmt.is_empty() { result.push(stmt); } current = String::new(); } else { current.push(ob[i] as char); } i += 1; } let stmt = current.trim().to_string(); if !stmt.is_empty() { result.push(stmt); } result } /// Ensure the schema_version table exists and run any pending migrations. pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { sqlx::query( "CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, description TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(pool) .await .map_err(|e| KonError::StorageError(format!("Schema version table creation failed: {e}")))?; let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version") .fetch_one(pool) .await .map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?; for (version, description, sql) in MIGRATIONS { if *version > current { log::info!("Running migration {}: {}", version, description); let statements = split_statements(sql); for statement in &statements { sqlx::query(statement) .execute(pool) .await .map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?; } sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)") .bind(version) .bind(description) .execute(pool) .await .map_err(|e| KonError::StorageError(format!("Migration version record failed: {e}")))?; log::info!("Migration {} complete", version); } } Ok(()) } #[cfg(test)] mod tests { use super::*; use sqlx::sqlite::SqlitePoolOptions; #[tokio::test] async fn test_migrations_run_on_empty_db() { let pool = SqlitePoolOptions::new() .connect("sqlite::memory:") .await .unwrap(); run_migrations(&pool).await.unwrap(); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM schema_version") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 1); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) .await .unwrap(); } #[tokio::test] async fn test_migrations_idempotent() { let pool = SqlitePoolOptions::new() .connect("sqlite::memory:") .await .unwrap(); run_migrations(&pool).await.unwrap(); run_migrations(&pool).await.unwrap(); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM schema_version") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 1); } }