@sascha384/tic 5.16.0 → 5.17.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 (106) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/backends/ado/index.js +1 -1
  3. package/dist/backends/ado/index.js.map +1 -1
  4. package/dist/backends/ado/mappers.d.ts +2 -2
  5. package/dist/backends/ado/mappers.js +7 -2
  6. package/dist/backends/ado/mappers.js.map +1 -1
  7. package/dist/backends/files/index.d.ts +6 -0
  8. package/dist/backends/files/index.js +36 -0
  9. package/dist/backends/files/index.js.map +1 -1
  10. package/dist/backends/files/sync.d.ts +2 -2
  11. package/dist/backends/files/sync.js +9 -7
  12. package/dist/backends/files/sync.js.map +1 -1
  13. package/dist/backends/github/index.js +3 -3
  14. package/dist/backends/github/index.js.map +1 -1
  15. package/dist/backends/github/mappers.js +2 -1
  16. package/dist/backends/github/mappers.js.map +1 -1
  17. package/dist/backends/github/pr-mappers.d.ts +1 -1
  18. package/dist/backends/github/pr-mappers.js +1 -1
  19. package/dist/backends/github/pr-mappers.js.map +1 -1
  20. package/dist/backends/gitlab/index.js +13 -6
  21. package/dist/backends/gitlab/index.js.map +1 -1
  22. package/dist/backends/gitlab/mappers.js +2 -1
  23. package/dist/backends/gitlab/mappers.js.map +1 -1
  24. package/dist/backends/jira/index.js +2 -2
  25. package/dist/backends/jira/index.js.map +1 -1
  26. package/dist/backends/jira/mappers.d.ts +1 -1
  27. package/dist/backends/jira/mappers.js +5 -4
  28. package/dist/backends/jira/mappers.js.map +1 -1
  29. package/dist/backends/local/items.js +9 -2
  30. package/dist/backends/local/items.js.map +1 -1
  31. package/dist/backends/types.d.ts +2 -3
  32. package/dist/backends/types.js +8 -2
  33. package/dist/backends/types.js.map +1 -1
  34. package/dist/cli/commands/config.js +0 -1
  35. package/dist/cli/commands/config.js.map +1 -1
  36. package/dist/cli/commands/item.js +45 -17
  37. package/dist/cli/commands/item.js.map +1 -1
  38. package/dist/cli/commands/mcp.js +24 -9
  39. package/dist/cli/commands/mcp.js.map +1 -1
  40. package/dist/cli/commands/pr.js +12 -6
  41. package/dist/cli/commands/pr.js.map +1 -1
  42. package/dist/cli/index.js +13 -6
  43. package/dist/cli/index.js.map +1 -1
  44. package/dist/components/BranchList.js +4 -2
  45. package/dist/components/BranchList.js.map +1 -1
  46. package/dist/components/CommandBar.js +8 -7
  47. package/dist/components/CommandBar.js.map +1 -1
  48. package/dist/components/DetailPanel.js +2 -2
  49. package/dist/components/DetailPanel.js.map +1 -1
  50. package/dist/components/Settings.js +1 -1
  51. package/dist/components/Settings.js.map +1 -1
  52. package/dist/components/StatusScreen.js +2 -2
  53. package/dist/components/StatusScreen.js.map +1 -1
  54. package/dist/components/WorkItemForm.js +107 -73
  55. package/dist/components/WorkItemForm.js.map +1 -1
  56. package/dist/components/WorkItemList.d.ts +5 -3
  57. package/dist/components/WorkItemList.js +175 -124
  58. package/dist/components/WorkItemList.js.map +1 -1
  59. package/dist/components/buildTree.js +15 -13
  60. package/dist/components/buildTree.js.map +1 -1
  61. package/dist/components/fuzzyMatch.js +1 -1
  62. package/dist/components/fuzzyMatch.js.map +1 -1
  63. package/dist/components/getMarkedDistribution.d.ts +2 -2
  64. package/dist/components/getMarkedDistribution.js +1 -1
  65. package/dist/components/getMarkedDistribution.js.map +1 -1
  66. package/dist/git.d.ts +2 -1
  67. package/dist/hooks/useFormValidation.js +31 -21
  68. package/dist/hooks/useFormValidation.js.map +1 -1
  69. package/dist/implement.js +3 -3
  70. package/dist/implement.js.map +1 -1
  71. package/dist/index.js +3 -1
  72. package/dist/index.js.map +1 -1
  73. package/dist/storage/config.d.ts +0 -1
  74. package/dist/storage/config.js +0 -6
  75. package/dist/storage/config.js.map +1 -1
  76. package/dist/storage/index.d.ts +25 -6
  77. package/dist/storage/index.js +304 -183
  78. package/dist/storage/index.js.map +1 -1
  79. package/dist/storage/mappers.d.ts +1 -1
  80. package/dist/storage/mappers.js +5 -3
  81. package/dist/storage/mappers.js.map +1 -1
  82. package/dist/storage/schema.d.ts +83 -380
  83. package/dist/storage/schema.js +27 -55
  84. package/dist/storage/schema.js.map +1 -1
  85. package/dist/storage/syncQueue.d.ts +2 -3
  86. package/dist/storage/syncQueue.js +10 -17
  87. package/dist/storage/syncQueue.js.map +1 -1
  88. package/dist/storage/undo.js +7 -6
  89. package/dist/storage/undo.js.map +1 -1
  90. package/dist/stores/backendDataStore.js +3 -1
  91. package/dist/stores/backendDataStore.js.map +1 -1
  92. package/dist/stores/formStackStore.d.ts +1 -1
  93. package/dist/stores/listViewStore.d.ts +6 -6
  94. package/dist/stores/navigationStore.d.ts +7 -7
  95. package/dist/stores/undoStore.d.ts +2 -2
  96. package/dist/sync/SyncManager.d.ts +6 -1
  97. package/dist/sync/SyncManager.js +80 -76
  98. package/dist/sync/SyncManager.js.map +1 -1
  99. package/dist/sync/types.d.ts +6 -7
  100. package/dist/test-helpers.d.ts +1 -1
  101. package/dist/test-helpers.js +11 -8
  102. package/dist/test-helpers.js.map +1 -1
  103. package/dist/types.d.ts +6 -5
  104. package/drizzle/0006_dual_id.sql +173 -0
  105. package/drizzle/meta/_journal.json +7 -0
  106. package/package.json +1 -1
