trekoon 0.3.6 → 0.3.8

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.
@@ -1,7 +1,7 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
3
  import { writeTransaction } from "../storage/database";
4
- import { appendEventWithGitContext } from "../sync/event-writes";
4
+ import { appendEventWithGitContext, prepareEventWriteContext, withTransactionEventContext } from "../sync/event-writes";
5
5
  import { ENTITY_OPERATIONS } from "./mutation-operations";
6
6
  import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
7
7
  import {
@@ -26,6 +26,31 @@ import {
26
26
  DomainError,
27
27
  } from "./types";
28
28
 
29
+ interface AtomicIdempotencyClaim {
30
+ readonly scope: "subtask" | "dependency" | "deleted_subtask" | "deleted_dependency";
31
+ readonly idempotencyKey: string;
32
+ readonly requestFingerprint: string;
33
+ readonly conflictMessage: string;
34
+ }
35
+
36
+ interface AtomicIdempotencyReplayResult {
37
+ readonly state: "replay";
38
+ readonly status: number;
39
+ readonly responseData: Record<string, unknown>;
40
+ }
41
+
42
+ interface AtomicIdempotencyCompletedResult {
43
+ readonly state: "completed";
44
+ readonly status: number;
45
+ readonly responseData: Record<string, unknown>;
46
+ }
47
+
48
+ type AtomicIdempotentMutationResult =
49
+ | AtomicIdempotencyReplayResult
50
+ | AtomicIdempotencyCompletedResult;
51
+
52
+ const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
53
+
29
54
  function countMatches(value: string, searchText: string): number {
30
55
  if (searchText.length === 0) {
31
56
  return 0;
@@ -104,8 +129,54 @@ export class MutationService {
104
129
  this.#domain = new TrackerDomain(db);
105
130
  }
106
131
 
132
+ #writeTransaction<T>(fn: () => T): T {
133
+ const eventContext = prepareEventWriteContext(this.#db, this.#cwd);
134
+ return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, eventContext, fn));
135
+ }
136
+
137
+ #dependencyEventEntityId(input: {
138
+ sourceId: string;
139
+ sourceKind: string;
140
+ dependsOnId: string;
141
+ dependsOnKind: string;
142
+ }): string {
143
+ return `${input.sourceKind}:${input.sourceId}->${input.dependsOnKind}:${input.dependsOnId}`;
144
+ }
145
+
146
+ #dependencyEventFields(input: {
147
+ dependencyId?: string | undefined;
148
+ sourceId: string;
149
+ sourceKind?: string | undefined;
150
+ dependsOnId: string;
151
+ dependsOnKind?: string | undefined;
152
+ sourceEventId?: string | undefined;
153
+ }): Record<string, string> {
154
+ const fields: Record<string, string> = {
155
+ source_id: input.sourceId,
156
+ depends_on_id: input.dependsOnId,
157
+ };
158
+
159
+ if (input.dependencyId) {
160
+ fields.dependency_id = input.dependencyId;
161
+ }
162
+
163
+ if (input.sourceKind) {
164
+ fields.source_kind = input.sourceKind;
165
+ }
166
+
167
+ if (input.dependsOnKind) {
168
+ fields.depends_on_kind = input.dependsOnKind;
169
+ }
170
+
171
+ if (input.sourceEventId) {
172
+ fields.source_event_id = input.sourceEventId;
173
+ }
174
+
175
+ return fields;
176
+ }
177
+
107
178
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
108
- return writeTransaction(this.#db, (): EpicRecord => {
179
+ return this.#writeTransaction((): EpicRecord => {
109
180
  const epic = this.#domain.createEpic(input);
110
181
  this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
111
182
  title: epic.title,
@@ -124,7 +195,7 @@ export class MutationService {
124
195
  subtaskSpecs: readonly CompactSubtaskSpec[];
125
196
  dependencySpecs: readonly CompactDependencySpec[];
126
197
  }): CompactEpicCreateResult {
127
- return writeTransaction(this.#db, (): CompactEpicCreateResult => {
198
+ return this.#writeTransaction((): CompactEpicCreateResult => {
128
199
  const epic = this.#domain.createEpic(input);
129
200
  const created = this.#domain.expandEpic({
130
201
  epicId: epic.id,
@@ -158,12 +229,18 @@ export class MutationService {
158
229
  }
159
230
 
160
231
  for (const dependency of created.dependencies) {
161
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
162
- source_id: dependency.sourceId,
163
- source_kind: dependency.sourceKind,
164
- depends_on_id: dependency.dependsOnId,
165
- depends_on_kind: dependency.dependsOnKind,
166
- });
232
+ this.#appendEntityEvent(
233
+ "dependency",
234
+ this.#dependencyEventEntityId(dependency),
235
+ ENTITY_OPERATIONS.dependency.added,
236
+ this.#dependencyEventFields({
237
+ dependencyId: dependency.id,
238
+ sourceId: dependency.sourceId,
239
+ sourceKind: dependency.sourceKind,
240
+ dependsOnId: dependency.dependsOnId,
241
+ dependsOnKind: dependency.dependsOnKind,
242
+ }),
243
+ );
167
244
  }
