use kon_core::error::{KonError, Result}; use sqlx::SqlitePool; /// 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) "#, ), ( 3, "micro-stepping: parent_task_id on tasks", r#" ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id) "#, ), ( 4, "tasks_meta: notes column for persisted free-text", r#" ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT '' "#, ), ( 5, "transcripts_meta", r#" ALTER TABLE transcripts ADD COLUMN starred INTEGER NOT NULL DEFAULT 0; ALTER TABLE transcripts ADD COLUMN manual_tags TEXT NOT NULL DEFAULT ''; ALTER TABLE transcripts ADD COLUMN template TEXT NOT NULL DEFAULT ''; ALTER TABLE transcripts ADD COLUMN language TEXT NOT NULL DEFAULT ''; ALTER TABLE transcripts ADD COLUMN segments_json TEXT NOT NULL DEFAULT ''; "#, ), ( 6, "profiles", r#" CREATE TABLE IF NOT EXISTS profiles ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, initial_prompt TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS profile_terms ( id TEXT PRIMARY KEY, profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, term TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, UNIQUE(profile_id, term) ); CREATE INDEX IF NOT EXISTS idx_profile_terms_profile_id ON profile_terms(profile_id); INSERT OR IGNORE INTO profiles (id, name, initial_prompt, created_at) VALUES ('00000000-0000-0000-0000-000000000001', 'Default', '', datetime('now')); INSERT OR IGNORE INTO profile_terms (id, profile_id, term, note, created_at) SELECT d.id, '00000000-0000-0000-0000-000000000001', d.term, COALESCE(d.note, ''), d.created_at FROM dictionary d; CREATE TRIGGER IF NOT EXISTS trg_protect_default_profile_delete BEFORE DELETE ON profiles FOR EACH ROW WHEN OLD.id = '00000000-0000-0000-0000-000000000001' BEGIN SELECT RAISE(ABORT, 'Default profile cannot be deleted'); END; CREATE TRIGGER IF NOT EXISTS trg_protect_default_profile_rename BEFORE UPDATE OF name ON profiles FOR EACH ROW WHEN OLD.id = '00000000-0000-0000-0000-000000000001' AND NEW.name != OLD.name BEGIN SELECT RAISE(ABORT, 'Default profile cannot be renamed'); END; "#, ), ( 7, "drop_dictionary", r#" DROP INDEX IF EXISTS idx_dictionary_term; DROP TABLE IF EXISTS dictionary; "#, ), ( 8, "transcript_profile_provenance", r#" ALTER TABLE transcripts ADD COLUMN profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'; CREATE INDEX IF NOT EXISTS idx_transcripts_profile_id ON transcripts(profile_id); "#, ), ( 9, "transcript_profile_fk", r#" INSERT OR IGNORE INTO profiles (id, name, initial_prompt, created_at) VALUES ('00000000-0000-0000-0000-000000000001', 'Default', '', datetime('now')); DROP TRIGGER IF EXISTS transcripts_ai; DROP TRIGGER IF EXISTS transcripts_ad; DROP TRIGGER IF EXISTS transcripts_au; DROP TABLE IF EXISTS transcripts_fts; DROP INDEX IF EXISTS idx_segments_transcript; DROP INDEX IF EXISTS idx_transcripts_created; DROP INDEX IF EXISTS idx_transcripts_profile_id; ALTER TABLE segments RENAME TO segments_old; ALTER TABLE transcripts RENAME TO transcripts_old; CREATE TABLE 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')), starred INTEGER NOT NULL DEFAULT 0, manual_tags TEXT NOT NULL DEFAULT '', template TEXT NOT NULL DEFAULT '', language TEXT NOT NULL DEFAULT '', segments_json TEXT NOT NULL DEFAULT '', profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001' REFERENCES profiles(id) ON DELETE RESTRICT ); CREATE INDEX idx_transcripts_created ON transcripts(created_at); CREATE INDEX idx_transcripts_profile_id ON transcripts(profile_id); INSERT INTO transcripts ( id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, profile_id ) SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, CASE WHEN profile_id IS NOT NULL AND EXISTS ( SELECT 1 FROM profiles WHERE id = transcripts_old.profile_id ) THEN profile_id ELSE '00000000-0000-0000-0000-000000000001' END FROM transcripts_old; CREATE TABLE 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 INDEX idx_segments_transcript ON segments(transcript_id); INSERT INTO segments (id, transcript_id, start_time, end_time, text) SELECT id, transcript_id, start_time, end_time, text FROM segments_old; DROP TABLE segments_old; DROP TABLE transcripts_old; CREATE VIRTUAL TABLE transcripts_fts USING fts5( text, title, content='transcripts', content_rowid='rowid', tokenize='porter unicode61 remove_diacritics 2' ); CREATE TRIGGER 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 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 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; INSERT INTO transcripts_fts(rowid, text, title) SELECT rowid, text, COALESCE(title, '') FROM transcripts; "#, ), ( 10, "feedback: HITL thumbs + correction capture", r#" -- Feedback rows capture human-in-the-loop signal on AI-generated -- output. Two flavours bundled into one table: -- - thumbs (rating = -1 | +1, original_text optional, corrected_text NULL) -- - correction (rating defaults to +1, original_text + corrected_text present) -- -- `target_type` names the producing surface: -- 'microstep' — subtask decomposition from DECOMPOSE_TASK_SYSTEM -- 'task_extraction' — tasks lifted from a transcript (EXTRACT_TASKS_SYSTEM) -- 'cleanup' — transcript cleanup output -- -- `target_id` is the surface-specific identifier where one exists -- (subtask id, task id, transcript id). NULL is allowed because -- not every feedback event has a stable target id yet. -- -- `context_json` carries the input the AI was conditioned on -- (parent task text, transcript chunk, etc.) so future prompt -- builders can reconstruct the original I/O pair for few-shot -- injection or semantic retrieval. CREATE TABLE feedback ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_type TEXT NOT NULL CHECK (target_type IN ('microstep', 'task_extraction', 'cleanup')), target_id TEXT, rating INTEGER NOT NULL CHECK (rating IN (-1, 0, 1)), original_text TEXT, corrected_text TEXT, context_json TEXT, profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001' REFERENCES profiles(id) ON DELETE RESTRICT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_feedback_target_type_rating ON feedback(target_type, rating, created_at DESC); CREATE INDEX idx_feedback_profile ON feedback(profile_id, target_type, created_at DESC); "#, ), ( 11, "tasks: energy tagging for match-my-energy sort", r#" -- Phase 3 of the feature-complete roadmap: replaces the cut -- temptation-bundling feature with a deterministic client-side -- sort that matches tasks to the user's current energy state. -- NULL is the expected normal case — users who never tag get -- Medium-equivalent treatment at sort time (see Match-my-energy -- logic in src/lib/pages/TasksPage.svelte). -- -- profile_id is deliberately absent from the index: tasks -- currently carry no profile_id column, so a per-profile index -- is out of scope until the broader task → profile migration -- lands. See HANDOVER deferred list. ALTER TABLE tasks ADD COLUMN energy TEXT CHECK (energy IS NULL OR energy IN ('high', 'medium', 'brain_dead')); CREATE INDEX idx_tasks_energy_created ON tasks(energy, created_at DESC); "#, ), ( 12, "implementation intentions: if-then automation rules", r#" -- Phase 7 of the feature-complete roadmap. Rules are local-only, -- user-authored implementation intentions: "if this happens, then -- do this small thing". Execution stays in the frontend event bus; -- SQLite owns the durable definition and the once-per-day marker -- for time-of-day rules. CREATE TABLE implementation_rules ( id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ( 'time_of_day', 'task_completed', 'morning_triage_finished' )), trigger_value TEXT NOT NULL DEFAULT '', actions_json TEXT NOT NULL DEFAULT '[]', last_fired_key TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_implementation_rules_enabled_trigger ON implementation_rules(enabled, trigger_kind); "#, ), ( 13, "gamification: auto_completed flag for cascade-completed parents", r#" -- Phase 8 of the feature-complete roadmap. Parents that close via -- the complete_subtask_and_check_parent cascade must not count -- towards daily completion totals. The user already got credit -- for ticking the subtask. This column distinguishes manual -- completions (0) from cascade completions (1). The daily-count -- query then excludes auto_completed = 1. -- -- Partial index keeps the index small: only completed rows occupy -- it, since uncompleted rows have done_at IS NULL. ALTER TABLE tasks ADD COLUMN auto_completed INTEGER NOT NULL DEFAULT 0 CHECK (auto_completed IN (0, 1)); CREATE INDEX idx_tasks_done_at_auto_completed ON tasks(done_at, auto_completed) WHERE done_at IS NOT NULL; "#, ), ]; /// 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<()> { run_migrations_slice(pool, MIGRATIONS).await } /// Apply the pending prefix of `migrations`, each inside its own /// transaction along with the matching `schema_version` row insert. /// /// Atomicity was added in response to the 2026-04-22 review (RB-02): /// the previous implementation executed statements individually against /// the pool and only recorded the new version after all statements had /// succeeded. A multi-statement migration that failed midway therefore /// left the schema partially changed but still appearing unapplied — /// the next startup would replay the migration against a mutated DB /// and fail in surprising ways. /// /// Wrapping both the statements and the version record in a single /// `Transaction` is sufficient for SQLite (DDL participates in /// transactions there). If a future migration needs an operation that /// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split /// out into its own non-transactional migration — reviewer's job to /// flag. async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)]) -> 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 mut tx = pool.begin().await.map_err(|e| { KonError::StorageError(format!("Migration {} tx begin failed: {e}", version)) })?; for statement in split_statements(sql) { sqlx::query(&statement) .execute(&mut *tx) .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(&mut *tx) .await .map_err(|e| { KonError::StorageError(format!("Migration version record failed: {e}")) })?; tx.commit().await.map_err(|e| { KonError::StorageError(format!("Migration {} commit failed: {e}", version)) })?; log::info!("Migration {} complete", version); } } Ok(()) } #[cfg(test)] mod tests { use super::*; use sqlx::sqlite::SqlitePoolOptions; use sqlx::Row; async fn fk_test_pool() -> SqlitePool { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .expect("enable foreign keys"); pool } #[tokio::test] async fn test_migrations_run_on_empty_db() { let pool = fk_test_pool().await; run_migrations(&pool).await.unwrap(); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM schema_version") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 13); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) .await .unwrap(); } #[tokio::test] async fn test_migrations_idempotent() { let pool = fk_test_pool().await; 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, 13); } #[tokio::test] async fn migration_tasks_meta_adds_columns() { // Task 2.6 — verify list_id / effort / notes are all present on the // tasks table after migrations run. list_id and effort have been // present since v1 (nullable); notes is added by v4. let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); run_migrations(&pool).await.expect("migrate"); let info = sqlx::query("PRAGMA table_info(tasks)") .fetch_all(&pool) .await .unwrap(); let names: Vec = info.iter().map(|r| r.get::("name")).collect(); for col in ["list_id", "effort", "notes"] { assert!( names.contains(&col.to_string()), "tasks must have {col}; got {names:?}" ); } } #[tokio::test] async fn migration_implementation_rules_adds_rule_table() { let pool = fk_test_pool().await; run_migrations(&pool).await.expect("migrate"); let info = sqlx::query("PRAGMA table_info(implementation_rules)") .fetch_all(&pool) .await .unwrap(); let names: Vec = info.iter().map(|r| r.get::("name")).collect(); for col in [ "id", "enabled", "trigger_kind", "trigger_value", "actions_json", "last_fired_key", "created_at", "updated_at", ] { assert!( names.contains(&col.to_string()), "implementation_rules must have {col}; got {names:?}" ); } let rejected = sqlx::query( "INSERT INTO implementation_rules (id, trigger_kind, actions_json) VALUES ('bad', 'calendar_event', '[]')", ) .execute(&pool) .await; assert!( rejected.is_err(), "trigger_kind CHECK constraint must reject unknown triggers" ); } #[tokio::test] async fn migration_transcripts_meta_adds_columns() { // Task 2.5 — verify starred / manual_tags / template / language / // segments_json are all present on the transcripts table after // migrations run. All five are added by v5. let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); run_migrations(&pool).await.expect("migrate"); let info = sqlx::query("PRAGMA table_info(transcripts)") .fetch_all(&pool) .await .unwrap(); let names: Vec = info.iter().map(|r| r.get::("name")).collect(); for col in [ "starred", "manual_tags", "template", "language", "segments_json", ] { assert!( names.contains(&col.to_string()), "transcripts must have {col}; got {names:?}" ); } } #[tokio::test] async fn migration_transcript_profile_provenance_adds_profile_id() { let pool = fk_test_pool().await; run_migrations(&pool).await.expect("migrate"); let info = sqlx::query("PRAGMA table_info(transcripts)") .fetch_all(&pool) .await .unwrap(); let names: Vec = info.iter().map(|r| r.get::("name")).collect(); assert!( names.contains(&"profile_id".to_string()), "transcripts must have profile_id; got {names:?}" ); } #[tokio::test] async fn migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk() { let pool = fk_test_pool().await; run_migrations_up_to(&pool, 8).await.expect("migrate to v8"); sqlx::query( "INSERT INTO profiles (id, name, initial_prompt, created_at) VALUES ('profile-valid', 'Valid', '', datetime('now'))", ) .execute(&pool) .await .expect("seed valid profile"); sqlx::query( "INSERT INTO transcripts ( id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, profile_id ) VALUES ( 't-orphan', 'orphan body', 'microphone', 'Orphan', NULL, 0.0, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'), 0, '', '', '', '', 'profile-missing' )", ) .execute(&pool) .await .expect("seed orphan transcript"); sqlx::query( "INSERT INTO transcripts ( id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, profile_id ) VALUES ( 't-valid', 'valid body', 'microphone', 'Valid', NULL, 0.0, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'), 0, '', '', '', '', 'profile-valid' )", ) .execute(&pool) .await .expect("seed valid transcript"); sqlx::query( "INSERT INTO segments (transcript_id, start_time, end_time, text) VALUES ('t-orphan', 0.0, 1.0, 'segment')", ) .execute(&pool) .await .expect("seed segment"); run_migrations(&pool).await.expect("migrate to v9"); let orphan_profile: String = sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-orphan'") .fetch_one(&pool) .await .expect("read healed orphan"); assert_eq!(orphan_profile, crate::DEFAULT_PROFILE_ID); let valid_profile: String = sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-valid'") .fetch_one(&pool) .await .expect("read preserved profile"); assert_eq!(valid_profile, "profile-valid"); let segment_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM segments WHERE transcript_id = 't-orphan'") .fetch_one(&pool) .await .expect("read migrated segments"); assert_eq!(segment_count, 1, "segments must survive transcript rebuild"); let fk_rows = sqlx::query("PRAGMA foreign_key_list(transcripts)") .fetch_all(&pool) .await .expect("read transcript foreign keys"); assert!( fk_rows.iter().any(|row| { row.get::("table") == "profiles" && row.get::("from") == "profile_id" && row.get::("to") == "id" && row.get::("on_delete") == "RESTRICT" }), "transcripts.profile_id must reference profiles(id) with ON DELETE RESTRICT" ); let fts_hits: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transcripts_fts WHERE transcripts_fts MATCH 'orphan'", ) .fetch_one(&pool) .await .expect("query rebuilt fts"); assert_eq!( fts_hits, 1, "fts index must be rebuilt for existing transcripts" ); } #[tokio::test] async fn test_parent_task_id_cascade_delete() { let pool = fk_test_pool().await; run_migrations(&pool).await.unwrap(); // Insert parent task sqlx::query("INSERT INTO tasks (id, text) VALUES ('parent-1', 'Parent task')") .execute(&pool) .await .unwrap(); // Insert child task with parent_task_id sqlx::query("INSERT INTO tasks (id, text, parent_task_id) VALUES ('child-1', 'Child task', 'parent-1')") .execute(&pool) .await .unwrap(); // Delete parent — child should cascade sqlx::query("DELETE FROM tasks WHERE id = 'parent-1'") .execute(&pool) .await .unwrap(); let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks") .fetch_one(&pool) .await .unwrap(); assert_eq!( remaining, 0, "cascade delete should remove child when parent is deleted" ); } /// Test-only helper: run migrations only up to (and including) `target_version`. /// Used by the v6 upgrade-path test to seed a v5 schema with /// dictionary rows before applying v6. async fn run_migrations_up_to(pool: &SqlitePool, target_version: i64) -> Result<()> { let filtered: Vec<(i64, &str, &str)> = MIGRATIONS .iter() .filter(|(v, _, _)| *v <= target_version) .copied() .collect(); run_migrations_slice(pool, &filtered).await } #[tokio::test] async fn migration_v6_seeds_default_profile_on_fresh_db() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); run_migrations(&pool).await.expect("migrate"); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 1, "Default profile must be seeded on fresh install"); let name: String = sqlx::query_scalar("SELECT name FROM profiles WHERE id = ?") .bind(crate::DEFAULT_PROFILE_ID) .fetch_one(&pool) .await .unwrap(); assert_eq!(name, "Default"); let terms_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms") .fetch_one(&pool) .await .unwrap(); assert_eq!(terms_count, 0, "Fresh DB has no dictionary rows to copy"); } #[tokio::test] async fn migration_v6_copies_dictionary_rows_to_default_profile_terms() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); // Bring schema up to v5 (dictionary + transcripts_meta, no profile_terms). run_migrations_up_to(&pool, 5).await.expect("migrate to v5"); // dictionary.id is INTEGER PK AUTOINCREMENT (see v2); let SQLite assign rowids. sqlx::query( "INSERT INTO dictionary (term, note, created_at) VALUES \ ('Kon', '', datetime('now')), \ ('CORBEL', 'brand', datetime('now')), \ ('Wren', '', datetime('now'))", ) .execute(&pool) .await .expect("seed dictionary"); run_migrations(&pool).await.expect("migrate to v6"); let copied: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?") .bind(crate::DEFAULT_PROFILE_ID) .fetch_one(&pool) .await .unwrap(); assert_eq!(copied, 3); let corbel_note: String = sqlx::query_scalar("SELECT note FROM profile_terms WHERE term = 'CORBEL'") .fetch_one(&pool) .await .unwrap(); assert_eq!(corbel_note, "brand"); } #[tokio::test] async fn migration_v6_trigger_rejects_default_profile_delete() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); run_migrations(&pool).await.expect("migrate"); let result = sqlx::query("DELETE FROM profiles WHERE id = ?") .bind(crate::DEFAULT_PROFILE_ID) .execute(&pool) .await; assert!(result.is_err(), "trigger must block Default deletion"); let msg = result.unwrap_err().to_string(); assert!( msg.contains("Default profile cannot be deleted"), "got: {msg}" ); } #[tokio::test] async fn migration_v6_trigger_rejects_default_profile_rename() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); run_migrations(&pool).await.expect("migrate"); let result = sqlx::query("UPDATE profiles SET name = 'NotDefault' WHERE id = ?") .bind(crate::DEFAULT_PROFILE_ID) .execute(&pool) .await; assert!(result.is_err(), "trigger must block Default rename"); } #[tokio::test] async fn migration_v7_drops_dictionary_table() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); run_migrations(&pool).await.expect("migrate"); // dictionary table should not exist after v7. let result: std::result::Result = sqlx::query_scalar("SELECT COUNT(*) FROM dictionary") .fetch_one(&pool) .await; assert!(result.is_err(), "dictionary table must be gone after v7"); let err = result.unwrap_err().to_string().to_lowercase(); assert!(err.contains("no such table"), "got: {err}"); } // RB-02 regression: a multi-statement migration that fails part-way // through must leave no trace on disk — the transaction rolls back // both the partial schema change and (implicitly) the `schema_version` // row that the pre-fix implementation would have recorded after // statement-level success. // // The poisoned migration below first creates `poison_marker` // (syntactically valid, would succeed against any SQLite) and then // runs a guaranteed-invalid function call. Under the new atomic // implementation, neither `poison_marker` nor the poison row should // survive the failed call. // // Version number must sit above the real MIGRATIONS max so the // baseline migrate cleanly finishes first. #[tokio::test] async fn multi_statement_migration_rolls_back_on_failure() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); run_migrations(&pool).await.expect("baseline migrate"); // Discover the real max version so the poison migration is // always exactly one past the end of MIGRATIONS, regardless of // how many real migrations we add in future. let real_max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version") .fetch_one(&pool) .await .expect("read schema_version"); let poison_version = real_max + 1; let poison: &[(i64, &str, &str)] = &[( poison_version, "rb-02 atomicity poison", r#" CREATE TABLE poison_marker (id INTEGER PRIMARY KEY); SELECT this_function_does_not_exist(); "#, )]; let result = run_migrations_slice(&pool, poison).await; assert!( result.is_err(), "poisoned migration must return Err, got: {result:?}" ); // `poison_marker` must not be on disk — transaction rolled back. let marker: std::result::Result = sqlx::query_scalar("SELECT COUNT(*) FROM poison_marker") .fetch_one(&pool) .await; assert!( marker.is_err(), "poison_marker must not exist; got: {marker:?}" ); // `schema_version` must not include the poison version — version // insert is part of the same transaction that rolled back. let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version") .fetch_one(&pool) .await .expect("read schema_version"); assert_eq!( max, real_max, "schema_version must not advance past the failed migration" ); } #[tokio::test] async fn migration_v13_adds_auto_completed_column() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("pool"); run_migrations(&pool).await.expect("migrate"); // Column exists. let info = sqlx::query("PRAGMA table_info(tasks)") .fetch_all(&pool) .await .expect("pragma"); let names: Vec = info.iter().map(|r| r.get::("name")).collect(); assert!( names.iter().any(|n| n == "auto_completed"), "expected auto_completed column, got {names:?}" ); // Existing completed rows default to 0. Insert a pre-existing-looking // task via raw SQL to simulate a row from before the migration. sqlx::query( "INSERT INTO tasks (id, text, bucket, done, done_at) \ VALUES ('t1', 'pre-existing', 'inbox', 1, '2026-04-20 12:00:00')", ) .execute(&pool) .await .expect("insert"); let auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 't1'") .fetch_one(&pool) .await .expect("query"); assert_eq!(auto, 0, "pre-existing completed rows must default to 0"); } }