Skip to main content

track/db/
migrate.rs

1//! SQLite schema migrations for existing track databases.
2
3use crate::models::{TaskStatus, TodoStatus};
4use crate::utils::Result;
5use rusqlite::Connection;
6
7/// Adds CHECK constraints on task/todo status columns for existing databases.
8pub(crate) fn migrate_status_check_constraints(conn: &Connection) -> Result<()> {
9    let tasks_sql: String = conn
10        .query_row(
11            "SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'",
12            [],
13            |row| row.get(0),
14        )
15        .unwrap_or_default();
16
17    if tasks_sql.contains("CHECK") {
18        return Ok(());
19    }
20
21    let invalid_tasks: i64 = conn.query_row(
22        &format!(
23            "SELECT COUNT(*) FROM tasks WHERE status NOT IN ('{}', '{}')",
24            TaskStatus::ACTIVE,
25            TaskStatus::ARCHIVED
26        ),
27        [],
28        |row| row.get(0),
29    )?;
30    if invalid_tasks > 0 {
31        return Err(crate::utils::TrackError::MigrationBlocked {
32            detail: format!("{invalid_tasks} tasks have invalid status values"),
33        });
34    }
35
36    let invalid_todos: i64 = conn.query_row(
37        &format!(
38            "SELECT COUNT(*) FROM todos WHERE status NOT IN ('{}', '{}', '{}')",
39            TodoStatus::PENDING,
40            TodoStatus::DONE,
41            TodoStatus::CANCELLED
42        ),
43        [],
44        |row| row.get(0),
45    )?;
46    if invalid_todos > 0 {
47        return Err(crate::utils::TrackError::MigrationBlocked {
48            detail: format!("{invalid_todos} todos have invalid status values"),
49        });
50    }
51
52    let task_check = format!(
53        "CHECK (status IN ('{}', '{}'))",
54        TaskStatus::ACTIVE,
55        TaskStatus::ARCHIVED
56    );
57    let todo_check = format!(
58        "CHECK (status IN ('{}', '{}', '{}'))",
59        TodoStatus::PENDING,
60        TodoStatus::DONE,
61        TodoStatus::CANCELLED
62    );
63
64    conn.execute_batch(&format!(
65            r#"
66            CREATE TABLE tasks_new (
67                id INTEGER PRIMARY KEY AUTOINCREMENT,
68                name TEXT NOT NULL,
69                description TEXT,
70                status TEXT NOT NULL DEFAULT '{task_active}' {task_check},
71                ticket_id TEXT,
72                ticket_url TEXT,
73                alias TEXT,
74                is_today_task INTEGER DEFAULT 0,
75                created_at TEXT NOT NULL
76            );
77            INSERT INTO tasks_new (id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at)
78            SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks;
79            DROP TABLE tasks;
80            ALTER TABLE tasks_new RENAME TO tasks;
81            CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias);
82            CREATE INDEX IF NOT EXISTS idx_tasks_is_today_task ON tasks(is_today_task);
83
84            CREATE TABLE todos_new (
85                id INTEGER PRIMARY KEY AUTOINCREMENT,
86                task_id INTEGER NOT NULL,
87                task_index INTEGER,
88                content TEXT NOT NULL,
89                status TEXT NOT NULL DEFAULT '{todo_pending}' {todo_check},
90                worktree_requested INTEGER NOT NULL DEFAULT 0,
91                requires_workspace INTEGER NOT NULL DEFAULT 1,
92                created_at TEXT NOT NULL,
93                completed_at TEXT,
94                FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
95            );
96            INSERT INTO todos_new (id, task_id, task_index, content, status, worktree_requested, requires_workspace, created_at, completed_at)
97            SELECT id, task_id, task_index, content, status, worktree_requested, 1, created_at, completed_at FROM todos;
98            DROP TABLE todos;
99            ALTER TABLE todos_new RENAME TO todos;
100            CREATE INDEX IF NOT EXISTS idx_todos_task_id ON todos(task_id);
101            CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_task_index ON todos(task_id, task_index);
102            "#,
103            task_active = TaskStatus::ACTIVE,
104            task_check = task_check,
105            todo_pending = TodoStatus::PENDING,
106            todo_check = todo_check,
107        ))?;
108
109    Ok(())
110}
111
112pub fn migrate_schema(conn: &Connection) -> Result<()> {
113    // Migrate git_items table to worktrees (for existing databases)
114    let git_items_exists: i64 = conn.query_row(
115        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_items'",
116        [],
117        |row| row.get(0),
118    )?;
119
120    if git_items_exists > 0 {
121        // Drop old indexes before renaming table
122        conn.execute("DROP INDEX IF EXISTS idx_git_items_task_id", [])?;
123        conn.execute("DROP INDEX IF EXISTS idx_git_items_todo_id", [])?;
124
125        // Rename git_items table to worktrees
126        conn.execute("ALTER TABLE git_items RENAME TO worktrees", [])?;
127
128        // Create new indexes with correct names
129        conn.execute(
130            "CREATE INDEX IF NOT EXISTS idx_worktrees_task_id ON worktrees(task_id)",
131            [],
132        )?;
133    }
134
135    // Migrate repo_links.git_item_id to worktree_id (for existing databases)
136    let git_item_id_exists: i64 = conn.query_row(
137        "SELECT COUNT(*) FROM pragma_table_info('repo_links') WHERE name='git_item_id'",
138        [],
139        |row| row.get(0),
140    )?;
141
142    if git_item_id_exists > 0 {
143        // SQLite doesn't support renaming columns directly in older versions
144        // We need to recreate the table
145        conn.execute_batch(
146            r#"
147                -- Create new repo_links table with worktree_id
148                CREATE TABLE repo_links_new (
149                    id INTEGER PRIMARY KEY AUTOINCREMENT,
150                    worktree_id INTEGER NOT NULL,
151                    url TEXT NOT NULL,
152                    kind TEXT NOT NULL,
153                    created_at TEXT NOT NULL,
154                    FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
155                );
156
157                -- Copy data from old table
158                INSERT INTO repo_links_new (id, worktree_id, url, kind, created_at)
159                SELECT id, git_item_id, url, kind, created_at FROM repo_links;
160
161                -- Drop old table
162                DROP TABLE repo_links;
163
164                -- Rename new table to repo_links
165                ALTER TABLE repo_links_new RENAME TO repo_links;
166
167                -- Recreate index
168                CREATE INDEX IF NOT EXISTS idx_repo_links_worktree_id ON repo_links(worktree_id);
169                "#,
170        )?;
171    }
172
173    // Check for todo_id column in worktrees
174    let count: i64 = conn.query_row(
175        "SELECT COUNT(*) FROM pragma_table_info('worktrees') WHERE name='todo_id'",
176        [],
177        |row| row.get(0),
178    )?;
179
180    if count == 0 {
181        conn.execute("ALTER TABLE worktrees ADD COLUMN todo_id INTEGER REFERENCES todos(id) ON DELETE SET NULL", [])?;
182    }
183    conn.execute(
184        "CREATE INDEX IF NOT EXISTS idx_worktrees_todo_id ON worktrees(todo_id)",
185        [],
186    )?;
187
188    // Check for is_base column in worktrees
189    let count: i64 = conn.query_row(
190        "SELECT COUNT(*) FROM pragma_table_info('worktrees') WHERE name='is_base'",
191        [],
192        |row| row.get(0),
193    )?;
194
195    if count == 0 {
196        conn.execute(
197            "ALTER TABLE worktrees ADD COLUMN is_base INTEGER DEFAULT 0",
198            [],
199        )?;
200    }
201
202    // Check for description column in tasks
203    let count: i64 = conn.query_row(
204        "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='description'",
205        [],
206        |row| row.get(0),
207    )?;
208
209    if count == 0 {
210        conn.execute("ALTER TABLE tasks ADD COLUMN description TEXT", [])?;
211    }
212
213    // Check for task_index column in todos
214    let count: i64 = conn.query_row(
215        "SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='task_index'",
216        [],
217        |row| row.get(0),
218    )?;
219
220    if count == 0 {
221        // Add task_index column
222        conn.execute("ALTER TABLE todos ADD COLUMN task_index INTEGER", [])?;
223
224        // Populate task_index for existing TODOs based on creation order
225        conn.execute_batch(
226            r#"
227                WITH numbered_todos AS (
228                    SELECT id, task_id, 
229                           ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
230                    FROM todos
231                )
232                UPDATE todos 
233                SET task_index = (
234                    SELECT idx FROM numbered_todos WHERE numbered_todos.id = todos.id
235                )
236                "#,
237        )?;
238
239        // Create unique index on (task_id, task_index)
240        conn.execute(
241            "CREATE UNIQUE INDEX idx_todos_task_index ON todos(task_id, task_index)",
242            [],
243        )?;
244    }
245
246    // Check for worktree_requested column in todos
247    let count: i64 = conn.query_row(
248        "SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='worktree_requested'",
249        [],
250        |row| row.get(0),
251    )?;
252
253    if count == 0 {
254        conn.execute(
255            "ALTER TABLE todos ADD COLUMN worktree_requested INTEGER NOT NULL DEFAULT 0",
256            [],
257        )?;
258    }
259
260    // Check for requires_workspace column in todos
261    let count: i64 = conn.query_row(
262        "SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='requires_workspace'",
263        [],
264        |row| row.get(0),
265    )?;
266
267    if count == 0 {
268        conn.execute(
269            "ALTER TABLE todos ADD COLUMN requires_workspace INTEGER NOT NULL DEFAULT 1",
270            [],
271        )?;
272    }
273
274    // Check for base_branch column in task_repos
275    let count: i64 = conn.query_row(
276        "SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='base_branch'",
277        [],
278        |row| row.get(0),
279    )?;
280
281    if count == 0 {
282        conn.execute("ALTER TABLE task_repos ADD COLUMN base_branch TEXT", [])?;
283    }
284
285    // Check for base_commit_hash column in task_repos
286    let count: i64 = conn.query_row(
287        "SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='base_commit_hash'",
288        [],
289        |row| row.get(0),
290    )?;
291
292    if count == 0 {
293        conn.execute(
294            "ALTER TABLE task_repos ADD COLUMN base_commit_hash TEXT",
295            [],
296        )?;
297    }
298
299    // Check for completed_at column in todos
300    let count: i64 = conn.query_row(
301        "SELECT COUNT(*) FROM pragma_table_info('todos') WHERE name='completed_at'",
302        [],
303        |row| row.get(0),
304    )?;
305
306    if count == 0 {
307        conn.execute("ALTER TABLE todos ADD COLUMN completed_at TEXT", [])?;
308    }
309
310    // Check for task_index column in scraps
311    let count: i64 = conn.query_row(
312        "SELECT COUNT(*) FROM pragma_table_info('scraps') WHERE name='task_index'",
313        [],
314        |row| row.get(0),
315    )?;
316
317    if count == 0 {
318        // Add task_index column
319        conn.execute("ALTER TABLE scraps ADD COLUMN task_index INTEGER", [])?;
320
321        // Populate task_index for existing scraps based on creation order
322        conn.execute_batch(
323            r#"
324                WITH numbered_scraps AS (
325                    SELECT id, task_id, 
326                           ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
327                    FROM scraps
328                )
329                UPDATE scraps 
330                SET task_index = (
331                    SELECT idx FROM numbered_scraps WHERE numbered_scraps.id = scraps.id
332                )
333                "#,
334        )?;
335
336        // Create unique index on (task_id, task_index)
337        conn.execute(
338            "CREATE UNIQUE INDEX idx_scraps_task_index ON scraps(task_id, task_index)",
339            [],
340        )?;
341    }
342
343    // Ensure repo_links index exists (for both new and migrated databases)
344    conn.execute(
345        "CREATE INDEX IF NOT EXISTS idx_repo_links_worktree_id ON repo_links(worktree_id)",
346        [],
347    )?;
348
349    // Check for alias column in tasks
350    let count: i64 = conn.query_row(
351        "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='alias'",
352        [],
353        |row| row.get(0),
354    )?;
355
356    if count == 0 {
357        // Add column without UNIQUE constraint first (SQLite limitation)
358        conn.execute("ALTER TABLE tasks ADD COLUMN alias TEXT", [])?;
359    }
360
361    // Create UNIQUE index for alias column
362    conn.execute(
363        "CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias)",
364        [],
365    )?;
366
367    // Check for task_index column in links
368    let count: i64 = conn.query_row(
369        "SELECT COUNT(*) FROM pragma_table_info('links') WHERE name='task_index'",
370        [],
371        |row| row.get(0),
372    )?;
373
374    if count == 0 {
375        // Add task_index column
376        conn.execute("ALTER TABLE links ADD COLUMN task_index INTEGER", [])?;
377
378        // Populate task_index for existing links based on creation order
379        conn.execute_batch(
380            r#"
381                WITH numbered_links AS (
382                    SELECT id, task_id, 
383                           ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
384                    FROM links
385                )
386                UPDATE links 
387                SET task_index = (
388                    SELECT idx FROM numbered_links WHERE numbered_links.id = links.id
389                )
390                "#,
391        )?;
392
393        // Create unique index on (task_id, task_index)
394        conn.execute(
395            "CREATE UNIQUE INDEX idx_links_task_index ON links(task_id, task_index)",
396            [],
397        )?;
398    }
399
400    // Check for task_index column in task_repos
401    let count: i64 = conn.query_row(
402        "SELECT COUNT(*) FROM pragma_table_info('task_repos') WHERE name='task_index'",
403        [],
404        |row| row.get(0),
405    )?;
406
407    if count == 0 {
408        // Add task_index column
409        conn.execute("ALTER TABLE task_repos ADD COLUMN task_index INTEGER", [])?;
410
411        // Populate task_index for existing repos based on creation order
412        conn.execute_batch(
413            r#"
414                WITH numbered_repos AS (
415                    SELECT id, task_id, 
416                           ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at) as idx
417                    FROM task_repos
418                )
419                UPDATE task_repos 
420                SET task_index = (
421                    SELECT idx FROM numbered_repos WHERE numbered_repos.id = task_repos.id
422                )
423                "#,
424        )?;
425
426        // Create unique index on (task_id, task_index)
427        conn.execute(
428                "CREATE UNIQUE INDEX IF NOT EXISTS idx_task_repos_task_index ON task_repos(task_id, task_index)",
429                [],
430            )?;
431    }
432
433    // Check for active_todo_id column in scraps
434    let count: i64 = conn.query_row(
435        "SELECT COUNT(*) FROM pragma_table_info('scraps') WHERE name='active_todo_id'",
436        [],
437        |row| row.get(0),
438    )?;
439
440    if count == 0 {
441        // Add active_todo_id column to track which todo was active when scrap was created
442        // Active todo = the oldest pending todo at the time of scrap creation
443        conn.execute("ALTER TABLE scraps ADD COLUMN active_todo_id INTEGER", [])?;
444
445        // For existing scraps, populate active_todo_id based on the oldest pending todo
446        // at the time of scrap creation. We need to find the first todo that was either:
447        // 1. Still pending at scrap creation time, OR
448        // 2. Completed after scrap creation time
449        conn.execute_batch(
450            r#"
451                UPDATE scraps
452                SET active_todo_id = (
453                    SELECT task_index
454                    FROM todos
455                    WHERE todos.task_id = scraps.task_id
456                      AND (
457                        todos.status = 'pending'
458                        OR todos.completed_at IS NULL
459                        OR todos.completed_at > scraps.created_at
460                      )
461                    ORDER BY todos.task_index ASC
462                    LIMIT 1
463                )
464                "#,
465        )?;
466    }
467
468    // Create index on active_todo_id for efficient lookups
469    conn.execute(
470        "CREATE INDEX IF NOT EXISTS idx_scraps_active_todo_id ON scraps(active_todo_id)",
471        [],
472    )?;
473
474    // Check for is_today_task column in tasks
475    let count: i64 = conn.query_row(
476        "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='is_today_task'",
477        [],
478        |row| row.get(0),
479    )?;
480
481    if count == 0 {
482        conn.execute(
483            "ALTER TABLE tasks ADD COLUMN is_today_task INTEGER DEFAULT 0",
484            [],
485        )?;
486    }
487
488    // Create index on is_today_task for efficient lookups
489    conn.execute(
490        "CREATE INDEX IF NOT EXISTS idx_tasks_is_today_task ON tasks(is_today_task)",
491        [],
492    )?;
493
494    migrate_status_check_constraints(conn)?;
495
496    Ok(())
497}