168
245
 
169
246
  return {
@@ -180,7 +257,7 @@ export class MutationService {
180
257
  id: string,
181
258
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
182
259
  ): EpicRecord {
183
- return writeTransaction(this.#db, (): EpicRecord => {
260
+ return this.#writeTransaction((): EpicRecord => {
184
261
  if (input.status !== undefined) {
185
262
  const existing = this.#domain.getEpicOrThrow(id);
186
263
  validateStatusTransition(existing.status, input.status, "epic", id);
@@ -196,7 +273,7 @@ export class MutationService {
196
273
  }
197
274
 
198
275
  updateEpicStatusCascade(id: string, status: string): StatusCascadePlan {
199
- return writeTransaction(this.#db, (): StatusCascadePlan => {
276
+ return this.#writeTransaction((): StatusCascadePlan => {
200
277
  const plan = this.#domain.planStatusCascade("epic", id, status);
201
278
  this.#assertCascadeNotBlocked(plan);
202
279
  this.#applyStatusCascadePlan(plan);
@@ -205,14 +282,14 @@ export class MutationService {
205
282
  }
206
283
 
207
284
  deleteEpic(id: string): void {
208
- writeTransaction(this.#db, (): void => {
285
+ this.#writeTransaction((): void => {
209
286
  this.#domain.deleteEpic(id);
210
287
  this.#appendEntityEvent("epic", id, ENTITY_OPERATIONS.epic.deleted, {});
211
288
  });
212
289
  }
213
290
 
214
291
  createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
215
- return writeTransaction(this.#db, (): TaskRecord => {
292
+ return this.#writeTransaction((): TaskRecord => {
216
293
  const task = this.#domain.createTask(input);
217
294
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
218
295
  epic_id: task.epicId,
@@ -225,7 +302,7 @@ export class MutationService {
225
302
  }
226
303
 
227
304
  createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
228
- return writeTransaction(this.#db, (): CompactTaskBatchCreateResult => {
305
+ return this.#writeTransaction((): CompactTaskBatchCreateResult => {
229
306
  const created = this.#domain.createTaskBatch(input);
230
307
  for (const task of created.tasks) {
231
308
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
@@ -245,7 +322,7 @@ export class MutationService {
245
322
  subtaskSpecs: readonly CompactSubtaskSpec[];
246
323
  dependencySpecs: readonly CompactDependencySpec[];
247
324
  }): CompactEpicExpandResult {
248
- return writeTransaction(this.#db, (): CompactEpicExpandResult => {
325
+ return this.#writeTransaction((): CompactEpicExpandResult => {
249
326
  const created = this.#domain.expandEpic(input);
250
327
  for (const task of created.tasks) {
251
328
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
@@ -266,12 +343,18 @@ export class MutationService {
266
343
  }
267
344
 
268
345
  for (const dependency of created.dependencies) {
269
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
270
- source_id: dependency.sourceId,
271
- source_kind: dependency.sourceKind,
272
- depends_on_id: dependency.dependsOnId,
273
- depends_on_kind: dependency.dependsOnKind,
274
- });
346
+ this.#appendEntityEvent(
347
+ "dependency",
348
+ this.#dependencyEventEntityId(dependency),
349
+ ENTITY_OPERATIONS.dependency.added,
350
+ this.#dependencyEventFields({
351
+ dependencyId: dependency.id,
352
+ sourceId: dependency.sourceId,
353
+ sourceKind: dependency.sourceKind,
354
+ dependsOnId: dependency.dependsOnId,
355
+ dependsOnKind: dependency.dependsOnKind,
356
+ }),
357
+ );
275
358
  }
276
359
 
277
360
  return created;
@@ -282,7 +365,7 @@ export class MutationService {
282
365
  id: string,
283
366
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
284
367
  ): TaskRecord {
285
- return writeTransaction(this.#db, (): TaskRecord => {
368
+ return this.#writeTransaction((): TaskRecord => {
286
369
  if (input.status !== undefined) {
287
370
  const existing = this.#domain.getTaskOrThrow(id);
288
371
  validateStatusTransition(existing.status, input.status, "task", id);
@@ -300,7 +383,7 @@ export class MutationService {
300
383
  }
301
384
 
302
385
  updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
303
- return writeTransaction(this.#db, (): StatusCascadePlan => {
386
+ return this.#writeTransaction((): StatusCascadePlan => {
304
387
  const plan = this.#domain.planStatusCascade("task", id, status);
305
388
  this.#assertCascadeNotBlocked(plan);
306
389
  this.#applyStatusCascadePlan(plan);
@@ -308,10 +391,39 @@ export class MutationService {
308
391
  });
309
392
  }
310
393
 
311
- deleteTask(id: string): void {
312
- writeTransaction(this.#db, (): void => {
394
+ deleteTask(id: string): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } {
395
+ return this.#writeTransaction((): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } => {
396
+ const plan = this.#domain.planTaskDeletion(id);
313
397
  this.#domain.deleteTask(id);
314
- this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
398
+ const taskDeleteEventId = this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
399
+
400
+ for (const subtaskId of plan.subtaskIds) {
401
+ this.#appendEntityEvent("subtask", subtaskId, ENTITY_OPERATIONS.subtask.deleted, {
402
+ task_id: id,
403
+ source_event_id: taskDeleteEventId,
404
+ });
405
+ }
406
+
407
+ for (const dependency of plan.touchingDependencies) {
408
+ this.#appendEntityEvent(
409
+ "dependency",
410
+ this.#dependencyEventEntityId(dependency),
411
+ ENTITY_OPERATIONS.dependency.removed,
412
+ this.#dependencyEventFields({
413
+ dependencyId: dependency.id,
414
+ sourceId: dependency.sourceId,
415
+ sourceKind: dependency.sourceKind,
416
+ dependsOnId: dependency.dependsOnId,
417
+ dependsOnKind: dependency.dependsOnKind,
418
+ sourceEventId: taskDeleteEventId,
419
+ }),
420
+ );
421
+ }
422
+
423
+ return {
424
+ deletedSubtaskIds: [...plan.subtaskIds],
425
+ deletedDependencyIds: plan.touchingDependencies.map((dependency) => dependency.id),
426
+ };
315
427
  });
316
428
  }
317
429
 
@@ -321,7 +433,7 @@ export class MutationService {
321
433
  description?: string | undefined;
322
434
  status?: string | undefined;
323
435
  }): SubtaskRecord {
324
- return writeTransaction(this.#db, (): SubtaskRecord => {
436
+ return this.#writeTransaction((): SubtaskRecord => {
325
437
  const subtask = this.#domain.createSubtask(input);
326
438
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
327
439
  task_id: subtask.taskId,
@@ -333,8 +445,32 @@ export class MutationService {
333
445
  });
334
446
  }
