feat(storage): migration v6 — profiles + profile_terms + Default-guard triggers; seed from dictionary
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user