@nickmeriano/task 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +12 -7
  2. package/dist/check.d.ts +42 -0
  3. package/dist/check.d.ts.map +1 -0
  4. package/dist/check.js +364 -0
  5. package/dist/check.js.map +1 -0
  6. package/dist/check.test.d.ts +9 -0
  7. package/dist/check.test.d.ts.map +1 -0
  8. package/dist/check.test.js +209 -0
  9. package/dist/check.test.js.map +1 -0
  10. package/dist/claim.d.ts +56 -3
  11. package/dist/claim.d.ts.map +1 -1
  12. package/dist/claim.js +164 -9
  13. package/dist/claim.js.map +1 -1
  14. package/dist/claim.test.js +168 -14
  15. package/dist/claim.test.js.map +1 -1
  16. package/dist/cli.js +504 -93
  17. package/dist/cli.js.map +1 -1
  18. package/dist/file-store.d.ts +41 -15
  19. package/dist/file-store.d.ts.map +1 -1
  20. package/dist/file-store.js +197 -99
  21. package/dist/file-store.js.map +1 -1
  22. package/dist/index.d.ts +5 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/overview.d.ts +43 -0
  27. package/dist/overview.d.ts.map +1 -0
  28. package/dist/overview.js +53 -0
  29. package/dist/overview.js.map +1 -0
  30. package/dist/overview.test.d.ts +8 -0
  31. package/dist/overview.test.d.ts.map +1 -0
  32. package/dist/overview.test.js +47 -0
  33. package/dist/overview.test.js.map +1 -0
  34. package/dist/promote.test.d.ts +14 -0
  35. package/dist/promote.test.d.ts.map +1 -0
  36. package/dist/promote.test.js +106 -0
  37. package/dist/promote.test.js.map +1 -0
  38. package/dist/search.d.ts +32 -0
  39. package/dist/search.d.ts.map +1 -0
  40. package/dist/search.js +66 -0
  41. package/dist/search.js.map +1 -0
  42. package/dist/search.test.d.ts +2 -0
  43. package/dist/search.test.d.ts.map +1 -0
  44. package/dist/search.test.js +53 -0
  45. package/dist/search.test.js.map +1 -0
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +10 -2
  48. package/dist/server.js.map +1 -1
  49. package/dist/store.d.ts +16 -50
  50. package/dist/store.d.ts.map +1 -1
  51. package/dist/store.js +0 -368
  52. package/dist/store.js.map +1 -1
  53. package/dist/store.test.d.ts +1 -2
  54. package/dist/store.test.d.ts.map +1 -1
  55. package/dist/store.test.js +77 -48
  56. package/dist/store.test.js.map +1 -1
  57. package/dist/ticket-doc.d.ts +55 -2
  58. package/dist/ticket-doc.d.ts.map +1 -1
  59. package/dist/ticket-doc.js +177 -7
  60. package/dist/ticket-doc.js.map +1 -1
  61. package/dist/types.d.ts +67 -14
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/package.json +1 -1
  65. package/skill/SKILL.md +97 -31
  66. package/src/check.test.ts +271 -0
  67. package/src/check.ts +454 -0
  68. package/src/claim.test.ts +202 -14
  69. package/src/claim.ts +194 -10
  70. package/src/cli.ts +510 -89
  71. package/src/file-store.ts +217 -111
  72. package/src/index.ts +4 -2
  73. package/src/overview.test.ts +51 -0
  74. package/src/overview.ts +90 -0
  75. package/src/promote.test.ts +131 -0
  76. package/src/search.test.ts +64 -0
  77. package/src/search.ts +91 -0
  78. package/src/server.ts +11 -2
  79. package/src/store.test.ts +89 -57
  80. package/src/store.ts +18 -431
  81. package/src/ticket-doc.ts +210 -10
  82. package/src/types.ts +71 -14
  83. package/ui/dist/assets/{index-oJzomUDL.js → index-BJmOsOdR.js} +76 -76
  84. package/ui/dist/assets/index-BoqQlqSU.css +1 -0
  85. package/ui/dist/index.html +2 -2
  86. package/ui/dist/assets/index-CXW8uT5f.css +0 -1