335
447
 
448
+ createSubtaskAtomicallyWithIdempotency(input: {
449
+ taskId: string;
450
+ title: string;
451
+ description?: string | undefined;
452
+ status?: string | undefined;
453
+ claim: AtomicIdempotencyClaim;
454
+ buildResponseData: (result: { subtask: SubtaskRecord; domain: TrackerDomain }) => Record<string, unknown>;
455
+ }): AtomicIdempotentMutationResult {
456
+ return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
457
+ const subtask = this.#domain.createSubtask(input);
458
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
459
+ task_id: subtask.taskId,
460
+ title: subtask.title,
461
+ description: subtask.description,
462
+ status: subtask.status,
463
+ });
464
+ return {
465
+ state: "completed",
466
+ status: 201,
467
+ responseData: input.buildResponseData({ subtask, domain: this.#domain }),
468
+ };
469
+ });
470
+ }
471
+
336
472
  createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
337
- return writeTransaction(this.#db, (): CompactSubtaskBatchCreateResult => {
473
+ return this.#writeTransaction((): CompactSubtaskBatchCreateResult => {
338
474
  const created = this.#domain.createSubtaskBatch(input);
339
475
  for (const subtask of created.subtasks) {
340
476
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
@@ -352,7 +488,7 @@ export class MutationService {
352
488
  id: string,
353
489
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
354
490
  ): SubtaskRecord {
355
- return writeTransaction(this.#db, (): SubtaskRecord => {
491
+ return this.#writeTransaction((): SubtaskRecord => {
356
492
  if (input.status !== undefined) {
357
493
  const existing = this.#domain.getSubtaskOrThrow(id);
358
494
  validateStatusTransition(existing.status, input.status, "subtask", id);
@@ -369,54 +505,224 @@ export class MutationService {
369
505
  });
370
506
  }
371
507
 
372
- deleteSubtask(id: string): void {
373
- writeTransaction(this.#db, (): void => {
508
+ deleteSubtask(id: string): { deletedDependencyIds: string[] } {
509
+ return this.#writeTransaction((): { deletedDependencyIds: string[] } => {
510
+ const touchingDependencies = this.#domain.listDependenciesTouchingNode(id);
374
511
  this.#domain.deleteSubtask(id);
375
- this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
512
+ const subtaskDeleteEventId = this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
513
+ for (const dependency of touchingDependencies) {
514
+ this.#appendEntityEvent(
515
+ "dependency",
516
+ this.#dependencyEventEntityId(dependency),
517
+ ENTITY_OPERATIONS.dependency.removed,
518
+ this.#dependencyEventFields({
519
+ dependencyId: dependency.id,
520
+ sourceId: dependency.sourceId,
521
+ sourceKind: dependency.sourceKind,
522
+ dependsOnId: dependency.dependsOnId,
523
+ dependsOnKind: dependency.dependsOnKind,
524
+ sourceEventId: subtaskDeleteEventId,
525
+ }),
526
+ );
527
+ }
528
+ return {
529
+ deletedDependencyIds: touchingDependencies.map((dependency) => dependency.id),
530
+ };
531
+ });
532
+ }
533
+
534
+ deleteSubtaskAtomicallyWithIdempotency(input: {
535
+ id: string;
536
+ claim: AtomicIdempotencyClaim;
537
+ buildResponseData: (result: {
538
+ subtaskId: string;
539
+ deletedDependencyIds: string[];
540
+ domain: TrackerDomain;
541
+ taskId: string;
542
+ epicId: string;
543
+ }) => Record<string, unknown>;
544
+ }): AtomicIdempotentMutationResult {
545
+ return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
546
+ const existingSubtask = this.#domain.getSubtaskOrThrow(input.id);
547
+ const task = this.#domain.getTaskOrThrow(existingSubtask.taskId);
548
+ const touchingDependencies = this.#domain.listDependenciesTouchingNode(input.id);
549
+ this.#domain.deleteSubtask(input.id);
550
+ const subtaskDeleteEventId = this.#appendEntityEvent("subtask", input.id, ENTITY_OPERATIONS.subtask.deleted, {});
551
+ for (const dependency of touchingDependencies) {
552
+ this.#appendEntityEvent(
553
+ "dependency",
554
+ this.#dependencyEventEntityId(dependency),
555
+ ENTITY_OPERATIONS.dependency.removed,
556
+ this.#dependencyEventFields({
557
+ dependencyId: dependency.id,
558
+ sourceId: dependency.sourceId,
559
+ sourceKind: dependency.sourceKind,
560
+ dependsOnId: dependency.dependsOnId,
561
+ dependsOnKind: dependency.dependsOnKind,
562
+ sourceEventId: subtaskDeleteEventId,
563
+ }),
564
+ );
565
+ }
566
+
567
+ return {
568
+ state: "completed",
569
+ status: 200,
570
+ responseData: input.buildResponseData({
571
+ subtaskId: input.id,
572
+ deletedDependencyIds: touchingDependencies.map((dependency) => dependency.id),
573
+ domain: this.#domain,
574
+ taskId: task.id,
575
+ epicId: task.epicId,
576
+ }),
577
+ };
376
578
  });
