trekoon 0.3.5 → 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.
- package/package.json +1 -1
- package/src/board/assets/app.js +11 -0
- package/src/board/assets/components/Notice.js +18 -4
- package/src/board/assets/state/actions.js +6 -6
- package/src/board/assets/state/api.js +151 -26
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +73 -13
- package/src/board/routes.ts +392 -52
- package/src/board/snapshot.ts +151 -168
- package/src/commands/events.ts +17 -11
- package/src/commands/init.ts +36 -0
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +310 -26
- package/src/domain/tracker-domain.ts +169 -5
- package/src/storage/migrations.ts +98 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +17 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +43 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +578 -149
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
312
|
-
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
373
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
):
|
|
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
|
-
|
|
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")
|