@sascha384/tic 1.34.0 → 2.0.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 (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/backends/availability.js +4 -2
  3. package/dist/backends/availability.js.map +1 -1
  4. package/dist/backends/factory.d.ts +5 -3
  5. package/dist/backends/factory.js +28 -43
  6. package/dist/backends/factory.js.map +1 -1
  7. package/dist/backends/files/hash.d.ts +1 -0
  8. package/dist/backends/files/hash.js +5 -0
  9. package/dist/backends/files/hash.js.map +1 -0
  10. package/dist/backends/files/index.d.ts +48 -0
  11. package/dist/backends/files/index.js +174 -0
  12. package/dist/backends/files/index.js.map +1 -0
  13. package/dist/backends/files/sync.d.ts +13 -0
  14. package/dist/backends/files/sync.js +69 -0
  15. package/dist/backends/files/sync.js.map +1 -0
  16. package/dist/backends/jira/config.d.ts +1 -1
  17. package/dist/backends/jira/config.js +6 -9
  18. package/dist/backends/jira/config.js.map +1 -1
  19. package/dist/backends/types.d.ts +12 -0
  20. package/dist/backends/types.js +5 -1
  21. package/dist/backends/types.js.map +1 -1
  22. package/dist/cli/commands/config.js +27 -14
  23. package/dist/cli/commands/config.js.map +1 -1
  24. package/dist/cli/commands/init.js +10 -3
  25. package/dist/cli/commands/init.js.map +1 -1
  26. package/dist/cli/commands/mcp.d.ts +4 -4
  27. package/dist/cli/commands/mcp.js +16 -25
  28. package/dist/cli/commands/mcp.js.map +1 -1
  29. package/dist/cli/index.js +16 -19
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/commands.d.ts +2 -0
  32. package/dist/commands.js +33 -0
  33. package/dist/commands.js.map +1 -1
  34. package/dist/components/Header.js +9 -2
  35. package/dist/components/Header.js.map +1 -1
  36. package/dist/components/HelpScreen.js +3 -0
  37. package/dist/components/HelpScreen.js.map +1 -1
  38. package/dist/components/OverlayPanel.d.ts +2 -1
  39. package/dist/components/OverlayPanel.js +14 -1
  40. package/dist/components/OverlayPanel.js.map +1 -1
  41. package/dist/components/Settings.js +6 -11
  42. package/dist/components/Settings.js.map +1 -1
  43. package/dist/components/StatusScreen.js +29 -4
  44. package/dist/components/StatusScreen.js.map +1 -1
  45. package/dist/components/WorkItemForm.js +6 -9
  46. package/dist/components/WorkItemForm.js.map +1 -1
  47. package/dist/components/WorkItemList.js +353 -36
  48. package/dist/components/WorkItemList.js.map +1 -1
  49. package/dist/filters.d.ts +28 -0
  50. package/dist/filters.js +47 -0
  51. package/dist/filters.js.map +1 -0
  52. package/dist/implement.js +4 -56
  53. package/dist/implement.js.map +1 -1
  54. package/dist/index.js +20 -8
  55. package/dist/index.js.map +1 -1
  56. package/dist/storage/config.d.ts +61 -0
  57. package/dist/storage/config.js +309 -0
  58. package/dist/storage/config.js.map +1 -0
  59. package/dist/storage/db.d.ts +11 -0
  60. package/dist/storage/db.js +34 -0
  61. package/dist/storage/db.js.map +1 -0
  62. package/dist/storage/index.d.ts +73 -0
  63. package/dist/storage/index.js +966 -0
  64. package/dist/storage/index.js.map +1 -0
  65. package/dist/storage/mappers.d.ts +35 -0
  66. package/dist/storage/mappers.js +70 -0
  67. package/dist/storage/mappers.js.map +1 -0
  68. package/dist/storage/schema.d.ts +1844 -0
  69. package/dist/storage/schema.js +197 -0
  70. package/dist/storage/schema.js.map +1 -0
  71. package/dist/storage/syncQueue.d.ts +13 -0
  72. package/dist/storage/syncQueue.js +98 -0
  73. package/dist/storage/syncQueue.js.map +1 -0
  74. package/dist/storage/undo.d.ts +22 -0
  75. package/dist/storage/undo.js +129 -0
  76. package/dist/storage/undo.js.map +1 -0
  77. package/dist/stores/backendDataStore.d.ts +4 -1
  78. package/dist/stores/backendDataStore.js +61 -40
  79. package/dist/stores/backendDataStore.js.map +1 -1
  80. package/dist/stores/configStore.d.ts +3 -1
  81. package/dist/stores/configStore.js +25 -65
  82. package/dist/stores/configStore.js.map +1 -1
  83. package/dist/stores/filterStore.d.ts +13 -0
  84. package/dist/stores/filterStore.js +38 -0
  85. package/dist/stores/filterStore.js.map +1 -0
  86. package/dist/stores/listViewStore.d.ts +1 -0
  87. package/dist/stores/listViewStore.js +1 -0
  88. package/dist/stores/listViewStore.js.map +1 -1
  89. package/dist/stores/uiStore.d.ts +8 -0
  90. package/dist/stores/uiStore.js.map +1 -1
  91. package/dist/stores/undoStore.d.ts +4 -0
  92. package/dist/stores/undoStore.js +32 -0
  93. package/dist/stores/undoStore.js.map +1 -1
  94. package/dist/sync/SyncManager.d.ts +5 -4
  95. package/dist/sync/SyncManager.js +129 -36
  96. package/dist/sync/SyncManager.js.map +1 -1
  97. package/dist/sync/types.d.ts +25 -1
  98. package/package.json +5 -1
  99. package/dist/backends/local/config.d.ts +0 -23
  100. package/dist/backends/local/config.js +0 -42
  101. package/dist/backends/local/config.js.map +0 -1
  102. package/dist/backends/local/index.d.ts +0 -45
  103. package/dist/backends/local/index.js +0 -291
  104. package/dist/backends/local/index.js.map +0 -1
  105. package/dist/sync/queue.d.ts +0 -12
  106. package/dist/sync/queue.js +0 -56
  107. package/dist/sync/queue.js.map +0 -1
@@ -0,0 +1,966 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import yaml from 'yaml';
5
+ import { eq, and, isNull, isNotNull, inArray } from 'drizzle-orm';
6
+ import { BaseBackend } from '../backends/types.js';
7
+ import { createDatabase } from './db.js';
8
+ import * as schema from './schema.js';
9
+ import { insertConfigTx } from './config.js';
10
+ import { rowToWorkItem, rowToTemplate, } from './mappers.js';
11
+ const DEFAULT_STATUSES = ['backlog', 'todo', 'in-progress', 'review', 'done'];
12
+ const DEFAULT_TYPES = ['epic', 'issue', 'task'];
13
+ const DEFAULT_ITERATIONS = ['default'];
14
+ const DEFAULT_CURRENT_ITERATION = 'default';
15
+ const DEFAULT_NEXT_ID = 1;
16
+ const DEFAULT_BRANCH_MODE = 'worktree';
17
+ const DEFAULT_AUTO_UPDATE = true;
18
+ const DEFAULT_BRANCH_COMMAND = `claude "Brainstorm the implementation of issue #$TIC_ITEM_ID: $TIC_ITEM_TITLE. $TIC_ITEM_DESCRIPTION"`;
19
+ const DEFAULT_COPY_TO_CLIPBOARD = true;
20
+ export class Storage extends BaseBackend {
21
+ db;
22
+ root;
23
+ tempIds;
24
+ constructor(db, root, options) {
25
+ super(0); // No TTL — DB is always fresh
26
+ this.db = db;
27
+ this.root = root;
28
+ this.tempIds = options?.tempIds ?? false;
29
+ }
30
+ /**
31
+ * Create a Storage instance, initializing the database and seeding defaults.
32
+ */
33
+ static create(root, options) {
34
+ const db = createDatabase(root);
35
+ const backend = new Storage(db, root, options);
36
+ backend.seedDefaults();
37
+ backend.migrateFromYaml();
38
+ return backend;
39
+ }
40
+ /**
41
+ * Create a Storage instance from an existing database instance (for testing).
42
+ */
43
+ static createFromDb(db, options) {
44
+ const backend = new Storage(db, ':memory:', options);
45
+ backend.seedDefaults();
46
+ return backend;
47
+ }
48
+ getDatabase() {
49
+ return this.db;
50
+ }
51
+ getRoot() {
52
+ return this.root;
53
+ }
54
+ /**
55
+ * Seed default configuration, statuses, types, and iterations using INSERT OR IGNORE.
56
+ */
57
+ seedDefaults() {
58
+ // Seed project config (id=1) only if not already present
59
+ this.db
60
+ .insert(schema.projectConfig)
61
+ .values({
62
+ id: 1,
63
+ backend: 'drizzle',
64
+ currentIteration: DEFAULT_CURRENT_ITERATION,
65
+ nextId: DEFAULT_NEXT_ID,
66
+ branchMode: DEFAULT_BRANCH_MODE,
67
+ branchCommand: DEFAULT_BRANCH_COMMAND,
68
+ copyToClipboard: DEFAULT_COPY_TO_CLIPBOARD,
69
+ autoUpdate: DEFAULT_AUTO_UPDATE,
70
+ })
71
+ .onConflictDoNothing()
72
+ .run();
73
+ // Seed statuses
74
+ for (let i = 0; i < DEFAULT_STATUSES.length; i++) {
75
+ this.db
76
+ .insert(schema.statuses)
77
+ .values({ name: DEFAULT_STATUSES[i], sortOrder: i })
78
+ .onConflictDoNothing()
79
+ .run();
80
+ }
81
+ // Seed types
82
+ for (let i = 0; i < DEFAULT_TYPES.length; i++) {
83
+ this.db
84
+ .insert(schema.workItemTypes)
85
+ .values({ name: DEFAULT_TYPES[i], sortOrder: i })
86
+ .onConflictDoNothing()
87
+ .run();
88
+ }
89
+ // Seed iterations
90
+ for (let i = 0; i < DEFAULT_ITERATIONS.length; i++) {
91
+ this.db
92
+ .insert(schema.iterations)
93
+ .values({ name: DEFAULT_ITERATIONS[i], sortOrder: i })
94
+ .onConflictDoNothing()
95
+ .run();
96
+ }
97
+ }
98
+ /**
99
+ * If a legacy config.yml exists and the DB still has seed defaults,
100
+ * migrate the YAML config into the database and rename the file.
101
+ */
102
+ migrateFromYaml() {
103
+ if (this.root === ':memory:')
104
+ return;
105
+ const yamlPath = path.join(this.root, '.tic', 'config.yml');
106
+ if (!fs.existsSync(yamlPath))
107
+ return;
108
+ // Only migrate if DB still has the seed default backend ('drizzle')
109
+ const row = this.db
110
+ .select({ backend: schema.projectConfig.backend })
111
+ .from(schema.projectConfig)
112
+ .where(eq(schema.projectConfig.id, 1))
113
+ .get();
114
+ if (row?.backend !== 'drizzle')
115
+ return;
116
+ try {
117
+ const raw = fs.readFileSync(yamlPath, 'utf-8');
118
+ const config = yaml.parse(raw);
119
+ this.db.transaction((tx) => {
120
+ insertConfigTx(tx, config);
121
+ });
122
+ fs.renameSync(yamlPath, yamlPath + '.migrated');
123
+ }
124
+ catch {
125
+ // Migration failure is not fatal — continue with DB defaults
126
+ }
127
+ }
128
+ /**
129
+ * Close the database connection. Call this when done with the backend.
130
+ */
131
+ destroy() {
132
+ this.db.close();
133
+ }
134
+ // ─── Capabilities ───────────────────────────────────────────────────
135
+ getCapabilities() {
136
+ return {
137
+ relationships: true,
138
+ customTypes: true,
139
+ customStatuses: true,
140
+ iterations: true,
141
+ comments: true,
142
+ fields: {
143
+ priority: true,
144
+ assignee: true,
145
+ labels: true,
146
+ parent: true,
147
+ dependsOn: true,
148
+ },
149
+ templates: true,
150
+ templateFields: {
151
+ type: true,
152
+ status: true,
153
+ priority: true,
154
+ assignee: true,
155
+ labels: true,
156
+ iteration: true,
157
+ parent: true,
158
+ dependsOn: true,
159
+ description: true,
160
+ },
161
+ };
162
+ }
163
+ // ─── Read: metadata lists ──────────────────────────────────────────
164
+ // eslint-disable-next-line @typescript-eslint/require-await
165
+ async getStatuses() {
166
+ const rows = this.db
167
+ .select()
168
+ .from(schema.statuses)
169
+ .orderBy(schema.statuses.sortOrder)
170
+ .all();
171
+ return rows.map((r) => r.name);
172
+ }
173
+ // eslint-disable-next-line @typescript-eslint/require-await
174
+ async getIterations() {
175
+ const rows = this.db
176
+ .select()
177
+ .from(schema.iterations)
178
+ .orderBy(schema.iterations.sortOrder)
179
+ .all();
180
+ return rows.map((r) => r.name);
181
+ }
182
+ // eslint-disable-next-line @typescript-eslint/require-await
183
+ async getWorkItemTypes() {
184
+ const rows = this.db
185
+ .select()
186
+ .from(schema.workItemTypes)
187
+ .orderBy(schema.workItemTypes.sortOrder)
188
+ .all();
189
+ return rows.map((r) => r.name);
190
+ }
191
+ // eslint-disable-next-line @typescript-eslint/require-await
192
+ async getAssignees() {
193
+ const rows = this.db
194
+ .select({ assignee: schema.workItems.assignee })
195
+ .from(schema.workItems)
196
+ .where(isNull(schema.workItems.deletedAt))
197
+ .all();
198
+ // Filter out empty assignees in JS (they don't represent real assignees)
199
+ const assignees = new Set();
200
+ for (const r of rows) {
201
+ if (r.assignee)
202
+ assignees.add(r.assignee);
203
+ }
204
+ return [...assignees].sort();
205
+ }
206
+ // eslint-disable-next-line @typescript-eslint/require-await
207
+ async getLabels() {
208
+ // Get labels only for non-deleted items
209
+ const rows = this.db
210
+ .select({ label: schema.workItemLabels.label })
211
+ .from(schema.workItemLabels)
212
+ .innerJoin(schema.workItems, eq(schema.workItemLabels.workItemId, schema.workItems.id))
213
+ .where(isNull(schema.workItems.deletedAt))
214
+ .all();
215
+ const labels = new Set();
216
+ for (const r of rows) {
217
+ labels.add(r.label);
218
+ }
219
+ return [...labels].sort();
220
+ }
221
+ // eslint-disable-next-line @typescript-eslint/require-await
222
+ async getCurrentIteration() {
223
+ const row = this.db
224
+ .select({ currentIteration: schema.projectConfig.currentIteration })
225
+ .from(schema.projectConfig)
226
+ .where(eq(schema.projectConfig.id, 1))
227
+ .get();
228
+ return row?.currentIteration ?? DEFAULT_CURRENT_ITERATION;
229
+ }
230
+ // eslint-disable-next-line @typescript-eslint/require-await
231
+ async setCurrentIteration(name) {
232
+ // Ensure iteration exists
233
+ this.db
234
+ .insert(schema.iterations)
235
+ .values({ name, sortOrder: 0 })
236
+ .onConflictDoNothing()
237
+ .run();
238
+ this.db
239
+ .update(schema.projectConfig)
240
+ .set({ currentIteration: name })
241
+ .where(eq(schema.projectConfig.id, 1))
242
+ .run();
243
+ }
244
+ // ─── Read: work items ──────────────────────────────────────────────
245
+ // eslint-disable-next-line @typescript-eslint/require-await
246
+ async listWorkItems(iteration) {
247
+ const itemRows = iteration
248
+ ? this.db
249
+ .select()
250
+ .from(schema.workItems)
251
+ .where(and(isNull(schema.workItems.deletedAt), eq(schema.workItems.iteration, iteration)))
252
+ .all()
253
+ : this.db
254
+ .select()
255
+ .from(schema.workItems)
256
+ .where(isNull(schema.workItems.deletedAt))
257
+ .all();
258
+ if (itemRows.length === 0)
259
+ return [];
260
+ return this.assembleWorkItems(itemRows);
261
+ }
262
+ // eslint-disable-next-line @typescript-eslint/require-await
263
+ async getWorkItem(id) {
264
+ const row = this.db
265
+ .select()
266
+ .from(schema.workItems)
267
+ .where(and(eq(schema.workItems.id, id), isNull(schema.workItems.deletedAt)))
268
+ .get();
269
+ if (!row) {
270
+ throw new Error(`Work item #${id} not found`);
271
+ }
272
+ const labels = this.db
273
+ .select()
274
+ .from(schema.workItemLabels)
275
+ .where(eq(schema.workItemLabels.workItemId, id))
276
+ .all();
277
+ const deps = this.db
278
+ .select()
279
+ .from(schema.workItemDeps)
280
+ .where(eq(schema.workItemDeps.workItemId, id))
281
+ .all();
282
+ const itemComments = this.db
283
+ .select()
284
+ .from(schema.comments)
285
+ .where(eq(schema.comments.workItemId, id))
286
+ .all();
287
+ return rowToWorkItem(row, labels, deps, itemComments);
288
+ }
289
+ // ─── Read: relationships (SQL-optimized overrides) ─────────────────
290
+ // eslint-disable-next-line @typescript-eslint/require-await
291
+ async getChildren(id) {
292
+ const childRows = this.db
293
+ .select()
294
+ .from(schema.workItems)
295
+ .where(and(eq(schema.workItems.parent, id), isNull(schema.workItems.deletedAt)))
296
+ .all();
297
+ if (childRows.length === 0)
298
+ return [];
299
+ return this.assembleWorkItems(childRows);
300
+ }
301
+ // eslint-disable-next-line @typescript-eslint/require-await
302
+ async getDependents(id) {
303
+ // Find items that depend on `id`
304
+ const depRows = this.db
305
+ .select({ workItemId: schema.workItemDeps.workItemId })
306
+ .from(schema.workItemDeps)
307
+ .where(eq(schema.workItemDeps.dependsOnId, id))
308
+ .all();
309
+ if (depRows.length === 0)
310
+ return [];
311
+ const dependentIds = depRows.map((r) => r.workItemId);
312
+ const itemRows = this.db
313
+ .select()
314
+ .from(schema.workItems)
315
+ .where(and(inArray(schema.workItems.id, dependentIds), isNull(schema.workItems.deletedAt)))
316
+ .all();
317
+ if (itemRows.length === 0)
318
+ return [];
319
+ return this.assembleWorkItems(itemRows);
320
+ }
321
+ /**
322
+ * Helper: given a set of work item rows, fetch their labels/deps/comments and assemble.
323
+ */
324
+ assembleWorkItems(itemRows) {
325
+ const itemIds = itemRows.map((r) => r.id);
326
+ const labelRows = this.db
327
+ .select()
328
+ .from(schema.workItemLabels)
329
+ .where(inArray(schema.workItemLabels.workItemId, itemIds))
330
+ .all();
331
+ const depRows = this.db
332
+ .select()
333
+ .from(schema.workItemDeps)
334
+ .where(inArray(schema.workItemDeps.workItemId, itemIds))
335
+ .all();
336
+ const commentRows = this.db
337
+ .select()
338
+ .from(schema.comments)
339
+ .where(inArray(schema.comments.workItemId, itemIds))
340
+ .all();
341
+ const labelsByItem = new Map();
342
+ for (const l of labelRows) {
343
+ const arr = labelsByItem.get(l.workItemId);
344
+ if (arr)
345
+ arr.push(l);
346
+ else
347
+ labelsByItem.set(l.workItemId, [l]);
348
+ }
349
+ const depsByItem = new Map();
350
+ for (const d of depRows) {
351
+ const arr = depsByItem.get(d.workItemId);
352
+ if (arr)
353
+ arr.push(d);
354
+ else
355
+ depsByItem.set(d.workItemId, [d]);
356
+ }
357
+ const commentsByItem = new Map();
358
+ for (const c of commentRows) {
359
+ const arr = commentsByItem.get(c.workItemId);
360
+ if (arr)
361
+ arr.push(c);
362
+ else
363
+ commentsByItem.set(c.workItemId, [c]);
364
+ }
365
+ return itemRows.map((row) => rowToWorkItem(row, labelsByItem.get(row.id) ?? [], depsByItem.get(row.id) ?? [], commentsByItem.get(row.id) ?? []));
366
+ }
367
+ // ─── Read: item URL ────────────────────────────────────────────────
368
+ getItemUrl(id) {
369
+ return `${this.root}/.tic/items/${id}.md`;
370
+ }
371
+ // ─── Relationship validation ─────────────────────────────────────
372
+ validateRelationships(id, parent, dependsOn) {
373
+ // Validate parent
374
+ if (parent !== null && parent !== undefined) {
375
+ if (parent === id) {
376
+ throw new Error(`Work item #${id} cannot be its own parent`);
377
+ }
378
+ const parentRow = this.db
379
+ .select({ id: schema.workItems.id })
380
+ .from(schema.workItems)
381
+ .where(and(eq(schema.workItems.id, parent), isNull(schema.workItems.deletedAt)))
382
+ .get();
383
+ if (!parentRow) {
384
+ throw new Error(`Parent #${parent} does not exist`);
385
+ }
386
+ // Walk up the parent chain to detect circular references
387
+ let current = parent;
388
+ const visited = new Set();
389
+ while (current !== null) {
390
+ if (current === id) {
391
+ throw new Error(`Circular parent chain detected for #${id}`);
392
+ }
393
+ if (visited.has(current))
394
+ break;
395
+ visited.add(current);
396
+ const row = this.db
397
+ .select({ parent: schema.workItems.parent })
398
+ .from(schema.workItems)
399
+ .where(and(eq(schema.workItems.id, current), isNull(schema.workItems.deletedAt)))
400
+ .get();
401
+ current = row?.parent ?? null;
402
+ }
403
+ }
404
+ // Validate dependencies
405
+ if (dependsOn !== undefined && dependsOn.length > 0) {
406
+ for (const depId of dependsOn) {
407
+ if (depId === id) {
408
+ throw new Error(`Work item #${id} cannot depend on itself`);
409
+ }
410
+ }
411
+ // Check all deps exist in one query
412
+ const existingRows = this.db
413
+ .select({ id: schema.workItems.id })
414
+ .from(schema.workItems)
415
+ .where(and(inArray(schema.workItems.id, dependsOn), isNull(schema.workItems.deletedAt)))
416
+ .all();
417
+ const existingIds = new Set(existingRows.map((r) => r.id));
418
+ for (const depId of dependsOn) {
419
+ if (!existingIds.has(depId)) {
420
+ throw new Error(`Dependency #${depId} does not exist`);
421
+ }
422
+ }
423
+ // Check for circular dependency chains
424
+ const hasCycle = (startId, targetId) => {
425
+ const visited = new Set();
426
+ const stack = [startId];
427
+ while (stack.length > 0) {
428
+ const current = stack.pop();
429
+ if (current === targetId)
430
+ return true;
431
+ if (visited.has(current))
432
+ continue;
433
+ visited.add(current);
434
+ const deps = this.db
435
+ .select({ dependsOnId: schema.workItemDeps.dependsOnId })
436
+ .from(schema.workItemDeps)
437
+ .where(eq(schema.workItemDeps.workItemId, current))
438
+ .all();
439
+ for (const dep of deps) {
440
+ stack.push(dep.dependsOnId);
441
+ }
442
+ }
443
+ return false;
444
+ };
445
+ for (const depId of dependsOn) {
446
+ if (hasCycle(depId, id)) {
447
+ throw new Error(`Circular dependency chain detected for #${id}`);
448
+ }
449
+ }
450
+ }
451
+ }
452
+ // ─── Write: createWorkItem ────────────────────────────────────────
453
+ // eslint-disable-next-line @typescript-eslint/require-await
454
+ async createWorkItem(data) {
455
+ this.validateFields(data);
456
+ const now = new Date().toISOString();
457
+ // Get and increment nextId
458
+ const config = this.db
459
+ .select()
460
+ .from(schema.projectConfig)
461
+ .where(eq(schema.projectConfig.id, 1))
462
+ .get();
463
+ const nextId = config?.nextId ?? 1;
464
+ const id = this.tempIds ? `local-${nextId}` : String(nextId);
465
+ // Validate relationships before inserting
466
+ this.validateRelationships(id, data.parent, data.dependsOn);
467
+ this.db.transaction((tx) => {
468
+ tx.update(schema.projectConfig)
469
+ .set({ nextId: nextId + 1 })
470
+ .where(eq(schema.projectConfig.id, 1))
471
+ .run();
472
+ // Ensure iteration exists
473
+ if (data.iteration) {
474
+ tx.insert(schema.iterations)
475
+ .values({ name: data.iteration, sortOrder: 0 })
476
+ .onConflictDoNothing()
477
+ .run();
478
+ }
479
+ // Insert work item
480
+ tx.insert(schema.workItems)
481
+ .values({
482
+ id,
483
+ title: data.title,
484
+ type: data.type,
485
+ status: data.status,
486
+ iteration: data.iteration,
487
+ priority: data.priority,
488
+ assignee: data.assignee,
489
+ description: data.description,
490
+ parent: data.parent,
491
+ created: now,
492
+ updated: now,
493
+ })
494
+ .run();
495
+ // Insert labels
496
+ if (data.labels.length > 0) {
497
+ tx.insert(schema.workItemLabels)
498
+ .values(data.labels.map((label) => ({ workItemId: id, label })))
499
+ .run();
500
+ }
501
+ // Insert deps
502
+ if (data.dependsOn.length > 0) {
503
+ tx.insert(schema.workItemDeps)
504
+ .values(data.dependsOn.map((dependsOnId) => ({
505
+ workItemId: id,
506
+ dependsOnId,
507
+ })))
508
+ .run();
509
+ }
510
+ });
511
+ this.invalidateCache();
512
+ return {
513
+ id,
514
+ title: data.title,
515
+ type: data.type,
516
+ status: data.status,
517
+ iteration: data.iteration,
518
+ priority: data.priority,
519
+ assignee: data.assignee,
520
+ labels: [...data.labels],
521
+ description: data.description,
522
+ parent: data.parent,
523
+ dependsOn: [...data.dependsOn],
524
+ created: now,
525
+ updated: now,
526
+ comments: [],
527
+ };
528
+ }
529
+ // ─── Write: importWorkItem (for sync) ───────────────────────────────
530
+ // eslint-disable-next-line @typescript-eslint/require-await
531
+ async importWorkItem(item) {
532
+ this.db.transaction((tx) => {
533
+ // Ensure iteration exists
534
+ if (item.iteration) {
535
+ tx.insert(schema.iterations)
536
+ .values({ name: item.iteration, sortOrder: 0 })
537
+ .onConflictDoNothing()
538
+ .run();
539
+ }
540
+ // Upsert work item
541
+ tx.insert(schema.workItems)
542
+ .values({
543
+ id: item.id,
544
+ title: item.title,
545
+ type: item.type,
546
+ status: item.status,
547
+ iteration: item.iteration,
548
+ priority: item.priority,
549
+ assignee: item.assignee,
550
+ description: item.description,
551
+ parent: item.parent,
552
+ created: item.created,
553
+ updated: item.updated,
554
+ })
555
+ .onConflictDoUpdate({
556
+ target: schema.workItems.id,
557
+ set: {
558
+ title: item.title,
559
+ type: item.type,
560
+ status: item.status,
561
+ iteration: item.iteration,
562
+ priority: item.priority,
563
+ assignee: item.assignee,
564
+ description: item.description,
565
+ parent: item.parent,
566
+ created: item.created,
567
+ updated: item.updated,
568
+ },
569
+ })
570
+ .run();
571
+ // Replace labels
572
+ tx.delete(schema.workItemLabels)
573
+ .where(eq(schema.workItemLabels.workItemId, item.id))
574
+ .run();
575
+ if (item.labels.length > 0) {
576
+ tx.insert(schema.workItemLabels)
577
+ .values(item.labels.map((label) => ({ workItemId: item.id, label })))
578
+ .run();
579
+ }
580
+ // Replace deps
581
+ tx.delete(schema.workItemDeps)
582
+ .where(eq(schema.workItemDeps.workItemId, item.id))
583
+ .run();
584
+ if (item.dependsOn.length > 0) {
585
+ tx.insert(schema.workItemDeps)
586
+ .values(item.dependsOn.map((dependsOnId) => ({
587
+ workItemId: item.id,
588
+ dependsOnId,
589
+ })))
590
+ .run();
591
+ }
592
+ // Replace comments
593
+ tx.delete(schema.comments)
594
+ .where(eq(schema.comments.workItemId, item.id))
595
+ .run();
596
+ if (item.comments.length > 0) {
597
+ for (const c of item.comments) {
598
+ tx.insert(schema.comments)
599
+ .values({
600
+ workItemId: item.id,
601
+ author: c.author,
602
+ body: c.body,
603
+ created: c.date,
604
+ })
605
+ .run();
606
+ }
607
+ }
608
+ });
609
+ this.invalidateCache();
610
+ return item;
611
+ }
612
+ // ─── Write: updateWorkItem ────────────────────────────────────────
613
+ async updateWorkItem(id, data) {
614
+ this.validateFields(data);
615
+ // 1. Read existing item (throw if not found)
616
+ const existingRow = this.db
617
+ .select()
618
+ .from(schema.workItems)
619
+ .where(and(eq(schema.workItems.id, id), isNull(schema.workItems.deletedAt)))
620
+ .get();
621
+ if (!existingRow) {
622
+ throw new Error(`Work item #${id} not found`);
623
+ }
624
+ // 2. Validate relationships if parent/dependsOn changed
625
+ const newParent = 'parent' in data ? data.parent : undefined;
626
+ const newDepsOn = 'dependsOn' in data ? data.dependsOn : undefined;
627
+ if (newParent !== undefined || newDepsOn !== undefined) {
628
+ this.validateRelationships(id, newParent !== undefined ? (newParent ?? null) : undefined, newDepsOn);
629
+ }
630
+ const now = new Date().toISOString();
631
+ // 3. In a transaction: update workItems row, delete+re-insert labels/deps
632
+ this.db.transaction((tx) => {
633
+ // Build the set of fields to update on the work_items row
634
+ const updateSet = { updated: now };
635
+ if ('title' in data)
636
+ updateSet['title'] = data.title;
637
+ if ('type' in data)
638
+ updateSet['type'] = data.type;
639
+ if ('status' in data)
640
+ updateSet['status'] = data.status;
641
+ if ('iteration' in data)
642
+ updateSet['iteration'] = data.iteration;
643
+ if ('priority' in data)
644
+ updateSet['priority'] = data.priority;
645
+ if ('assignee' in data)
646
+ updateSet['assignee'] = data.assignee;
647
+ if ('description' in data)
648
+ updateSet['description'] = data.description;
649
+ if ('parent' in data)
650
+ updateSet['parent'] = data.parent ?? null;
651
+ tx.update(schema.workItems)
652
+ .set(updateSet)
653
+ .where(eq(schema.workItems.id, id))
654
+ .run();
655
+ // Replace labels if changed
656
+ if ('labels' in data && data.labels !== undefined) {
657
+ tx.delete(schema.workItemLabels)
658
+ .where(eq(schema.workItemLabels.workItemId, id))
659
+ .run();
660
+ if (data.labels.length > 0) {
661
+ tx.insert(schema.workItemLabels)
662
+ .values(data.labels.map((label) => ({ workItemId: id, label })))
663
+ .run();
664
+ }
665
+ }
666
+ // Replace deps if changed
667
+ if ('dependsOn' in data && data.dependsOn !== undefined) {
668
+ tx.delete(schema.workItemDeps)
669
+ .where(eq(schema.workItemDeps.workItemId, id))
670
+ .run();
671
+ if (data.dependsOn.length > 0) {
672
+ tx.insert(schema.workItemDeps)
673
+ .values(data.dependsOn.map((dependsOnId) => ({
674
+ workItemId: id,
675
+ dependsOnId,
676
+ })))
677
+ .run();
678
+ }
679
+ }
680
+ // Ensure iteration exists if changed
681
+ if ('iteration' in data && data.iteration) {
682
+ tx.insert(schema.iterations)
683
+ .values({ name: data.iteration, sortOrder: 0 })
684
+ .onConflictDoNothing()
685
+ .run();
686
+ }
687
+ });
688
+ this.invalidateCache();
689
+ // 4. Return updated item
690
+ return this.getWorkItem(id);
691
+ }
692
+ // ─── Write: deleteWorkItem ────────────────────────────────────────
693
+ // eslint-disable-next-line @typescript-eslint/require-await
694
+ async deleteWorkItem(id) {
695
+ this.db.transaction((tx) => {
696
+ // 1. Null out parent on children
697
+ tx.update(schema.workItems)
698
+ .set({ parent: null })
699
+ .where(eq(schema.workItems.parent, id))
700
+ .run();
701
+ // 2. Remove deps referencing this item (other items depending on this one)
702
+ tx.delete(schema.workItemDeps)
703
+ .where(eq(schema.workItemDeps.dependsOnId, id))
704
+ .run();
705
+ // 3. Delete the item (cascade handles labels, deps, comments of this item)
706
+ tx.delete(schema.workItems).where(eq(schema.workItems.id, id)).run();
707
+ });
708
+ this.invalidateCache();
709
+ }
710
+ // ─── Write: soft delete ───────────────────────────────────────────
711
+ // eslint-disable-next-line @typescript-eslint/require-await
712
+ async softDeleteWorkItem(id) {
713
+ const now = new Date().toISOString();
714
+ this.db
715
+ .update(schema.workItems)
716
+ .set({ deletedAt: now })
717
+ .where(eq(schema.workItems.id, id))
718
+ .run();
719
+ this.invalidateCache();
720
+ }
721
+ // ─── Write: addComment ────────────────────────────────────────────
722
+ // eslint-disable-next-line @typescript-eslint/require-await
723
+ async addComment(workItemId, comment) {
724
+ // 1. Verify item exists
725
+ const row = this.db
726
+ .select({ id: schema.workItems.id })
727
+ .from(schema.workItems)
728
+ .where(and(eq(schema.workItems.id, workItemId), isNull(schema.workItems.deletedAt)))
729
+ .get();
730
+ if (!row) {
731
+ throw new Error(`Work item #${workItemId} not found`);
732
+ }
733
+ // 2. Insert comment (do NOT update work item's updated timestamp)
734
+ const now = new Date().toISOString();
735
+ this.db
736
+ .insert(schema.comments)
737
+ .values({
738
+ workItemId,
739
+ author: comment.author,
740
+ body: comment.body,
741
+ created: now,
742
+ })
743
+ .run();
744
+ this.invalidateCache();
745
+ return {
746
+ author: comment.author,
747
+ date: now,
748
+ body: comment.body,
749
+ };
750
+ }
751
+ // ─── Write: restore (undo soft delete) ──────────────────────────
752
+ // eslint-disable-next-line @typescript-eslint/require-await
753
+ async restoreWorkItem(id) {
754
+ this.db
755
+ .update(schema.workItems)
756
+ .set({ deletedAt: null })
757
+ .where(eq(schema.workItems.id, id))
758
+ .run();
759
+ this.invalidateCache();
760
+ }
761
+ // ─── Write: permanent delete ───────────────────────────────────
762
+ // eslint-disable-next-line @typescript-eslint/require-await
763
+ async permanentlyDeleteWorkItem(id) {
764
+ this.db.delete(schema.workItems).where(eq(schema.workItems.id, id)).run();
765
+ this.invalidateCache();
766
+ }
767
+ // ─── Write: cleanup all soft-deleted items ─────────────────────
768
+ // eslint-disable-next-line @typescript-eslint/require-await
769
+ async cleanupTrash() {
770
+ this.db
771
+ .delete(schema.workItems)
772
+ .where(isNotNull(schema.workItems.deletedAt))
773
+ .run();
774
+ this.invalidateCache();
775
+ }
776
+ // ─── Open item in editor ───────────────────────────────────────
777
+ async openItem(id) {
778
+ const filePath = this.getItemUrl(id);
779
+ const editor = process.env['VISUAL'] || process.env['EDITOR'] || 'vi';
780
+ return new Promise((resolve, reject) => {
781
+ const child = spawn(editor, [filePath], { stdio: 'inherit' });
782
+ child.on('close', (code) => {
783
+ if (code === 0)
784
+ resolve();
785
+ else
786
+ reject(new Error(`Editor exited with code ${code}`));
787
+ });
788
+ child.on('error', reject);
789
+ });
790
+ }
791
+ // ─── Templates ─────────────────────────────────────────────────
792
+ /**
793
+ * Slugify a template name (same logic as LocalBackend).
794
+ */
795
+ slugifyName(name) {
796
+ return name
797
+ .toLowerCase()
798
+ .replace(/[^a-z0-9\s-]/g, '')
799
+ .replace(/[\s-]+/g, '-')
800
+ .replace(/^-+|-+$/g, '');
801
+ }
802
+ // eslint-disable-next-line @typescript-eslint/require-await
803
+ async listTemplates() {
804
+ const templateRows = this.db.select().from(schema.templates).all();
805
+ if (templateRows.length === 0)
806
+ return [];
807
+ const slugs = templateRows.map((r) => r.slug);
808
+ const labelRows = this.db
809
+ .select()
810
+ .from(schema.templateLabels)
811
+ .where(inArray(schema.templateLabels.templateSlug, slugs))
812
+ .all();
813
+ const depRows = this.db
814
+ .select()
815
+ .from(schema.templateDeps)
816
+ .where(inArray(schema.templateDeps.templateSlug, slugs))
817
+ .all();
818
+ const labelsBySlug = new Map();
819
+ for (const l of labelRows) {
820
+ const arr = labelsBySlug.get(l.templateSlug);
821
+ if (arr)
822
+ arr.push(l);
823
+ else
824
+ labelsBySlug.set(l.templateSlug, [l]);
825
+ }
826
+ const depsBySlug = new Map();
827
+ for (const d of depRows) {
828
+ const arr = depsBySlug.get(d.templateSlug);
829
+ if (arr)
830
+ arr.push(d);
831
+ else
832
+ depsBySlug.set(d.templateSlug, [d]);
833
+ }
834
+ return templateRows.map((row) => rowToTemplate(row, labelsBySlug.get(row.slug) ?? [], depsBySlug.get(row.slug) ?? []));
835
+ }
836
+ // eslint-disable-next-line @typescript-eslint/require-await
837
+ async getTemplate(slug) {
838
+ const row = this.db
839
+ .select()
840
+ .from(schema.templates)
841
+ .where(eq(schema.templates.slug, slug))
842
+ .get();
843
+ if (!row) {
844
+ throw new Error(`Template '${slug}' not found`);
845
+ }
846
+ const labels = this.db
847
+ .select()
848
+ .from(schema.templateLabels)
849
+ .where(eq(schema.templateLabels.templateSlug, slug))
850
+ .all();
851
+ const deps = this.db
852
+ .select()
853
+ .from(schema.templateDeps)
854
+ .where(eq(schema.templateDeps.templateSlug, slug))
855
+ .all();
856
+ return rowToTemplate(row, labels, deps);
857
+ }
858
+ async createTemplate(template) {
859
+ const slug = this.slugifyName(template.name);
860
+ this.db.transaction((tx) => {
861
+ tx.insert(schema.templates)
862
+ .values({
863
+ slug,
864
+ name: template.name,
865
+ type: template.type ?? '',
866
+ status: template.status ?? '',
867
+ priority: template.priority ?? '',
868
+ assignee: template.assignee ?? '',
869
+ iteration: template.iteration ?? '',
870
+ parent: template.parent ?? null,
871
+ description: template.description ?? '',
872
+ })
873
+ .run();
874
+ if (template.labels && template.labels.length > 0) {
875
+ tx.insert(schema.templateLabels)
876
+ .values(template.labels.map((label) => ({ templateSlug: slug, label })))
877
+ .run();
878
+ }
879
+ if (template.dependsOn && template.dependsOn.length > 0) {
880
+ tx.insert(schema.templateDeps)
881
+ .values(template.dependsOn.map((dependsOnId) => ({
882
+ templateSlug: slug,
883
+ dependsOnId,
884
+ })))
885
+ .run();
886
+ }
887
+ });
888
+ return this.getTemplate(slug);
889
+ }
890
+ async updateTemplate(oldSlug, template) {
891
+ const newSlug = this.slugifyName(template.name);
892
+ this.db.transaction((tx) => {
893
+ if (oldSlug !== newSlug) {
894
+ // Slug changed: delete old (cascade handles labels/deps), insert new
895
+ tx.delete(schema.templates)
896
+ .where(eq(schema.templates.slug, oldSlug))
897
+ .run();
898
+ tx.insert(schema.templates)
899
+ .values({
900
+ slug: newSlug,
901
+ name: template.name,
902
+ type: template.type ?? '',
903
+ status: template.status ?? '',
904
+ priority: template.priority ?? '',
905
+ assignee: template.assignee ?? '',
906
+ iteration: template.iteration ?? '',
907
+ parent: template.parent ?? null,
908
+ description: template.description ?? '',
909
+ })
910
+ .run();
911
+ }
912
+ else {
913
+ // Same slug: update in place
914
+ tx.update(schema.templates)
915
+ .set({
916
+ name: template.name,
917
+ type: template.type ?? '',
918
+ status: template.status ?? '',
919
+ priority: template.priority ?? '',
920
+ assignee: template.assignee ?? '',
921
+ iteration: template.iteration ?? '',
922
+ parent: template.parent ?? null,
923
+ description: template.description ?? '',
924
+ })
925
+ .where(eq(schema.templates.slug, oldSlug))
926
+ .run();
927
+ // Delete and re-insert labels
928
+ tx.delete(schema.templateLabels)
929
+ .where(eq(schema.templateLabels.templateSlug, oldSlug))
930
+ .run();
931
+ }
932
+ // Insert labels (for both new slug and same slug cases)
933
+ if (template.labels && template.labels.length > 0) {
934
+ tx.insert(schema.templateLabels)
935
+ .values(template.labels.map((label) => ({
936
+ templateSlug: newSlug,
937
+ label,
938
+ })))
939
+ .run();
940
+ }
941
+ // Delete and re-insert deps (only for same-slug; new slug cascade already handled it)
942
+ if (oldSlug === newSlug) {
943
+ tx.delete(schema.templateDeps)
944
+ .where(eq(schema.templateDeps.templateSlug, oldSlug))
945
+ .run();
946
+ }
947
+ if (template.dependsOn && template.dependsOn.length > 0) {
948
+ tx.insert(schema.templateDeps)
949
+ .values(template.dependsOn.map((dependsOnId) => ({
950
+ templateSlug: newSlug,
951
+ dependsOnId,
952
+ })))
953
+ .run();
954
+ }
955
+ });
956
+ return this.getTemplate(newSlug);
957
+ }
958
+ // eslint-disable-next-line @typescript-eslint/require-await
959
+ async deleteTemplate(slug) {
960
+ this.db
961
+ .delete(schema.templates)
962
+ .where(eq(schema.templates.slug, slug))
963
+ .run();
964
+ }
965
+ }
966
+ //# sourceMappingURL=index.js.map