377
579
  }
378
580
 
379
581
  addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
380
- return writeTransaction(this.#db, (): DependencyRecord => {
582
+ return this.#writeTransaction((): DependencyRecord => {
381
583
  const dependency = this.#domain.addDependency(sourceId, dependsOnId);
382
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
383
- source_id: dependency.sourceId,
384
- source_kind: dependency.sourceKind,
385
- depends_on_id: dependency.dependsOnId,
386
- depends_on_kind: dependency.dependsOnKind,
387
- });
584
+ this.#appendEntityEvent(
585
+ "dependency",
586
+ this.#dependencyEventEntityId(dependency),
587
+ ENTITY_OPERATIONS.dependency.added,
588
+ this.#dependencyEventFields({
589
+ dependencyId: dependency.id,
590
+ sourceId: dependency.sourceId,
591
+ sourceKind: dependency.sourceKind,
592
+ dependsOnId: dependency.dependsOnId,
593
+ dependsOnKind: dependency.dependsOnKind,
594
+ }),
595
+ );
388
596
  return dependency;
389
597
  });
390
598
  }
391
599
 
600
+ addDependencyAtomicallyWithIdempotency(input: {
601
+ sourceId: string;
602
+ dependsOnId: string;
603
+ claim: AtomicIdempotencyClaim;
604
+ buildResponseData: (result: { dependency: DependencyRecord; domain: TrackerDomain }) => Record<string, unknown>;
605
+ }): AtomicIdempotentMutationResult {
606
+ return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
607
+ const dependency = this.#domain.addDependency(input.sourceId, input.dependsOnId);
608
+ this.#appendEntityEvent(
609
+ "dependency",
610
+ this.#dependencyEventEntityId(dependency),
611
+ ENTITY_OPERATIONS.dependency.added,
612
+ this.#dependencyEventFields({
613
+ dependencyId: dependency.id,
614
+ sourceId: dependency.sourceId,
615
+ sourceKind: dependency.sourceKind,
616
+ dependsOnId: dependency.dependsOnId,
617
+ dependsOnKind: dependency.dependsOnKind,
618
+ }),
619
+ );
620
+ return {
621
+ state: "completed",
622
+ status: 201,
623
+ responseData: input.buildResponseData({ dependency, domain: this.#domain }),
624
+ };
625
+ });
626
+ }
627
+
392
628
  addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
