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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user