diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index afe07a4..50df542 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -2,6 +2,10 @@ pub mod database; pub mod file_storage; pub mod migrations; +/// Stable identifier for the seeded Default profile (see migration v6). +/// The Default profile cannot be renamed or deleted — guarded by SQLite triggers. +pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001"; + pub use database::{ add_dictionary_entry, complete_subtask_and_check_parent, complete_task, count_transcripts, delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task, diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 544e808..1f3f992 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -126,6 +126,59 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ 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; + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -226,7 +279,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 5); + assert_eq!(count, 6); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -248,7 +301,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 5); + assert_eq!(count, 6); } #[tokio::test] @@ -340,4 +393,133 @@ mod tests { .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`. + /// Mirrors `run_migrations` but stops early — 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<()> { + 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 && *version <= target_version { + 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}")))?; + } + } + Ok(()) + } + + #[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"); + } }