393
- return writeTransaction(this.#db, (): CompactDependencyBatchAddResult => {
629
+ return this.#writeTransaction((): CompactDependencyBatchAddResult => {
394
630
  const created = this.#domain.addDependencyBatch(input);
395
631
  for (const dependency of created.dependencies) {
396
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
397
- source_id: dependency.sourceId,
398
- source_kind: dependency.sourceKind,
399
- depends_on_id: dependency.dependsOnId,
400
- depends_on_kind: dependency.dependsOnKind,
401
- });
632
+ this.#appendEntityEvent(
633
+ "dependency",
634
+ this.#dependencyEventEntityId(dependency),
635
+ ENTITY_OPERATIONS.dependency.added,
636
+ this.#dependencyEventFields({
637
+ dependencyId: dependency.id,
638
+ sourceId: dependency.sourceId,
639
+ sourceKind: dependency.sourceKind,
640
+ dependsOnId: dependency.dependsOnId,
641
+ dependsOnKind: dependency.dependsOnKind,
642
+ }),
643
+ );
402
644
  }
403
645
  return created;
404
646
  });
405
647
  }
406
648
 
407
649
  removeDependency(sourceId: string, dependsOnId: string): number {
408
- return writeTransaction(this.#db, (): number => {
650
+ return this.#writeTransaction((): number => {
651
+ const existingDependency = this.#domain.listDependencies(sourceId)
652
+ .find((dependency) => dependency.dependsOnId === dependsOnId);
409
653
  const removed = this.#domain.removeDependency(sourceId, dependsOnId);
410
654
  if (removed > 0) {
411
- this.#appendEntityEvent("dependency", `${sourceId}->${dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
412
- source_id: sourceId,
413
- depends_on_id: dependsOnId,
414
- });
655
+ this.#appendEntityEvent("dependency", this.#dependencyEventEntityId({
656
+ sourceId,
657
+ sourceKind: existingDependency?.sourceKind ?? "task",
658
+ dependsOnId,
659
+ dependsOnKind: existingDependency?.dependsOnKind ?? "task",
660
+ }), ENTITY_OPERATIONS.dependency.removed, this.#dependencyEventFields({
661
+ dependencyId: existingDependency?.id,
662
+ sourceId,
663
+ sourceKind: existingDependency?.sourceKind,
664
+ dependsOnId,
665
+ dependsOnKind: existingDependency?.dependsOnKind,
666
+ }));
415
667
  }
