From c61f0585e03f2e7bb25dd01808cd0b2b01bbad52 Mon Sep 17 00:00:00 2001 From: jake Date: Sat, 21 Mar 2026 14:43:06 +0000 Subject: [PATCH] feat(storage): add parent_task_id migration for micro-stepping Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/storage/src/database.rs | 50 ++++++++++++++++++++++++++++++-- crates/storage/src/lib.rs | 11 +++---- crates/storage/src/migrations.rs | 8 +++-- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index b61bb7d..fab988c 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -194,7 +194,7 @@ pub async fn insert_task( pub async fn list_tasks(pool: &SqlitePool) -> Result> { 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 ORDER BY sort_order ASC, 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, parent_task_id FROM tasks ORDER BY sort_order ASC, created_at DESC") .fetch_all(pool) .await .map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?; @@ -303,7 +303,7 @@ pub async fn list_tasks_by_status( limit: i64, ) -> Result> { 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 + "SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, priority, project, status, updated_at, sort_order, notes, parent_task_id FROM tasks WHERE status = ? ORDER BY sort_order ASC, created_at DESC LIMIT ?", @@ -317,6 +317,49 @@ pub async fn list_tasks_by_status( Ok(rows.into_iter().map(task_row_from).collect()) } +// --- Subtask (micro-step) queries --- + +/// List subtasks (children) of a parent task, ordered by sort_order. +pub async fn list_subtasks(pool: &SqlitePool, parent_task_id: &str) -> Result> { + 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, parent_task_id + FROM tasks WHERE parent_task_id = ? + ORDER BY sort_order ASC", + ) + .bind(parent_task_id) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List subtasks failed: {e}")))?; + + Ok(rows.into_iter().map(task_row_from).collect()) +} + +/// Check whether all subtasks of a parent are done. +pub async fn all_subtasks_done(pool: &SqlitePool, parent_task_id: &str) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0", + ) + .bind(parent_task_id) + .fetch_one(pool) + .await + .map_err(|e| KonError::StorageError(format!("Check subtasks failed: {e}")))?; + + Ok(count == 0) +} + +/// Check whether a task has any subtasks. +pub async fn has_subtasks(pool: &SqlitePool, task_id: &str) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?", + ) + .bind(task_id) + .fetch_one(pool) + .await + .map_err(|e| KonError::StorageError(format!("Check has subtasks failed: {e}")))?; + + Ok(count > 0) +} + // --- FTS5 Search --- /// Full-text search across transcripts using the FTS5 virtual table. @@ -455,6 +498,8 @@ pub struct TaskRow { pub updated_at: String, pub sort_order: i64, pub notes: String, + // v3 field + pub parent_task_id: Option, } #[derive(Debug, Clone)] @@ -484,6 +529,7 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow { updated_at: r.get("updated_at"), sort_order: r.get("sort_order"), notes: r.get("notes"), + parent_task_id: r.get("parent_task_id"), } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index b73b59e..825ef5a 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -3,10 +3,11 @@ pub mod file_storage; pub mod migrations; pub use database::{ - clear_timer_state, complete_task, delete_task, delete_transcript, get_segments, - get_setting, get_timer_state, get_transcript, init, insert_segments, insert_task, - insert_task_v2, insert_transcript, list_tasks, list_tasks_by_status, list_transcripts, - log_error, reorder_tasks, save_timer_state, search_transcripts, set_setting, - update_task_v2, InsertTranscriptParams, SegmentRow, TaskRow, TimerStateRow, TranscriptRow, + all_subtasks_done, clear_timer_state, complete_task, delete_task, delete_transcript, + get_segments, get_setting, get_timer_state, get_transcript, has_subtasks, init, + insert_segments, insert_task, insert_task_v2, insert_transcript, list_subtasks, list_tasks, + list_tasks_by_status, list_transcripts, log_error, reorder_tasks, save_timer_state, + search_transcripts, set_setting, update_task_v2, InsertTranscriptParams, SegmentRow, + TaskRow, TimerStateRow, TranscriptRow, }; pub use file_storage::{app_data_dir, database_path, recordings_dir}; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 6fc1d91..7b21e23 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -118,6 +118,10 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project) "#), + (3, "phase 2b — parent_task_id for micro-stepping", r#" + ALTER TABLE tasks ADD COLUMN parent_task_id TEXT; + CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id) + "#), ]; /// Split SQL text into individual statements, respecting BEGIN...END blocks @@ -236,7 +240,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 2); + assert_eq!(count, 3); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -279,6 +283,6 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 2); + assert_eq!(count, 3); } }