feat(storage): add migration v2 — task fields, FTS5 search, timer state

- Migration v2: adds priority, project, status, updated_at, sort_order, notes columns to tasks table
- FTS5 virtual table on transcripts with insert/update/delete triggers for automatic index sync
- Timer state table for persisting active countdown timers across app restarts
- New database functions: insert_task_v2, update_task_v2, reorder_tasks, list_tasks_by_status, search_transcripts, save/get/clear_timer_state
- TimerStateRow type for timer persistence
- Extended TaskRow with v2 fields
- Fixed migration runner to handle BEGIN...END blocks in CREATE TRIGGER statements
- All 11 tests passing, Tauri app compiles cleanly
This commit is contained in:
jake
2026-03-21 12:38:43 +00:00
parent 103585d7ea
commit d79ad40b33
3 changed files with 524 additions and 26 deletions

View File

@@ -73,8 +73,106 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
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, "phase 2 — task fields, FTS5, timer state", r#"
ALTER TABLE tasks ADD COLUMN priority TEXT NOT NULL DEFAULT 'medium';
ALTER TABLE tasks ADD COLUMN project TEXT;
ALTER TABLE tasks ADD COLUMN status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE tasks ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now'));
ALTER TABLE tasks ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT '';
CREATE VIRTUAL TABLE IF NOT EXISTS transcripts_fts USING fts5(
text,
title,
content='transcripts',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS transcripts_ai AFTER INSERT ON transcripts BEGIN
INSERT INTO transcripts_fts(rowid, text, title)
VALUES (new.rowid, new.text, 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, 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, old.title);
INSERT INTO transcripts_fts(rowid, text, title)
VALUES (new.rowid, new.text, new.title);
END;
CREATE TABLE IF NOT EXISTS timer_state (
id TEXT PRIMARY KEY DEFAULT 'active',
task_id TEXT NOT NULL,
total_seconds INTEGER NOT NULL,
remaining_seconds INTEGER NOT NULL,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
paused INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)
"#),
];
/// Split SQL text into individual statements, respecting BEGIN...END blocks
/// used by CREATE TRIGGER statements. A naive split on ';' would break
/// triggers that contain semicolons inside their body.
fn split_sql_statements(sql: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current = String::new();
let mut depth = 0; // Track BEGIN...END nesting
for fragment in sql.split(';') {
let trimmed = fragment.trim();
if trimmed.is_empty() {
continue;
}
let upper = trimmed.to_uppercase();
// Count BEGIN/END keywords to track trigger body nesting
if upper.contains("BEGIN") {
depth += 1;
}
if depth > 0 {
// Inside a BEGIN...END block — accumulate fragments
if !current.is_empty() {
current.push(';');
}
current.push_str(fragment);
if upper.trim_end().ends_with("END") {
depth -= 1;
if depth == 0 {
let stmt = current.trim().to_string();
if !stmt.is_empty() {
statements.push(stmt);
}
current.clear();
}
}
} else {
// Normal statement — emit directly
statements.push(trimmed.to_string());
}
}
// Emit anything left over
let leftover = current.trim().to_string();
if !leftover.is_empty() {
statements.push(leftover);
}
statements
}
/// Ensure the schema_version table exists and run any pending migrations.
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
sqlx::query(
@@ -97,16 +195,13 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
if *version > current {
log::info!("Running migration {}: {}", version, description);
let statements: Vec<&str> = sql.split(';')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let statements = split_sql_statements(sql);
for statement in statements {
for statement in &statements {
sqlx::query(statement)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?;
.map_err(|e| KonError::StorageError(format!("Migration {} failed on statement: {e}\nSQL: {}", version, statement)))?;
}
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
@@ -141,7 +236,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
assert_eq!(count, 2);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -149,6 +244,27 @@ mod tests {
.unwrap();
}
#[test]
fn test_split_sql_handles_triggers() {
let sql = r#"
ALTER TABLE tasks ADD COLUMN priority TEXT;
CREATE TRIGGER IF NOT EXISTS test_trigger AFTER INSERT ON tasks BEGIN
INSERT INTO log(msg) VALUES (new.id);
END;
CREATE TABLE IF NOT EXISTS timer (id TEXT PRIMARY KEY)
"#;
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 3, "Expected 3 statements, got: {stmts:#?}");
assert!(stmts[0].contains("ALTER TABLE"));
assert!(stmts[1].contains("CREATE TRIGGER"));
assert!(stmts[1].contains("BEGIN"));
assert!(stmts[1].contains("END"));
assert!(stmts[2].contains("CREATE TABLE"));
}
#[tokio::test]
async fn test_migrations_idempotent() {
let pool = SqlitePoolOptions::new()
@@ -163,6 +279,6 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
assert_eq!(count, 2);
}
}