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

@@ -136,25 +136,12 @@ pub async fn insert_task(
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> { pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows = let rows =
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id FROM tasks ORDER BY created_at DESC") sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, priority, project, status, updated_at, sort_order, notes FROM tasks ORDER BY sort_order ASC, created_at DESC")
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?; .map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
Ok(rows Ok(rows.into_iter().map(task_row_from).collect())
.into_iter()
.map(|r| TaskRow {
id: r.get("id"),
text: r.get("text"),
bucket: r.get("bucket"),
list_id: r.get("list_id"),
effort: r.get("effort"),
done: r.get("done"),
done_at: r.get("done_at"),
created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"),
})
.collect())
} }
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
@@ -175,6 +162,180 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
Ok(()) Ok(())
} }
// --- Task v2 CRUD (extended fields) ---
/// Insert a task with all v2 fields.
pub async fn insert_task_v2(
pool: &SqlitePool,
id: &str,
text: &str,
priority: &str,
project: Option<&str>,
status: &str,
bucket: &str,
effort: Option<&str>,
source_transcript_id: Option<&str>,
sort_order: i64,
) -> Result<()> {
sqlx::query(
"INSERT INTO tasks (id, text, priority, project, status, bucket, effort, source_transcript_id, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(text)
.bind(priority)
.bind(project)
.bind(status)
.bind(bucket)
.bind(effort)
.bind(source_transcript_id)
.bind(sort_order)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert task v2 failed: {e}")))?;
Ok(())
}
/// Update a task's mutable fields, setting updated_at to now.
pub async fn update_task_v2(
pool: &SqlitePool,
id: &str,
text: &str,
priority: &str,
project: Option<&str>,
status: &str,
bucket: &str,
effort: Option<&str>,
notes: &str,
) -> Result<()> {
sqlx::query(
"UPDATE tasks SET text = ?, priority = ?, project = ?, status = ?, bucket = ?, effort = ?, notes = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(text)
.bind(priority)
.bind(project)
.bind(status)
.bind(bucket)
.bind(effort)
.bind(notes)
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Update task v2 failed: {e}")))?;
Ok(())
}
/// Reorder tasks by setting sort_order based on position in the given ID list.
pub async fn reorder_tasks(pool: &SqlitePool, task_ids: &[String]) -> Result<()> {
for (i, id) in task_ids.iter().enumerate() {
sqlx::query("UPDATE tasks SET sort_order = ?, updated_at = datetime('now') WHERE id = ?")
.bind(i as i64)
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Reorder tasks failed: {e}")))?;
}
Ok(())
}
/// List tasks filtered by status, with a limit.
pub async fn list_tasks_by_status(
pool: &SqlitePool,
status: &str,
limit: i64,
) -> Result<Vec<TaskRow>> {
let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, priority, project, status, updated_at, sort_order, notes
FROM tasks WHERE status = ?
ORDER BY sort_order ASC, created_at DESC
LIMIT ?",
)
.bind(status)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List tasks by status failed: {e}")))?;
Ok(rows.into_iter().map(task_row_from).collect())
}
// --- FTS5 Search ---
/// Full-text search across transcripts using the FTS5 virtual table.
pub async fn search_transcripts(
pool: &SqlitePool,
query: &str,
limit: i64,
) -> Result<Vec<TranscriptRow>> {
let rows = sqlx::query(
"SELECT t.id, t.text, t.source, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at
FROM transcripts t
JOIN transcripts_fts fts ON t.rowid = fts.rowid
WHERE transcripts_fts MATCH ?1
ORDER BY rank
LIMIT ?2",
)
.bind(query)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("FTS search failed: {e}")))?;
Ok(rows.iter().map(transcript_row_from).collect())
}
// --- Timer State Persistence ---
/// Save or replace the active timer state.
pub async fn save_timer_state(
pool: &SqlitePool,
task_id: &str,
total_seconds: i64,
remaining_seconds: i64,
paused: bool,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO timer_state (id, task_id, total_seconds, remaining_seconds, paused)
VALUES ('active', ?, ?, ?, ?)",
)
.bind(task_id)
.bind(total_seconds)
.bind(remaining_seconds)
.bind(paused)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Save timer state failed: {e}")))?;
Ok(())
}
/// Retrieve the currently active timer state, if any.
pub async fn get_timer_state(pool: &SqlitePool) -> Result<Option<TimerStateRow>> {
let row = sqlx::query(
"SELECT id, task_id, total_seconds, remaining_seconds, started_at, paused FROM timer_state WHERE id = 'active'",
)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get timer state failed: {e}")))?;
Ok(row.map(|r| TimerStateRow {
id: r.get("id"),
task_id: r.get("task_id"),
total_seconds: r.get("total_seconds"),
remaining_seconds: r.get("remaining_seconds"),
started_at: r.get("started_at"),
paused: r.get("paused"),
}))
}
/// Clear the active timer state (timer completed or cancelled).
pub async fn clear_timer_state(pool: &SqlitePool) -> Result<()> {
sqlx::query("DELETE FROM timer_state WHERE id = 'active'")
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Clear timer state failed: {e}")))?;
Ok(())
}
// --- Settings CRUD --- // --- Settings CRUD ---
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> { pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
@@ -229,6 +390,43 @@ pub struct TaskRow {
pub done_at: Option<String>, pub done_at: Option<String>,
pub created_at: String, pub created_at: String,
pub source_transcript_id: Option<String>, pub source_transcript_id: Option<String>,
// v2 fields
pub priority: String,
pub project: Option<String>,
pub status: String,
pub updated_at: String,
pub sort_order: i64,
pub notes: String,
}
#[derive(Debug, Clone)]
pub struct TimerStateRow {
pub id: String,
pub task_id: String,
pub total_seconds: i64,
pub remaining_seconds: i64,
pub started_at: String,
pub paused: bool,
}
fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
TaskRow {
id: r.get("id"),
text: r.get("text"),
bucket: r.get("bucket"),
list_id: r.get("list_id"),
effort: r.get("effort"),
done: r.get("done"),
done_at: r.get("done_at"),
created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"),
priority: r.get("priority"),
project: r.get("project"),
status: r.get("status"),
updated_at: r.get("updated_at"),
sort_order: r.get("sort_order"),
notes: r.get("notes"),
}
} }
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow { fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
@@ -357,6 +555,12 @@ mod tests {
assert_eq!(tasks.len(), 1); assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].text, "Buy groceries"); assert_eq!(tasks[0].text, "Buy groceries");
assert!(!tasks[0].done); assert!(!tasks[0].done);
// v2 defaults should be populated
assert_eq!(tasks[0].priority, "medium");
assert_eq!(tasks[0].status, "pending");
assert_eq!(tasks[0].sort_order, 0);
assert_eq!(tasks[0].notes, "");
assert!(tasks[0].project.is_none());
complete_task(&pool, "task1").await.unwrap(); complete_task(&pool, "task1").await.unwrap();
let tasks = list_tasks(&pool).await.unwrap(); let tasks = list_tasks(&pool).await.unwrap();
@@ -367,6 +571,182 @@ mod tests {
assert!(tasks.is_empty()); assert!(tasks.is_empty());
} }
#[tokio::test]
async fn task_v2_crud_roundtrip() {
let pool = test_pool().await;
insert_task_v2(
&pool,
"task-v2",
"Write unit tests",
"high",
Some("kon"),
"active",
"today",
Some("small"),
None,
1,
)
.await
.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].text, "Write unit tests");
assert_eq!(tasks[0].priority, "high");
assert_eq!(tasks[0].project.as_deref(), Some("kon"));
assert_eq!(tasks[0].status, "active");
assert_eq!(tasks[0].sort_order, 1);
update_task_v2(
&pool,
"task-v2",
"Write integration tests",
"medium",
None,
"done",
"today",
Some("medium"),
"Completed all tests",
)
.await
.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert_eq!(tasks[0].text, "Write integration tests");
assert_eq!(tasks[0].priority, "medium");
assert!(tasks[0].project.is_none());
assert_eq!(tasks[0].status, "done");
assert_eq!(tasks[0].notes, "Completed all tests");
}
#[tokio::test]
async fn task_list_by_status() {
let pool = test_pool().await;
insert_task_v2(&pool, "t1", "Task A", "high", None, "pending", "inbox", None, None, 0).await.unwrap();
insert_task_v2(&pool, "t2", "Task B", "low", None, "active", "today", None, None, 1).await.unwrap();
insert_task_v2(&pool, "t3", "Task C", "medium", None, "pending", "inbox", None, None, 2).await.unwrap();
let pending = list_tasks_by_status(&pool, "pending", 10).await.unwrap();
assert_eq!(pending.len(), 2);
let active = list_tasks_by_status(&pool, "active", 10).await.unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].text, "Task B");
}
#[tokio::test]
async fn task_reorder() {
let pool = test_pool().await;
insert_task_v2(&pool, "t1", "First", "medium", None, "pending", "inbox", None, None, 0).await.unwrap();
insert_task_v2(&pool, "t2", "Second", "medium", None, "pending", "inbox", None, None, 1).await.unwrap();
insert_task_v2(&pool, "t3", "Third", "medium", None, "pending", "inbox", None, None, 2).await.unwrap();
// Reverse the order
reorder_tasks(&pool, &["t3".to_string(), "t2".to_string(), "t1".to_string()])
.await
.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert_eq!(tasks[0].id, "t3");
assert_eq!(tasks[1].id, "t2");
assert_eq!(tasks[2].id, "t1");
}
#[tokio::test]
async fn fts_search_transcripts() {
let pool = test_pool().await;
insert_transcript(
&pool,
&InsertTranscriptParams {
id: "tr1",
text: "Meeting about the quarterly budget review",
source: "microphone",
title: Some("Budget Meeting"),
audio_path: None,
duration: 120.0,
engine: Some("whisper"),
model_id: None,
inference_ms: None,
sample_rate: None,
audio_channels: None,
format_mode: None,
remove_fillers: false,
british_english: true,
anti_hallucination: false,
},
)
.await
.unwrap();
insert_transcript(
&pool,
&InsertTranscriptParams {
id: "tr2",
text: "Grocery list for the weekend shop",
source: "microphone",
title: Some("Shopping"),
audio_path: None,
duration: 30.0,
engine: Some("whisper"),
model_id: None,
inference_ms: None,
sample_rate: None,
audio_channels: None,
format_mode: None,
remove_fillers: false,
british_english: true,
anti_hallucination: false,
},
)
.await
.unwrap();
let results = search_transcripts(&pool, "budget", 10).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "tr1");
let results = search_transcripts(&pool, "grocery", 10).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "tr2");
let results = search_transcripts(&pool, "nonexistent", 10).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn timer_state_persistence() {
let pool = test_pool().await;
// No timer state initially
let state = get_timer_state(&pool).await.unwrap();
assert!(state.is_none());
// Save timer state
save_timer_state(&pool, "task-1", 300, 250, false).await.unwrap();
let state = get_timer_state(&pool).await.unwrap().unwrap();
assert_eq!(state.task_id, "task-1");
assert_eq!(state.total_seconds, 300);
assert_eq!(state.remaining_seconds, 250);
assert!(!state.paused);
// Update timer state (pause)
save_timer_state(&pool, "task-1", 300, 200, true).await.unwrap();
let state = get_timer_state(&pool).await.unwrap().unwrap();
assert_eq!(state.remaining_seconds, 200);
assert!(state.paused);
// Clear timer
clear_timer_state(&pool).await.unwrap();
let state = get_timer_state(&pool).await.unwrap();
assert!(state.is_none());
}
#[tokio::test] #[tokio::test]
async fn settings_crud_roundtrip() { async fn settings_crud_roundtrip() {
let pool = test_pool().await; let pool = test_pool().await;

View File

@@ -3,8 +3,10 @@ pub mod file_storage;
pub mod migrations; pub mod migrations;
pub use database::{ pub use database::{
complete_task, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task, clear_timer_state, complete_task, delete_task, delete_transcript, get_setting,
insert_transcript, list_tasks, list_transcripts, log_error, set_setting, get_timer_state, get_transcript, init, insert_task, insert_task_v2, insert_transcript,
InsertTranscriptParams, TaskRow, TranscriptRow, list_tasks, list_tasks_by_status, list_transcripts, log_error, reorder_tasks,
save_timer_state, search_transcripts, set_setting, update_task_v2,
InsertTranscriptParams, TaskRow, TimerStateRow, TranscriptRow,
}; };
pub use file_storage::{app_data_dir, database_path, recordings_dir}; pub use file_storage::{app_data_dir, database_path, recordings_dir};

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_tasks_transcript ON tasks(source_transcript_id);
CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context) 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. /// Ensure the schema_version table exists and run any pending migrations.
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
sqlx::query( sqlx::query(
@@ -97,16 +195,13 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
if *version > current { if *version > current {
log::info!("Running migration {}: {}", version, description); log::info!("Running migration {}: {}", version, description);
let statements: Vec<&str> = sql.split(';') let statements = split_sql_statements(sql);
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for statement in statements { for statement in &statements {
sqlx::query(statement) sqlx::query(statement)
.execute(pool) .execute(pool)
.await .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 (?, ?)") sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
@@ -141,7 +236,7 @@ mod tests {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(count, 1); assert_eq!(count, 2);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool) .execute(&pool)
@@ -149,6 +244,27 @@ mod tests {
.unwrap(); .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] #[tokio::test]
async fn test_migrations_idempotent() { async fn test_migrations_idempotent() {
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
@@ -163,6 +279,6 @@ mod tests {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(count, 1); assert_eq!(count, 2);
} }
} }