416
668
  return removed;
417
669
  });
418
670
  }
419
671
 
672
+ removeDependencyAtomicallyWithIdempotency(input: {
673
+ sourceId: string;
674
+ dependsOnId: string;
675
+ claim: AtomicIdempotencyClaim;
676
+ buildResponseData: (result: {
677
+ sourceId: string;
678
+ dependsOnId: string;
679
+ removed: number;
680
+ existingDependencyIds: string[];
681
+ domain: TrackerDomain;
682
+ }) => Record<string, unknown>;
683
+ }): AtomicIdempotentMutationResult {
684
+ return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
685
+ const existingDependencies = this.#domain.listDependencies(input.sourceId)
686
+ .filter((dependency) => dependency.dependsOnId === input.dependsOnId);
687
+ const existingDependencyIds = existingDependencies.map((dependency) => dependency.id);
688
+ const existingDependency = existingDependencies[0];
689
+ const removed = this.#domain.removeDependency(input.sourceId, input.dependsOnId);
690
+ if (removed === 0) {
691
+ throw new DomainError({
692
+ code: "not_found",
693
+ message: "Dependency edge not found",
694
+ details: {
695
+ sourceId: input.sourceId,
696
+ dependsOnId: input.dependsOnId,
697
+ },
698
+ });
699
+ }
700
+ this.#appendEntityEvent("dependency", this.#dependencyEventEntityId({
701
+ sourceId: input.sourceId,
702
+ sourceKind: existingDependency?.sourceKind ?? "task",
703
+ dependsOnId: input.dependsOnId,
704
+ dependsOnKind: existingDependency?.dependsOnKind ?? "task",
705
+ }), ENTITY_OPERATIONS.dependency.removed, this.#dependencyEventFields({
706
+ dependencyId: existingDependency?.id,
707
+ sourceId: input.sourceId,
708
+ sourceKind: existingDependency?.sourceKind,
709
+ dependsOnId: input.dependsOnId,
710
+ dependsOnKind: existingDependency?.dependsOnKind,
711
+ }));
712
+ return {
713
+ state: "completed",
714
+ status: 200,
715
+ responseData: input.buildResponseData({
716
+ sourceId: input.sourceId,
717
+ dependsOnId: input.dependsOnId,
718
+ removed,
719
+ existingDependencyIds,
720
+ domain: this.#domain,
721
+ }),
722
+ };
723
+ });
724
+ }
725
+
420
726
  describeError(error: unknown): string | undefined {
421
727
  if (!(error instanceof DomainError)) {
422
728
  return undefined;
@@ -523,8 +829,8 @@ export class MutationService {
523
829
  entityId: string,
524
830
  operation: string,
525
831
  fields: Record<string, unknown>,
526
- ): void {
527
- appendEventWithGitContext(this.#db, this.#cwd, {
832
+ ): string {
833
+ return appendEventWithGitContext(this.#db, this.#cwd, {
528
834
  entityKind,
529
835
  entityId,
530
836
  operation,
@@ -532,6 +838,98 @@ export class MutationService {
532
838
  });
533
839
  }
534
840
 
841
+ #completeAtomicIdempotentMutation(
842
+ claim: AtomicIdempotencyClaim,
843
+ mutate: () => AtomicIdempotencyCompletedResult,
844
+ ): AtomicIdempotentMutationResult {
845
+ return this.#writeTransaction((): AtomicIdempotentMutationResult => {
846
+ this.#pruneExpiredIdempotencyKeys();
847
+ const inserted = this.#db.query(
848
+ `
849
+ INSERT INTO board_idempotency_keys (
850
+ scope,
851
+ idempotency_key,
852
+ request_fingerprint,
853
+ state,
854
+ response_status,
855
+ response_body,
856
+ created_at
857
+ ) VALUES (?, ?, ?, 'pending', 0, '{}', ?)
858
+ ON CONFLICT(scope, idempotency_key) DO NOTHING
859
+ `,
860
+ ).run(claim.scope, claim.idempotencyKey, claim.requestFingerprint, Date.now());
861
+
862
+ if (inserted.changes === 0) {
863
+ const row = this.#db.query(
864
+ `
865
+ SELECT request_fingerprint, response_status, response_body
866
+ FROM board_idempotency_keys
867
+ WHERE scope = ? AND idempotency_key = ?
868
+ `,
869
+ ).get(claim.scope, claim.idempotencyKey) as {
870
+ request_fingerprint: string;
871
+ response_status: number;
872
+ response_body: string;
873
+ } | null;
874
+
875
+ if (!row) {
876
+ throw new DomainError({
877
+ code: "invalid_input",
878
+ message: "Idempotency claim changed while processing request; retry the request",
879
+ });
880
+ }
881
+
882
+ if (row.request_fingerprint !== claim.requestFingerprint) {
883
+ throw new DomainError({
884
+ code: "invalid_input",
885
+ message: claim.conflictMessage,
886
+ details: { field: "clientRequestId" },
887
+ });
888
+ }
889
+
890
+ if (row.response_status === 0) {
891
+ throw new DomainError({
892
+ code: "invalid_input",
893
+ message: "Idempotency record is incomplete; retry the request with a new idempotency key",
894
+ details: { field: "clientRequestId" },
895
+ });
896
+ }
897
+
898
+ return {
899
+ state: "replay",
900
+ status: row.response_status,
901
+ responseData: JSON.parse(row.response_body) as Record<string, unknown>,
902
+ };
903
+ }
904
+
905
+ const result = mutate();
906
+ this.#db.query(
907
+ `
908
+ UPDATE board_idempotency_keys
909
+ SET state = 'completed',
910
+ response_status = ?,
911
+ response_body = ?,
912
+ created_at = ?
913
+ WHERE scope = ?
914
+ AND idempotency_key = ?
915
+ AND request_fingerprint = ?
916
+ `,
917
+ ).run(result.status, JSON.stringify(result.responseData), Date.now(), claim.scope, claim.idempotencyKey, claim.requestFingerprint);
918
+ return result;
919
+ });
920
+ }
921
+
922
+ #pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
923
+ const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
924
+ this.#db.query(
925
+ `
926
+ DELETE FROM board_idempotency_keys
927
+ WHERE state = 'completed'
928
+ AND created_at < ?;
929
+ `,
930
+ ).run(cutoff);
931
+ }
932
+
535
933
  #previewScopeReplacement(
536
934
  nodes: readonly SearchNode[],
537
935
  searchText: string,
@@ -607,7 +1005,7 @@ export class MutationService {
607
1005
  ): ScopeReplacementResult {
608
1006
  const result = this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields, "apply");
609
1007
 
610
- writeTransaction(this.#db, (): void => {
1008
+ this.#writeTransaction((): void => {
611
1009
  for (const node of nodes) {
612
1010
  const nextTitle = fields.includes("title") ? replaceMatches(node.title, searchText, replacementText) : node.title;
613
1011
  const nextDescription = fields.includes("description")