@@ -12,7 +12,6 @@ const DEFAULT_STATUSES = ['backlog', 'todo', 'in-progress', 'review', 'done'];
12
12
  const DEFAULT_TYPES = ['epic', 'issue', 'task'];
13
13
  const DEFAULT_ITERATIONS = ['default'];
14
14
  const DEFAULT_CURRENT_ITERATION = 'default';
15
- const DEFAULT_NEXT_ID = 1;
16
15
  const DEFAULT_BRANCH_MODE = 'worktree';
17
16
  const DEFAULT_AUTO_UPDATE = true;
18
17
  const DEFAULT_BRANCH_COMMAND = `claude "Brainstorm the implementation of issue #$TIC_ITEM_ID: $TIC_ITEM_TITLE. $TIC_ITEM_DESCRIPTION"`;
@@ -20,19 +19,24 @@ const DEFAULT_COPY_TO_CLIPBOARD = true;
20
19
  export class Storage extends BaseBackend {
21
20
  db;
22
21
  root;
23
- tempIds;
24
- constructor(db, root, options) {
22
+ _hasRemoteBackend = false;
23
+ constructor(db, root) {
25
24
  super(0); // No TTL — DB is always fresh
26
25
  this.db = db;
27
26
  this.root = root;
28
- this.tempIds = options?.tempIds ?? false;
27
+ }
28
+ get hasRemoteBackend() {
29
+ return this._hasRemoteBackend;
30
+ }
31
+ setHasRemoteBackend(value) {
32
+ this._hasRemoteBackend = value;
29
33
  }
30
34
  /**
31
35
  * Create a Storage instance, initializing the database and seeding defaults.
32
36
  */
33
- static create(root, options) {
37
+ static create(root) {
34
38
  const db = createDatabase(root);
35
- const backend = new Storage(db, root, options);
39
+ const backend = new Storage(db, root);
36
40
  backend.seedDefaults();
37
41
  backend.migrateFromYaml();
38
42
  return backend;
@@ -40,8 +44,8 @@ export class Storage extends BaseBackend {
40
44
  /**
41
45
  * Create a Storage instance from an existing database instance (for testing).
42
46
  */
43
- static createFromDb(db, options) {
44
- const backend = new Storage(db, ':memory:', options);
47
+ static createFromDb(db) {
48
+ const backend = new Storage(db, ':memory:');
45
49
  backend.seedDefaults();
46
50
  return backend;
47
51
  }
@@ -62,7 +66,6 @@ export class Storage extends BaseBackend {
62
66
  id: 1,
63
67
  backend: 'drizzle',
64
68
  currentIteration: DEFAULT_CURRENT_ITERATION,
65
- nextId: DEFAULT_NEXT_ID,
66
69
  branchMode: DEFAULT_BRANCH_MODE,
67
70
  branchCommand: DEFAULT_BRANCH_COMMAND,
68
71
  copyToClipboard: DEFAULT_COPY_TO_CLIPBOARD,
@@ -116,15 +119,6 @@ export class Storage extends BaseBackend {
116
119
  try {
117
120
  const raw = fs.readFileSync(yamlPath, 'utf-8');
118
121
  const config = yaml.parse(raw);
119
- // Recalculate nextId from actual max item ID to prevent collisions
120
- // (the YAML next_id may be stale if items were synced from a remote)
121
- const maxRow = this.db.raw
122
- .prepare('SELECT MAX(CAST(id AS INTEGER)) AS maxId FROM work_items')
123
- .get();
124
- const maxId = maxRow?.maxId ?? 0;
125
- if (maxId >= config.next_id) {
126
- config.next_id = maxId + 1;
127
- }
128
122
  this.db.transaction((tx) => {
129
123
  insertConfigTx(tx, config);
130
124
  });
@@ -229,7 +223,7 @@ export class Storage extends BaseBackend {
229
223
  const rows = this.db
230
224
  .selectDistinct({ label: schema.workItemLabels.label })
231
225
  .from(schema.workItemLabels)
232
- .innerJoin(schema.workItems, eq(schema.workItemLabels.workItemId, schema.workItems.id))
226
+ .innerJoin(schema.workItems, eq(schema.workItemLabels.workItemRowId, schema.workItems.rowId))
233
227
  .where(isNull(schema.workItems.deletedAt))
234
228
  .all();
235
229
  return rows.map((r) => r.label).sort();
@@ -285,30 +279,63 @@ export class Storage extends BaseBackend {
285
279
  if (!row) {
286
280
  throw new Error(`Work item #${id} not found`);
287
281
  }
282
+ return this.assembleWorkItemByRowId(row);
283
+ }
284
+ // eslint-disable-next-line @typescript-eslint/require-await
285
+ async getWorkItemByRowId(rowId) {
286
+ const row = this.db
287
+ .select()
288
+ .from(schema.workItems)
289
+ .where(and(eq(schema.workItems.rowId, rowId), isNull(schema.workItems.deletedAt)))
290
+ .get();
291
+ if (!row) {
292
+ throw new Error(`Work item with rowId ${rowId} not found`);
293
+ }
294
+ return this.assembleWorkItemByRowId(row);
295
+ }
296
+ /**
297
+ * Get the display ID for a rowId, including soft-deleted items.
298
+ * Used by SyncManager to resolve display IDs for delete pushes.
299
+ */
300
+ getDisplayIdByRowId(rowId) {
301
+ const row = this.db
302
+ .select({ id: schema.workItems.id })
303
+ .from(schema.workItems)
304
+ .where(eq(schema.workItems.rowId, rowId))
305
+ .get();
306
+ if (!row)
307
+ return null;
308
+ return row.id;
309
+ }
310
+ /**
311
+ * Assemble a single work item from its row, loading labels/deps/comments by rowId.
312
+ */
313
+ assembleWorkItemByRowId(row) {
288
314
  const labels = this.db
289
315
  .select()
290
316
  .from(schema.workItemLabels)
291
- .where(eq(schema.workItemLabels.workItemId, id))
317
+ .where(eq(schema.workItemLabels.workItemRowId, row.rowId))
292
318
  .all();
293
319
  const deps = this.db
294
320
  .select()
295
321
  .from(schema.workItemDeps)
296
- .where(eq(schema.workItemDeps.workItemId, id))
322
+ .where(eq(schema.workItemDeps.workItemRowId, row.rowId))
297
323
  .all();
298
324
  const itemComments = this.db
299
325
  .select()
300
326
  .from(schema.comments)
301
- .where(eq(schema.comments.workItemId, id))
327
+ .where(eq(schema.comments.workItemRowId, row.rowId))
302
328
  .all();
303
329
  return rowToWorkItem(row, labels, deps, itemComments);
304
330
  }
305
331
  // ─── Read: relationships (SQL-optimized overrides) ─────────────────
306
332
  // eslint-disable-next-line @typescript-eslint/require-await
307
333
  async getChildren(id) {
334
+ const parentRowId = this.resolveRowId(id);
308
335
  const childRows = this.db
309
336
  .select()
310
337
  .from(schema.workItems)
311
- .where(and(eq(schema.workItems.parent, id), isNull(schema.workItems.deletedAt)))
338
+ .where(and(eq(schema.workItems.parent, parentRowId), isNull(schema.workItems.deletedAt)))
312
339
  .all();
313
340
  if (childRows.length === 0)
314
341
  return [];
@@ -316,19 +343,20 @@ export class Storage extends BaseBackend {
316
343
  }
317
344
  // eslint-disable-next-line @typescript-eslint/require-await
318
345
  async getDependents(id) {
319
- // Find items that depend on `id`
346
+ const targetRowId = this.resolveRowId(id);
347
+ // Find items that depend on `targetRowId`
320
348
  const depRows = this.db
321
- .select({ workItemId: schema.workItemDeps.workItemId })
349
+ .select({ workItemRowId: schema.workItemDeps.workItemRowId })
322
350
  .from(schema.workItemDeps)
323
- .where(eq(schema.workItemDeps.dependsOnId, id))
351
+ .where(eq(schema.workItemDeps.dependsOnRowId, targetRowId))
324
352
  .all();
325
353
  if (depRows.length === 0)
326
354
  return [];
327
- const dependentIds = depRows.map((r) => r.workItemId);
355
+ const dependentRowIds = depRows.map((r) => r.workItemRowId);
328
356
  const itemRows = this.db
329
357
  .select()
330
358
  .from(schema.workItems)
331
- .where(and(inArray(schema.workItems.id, dependentIds), isNull(schema.workItems.deletedAt)))
359
+ .where(and(inArray(schema.workItems.rowId, dependentRowIds), isNull(schema.workItems.deletedAt)))
332
360
  .all();
333
361
  if (itemRows.length === 0)
334
362
  return [];
@@ -338,75 +366,89 @@ export class Storage extends BaseBackend {
338
366
  * Helper: given a set of work item rows, fetch their labels/deps/comments and assemble.
339
367
  */
340
368
  assembleWorkItems(itemRows, options) {
341
- const itemIds = itemRows.map((r) => r.id);
369
+ const rowIds = itemRows.map((r) => r.rowId);
342
370
  const labelRows = this.db
343
371
  .select()
344
372
  .from(schema.workItemLabels)
345
- .where(inArray(schema.workItemLabels.workItemId, itemIds))
373
+ .where(inArray(schema.workItemLabels.workItemRowId, rowIds))
346
374
  .all();
347
375
  const depRows = this.db
348
376
  .select()
349
377
  .from(schema.workItemDeps)
350
- .where(inArray(schema.workItemDeps.workItemId, itemIds))
378
+ .where(inArray(schema.workItemDeps.workItemRowId, rowIds))
351
379
  .all();
352
380
  const commentRows = options?.includeComments
353
381
  ? this.db
354
382
  .select()
355
383
  .from(schema.comments)
356
- .where(inArray(schema.comments.workItemId, itemIds))
384
+ .where(inArray(schema.comments.workItemRowId, rowIds))
357
385
  .all()
358
386
  : [];
359
387
  const labelsByItem = new Map();
360
388
  for (const l of labelRows) {
361
- const arr = labelsByItem.get(l.workItemId);
389
+ const arr = labelsByItem.get(l.workItemRowId);
362
390
  if (arr)
363
391
  arr.push(l);
364
392
  else
365
- labelsByItem.set(l.workItemId, [l]);
393
+ labelsByItem.set(l.workItemRowId, [l]);
366
394
  }
367
395
  const depsByItem = new Map();
368
396
  for (const d of depRows) {
369
- const arr = depsByItem.get(d.workItemId);
397
+ const arr = depsByItem.get(d.workItemRowId);
370
398
  if (arr)
371
399
  arr.push(d);
372
400
  else
373
- depsByItem.set(d.workItemId, [d]);
401
+ depsByItem.set(d.workItemRowId, [d]);
374
402
  }
375
403
  const commentsByItem = new Map();
376
404
  for (const c of commentRows) {
377
- const arr = commentsByItem.get(c.workItemId);
405
+ const arr = commentsByItem.get(c.workItemRowId);
378
406
  if (arr)
379
407
  arr.push(c);
380
408
  else
381
- commentsByItem.set(c.workItemId, [c]);
409
+ commentsByItem.set(c.workItemRowId, [c]);
382
410
  }
383
- return itemRows.map((row) => rowToWorkItem(row, labelsByItem.get(row.id) ?? [], depsByItem.get(row.id) ?? [], commentsByItem.get(row.id) ?? []));
411
+ return itemRows.map((row) => rowToWorkItem(row, labelsByItem.get(row.rowId) ?? [], depsByItem.get(row.rowId) ?? [], commentsByItem.get(row.rowId) ?? []));
384
412
  }
385
413
  // ─── Read: item URL ────────────────────────────────────────────────
386
414
  getItemUrl(id) {
387
415
  return `${this.root}/.tic/items/${id}.md`;
388
416
  }
417
+ // ─── Row ID resolution ─────────────────────────────────────────
418
+ /**
419
+ * Resolve a display ID to a rowId. Throws if not found.
420
+ */
421
+ resolveRowId(displayId) {
422
+ const row = this.db
423
+ .select({ rowId: schema.workItems.rowId })
424
+ .from(schema.workItems)
425
+ .where(and(eq(schema.workItems.id, displayId), isNull(schema.workItems.deletedAt)))
426
+ .get();
427
+ if (!row)
428
+ throw new Error(`Work item "${displayId}" not found`);
429
+ return row.rowId;
430
+ }
389
431
  // ─── Relationship validation ─────────────────────────────────────
390
- validateRelationships(id, parent, dependsOn) {
432
+ validateRelationships(itemRowId, parent, dependsOn) {
391
433
  // Validate parent
392
434
  if (parent !== null && parent !== undefined) {
393
- if (parent === id) {
394
- throw new Error(`Work item #${id} cannot be its own parent`);
435
+ if (parent === itemRowId) {
436
+ throw new Error(`Work item #${this.displayIdForRowId(itemRowId)} cannot be its own parent`);
395
437
  }
396
438
  const parentRow = this.db
397
- .select({ id: schema.workItems.id })
439
+ .select({ rowId: schema.workItems.rowId })
398
440
  .from(schema.workItems)
399
- .where(and(eq(schema.workItems.id, parent), isNull(schema.workItems.deletedAt)))
441
+ .where(and(eq(schema.workItems.rowId, parent), isNull(schema.workItems.deletedAt)))
400
442
  .get();
401
443
  if (!parentRow) {
402
- throw new Error(`Parent #${parent} does not exist`);
444
+ throw new Error(`Parent #${this.displayIdForRowId(parent)} does not exist`);
403
445
  }
404
446
  // Walk up the parent chain to detect circular references
405
447
  let current = parent;
406
448
  const visited = new Set();
407
449
  while (current !== null) {
408
- if (current === id) {
409
- throw new Error(`Circular parent chain detected for #${id}`);
450
+ if (current === itemRowId) {
451
+ throw new Error(`Circular parent chain detected for #${this.displayIdForRowId(itemRowId)}`);
410
452
  }
411
453
  if (visited.has(current))
412
454
  break;
@@ -414,83 +456,86 @@ export class Storage extends BaseBackend {
414
456
  const row = this.db
415
457
  .select({ parent: schema.workItems.parent })
416
458
  .from(schema.workItems)
417
- .where(and(eq(schema.workItems.id, current), isNull(schema.workItems.deletedAt)))
459
+ .where(and(eq(schema.workItems.rowId, current), isNull(schema.workItems.deletedAt)))
418
460
  .get();
419
461
  current = row?.parent ?? null;
420
462
  }
421
463
  }
422
464
  // Validate dependencies
423
465
  if (dependsOn !== undefined && dependsOn.length > 0) {
424
- for (const depId of dependsOn) {
425
- if (depId === id) {
426
- throw new Error(`Work item #${id} cannot depend on itself`);
466
+ for (const depRowId of dependsOn) {
467
+ if (depRowId === itemRowId) {
468
+ throw new Error(`Work item #${this.displayIdForRowId(itemRowId)} cannot depend on itself`);
427
469
  }
428
470
  }
429
471
  // Check all deps exist in one query
430
472
  const existingRows = this.db
431
- .select({ id: schema.workItems.id })
473
+ .select({ rowId: schema.workItems.rowId })
432
474
  .from(schema.workItems)
433
- .where(and(inArray(schema.workItems.id, dependsOn), isNull(schema.workItems.deletedAt)))
475
+ .where(and(inArray(schema.workItems.rowId, dependsOn), isNull(schema.workItems.deletedAt)))
434
476
  .all();
435
- const existingIds = new Set(existingRows.map((r) => r.id));
436
- for (const depId of dependsOn) {
437
- if (!existingIds.has(depId)) {
438
- throw new Error(`Dependency #${depId} does not exist`);
477
+ const existingRowIds = new Set(existingRows.map((r) => r.rowId));
478
+ for (const depRowId of dependsOn) {
479
+ if (!existingRowIds.has(depRowId)) {
480
+ throw new Error(`Dependency #${this.displayIdForRowId(depRowId)} does not exist`);
439
481
  }
440
482
  }
441
483
  // Check for circular dependency chains
442
- const hasCycle = (startId, targetId) => {
484
+ const hasCycle = (startRowId, targetRowId) => {
443
485
  const visited = new Set();
444
- const stack = [startId];
486
+ const stack = [startRowId];
445
487
  while (stack.length > 0) {
446
488
  const current = stack.pop();
447
- if (current === targetId)
489
+ if (current === targetRowId)
448
490
  return true;
449
491
  if (visited.has(current))
450
492
  continue;
451
493
  visited.add(current);
452
494
  const deps = this.db
453
- .select({ dependsOnId: schema.workItemDeps.dependsOnId })
495
+ .select({
496
+ dependsOnRowId: schema.workItemDeps.dependsOnRowId,
497
+ })
454
498
  .from(schema.workItemDeps)
455
- .where(eq(schema.workItemDeps.workItemId, current))
499
+ .where(eq(schema.workItemDeps.workItemRowId, current))
456
500
  .all();
457
501
  for (const dep of deps) {
458
- stack.push(dep.dependsOnId);
502
+ stack.push(dep.dependsOnRowId);
459
503
  }
460
504
  }
461
505
  return false;
462
506
  };
463
- for (const depId of dependsOn) {
464
- if (hasCycle(depId, id)) {
465
- throw new Error(`Circular dependency chain detected for #${id}`);
507
+ for (const depRowId of dependsOn) {
508
+ if (hasCycle(depRowId, itemRowId)) {
509
+ throw new Error(`Circular dependency chain detected for #${this.displayIdForRowId(itemRowId)}`);
466
510
  }
467
511
  }
468
512
  }
469
513
  }
514
+ /**
515
+ * Get the display id for a rowId, for error messages.
516
+ * Returns the display id if set, otherwise the rowId as string.
517
+ */
518
+ displayIdForRowId(rowId) {
519
+ if (rowId === null)
520
+ return 'null';
521
+ const row = this.db
522
+ .select({ id: schema.workItems.id })
523
+ .from(schema.workItems)
524
+ .where(eq(schema.workItems.rowId, rowId))
525
+ .get();
526
+ return row?.id ?? String(rowId);
527
+ }
470
528
  // ─── Write: createWorkItem ────────────────────────────────────────
471
529
  // eslint-disable-next-line @typescript-eslint/require-await
472
530
  async createWorkItem(data) {
473
531
  this.validateFields(data);
474
532
  const now = new Date().toISOString();
475
- // Single IMMEDIATE transaction: allocate ID + validate + insert atomically.
476
- // IMMEDIATE acquires a write lock upfront, preventing the race condition where
477
- // two processes (e.g., parallel MCP calls) read the same nextId before either
478
- // commits (#37).
479
- const id = this.db.transaction((tx) => {
480
- // Allocate ID
481
- const config = tx
482
- .select()
483
- .from(schema.projectConfig)
484
- .where(eq(schema.projectConfig.id, 1))
485
- .get();
486
- const nid = config?.nextId ?? 1;
487
- const itemId = this.tempIds ? `local-${nid}` : String(nid);
488
- tx.update(schema.projectConfig)
489
- .set({ nextId: nid + 1 })
490
- .where(eq(schema.projectConfig.id, 1))
491
- .run();
492
- // Validate relationships
493
- this.validateRelationships(itemId, data.parent, data.dependsOn);
533
+ // Single IMMEDIATE transaction: validate + insert atomically.
534
+ // IMMEDIATE acquires a write lock upfront, preventing race conditions
535
+ // with parallel MCP calls (#37).
536
+ const result = this.db.transaction((tx) => {
537
+ // Validate relationships (these are rowIds now)
538
+ this.validateRelationships(null, data.parent, data.dependsOn);
494
539
  // Ensure iteration exists
495
540
  if (data.iteration) {
496
541
  tx.insert(schema.iterations)
@@ -498,10 +543,10 @@ export class Storage extends BaseBackend {
498
543
  .onConflictDoNothing()
499
544
  .run();
500
545
  }
501
- // Insert work item
502
- tx.insert(schema.workItems)
546
+ // Insert work item — let SQLite assign rowId via AUTOINCREMENT
547
+ const insertResult = tx
548
+ .insert(schema.workItems)
503
549
  .values({
504
- id: itemId,
505
550
  title: data.title,
506
551
  type: data.type,
507
552
  status: data.status,
@@ -514,26 +559,36 @@ export class Storage extends BaseBackend {
514
559
  updated: now,
515
560
  })
516
561
  .run();
562
+ const rowId = Number(insertResult.lastInsertRowid);
563
+ // Assign display ID: if no remote backend, use rowId as display ID
564
+ const displayId = this._hasRemoteBackend ? null : String(rowId);
565
+ if (displayId !== null) {
566
+ tx.update(schema.workItems)
567
+ .set({ id: displayId })
568
+ .where(eq(schema.workItems.rowId, rowId))
569
+ .run();
570
+ }
517
571
  // Insert labels
518
572
  if (data.labels.length > 0) {
519
573
  tx.insert(schema.workItemLabels)
520
- .values(data.labels.map((label) => ({ workItemId: itemId, label })))
574
+ .values(data.labels.map((label) => ({ workItemRowId: rowId, label })))
521
575
  .run();
522
576
  }
523
577
  // Insert deps
524
578
  if (data.dependsOn.length > 0) {
525
579
  tx.insert(schema.workItemDeps)
526
- .values(data.dependsOn.map((dependsOnId) => ({
527
- workItemId: itemId,
528
- dependsOnId,
580
+ .values(data.dependsOn.map((dependsOnRowId) => ({
581
+ workItemRowId: rowId,
582
+ dependsOnRowId,
529
583
  })))
530
584
  .run();
531
585
  }
532
- return itemId;
586
+ return { rowId, displayId };
533
587
  }, { behavior: 'immediate' });
534
588
  this.invalidateCache();
535
589
  return {
536
- id,
590
+ rowId: result.rowId,
591
+ id: result.displayId,
537
592
  title: data.title,
538
593
  type: data.type,
539
594
  status: data.status,
@@ -552,23 +607,7 @@ export class Storage extends BaseBackend {
552
607
  // ─── Write: importWorkItem (for sync) ───────────────────────────────
553
608
  // eslint-disable-next-line @typescript-eslint/require-await
554
609
  async importWorkItem(item) {
555
- this.db.transaction((tx) => {
556
- // Bump nextId past imported numeric IDs to prevent collisions
557
- const numericId = Number(item.id);
558
- if (!Number.isNaN(numericId) && numericId > 0) {
559
- const config = tx
560
- .select({ nextId: schema.projectConfig.nextId })
561
- .from(schema.projectConfig)
562
- .where(eq(schema.projectConfig.id, 1))
563
- .get();
564
- const currentNext = config?.nextId ?? 1;
565
- if (numericId >= currentNext) {
566
- tx.update(schema.projectConfig)
567
- .set({ nextId: numericId + 1 })
568
- .where(eq(schema.projectConfig.id, 1))
569
- .run();
570
- }
571
- }
610
+ const resultRowId = this.db.transaction((tx) => {
572
611
  // Ensure iteration exists
573
612
  if (item.iteration) {
574
613
  tx.insert(schema.iterations)
@@ -576,24 +615,43 @@ export class Storage extends BaseBackend {
576
615
  .onConflictDoNothing()
577
616
  .run();
578
617
  }
579
- // Upsert work item
580
- tx.insert(schema.workItems)
581
- .values({
582
- id: item.id,
583
- title: item.title,
584
- type: item.type,
585
- status: item.status,
586
- iteration: item.iteration,
587
- priority: item.priority,
588
- assignee: item.assignee,
589
- description: item.description,
590
- parent: item.parent,
591
- created: item.created,
592
- updated: item.updated,
593
- })
594
- .onConflictDoUpdate({
595
- target: schema.workItems.id,
596
- set: {
618
+ // Resolve parent: incoming item.parent is a remote numeric ID (as number).
619
+ // Look up the local rowId by display ID.
620
+ let parentRowId = null;
621
+ if (item.parent !== null) {
622
+ const parentRow = tx
623
+ .select({ rowId: schema.workItems.rowId })
624
+ .from(schema.workItems)
625
+ .where(eq(schema.workItems.id, String(item.parent)))
626
+ .get();
627
+ parentRowId = parentRow?.rowId ?? null;
628
+ }
629
+ // Resolve dependsOn: same logic
630
+ const depRowIds = [];
631
+ for (const depId of item.dependsOn) {
632
+ const depRow = tx
633
+ .select({ rowId: schema.workItems.rowId })
634
+ .from(schema.workItems)
635
+ .where(eq(schema.workItems.id, String(depId)))
636
+ .get();
637
+ if (depRow) {
638
+ depRowIds.push(depRow.rowId);
639
+ }
640
+ }
641
+ // Look up existing row by display id
642
+ const existing = item.id
643
+ ? tx
644
+ .select({ rowId: schema.workItems.rowId })
645
+ .from(schema.workItems)
646
+ .where(eq(schema.workItems.id, item.id))
647
+ .get()
648
+ : null;
649
+ let rowId;
650
+ if (existing) {
651
+ // Update existing row
652
+ rowId = existing.rowId;
653
+ tx.update(schema.workItems)
654
+ .set({
597
655
  title: item.title,
598
656
  type: item.type,
599
657
  status: item.status,
@@ -601,42 +659,63 @@ export class Storage extends BaseBackend {
601
659
  priority: item.priority,
602
660
  assignee: item.assignee,
603
661
  description: item.description,
604
- parent: item.parent,
662
+ parent: parentRowId,
605
663
  created: item.created,
606
664
  updated: item.updated,
607
- },
608
- })
609
- .run();
665
+ })
666
+ .where(eq(schema.workItems.rowId, rowId))
667
+ .run();
668
+ }
669
+ else {
670
+ // Insert new row
671
+ const insertResult = tx
672
+ .insert(schema.workItems)
673
+ .values({
674
+ id: item.id,
675
+ title: item.title,
676
+ type: item.type,
677
+ status: item.status,
678
+ iteration: item.iteration,
679
+ priority: item.priority,
680
+ assignee: item.assignee,
681
+ description: item.description,
682
+ parent: parentRowId,
683
+ created: item.created,
684
+ updated: item.updated,
685
+ })
686
+ .run();
687
+ rowId = Number(insertResult.lastInsertRowid);
688
+ }
610
689
  // Replace labels
611
690
  tx.delete(schema.workItemLabels)
612
- .where(eq(schema.workItemLabels.workItemId, item.id))
691
+ .where(eq(schema.workItemLabels.workItemRowId, rowId))
613
692
  .run();
614
693
  if (item.labels.length > 0) {
615
694
  tx.insert(schema.workItemLabels)
616
- .values(item.labels.map((label) => ({ workItemId: item.id, label })))
695
+ .values(item.labels.map((label) => ({ workItemRowId: rowId, label })))
617
696
  .run();
618
697
  }
619
698
  // Replace deps
620
699
  tx.delete(schema.workItemDeps)
621
- .where(eq(schema.workItemDeps.workItemId, item.id))
700
+ .where(eq(schema.workItemDeps.workItemRowId, rowId))
622
701
  .run();
623
- if (item.dependsOn.length > 0) {
702
+ if (depRowIds.length > 0) {
624
703
  tx.insert(schema.workItemDeps)
625
- .values(item.dependsOn.map((dependsOnId) => ({
626
- workItemId: item.id,
627
- dependsOnId,
704
+ .values(depRowIds.map((dependsOnRowId) => ({
705
+ workItemRowId: rowId,
706
+ dependsOnRowId,
628
707
  })))
629
708
  .run();
630
709
  }
631
710
  // Replace comments
632
711
  tx.delete(schema.comments)
633
- .where(eq(schema.comments.workItemId, item.id))
712
+ .where(eq(schema.comments.workItemRowId, rowId))
634
713
  .run();
635
714
  if (item.comments.length > 0) {
636
715
  for (const c of item.comments) {
637
716
  tx.insert(schema.comments)
638
717
  .values({
639
- workItemId: item.id,
718
+ workItemRowId: rowId,
640
719
  author: c.author,
641
720
  body: c.body,
642
721
  created: c.date,
@@ -644,9 +723,19 @@ export class Storage extends BaseBackend {
644
723
  .run();
645
724
  }
646
725
  }
726
+ return rowId;
647
727
  });
648
728
  this.invalidateCache();
649
- return item;
729
+ return { ...item, rowId: resultRowId };
730
+ }
731
+ // ─── Write: setDisplayId ───────────────────────────────────────────
732
+ setDisplayId(rowId, displayId) {
733
+ this.db
734
+ .update(schema.workItems)
735
+ .set({ id: displayId })
736
+ .where(eq(schema.workItems.rowId, rowId))
737
+ .run();
738
+ this.invalidateCache();
650
739
  }
651
740
  // ─── Write: updateWorkItem ────────────────────────────────────────
652
741
  async updateWorkItem(id, data) {
@@ -660,11 +749,12 @@ export class Storage extends BaseBackend {
660
749
  if (!existingRow) {
661
750
  throw new Error(`Work item #${id} not found`);
662
751
  }
752
+ const rowId = existingRow.rowId;
663
753
  // 2. Validate relationships if parent/dependsOn changed
664
754
  const newParent = 'parent' in data ? data.parent : undefined;
665
755
  const newDepsOn = 'dependsOn' in data ? data.dependsOn : undefined;
666
756
  if (newParent !== undefined || newDepsOn !== undefined) {
667
- this.validateRelationships(id, newParent !== undefined ? (newParent ?? null) : undefined, newDepsOn);
757
+ this.validateRelationships(rowId, newParent !== undefined ? (newParent ?? null) : undefined, newDepsOn);
668
758
  }
669
759
  const now = new Date().toISOString();
670
760
  // 3. In a transaction: update workItems row, delete+re-insert labels/deps
@@ -689,29 +779,29 @@ export class Storage extends BaseBackend {
689
779
  updateSet['parent'] = data.parent ?? null;
690
780
  tx.update(schema.workItems)
691
781
  .set(updateSet)
692
- .where(eq(schema.workItems.id, id))
782
+ .where(eq(schema.workItems.rowId, rowId))
693
783
  .run();
694
784
  // Replace labels if changed
695
785
  if ('labels' in data && data.labels !== undefined) {
696
786
  tx.delete(schema.workItemLabels)
697
- .where(eq(schema.workItemLabels.workItemId, id))
787
+ .where(eq(schema.workItemLabels.workItemRowId, rowId))
698
788
  .run();
699
789
  if (data.labels.length > 0) {
700
790
  tx.insert(schema.workItemLabels)
701
- .values(data.labels.map((label) => ({ workItemId: id, label })))
791
+ .values(data.labels.map((label) => ({ workItemRowId: rowId, label })))
702
792
  .run();
703
793
  }
704
794
  }
705
795
  // Replace deps if changed
706
796
  if ('dependsOn' in data && data.dependsOn !== undefined) {
707
797
  tx.delete(schema.workItemDeps)
708
- .where(eq(schema.workItemDeps.workItemId, id))
798
+ .where(eq(schema.workItemDeps.workItemRowId, rowId))
709
799
  .run();
710
800
  if (data.dependsOn.length > 0) {
711
801
  tx.insert(schema.workItemDeps)
712
- .values(data.dependsOn.map((dependsOnId) => ({
713
- workItemId: id,
714
- dependsOnId,
802
+ .values(data.dependsOn.map((dependsOnRowId) => ({
803
+ workItemRowId: rowId,
804
+ dependsOnRowId,
715
805
  })))
716
806
  .run();
717
807
  }
@@ -734,23 +824,23 @@ export class Storage extends BaseBackend {
734
824
  .all()
735
825
  .slice(-1)
736
826
  .map((r) => r.name));
737
- const cascadeIteration = (parentId) => {
827
+ const cascadeIteration = (parentRowId) => {
738
828
  const children = tx
739
829
  .select()
740
830
  .from(schema.workItems)
741
- .where(and(eq(schema.workItems.parent, parentId), isNull(schema.workItems.deletedAt)))
831
+ .where(and(eq(schema.workItems.parent, parentRowId), isNull(schema.workItems.deletedAt)))
742
832
  .all();
743
833
  for (const child of children) {
744
834
  if (closedStatuses.has(child.status))
745
835
  continue;
746
836
  tx.update(schema.workItems)
747
837
  .set({ iteration: data.iteration, updated: now })
748
- .where(eq(schema.workItems.id, child.id))
838
+ .where(eq(schema.workItems.rowId, child.rowId))
749
839
  .run();
750
- cascadeIteration(child.id);
840
+ cascadeIteration(child.rowId);
751
841
  }
752
842
  };
753
- cascadeIteration(id);
843
+ cascadeIteration(rowId);
754
844
  }
755
845
  });
756
846
  this.invalidateCache();
@@ -760,18 +850,21 @@ export class Storage extends BaseBackend {
760
850
  // ─── Write: deleteWorkItem ────────────────────────────────────────
761
851
  // eslint-disable-next-line @typescript-eslint/require-await
762
852
  async deleteWorkItem(id) {
853
+ const rowId = this.resolveRowId(id);
763
854
  this.db.transaction((tx) => {
764
855
  // 1. Null out parent on children
765
856
  tx.update(schema.workItems)
766
857
  .set({ parent: null })
767
- .where(eq(schema.workItems.parent, id))
858
+ .where(eq(schema.workItems.parent, rowId))
768
859
  .run();
769
860
  // 2. Remove deps referencing this item (other items depending on this one)
770
861
  tx.delete(schema.workItemDeps)
771
- .where(eq(schema.workItemDeps.dependsOnId, id))
862
+ .where(eq(schema.workItemDeps.dependsOnRowId, rowId))
772
863
  .run();
773
864
  // 3. Delete the item (cascade handles labels, deps, comments of this item)
774
- tx.delete(schema.workItems).where(eq(schema.workItems.id, id)).run();
865
+ tx.delete(schema.workItems)
866
+ .where(eq(schema.workItems.rowId, rowId))
867
+ .run();
775
868
  });
776
869
  this.invalidateCache();
777
870
  }
@@ -779,31 +872,32 @@ export class Storage extends BaseBackend {
779
872
  // eslint-disable-next-line @typescript-eslint/require-await
780
873
  async softDeleteWorkItem(id) {
781
874
  const now = new Date().toISOString();
875
+ // Resolve without the deletedAt filter since this method may be called on any item
876
+ const row = this.db
877
+ .select({ rowId: schema.workItems.rowId })
878
+ .from(schema.workItems)
879
+ .where(eq(schema.workItems.id, id))
880
+ .get();
881
+ if (!row)
882
+ return;
782
883
  this.db
783
884
  .update(schema.workItems)
784
885
  .set({ deletedAt: now })
785
- .where(eq(schema.workItems.id, id))
886
+ .where(eq(schema.workItems.rowId, row.rowId))
786
887
  .run();
787
888
  this.invalidateCache();
788
889
  }
789
890
  // ─── Write: addComment ────────────────────────────────────────────
790
891
  // eslint-disable-next-line @typescript-eslint/require-await
791
892
  async addComment(workItemId, comment) {
792
- // 1. Verify item exists
793
- const row = this.db
794
- .select({ id: schema.workItems.id })
795
- .from(schema.workItems)
796
- .where(and(eq(schema.workItems.id, workItemId), isNull(schema.workItems.deletedAt)))
797
- .get();
798
- if (!row) {
799
- throw new Error(`Work item #${workItemId} not found`);
800
- }
893
+ // 1. Verify item exists and resolve rowId
894
+ const rowId = this.resolveRowId(workItemId);
801
895
  // 2. Insert comment (do NOT update work item's updated timestamp)
802
896
  const now = new Date().toISOString();
803
897
  this.db
804
898
  .insert(schema.comments)
805
899
  .values({
806
- workItemId,
900
+ workItemRowId: rowId,
807
901
  author: comment.author,
808
902
  body: comment.body,
809
903
  created: now,
@@ -819,17 +913,35 @@ export class Storage extends BaseBackend {
819
913
  // ─── Write: restore (undo soft delete) ──────────────────────────
820
914
  // eslint-disable-next-line @typescript-eslint/require-await
821
915
  async restoreWorkItem(id) {
916
+ // Look up by display id without deletedAt filter (item is soft-deleted)
917
+ const row = this.db
918
+ .select({ rowId: schema.workItems.rowId })
919
+ .from(schema.workItems)
920
+ .where(eq(schema.workItems.id, id))
921
+ .get();
922
+ if (!row)
923
+ return;
822
924
  this.db
823
925
  .update(schema.workItems)
824
926
  .set({ deletedAt: null })
825
- .where(eq(schema.workItems.id, id))
927
+ .where(eq(schema.workItems.rowId, row.rowId))
826
928
  .run();
827
929
  this.invalidateCache();
828
930
  }
829
931
  // ─── Write: permanent delete ───────────────────────────────────
830
932
  // eslint-disable-next-line @typescript-eslint/require-await
831
933
  async permanentlyDeleteWorkItem(id) {
832
- this.db.delete(schema.workItems).where(eq(schema.workItems.id, id)).run();
934
+ const row = this.db
935
+ .select({ rowId: schema.workItems.rowId })
936
+ .from(schema.workItems)
937
+ .where(eq(schema.workItems.id, id))
938
+ .get();
939
+ if (!row)
940
+ return;
941
+ this.db
942
+ .delete(schema.workItems)
943
+ .where(eq(schema.workItems.rowId, row.rowId))
944
+ .run();
833
945
  this.invalidateCache();
834
946
  }
835
947
  // ─── Write: cleanup all soft-deleted items ─────────────────────
@@ -1084,9 +1196,9 @@ export class Storage extends BaseBackend {
1084
1196
  for (const link of linkRows) {
1085
1197
  const arr = linksByPr.get(link.prId);
1086
1198
  if (arr)
1087
- arr.push(link.itemId);
1199
+ arr.push(link.itemRowId);
1088
1200
  else
1089
- linksByPr.set(link.prId, [link.itemId]);
1201
+ linksByPr.set(link.prId, [link.itemRowId]);
1090
1202
  }
1091
1203
  return prRows.map((row) => rowToPullRequest(row, linksByPr.get(row.id) ?? []));
1092
1204
  }
@@ -1104,7 +1216,7 @@ export class Storage extends BaseBackend {
1104
1216
  .from(schema.prItemLinks)
1105
1217
  .where(eq(schema.prItemLinks.prId, id))
1106
1218
  .all();
1107
- return rowToPullRequest(row, linkRows.map((l) => l.itemId));
1219
+ return rowToPullRequest(row, linkRows.map((l) => l.itemRowId));
1108
1220
  }
1109
1221
  // eslint-disable-next-line @typescript-eslint/require-await
1110
1222
  async importPullRequest(pr) {
@@ -1146,17 +1258,18 @@ export class Storage extends BaseBackend {
1146
1258
  .run();
1147
1259
  if (pr.linkedItems.length > 0) {
1148
1260
  tx.insert(schema.prItemLinks)
1149
- .values(pr.linkedItems.map((itemId) => ({ prId: pr.id, itemId })))
1261
+ .values(pr.linkedItems.map((itemRowId) => ({ prId: pr.id, itemRowId })))
1150
1262
  .run();
1151
1263
  }
1152
1264
  });
1153
1265
  }
1154
1266
  // eslint-disable-next-line @typescript-eslint/require-await
1155
1267
  async getLinkedPullRequests(itemId) {
1268
+ const itemRowId = this.resolveRowId(itemId);
1156
1269
  const linkRows = this.db
1157
1270
  .select({ prId: schema.prItemLinks.prId })
1158
1271
  .from(schema.prItemLinks)
1159
- .where(eq(schema.prItemLinks.itemId, itemId))
1272
+ .where(eq(schema.prItemLinks.itemRowId, itemRowId))
1160
1273
  .all();
1161
1274
  if (linkRows.length === 0)
1162
1275
  return [];
@@ -1178,34 +1291,42 @@ export class Storage extends BaseBackend {
1178
1291
  for (const link of allLinks) {
1179
1292
  const arr = linksByPr.get(link.prId);
1180
1293
  if (arr)
1181
- arr.push(link.itemId);
1294
+ arr.push(link.itemRowId);
1182
1295
  else
1183
- linksByPr.set(link.prId, [link.itemId]);
1296
+ linksByPr.set(link.prId, [link.itemRowId]);
1184
1297
  }
1185
1298
  return prRows.map((row) => rowToPullRequest(row, linksByPr.get(row.id) ?? []));
1186
1299
  }
1187
1300
  // eslint-disable-next-line @typescript-eslint/require-await
1188
1301
  async getLinkedItems(prId) {
1189
1302
  const rows = this.db
1190
- .select({ itemId: schema.prItemLinks.itemId })
1303
+ .select({
1304
+ itemRowId: schema.prItemLinks.itemRowId,
1305
+ itemId: schema.workItems.id,
1306
+ })
1191
1307
  .from(schema.prItemLinks)
1308
+ .innerJoin(schema.workItems, eq(schema.prItemLinks.itemRowId, schema.workItems.rowId))
1192
1309
  .where(eq(schema.prItemLinks.prId, prId))
1193
1310
  .all();
1194
- return rows.map((r) => r.itemId);
1311
+ return rows
1312
+ .map((r) => r.itemId ?? String(r.itemRowId))
1313
+ .filter((id) => id.length > 0);
1195
1314
  }
1196
1315
  // eslint-disable-next-line @typescript-eslint/require-await
1197
1316
  async linkItem(prId, itemId) {
1317
+ const itemRowId = this.resolveRowId(itemId);
1198
1318
  this.db
1199
1319
  .insert(schema.prItemLinks)
1200
- .values({ prId, itemId })
1320
+ .values({ prId, itemRowId })
1201
1321
  .onConflictDoNothing()
1202
1322
  .run();
1203
1323
  }
1204
1324
  // eslint-disable-next-line @typescript-eslint/require-await
1205
1325
  async unlinkItem(prId, itemId) {
1326
+ const itemRowId = this.resolveRowId(itemId);
1206
1327
  this.db
1207
1328
  .delete(schema.prItemLinks)
1208
- .where(and(eq(schema.prItemLinks.prId, prId), eq(schema.prItemLinks.itemId, itemId)))
1329
+ .where(and(eq(schema.prItemLinks.prId, prId), eq(schema.prItemLinks.itemRowId, itemRowId)))
1209
1330
  .run();
1210
1331
  }
1211
1332
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await