package/src/store.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { DatabaseSync } from "node:sqlite"
2
1
  import { existsSync, readdirSync, readFileSync } from "node:fs"
3
2
  import { dirname, join, relative, sep } from "node:path"
4
3
  import type {
5
4
  Comment,
5
+ Goal,
6
+ GoalInput,
7
+ GoalPatch,
6
8
  ProjectConfig,
7
- Status,
8
9
  Task,
9
10
  TaskFilter,
10
11
  TaskInput,
@@ -12,17 +13,16 @@ import type {
12
13
  } from "./types.ts"
13
14
 
14
15
  export const TASK_DIR = ".task"
15
- export const DB_FILE = "tasks.db"
16
16
  export const CONFIG_FILE = "config.json"
17
17
 
18
18
  /** Spacing between adjacent board positions — leaves room to drop between. */
19
19
  export const POSITION_GAP = 1024
20
20
 
21
21
  /**
22
- * What a board's persistence layer looks like, whichever representation backs
23
- * it. Two implementations: `FileStore` (text files in `.task/tickets/`, the
24
- * canonical format) and the legacy `TaskStore` (a committed SQLite database),
25
- * which survives so un-migrated boards keep working. `openBoard` picks.
22
+ * What a board's persistence layer looks like. One implementation: `FileStore`
23
+ * (text files in `.task/tickets/`, the canonical format). The interface stays
24
+ * because it is the seam everything else the CLI, `task serve`, scripts —
25
+ * programs against.
26
26
  */
27
27
  export interface Store {
28
28
  readonly root: string
@@ -41,6 +41,17 @@ export interface Store {
41
41
  /** Move a done/canceled ticket to `.task/archive/`, out of the board's hot path. */
42
42
  archive(number: number): Task
43
43
  unarchive(number: number): Task
44
+ /** Live goals, in slug order — pass `true` to list `.task/goals/archive/` instead. */
45
+ goals(archived?: boolean): Goal[]
46
+ /** Live or archived — a ticket's `goal:` ref should resolve either way. */
47
+ getGoal(slug: string): Goal | null
48
+ createGoal(input: GoalInput): Goal
49
+ updateGoal(slug: string, patch: GoalPatch): Goal
50
+ /** Refused while any open (backlog/todo/in_progress) task references the goal. */
51
+ archiveGoal(slug: string): Goal
52
+ unarchiveGoal(slug: string): Goal
53
+ /** Refused while any live task references the goal, whatever its status. */
54
+ deleteGoal(slug: string): void
44
55
  comments(number: number): Comment[]
45
56
  addComment(number: number, body: string, author?: string): Comment
46
57
  deleteComment(number: number, commentId: string): Comment
@@ -153,427 +164,3 @@ export function derivePrefix(name: string): string {
153
164
  const word = name.toUpperCase().replace(/[^A-Z0-9]+/g, " ").trim().split(" ")[0]
154
165
  return (word || "TAS").slice(0, 3)
155
166
  }
156
-
157
- interface TaskRow {
158
- id: number
159
- title: string
160
- description: string
161
- status: string
162
- tags: string
163
- milestone: string | null
164
- needs_human: number
165
- prs: string
166
- position: number
167
- created_at: string
168
- updated_at: string
169
- }
170
-
171
- interface CommentRow {
172
- id: number
173
- task_id: number
174
- author: string
175
- body: string
176
- created_at: string
177
- }
178
-
179
- function now(): string {
180
- return new Date().toISOString()
181
- }
182
-
183
- /**
184
- * The legacy persistence layer: one SQLite database inside `.task/`, opened
185
- * synchronously via node:sqlite (built into Node ≥22.13 — zero dependencies,
186
- * nothing to compile, safe for `npx`). Boards created before the text format
187
- * still run on this until `task migrate` moves them over.
188
- */
189
- export class TaskStore implements Store {
190
- readonly root: string
191
- readonly taskDir: string
192
- readonly config: ProjectConfig
193
- private db: DatabaseSync
194
-
195
- constructor(root: string) {
196
- this.root = root
197
- this.taskDir = join(root, TASK_DIR)
198
- this.config = JSON.parse(
199
- readFileSync(join(this.taskDir, CONFIG_FILE), "utf8"),
200
- ) as ProjectConfig
201
- this.db = new DatabaseSync(join(this.taskDir, DB_FILE))
202
- // Multiple writers (CLI + serve + agents) are expected; wait for locks
203
- // instead of failing fast with SQLITE_BUSY.
204
- this.db.exec("PRAGMA busy_timeout = 3000")
205
- this.db.exec("PRAGMA foreign_keys = ON")
206
- migrate(this.db)
207
- }
208
-
209
- close(): void {
210
- this.db.close()
211
- }
212
-
213
- displayId(number: number): string {
214
- return `${this.config.prefix}-${number}`
215
- }
216
-
217
- /** Accepts "PHONE-12", "phone-12" or "12". */
218
- parseId(ref: string): number {
219
- const match = /^(?:[A-Za-z0-9]+-)?(\d+)$/.exec(ref.trim())
220
- if (!match) throw new Error(`invalid task id: ${ref}`)
221
- return Number(match[1])
222
- }
223
-
224
- private toTask(row: TaskRow): Task {
225
- return {
226
- id: this.displayId(row.id),
227
- number: row.id,
228
- title: row.title,
229
- description: row.description,
230
- status: row.status as Status,
231
- tags: JSON.parse(row.tags) as string[],
232
- milestone: row.milestone,
233
- needsHuman: row.needs_human !== 0,
234
- blocks: this.linkedNumbers("blocker_id", row.id),
235
- blockedBy: this.linkedNumbers("blocked_id", row.id),
236
- prs: JSON.parse(row.prs) as string[],
237
- position: row.position,
238
- createdAt: row.created_at,
239
- updatedAt: row.updated_at,
240
- }
241
- }
242
-
243
- /** The other end of every link where `column` is this task. */
244
- private linkedNumbers(column: "blocker_id" | "blocked_id", number: number): number[] {
245
- const other = column === "blocker_id" ? "blocked_id" : "blocker_id"
246
- const rows = this.db
247
- .prepare(`SELECT ${other} AS n FROM task_links WHERE ${column} = ? ORDER BY ${other}`)
248
- .all(number) as unknown as { n: number }[]
249
- return rows.map((r) => r.n)
250
- }
251
-
252
- private toComment(row: CommentRow): Comment {
253
- return {
254
- id: String(row.id),
255
- taskId: this.displayId(row.task_id),
256
- author: row.author,
257
- body: row.body,
258
- createdAt: row.created_at,
259
- }
260
- }
261
-
262
- list(filter: TaskFilter = {}): Task[] {
263
- if (filter.archived) {
264
- throw new Error("archiving needs a text-format board — run `task migrate` first")
265
- }
266
- const where: string[] = []
267
- const params: (string | number)[] = []
268
- if (filter.statuses?.length) {
269
- where.push(`status IN (${filter.statuses.map(() => "?").join(", ")})`)
270
- params.push(...filter.statuses)
271
- }
272
- if (filter.milestone) {
273
- where.push("milestone = ?")
274
- params.push(filter.milestone)
275
- }
276
- if (filter.needsHuman !== undefined) {
277
- where.push("needs_human = ?")
278
- params.push(filter.needsHuman ? 1 : 0)
279
- }
280
- const sql = `SELECT * FROM tasks${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY position, id`
281
- let rows = this.db.prepare(sql).all(...params) as unknown as TaskRow[]
282
- if (filter.tags?.length) {
283
- // Tags are a JSON blob, not a table — match them in JS, any-of.
284
- const wanted = new Set(filter.tags)
285
- rows = rows.filter((r) => (JSON.parse(r.tags) as string[]).some((t) => wanted.has(t)))
286
- }
287
- return rows.map((r) => this.toTask(r))
288
- }
289
-
290
- get(number: number): Task | null {
291
- const row = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(number) as
292
- | TaskRow
293
- | undefined
294
- return row ? this.toTask(row) : null
295
- }
296
-
297
- create(input: TaskInput): Task {
298
- const status = input.status ?? "todo"
299
- // New tasks land at the bottom of their column.
300
- const max = this.db
301
- .prepare("SELECT MAX(position) AS max FROM tasks WHERE status = ?")
302
- .get(status) as { max: number | null }
303
- const timestamp = now()
304
- const result = this.db
305
- .prepare(
306
- `INSERT INTO tasks (title, description, status, tags, milestone, needs_human, prs, position, created_at, updated_at)
307
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
308
- )
309
- .run(
310
- input.title,
311
- input.description ?? "",
312
- status,
313
- JSON.stringify(input.tags ?? []),
314
- input.milestone ?? null,
315
- input.needsHuman ? 1 : 0,
316
- JSON.stringify(input.prs ?? []),
317
- (max.max ?? 0) + POSITION_GAP,
318
- timestamp,
319
- timestamp,
320
- )
321
- const number = Number(result.lastInsertRowid)
322
- if (input.blocks !== undefined) this.reconcileLinks("blocker_id", number, input.blocks)
323
- if (input.blockedBy !== undefined) this.reconcileLinks("blocked_id", number, input.blockedBy)
324
- return this.get(number)!
325
- }
326
-
327
- update(number: number, patch: TaskPatch): Task {
328
- const existing = this.get(number)
329
- if (!existing) throw new Error(`no such task: ${this.displayId(number)}`)
330
-
331
- // Reconciled outside the UPDATE — links live in their own table. Done
332
- // first so a bad target rejects the whole patch before anything writes.
333
- let linksChanged = false
334
- if (patch.blocks !== undefined) {
335
- linksChanged = this.reconcileLinks("blocker_id", number, patch.blocks)
336
- }
337
- if (patch.blockedBy !== undefined) {
338
- linksChanged = this.reconcileLinks("blocked_id", number, patch.blockedBy) || linksChanged
339
- }
340
-
341
- const sets: string[] = []
342
- const params: (string | number | null)[] = []
343
- const set = (column: string, value: string | number | null) => {
344
- sets.push(`${column} = ?`)
345
- params.push(value)
346
- }
347
-
348
- if (patch.title !== undefined) set("title", patch.title)
349
- if (patch.description !== undefined) set("description", patch.description)
350
- if (patch.tags !== undefined) set("tags", JSON.stringify(patch.tags))
351
- if (patch.milestone !== undefined) set("milestone", patch.milestone)
352
- if (patch.needsHuman !== undefined) set("needs_human", patch.needsHuman ? 1 : 0)
353
- if (patch.prs !== undefined) set("prs", JSON.stringify(patch.prs))
354
- if (patch.status !== undefined) {
355
- set("status", patch.status)
356
- if (patch.position === undefined && patch.status !== existing.status) {
357
- // Moved columns without an explicit slot → land on top, where the
358
- // freshest movement is visible (Linear's behavior).
359
- const min = this.db
360
- .prepare("SELECT MIN(position) AS min FROM tasks WHERE status = ?")
361
- .get(patch.status) as { min: number | null }
362
- set("position", (min.min ?? 0) - POSITION_GAP)
363
- }
364
- }
365
- if (patch.position !== undefined) set("position", patch.position)
366
-
367
- if (sets.length === 0 && !linksChanged) return existing
368
- if (sets.length > 0) {
369
- set("updated_at", now())
370
- params.push(number)
371
- this.db.prepare(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params)
372
- }
373
- return this.get(number)!
374
- }
375
-
376
- /**
377
- * Make the link table agree with `targets` for one side of one task —
378
- * `blocker_id` when setting what `number` blocks, `blocked_id` when setting
379
- * what blocks it. The other task's view is the same row read from the other
380
- * end, so there is no second write to keep consistent. Every task touched
381
- * (both ends of every added or removed link) gets its `updated_at` bumped.
382
- */
383
- private reconcileLinks(
384
- column: "blocker_id" | "blocked_id",
385
- number: number,
386
- targets: number[],
387
- ): boolean {
388
- const other = column === "blocker_id" ? "blocked_id" : "blocker_id"
389
- const wanted = [...new Set(targets)]
390
- for (const target of wanted) {
391
- if (target === number) {
392
- throw new Error(`a task can't block itself: ${this.displayId(number)}`)
393
- }
394
- if (!this.db.prepare("SELECT id FROM tasks WHERE id = ?").get(target)) {
395
- throw new Error(`no such task: ${this.displayId(target)}`)
396
- }
397
- }
398
- const current = new Set(this.linkedNumbers(column, number))
399
- const removed = [...current].filter((n) => !wanted.includes(n))
400
- const added = wanted.filter((n) => !current.has(n))
401
- for (const n of removed) {
402
- this.db
403
- .prepare(`DELETE FROM task_links WHERE ${column} = ? AND ${other} = ?`)
404
- .run(number, n)
405
- }
406
- for (const n of added) {
407
- this.db
408
- .prepare(`INSERT OR IGNORE INTO task_links (${column}, ${other}) VALUES (?, ?)`)
409
- .run(number, n)
410
- }
411
- const touched = [...removed, ...added]
412
- if (touched.length > 0) {
413
- const timestamp = now()
414
- const bump = this.db.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?")
415
- for (const n of [...touched, number]) bump.run(timestamp, n)
416
- }
417
- return touched.length > 0
418
- }
419
-
420
- /** `task link A --blocks B` and friends — additive, unlike the patch form. */
421
- link(number: number, relation: "blocks" | "blocked_by", target: number): Task {
422
- if (!this.get(number)) throw new Error(`no such task: ${this.displayId(number)}`)
423
- const column = relation === "blocks" ? "blocker_id" : "blocked_id"
424
- const current = this.linkedNumbers(column, number)
425
- this.reconcileLinks(column, number, [...current, target])
426
- return this.get(number)!
427
- }
428
-
429
- unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task {
430
- if (!this.get(number)) throw new Error(`no such task: ${this.displayId(number)}`)
431
- const column = relation === "blocks" ? "blocker_id" : "blocked_id"
432
- const current = this.linkedNumbers(column, number)
433
- this.reconcileLinks(column, number, current.filter((n) => n !== target))
434
- return this.get(number)!
435
- }
436
-
437
- delete(number: number): void {
438
- const result = this.db.prepare("DELETE FROM tasks WHERE id = ?").run(number)
439
- if (result.changes === 0) throw new Error(`no such task: ${this.displayId(number)}`)
440
- }
441
-
442
- /** The archive is a text-format feature — a directory move, not a table. */
443
- archive(_number: number): Task {
444
- throw new Error("archiving needs a text-format board — run `task migrate` first")
445
- }
446
-
447
- unarchive(_number: number): Task {
448
- throw new Error("archiving needs a text-format board — run `task migrate` first")
449
- }
450
-
451
- comments(number: number): Comment[] {
452
- const rows = this.db
453
- .prepare("SELECT * FROM comments WHERE task_id = ? ORDER BY id")
454
- .all(number) as unknown as CommentRow[]
455
- return rows.map((r) => this.toComment(r))
456
- }
457
-
458
- addComment(number: number, body: string, author = ""): Comment {
459
- if (!this.get(number)) throw new Error(`no such task: ${this.displayId(number)}`)
460
- const result = this.db
461
- .prepare("INSERT INTO comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)")
462
- .run(number, author, body, now())
463
- const row = this.db
464
- .prepare("SELECT * FROM comments WHERE id = ?")
465
- .get(Number(result.lastInsertRowid)) as unknown as CommentRow
466
- return this.toComment(row)
467
- }
468
-
469
- deleteComment(number: number, commentId: string): Comment {
470
- const rowid = Number(commentId)
471
- const row = (
472
- Number.isInteger(rowid)
473
- ? this.db
474
- .prepare("SELECT * FROM comments WHERE id = ? AND task_id = ?")
475
- .get(rowid, number)
476
- : undefined
477
- ) as CommentRow | undefined
478
- if (!row) throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
479
- this.db.prepare("DELETE FROM comments WHERE id = ?").run(rowid)
480
- return this.toComment(row)
481
- }
482
-
483
- commentCounts(): Map<number, number> {
484
- const rows = this.db
485
- .prepare("SELECT task_id, COUNT(*) AS count FROM comments GROUP BY task_id")
486
- .all() as unknown as { task_id: number; count: number }[]
487
- return new Map(rows.map((r) => [r.task_id, r.count]))
488
- }
489
- }
490
-
491
- /**
492
- * Schema history, one entry per version, applied in order from whatever
493
- * `PRAGMA user_version` the file is at. `.task/tasks.db` is committed to git,
494
- * so an existing database is the normal case, not the exception: every step
495
- * has to be safe to run against a file someone else's checkout wrote.
496
- *
497
- * There is one linear path — a fresh database runs *every* step, so it lands
498
- * on a schema byte-identical to a migrated one, and there's no second
499
- * definition of "current" to keep in sync.
500
- */
501
- const MIGRATIONS: string[] = [
502
- // v1 — the original schema. `IF NOT EXISTS` is load-bearing: user_version 0
503
- // means "fresh *or* written before versioning existed", and this is the step
504
- // that makes both of those safe to start from.
505
- //
506
- // The odd indentation is deliberate and load-bearing: SQLite stores the
507
- // CREATE statement *verbatim* in sqlite_master, so keeping this byte-for-byte
508
- // as it originally shipped is what lets a freshly-created database and a
509
- // migrated one end up with an identical schema rather than one that only
510
- // matches in structure.
511
- `
512
- CREATE TABLE IF NOT EXISTS tasks (
513
- id INTEGER PRIMARY KEY AUTOINCREMENT,
514
- title TEXT NOT NULL,
515
- description TEXT NOT NULL DEFAULT '',
516
- status TEXT NOT NULL DEFAULT 'todo',
517
- priority TEXT NOT NULL DEFAULT 'none',
518
- assignee TEXT,
519
- labels TEXT NOT NULL DEFAULT '[]',
520
- position REAL NOT NULL DEFAULT 0,
521
- created_at TEXT NOT NULL,
522
- updated_at TEXT NOT NULL
523
- );
524
- CREATE TABLE IF NOT EXISTS comments (
525
- id INTEGER PRIMARY KEY AUTOINCREMENT,
526
- task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
527
- author TEXT NOT NULL DEFAULT '',
528
- body TEXT NOT NULL,
529
- created_at TEXT NOT NULL
530
- );
531
- `,
532
-
533
- // v2 — labels become tags, priority/assignee give way to milestone and a
534
- // needs-human flag. RENAME COLUMN (SQLite 3.25+) and DROP COLUMN (3.35+) are
535
- // both available on Node ≥ 22.13, which bundles 3.49.
536
- //
537
- // If DROP COLUMN ever weren't available the escape hatch is the usual table
538
- // rebuild — but note it would need `PRAGMA foreign_keys = OFF` issued
539
- // *outside* the transaction (the pragma is a no-op inside one), because
540
- // comments.task_id references tasks(id).
541
- `ALTER TABLE tasks RENAME COLUMN labels TO tags;
542
- ALTER TABLE tasks ADD COLUMN milestone TEXT;
543
- ALTER TABLE tasks ADD COLUMN needs_human INTEGER NOT NULL DEFAULT 0;
544
- ALTER TABLE tasks DROP COLUMN priority;
545
- ALTER TABLE tasks DROP COLUMN assignee;`,
546
-
547
- // v3 — dependencies and pull requests. One direction of "blocks" is the
548
- // whole relation: (blocker, blocked) read from the blocked side is
549
- // "blocked by", so the mirror is a query, not a second row. PRs are a JSON
550
- // list of URLs, same shape as tags.
551
- `CREATE TABLE task_links (
552
- blocker_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
553
- blocked_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
554
- PRIMARY KEY (blocker_id, blocked_id)
555
- );
556
- ALTER TABLE tasks ADD COLUMN prs TEXT NOT NULL DEFAULT '[]';`,
557
- ]
558
-
559
- /**
560
- * Bring `db` up to the newest schema. DDL and `PRAGMA user_version` are both
561
- * transactional in SQLite, so a failure halfway leaves the file exactly as it
562
- * was — schema *and* version.
563
- */
564
- function migrate(db: DatabaseSync): void {
565
- const { user_version: version } = db.prepare("PRAGMA user_version").get() as {
566
- user_version: number
567
- }
568
- if (version >= MIGRATIONS.length) return
569
- db.exec("BEGIN")
570
- try {
571
- for (const step of MIGRATIONS.slice(version)) db.exec(step)
572
- db.exec(`PRAGMA user_version = ${MIGRATIONS.length}`)
573
- db.exec("COMMIT")
574
- } catch (error) {
575
- db.exec("ROLLBACK")
576
- throw error
577
- }
578
- }
579
-