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.
- package/.agents/skills/trekoon/SKILL.md +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +39 -14
- package/docs/quickstart.md +21 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +19 -25
- 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 +155 -31
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +123 -30
- package/src/board/routes.ts +397 -54
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +205 -173
- package/src/commands/board.ts +1 -1
- package/src/commands/events.ts +17 -11
- package/src/commands/quickstart.ts +10 -0
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +452 -54
- package/src/domain/tracker-domain.ts +185 -7
- package/src/storage/migrations.ts +123 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +18 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +47 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +759 -151
|
@@ -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
|
|
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
|
|
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(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
|
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):
|
|
312
|
-
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
373
|
-
|
|
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
|
|
582
|
+
return this.#writeTransaction((): DependencyRecord => {
|
|
381
583
|
const dependency = this.#domain.addDependency(sourceId, dependsOnId);
|
|
382
|
-
this.#appendEntityEvent(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
629
|
+
return this.#writeTransaction((): CompactDependencyBatchAddResult => {
|
|
394
630
|
const created = this.#domain.addDependencyBatch(input);
|
|
395
631
|
for (const dependency of created.dependencies) {
|
|
396
|
-
this.#appendEntityEvent(
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
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",
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
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")
|