feat(storage): migration v6 — profiles + profile_terms + Default-guard triggers; seed from dictionary

This commit is contained in:
2026-04-19 20:39:33 +01:00
parent e5661b9111
commit 3f784313aa
2 changed files with 188 additions and 2 deletions

View File

@@ -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,

View File

@@ -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");
}
}