trekoon 0.3.6 → 0.3.7

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, 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,29 @@ 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
+
29
52
  function countMatches(value: string, searchText: string): number {
30
53
  if (searchText.length === 0) {
31
54
  return 0;
@@ -104,8 +127,12 @@ export class MutationService {
104
127
  this.#domain = new TrackerDomain(db);
105
128
  }
106
129
 
130
+ #writeTransaction<T>(fn: () => T): T {
131
+ return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, this.#cwd, fn));
132
+ }
133
+
107
134
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
108
- return writeTransaction(this.#db, (): EpicRecord => {
135
+ return this.#writeTransaction((): EpicRecord => {
109
136
  const epic = this.#domain.createEpic(input);
110
137
  this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
111
138
  title: epic.title,
@@ -124,7 +151,7 @@ export class MutationService {
124
151
  subtaskSpecs: readonly CompactSubtaskSpec[];
125
152
  dependencySpecs: readonly CompactDependencySpec[];
126
153
  }): CompactEpicCreateResult {
127
- return writeTransaction(this.#db, (): CompactEpicCreateResult => {
154
+ return this.#writeTransaction((): CompactEpicCreateResult => {
128
155
  const epic = this.#domain.createEpic(input);
129
156
  const created = this.#domain.expandEpic({
130
157
  epicId: epic.id,
@@ -180,7 +207,7 @@ export class MutationService {
180
207
  id: string,
181
208
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
182
209
  ): EpicRecord {
183
- return writeTransaction(this.#db, (): EpicRecord => {
210
+ return this.#writeTransaction((): EpicRecord => {
184
211
  if (input.status !== undefined) {
185
212
  const existing = this.#domain.getEpicOrThrow(id);
186
213
  validateStatusTransition(existing.status, input.status, "epic", id);
@@ -196,7 +223,7 @@ export class MutationService {
196
223
  }
197
224
 
198
225
  updateEpicStatusCascade(id: string, status: string): StatusCascadePlan {
199
- return writeTransaction(this.#db, (): StatusCascadePlan => {
226
+ return this.#writeTransaction((): StatusCascadePlan => {
200
227
  const plan = this.#domain.planStatusCascade("epic", id, status);
201
228
  this.#assertCascadeNotBlocked(plan);
202
229
  this.#applyStatusCascadePlan(plan);
@@ -205,14 +232,14 @@ export class MutationService {
205
232
  }
206
233
 
207
234
  deleteEpic(id: string): void {
208
- writeTransaction(this.#db, (): void => {
235
+ this.#writeTransaction((): void => {
209
236
  this.#domain.deleteEpic(id);
210
237
  this.#appendEntityEvent("epic", id, ENTITY_OPERATIONS.epic.deleted, {});
211
238
  });
212
239
  }
213
240
 
214
241
  createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
215
- return writeTransaction(this.#db, (): TaskRecord => {
242
+ return this.#writeTransaction((): TaskRecord => {
216
243
  const task = this.#domain.createTask(input);
217
244
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
218
245
  epic_id: task.epicId,
@@ -225,7 +252,7 @@ export class MutationService {
225
252
  }
226
253
 
227
254
  createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
228
- return writeTransaction(this.#db, (): CompactTaskBatchCreateResult => {
255
+ return this.#writeTransaction((): CompactTaskBatchCreateResult => {
229
256
  const created = this.#domain.createTaskBatch(input);
230
257
  for (const task of created.tasks) {
231
258
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
@@ -245,7 +272,7 @@ export class MutationService {
245
272
  subtaskSpecs: readonly CompactSubtaskSpec[];
246
273
  dependencySpecs: readonly CompactDependencySpec[];
247
274
  }): CompactEpicExpandResult {
248
- return writeTransaction(this.#db, (): CompactEpicExpandResult => {
275
+ return this.#writeTransaction((): CompactEpicExpandResult => {
249
276
  const created = this.#domain.expandEpic(input);
250
277
  for (const task of created.tasks) {
251
278
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
@@ -282,7 +309,7 @@ export class MutationService {
282
309
  id: string,
283
310
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
284
311
  ): TaskRecord {
285
- return writeTransaction(this.#db, (): TaskRecord => {
312
+ return this.#writeTransaction((): TaskRecord => {
286
313
  if (input.status !== undefined) {
287
314
  const existing = this.#domain.getTaskOrThrow(id);
288
315
  validateStatusTransition(existing.status, input.status, "task", id);
@@ -300,7 +327,7 @@ export class MutationService {
300
327
  }
301
328
 
302
329
  updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
303
- return writeTransaction(this.#db, (): StatusCascadePlan => {
330
+ return this.#writeTransaction((): StatusCascadePlan => {
304
331
  const plan = this.#domain.planStatusCascade("task", id, status);
305
332
  this.#assertCascadeNotBlocked(plan);
306
333
  this.#applyStatusCascadePlan(plan);
@@ -308,10 +335,36 @@ export class MutationService {
308
335
  });
309
336
  }
310
337
 
311
- deleteTask(id: string): void {
312
- writeTransaction(this.#db, (): void => {
338
+ deleteTask(id: string): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } {
339
+ return this.#writeTransaction((): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } => {
340
+ const plan = this.#domain.planTaskDeletion(id);
313
341
  this.#domain.deleteTask(id);
314
- this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
342
+ const taskDeleteEventId = this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
343
+
344
+ for (const subtaskId of plan.subtaskIds) {
345
+ this.#appendEntityEvent("subtask", subtaskId, ENTITY_OPERATIONS.subtask.deleted, {
346
+ task_id: id,
347
+ source_event_id: taskDeleteEventId,
348
+ });
349
+ }
350
+
351
+ for (const dependency of plan.touchingDependencies) {
352
+ this.#appendEntityEvent(
353
+ "dependency",
354
+ `${dependency.sourceId}->${dependency.dependsOnId}`,
355
+ ENTITY_OPERATIONS.dependency.removed,
356
+ {
357
+ source_id: dependency.sourceId,
358
+ depends_on_id: dependency.dependsOnId,
359
+ source_event_id: taskDeleteEventId,
360
+ },
361
+ );
362
+ }
363
+
364
+ return {
365
+ deletedSubtaskIds: [...plan.subtaskIds],
366
+ deletedDependencyIds: plan.touchingDependencies.map((dependency) => dependency.id),
367
+ };
315
368
  });
316
369
  }
317
370
 
@@ -321,7 +374,7 @@ export class MutationService {
321
374
  description?: string | undefined;
322
375
  status?: string | undefined;
323
376
  }): SubtaskRecord {
324
- return writeTransaction(this.#db, (): SubtaskRecord => {
377
+ return this.#writeTransaction((): SubtaskRecord => {
325
378
  const subtask = this.#domain.createSubtask(input);
326
379
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
327
380
  task_id: subtask.taskId,
@@ -333,8 +386,32 @@ export class MutationService {
333
386
  });
334
387
  }
335
388
 
389
+ createSubtaskAtomicallyWithIdempotency(input: {
390
+ taskId: string;
391
+ title: string;
392
+ description?: string | undefined;
393
+ status?: string | undefined;
394
+ claim: AtomicIdempotencyClaim;
395
+ buildResponseData: (result: { subtask: SubtaskRecord; domain: TrackerDomain }) => Record<string, unknown>;
396
+ }): AtomicIdempotentMutationResult {
397
+ return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
398
+ const subtask = this.#domain.createSubtask(input);
399
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
400
+ task_id: subtask.taskId,
401
+ title: subtask.title,
402
+ description: subtask.description,
403
+ status: subtask.status,
404
+ });
405
+ return {
406
+ state: "completed",
407
+ status: 201,
408
+ responseData: input.buildResponseData({ subtask, domain: this.#domain }),
409
+ };
410
+ });
411
+ }
412
+
336
413
  createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
337
- return writeTransaction(this.#db, (): CompactSubtaskBatchCreateResult => {
414
+ return this.#writeTransaction((): CompactSubtaskBatchCreateResult => {
338
415
  const created = this.#domain.createSubtaskBatch(input);
339
416
  for (const subtask of created.subtasks) {
340
417
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
@@ -352,7 +429,7 @@ export class MutationService {
352
429
  id: string,
353
430
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
354
431
  ): SubtaskRecord {
355
- return writeTransaction(this.#db, (): SubtaskRecord => {
432
+ return this.#writeTransaction((): SubtaskRecord => {
356
433
  if (input.status !== undefined) {
357
434
  const existing = this.#domain.getSubtaskOrThrow(id);
358
435
  validateStatusTransition(existing.status, input.status, "subtask", id);
@@ -369,15 +446,75 @@ export class MutationService {
369
446
  });
370
447
  }
371
448
 
372
- deleteSubtask(id: string): void {
373
- writeTransaction(this.#db, (): void => {
449
+ deleteSubtask(id: string): { deletedDependencyIds: string[] } {
450
+ return this.#writeTransaction((): { deletedDependencyIds: string[] } => {
451
+ const touchingDependencies = this.#domain.listDependenciesTouchingNode(id);
374
452
  this.#domain.deleteSubtask(id);
375
- this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
453
+ const subtaskDeleteEventId = this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
454
+ for (const dependency of touchingDependencies) {
455
+ this.#appendEntityEvent(
456
+ "dependency",
457
+ `${dependency.sourceId}->${dependency.dependsOnId}`,
458
+ ENTITY_OPERATIONS.dependency.removed,
459
+ {
460
+ source_id: dependency.sourceId,
461
+ depends_on_id: dependency.dependsOnId,
462
+ source_event_id: subtaskDeleteEventId,
463
+ },
464
+ );
465
+ }
466
+ return {
467
+ deletedDependencyIds: touchingDependencies.map((dependency) => dependency.id),
468
+ };
469
+ });
470
+ }
471
+
472
+ deleteSubtaskAtomicallyWithIdempotency(input: {
473
+ id: string;
474
+ claim: AtomicIdempotencyClaim;
475
+ buildResponseData: (result: {
476
+ subtaskId: string;
477
+ deletedDependencyIds: string[];
478
+ domain: TrackerDomain;
479
+ taskId: string;
480
+ epicId: string;
481
+ }) => Record<string, unknown>;
482
+ }): AtomicIdempotentMutationResult {
483
+ return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
484
+ const existingSubtask = this.#domain.getSubtaskOrThrow(input.id);
485
+ const task = this.#domain.getTaskOrThrow(existingSubtask.taskId);
486
+ const touchingDependencies = this.#domain.listDependenciesTouchingNode(input.id);
487
+ this.#domain.deleteSubtask(input.id);
488
+ const subtaskDeleteEventId = this.#appendEntityEvent("subtask", input.id, ENTITY_OPERATIONS.subtask.deleted, {});
489
+ for (const dependency of touchingDependencies) {
490
+ this.#appendEntityEvent(
491
+ "dependency",
492
+ `${dependency.sourceId}->${dependency.dependsOnId}`,
493
+ ENTITY_OPERATIONS.dependency.removed,
494
+ {
495
+ source_id: dependency.sourceId,
496
+ depends_on_id: dependency.dependsOnId,
497
+ source_event_id: subtaskDeleteEventId,
498
+ },
499
+ );
500
+ }
501
+
502
+ return {
503
+ state: "completed",
504
+ status: 200,
505
+ responseData: input.buildResponseData({
506
+ subtaskId: input.id,
507
+ deletedDependencyIds: touchingDependencies.map((dependency) => dependency.id),
508
+ domain: this.#domain,
509
+ taskId: task.id,
510
+ epicId: task.epicId,
511
+ }),
512
+ };
376
513
  });
377
514
  }
378
515
 
379
516
  addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
380
- return writeTransaction(this.#db, (): DependencyRecord => {
517
+ return this.#writeTransaction((): DependencyRecord => {
381
518
  const dependency = this.#domain.addDependency(sourceId, dependsOnId);
382
519
  this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
383
520
  source_id: dependency.sourceId,
@@ -389,8 +526,30 @@ export class MutationService {
389
526
  });
390
527
  }
391
528
 
529
+ addDependencyAtomicallyWithIdempotency(input: {
530
+ sourceId: string;
531
+ dependsOnId: string;
532
+ claim: AtomicIdempotencyClaim;
533
+ buildResponseData: (result: { dependency: DependencyRecord; domain: TrackerDomain }) => Record<string, unknown>;
534
+ }): AtomicIdempotentMutationResult {
535
+ return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
536
+ const dependency = this.#domain.addDependency(input.sourceId, input.dependsOnId);
537
+ this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
538
+ source_id: dependency.sourceId,
539
+ source_kind: dependency.sourceKind,
540
+ depends_on_id: dependency.dependsOnId,
541
+ depends_on_kind: dependency.dependsOnKind,
542
+ });
543
+ return {
544
+ state: "completed",
545
+ status: 201,
546
+ responseData: input.buildResponseData({ dependency, domain: this.#domain }),
547
+ };
548
+ });
549
+ }
550
+
392
551
  addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
393
- return writeTransaction(this.#db, (): CompactDependencyBatchAddResult => {
552
+ return this.#writeTransaction((): CompactDependencyBatchAddResult => {
394
553
  const created = this.#domain.addDependencyBatch(input);
395
554
  for (const dependency of created.dependencies) {
396
555
  this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
@@ -405,7 +564,7 @@ export class MutationService {
405
564
  }
406
565
 
407
566
  removeDependency(sourceId: string, dependsOnId: string): number {
408
- return writeTransaction(this.#db, (): number => {
567
+ return this.#writeTransaction((): number => {
409
568
  const removed = this.#domain.removeDependency(sourceId, dependsOnId);
410
569
  if (removed > 0) {
411
570
  this.#appendEntityEvent("dependency", `${sourceId}->${dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
@@ -417,6 +576,51 @@ export class MutationService {
417
576
  });
418
577
  }
419
578
 
579
+ removeDependencyAtomicallyWithIdempotency(input: {
580
+ sourceId: string;
581
+ dependsOnId: string;
582
+ claim: AtomicIdempotencyClaim;
583
+ buildResponseData: (result: {
584
+ sourceId: string;
585
+ dependsOnId: string;
586
+ removed: number;
587
+ existingDependencyIds: string[];
588
+ domain: TrackerDomain;
589
+ }) => Record<string, unknown>;
590
+ }): AtomicIdempotentMutationResult {
591
+ return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
592
+ const existingDependencyIds = this.#domain.listDependencies(input.sourceId)
593
+ .filter((dependency) => dependency.dependsOnId === input.dependsOnId)
594
+ .map((dependency) => dependency.id);
595
+ const removed = this.#domain.removeDependency(input.sourceId, input.dependsOnId);
596
+ if (removed === 0) {
597
+ throw new DomainError({
598
+ code: "not_found",
599
+ message: "Dependency edge not found",
600
+ details: {
601
+ sourceId: input.sourceId,
602
+ dependsOnId: input.dependsOnId,
603
+ },
604
+ });
605
+ }
606
+ this.#appendEntityEvent("dependency", `${input.sourceId}->${input.dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
607
+ source_id: input.sourceId,
608
+ depends_on_id: input.dependsOnId,
609
+ });
610
+ return {
611
+ state: "completed",
612
+ status: 200,
613
+ responseData: input.buildResponseData({
614
+ sourceId: input.sourceId,
615
+ dependsOnId: input.dependsOnId,
616
+ removed,
617
+ existingDependencyIds,
618
+ domain: this.#domain,
619
+ }),
620
+ };
621
+ });
622
+ }
623
+
420
624
  describeError(error: unknown): string | undefined {
421
625
  if (!(error instanceof DomainError)) {
422
626
  return undefined;
@@ -523,8 +727,8 @@ export class MutationService {
523
727
  entityId: string,
524
728
  operation: string,
525
729
  fields: Record<string, unknown>,
526
- ): void {
527
- appendEventWithGitContext(this.#db, this.#cwd, {
730
+ ): string {
731
+ return appendEventWithGitContext(this.#db, this.#cwd, {
528
732
  entityKind,
529
733
  entityId,
530
734
  operation,
@@ -532,6 +736,86 @@ export class MutationService {
532
736
  });
533
737
  }
534
738
 
739
+ #completeAtomicIdempotentMutation(
740
+ claim: AtomicIdempotencyClaim,
741
+ mutate: () => AtomicIdempotencyCompletedResult,
742
+ ): AtomicIdempotentMutationResult {
743
+ return this.#writeTransaction((): AtomicIdempotentMutationResult => {
744
+ const inserted = this.#db.query(
745
+ `
746
+ INSERT INTO board_idempotency_keys (
747
+ scope,
748
+ idempotency_key,
749
+ request_fingerprint,
750
+ state,
751
+ response_status,
752
+ response_body,
753
+ created_at
754
+ ) VALUES (?, ?, ?, 'pending', 0, '{}', ?)
755
+ ON CONFLICT(scope, idempotency_key) DO NOTHING
756
+ `,
757
+ ).run(claim.scope, claim.idempotencyKey, claim.requestFingerprint, Date.now());
758
+
759
+ if (inserted.changes === 0) {
760
+ const row = this.#db.query(
761
+ `
762
+ SELECT request_fingerprint, response_status, response_body
763
+ FROM board_idempotency_keys
764
+ WHERE scope = ? AND idempotency_key = ?
765
+ `,
766
+ ).get(claim.scope, claim.idempotencyKey) as {
767
+ request_fingerprint: string;
768
+ response_status: number;
769
+ response_body: string;
770
+ } | null;
771
+
772
+ if (!row) {
773
+ throw new DomainError({
774
+ code: "invalid_input",
775
+ message: "Idempotency claim changed while processing request; retry the request",
776
+ });
777
+ }
778
+
779
+ if (row.request_fingerprint !== claim.requestFingerprint) {
780
+ throw new DomainError({
781
+ code: "invalid_input",
782
+ message: claim.conflictMessage,
783
+ details: { field: "clientRequestId" },
784
+ });
785
+ }
786
+
787
+ if (row.response_status === 0) {
788
+ throw new DomainError({
789
+ code: "invalid_input",
790
+ message: "Idempotency record is incomplete; retry the request with a new idempotency key",
791
+ details: { field: "clientRequestId" },
792
+ });
793
+ }
794
+
795
+ return {
796
+ state: "replay",
797
+ status: row.response_status,
798
+ responseData: JSON.parse(row.response_body) as Record<string, unknown>,
799
+ };
800
+ }
801
+
802
+ const result = mutate();
803
+ this.#db.query(
804
+ `
805
+ UPDATE board_idempotency_keys
806
+ SET state = 'completed',
807
+ response_status = ?,
808
+ response_body = ?,
809
+ created_at = ?
810
+ WHERE scope = ?
811
+ AND idempotency_key = ?
812
+ AND request_fingerprint = ?
813
+ `,
814
+ ).run(result.status, JSON.stringify(result.responseData), Date.now(), claim.scope, claim.idempotencyKey, claim.requestFingerprint);
815
+ return result;
816
+ });
817
+ }
818
+
535
819
  #previewScopeReplacement(
536
820
  nodes: readonly SearchNode[],
537
821
  searchText: string,
@@ -607,7 +891,7 @@ export class MutationService {
607
891
  ): ScopeReplacementResult {
608
892
  const result = this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields, "apply");
609
893
 
610
- writeTransaction(this.#db, (): void => {
894
+ this.#writeTransaction((): void => {
611
895
  for (const node of nodes) {
612
896
  const nextTitle = fields.includes("title") ? replaceMatches(node.title, searchText, replacementText) : node.title;
613
897
  const nextDescription = fields.includes("description")