1use crate::models::{TaskStatus, TodoStatus};
4use crate::utils::Result;
5use rusqlite::Connection;
6
7pub(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 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 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 conn.execute("ALTER TABLE git_items RENAME TO worktrees", [])?;
127
128 conn.execute(
130 "CREATE INDEX IF NOT EXISTS idx_worktrees_task_id ON worktrees(task_id)",
131 [],
132 )?;
133 }
134
135 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 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 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 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 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 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 conn.execute("ALTER TABLE todos ADD COLUMN task_index INTEGER", [])?;
223
224 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 conn.execute(
241 "CREATE UNIQUE INDEX idx_todos_task_index ON todos(task_id, task_index)",
242 [],
243 )?;
244 }
245
246 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 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 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 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 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 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 conn.execute("ALTER TABLE scraps ADD COLUMN task_index INTEGER", [])?;
320
321 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 conn.execute(
338 "CREATE UNIQUE INDEX idx_scraps_task_index ON scraps(task_id, task_index)",
339 [],
340 )?;
341 }
342
343 conn.execute(
345 "CREATE INDEX IF NOT EXISTS idx_repo_links_worktree_id ON repo_links(worktree_id)",
346 [],
347 )?;
348
349 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 conn.execute("ALTER TABLE tasks ADD COLUMN alias TEXT", [])?;
359 }
360
361 conn.execute(
363 "CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias)",
364 [],
365 )?;
366
367 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 conn.execute("ALTER TABLE links ADD COLUMN task_index INTEGER", [])?;
377
378 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 conn.execute(
395 "CREATE UNIQUE INDEX idx_links_task_index ON links(task_id, task_index)",
396 [],
397 )?;
398 }
399
400 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 conn.execute("ALTER TABLE task_repos ADD COLUMN task_index INTEGER", [])?;
410
411 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 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 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 conn.execute("ALTER TABLE scraps ADD COLUMN active_todo_id INTEGER", [])?;
444
445 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 conn.execute(
470 "CREATE INDEX IF NOT EXISTS idx_scraps_active_todo_id ON scraps(active_todo_id)",
471 [],
472 )?;
473
474 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 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}