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
package/src/board/routes.ts
CHANGED
|
@@ -6,6 +6,15 @@ import { TrackerDomain } from "../domain/tracker-domain";
|
|
|
6
6
|
import { DomainError } from "../domain/types";
|
|
7
7
|
import { buildBoardSnapshot } from "./snapshot";
|
|
8
8
|
|
|
9
|
+
interface SnapshotDeltaSelection {
|
|
10
|
+
readonly epicIds?: readonly string[];
|
|
11
|
+
readonly taskIds?: readonly string[];
|
|
12
|
+
readonly subtaskIds?: readonly string[];
|
|
13
|
+
readonly dependencyIds?: readonly string[];
|
|
14
|
+
readonly deletedSubtaskIds?: readonly string[];
|
|
15
|
+
readonly deletedDependencyIds?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
interface BoardRouteContext {
|
|
10
19
|
readonly db: Database;
|
|
11
20
|
readonly cwd: string;
|
|
@@ -19,6 +28,31 @@ interface BoardRouteError {
|
|
|
19
28
|
readonly details?: Record<string, unknown>;
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
function buildCreateSubtaskFingerprint(body: Record<string, unknown>): string {
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
taskId: readRequiredString(body, "taskId"),
|
|
34
|
+
title: readRequiredString(body, "title"),
|
|
35
|
+
description: readOptionalString(body, "description") ?? null,
|
|
36
|
+
status: readOptionalString(body, "status") ?? null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildCreateDependencyFingerprint(sourceId: string, dependsOnId: string): string {
|
|
41
|
+
return JSON.stringify({ sourceId, dependsOnId });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildDeleteSubtaskFingerprint(subtaskId: string): string {
|
|
45
|
+
return JSON.stringify({ subtaskId });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildDeleteDependencyFingerprint(sourceId: string, dependsOnId: string): string {
|
|
49
|
+
return JSON.stringify({ sourceId, dependsOnId });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function compactIds(ids: readonly string[]): string[] {
|
|
53
|
+
return ids.filter((id) => id.length > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
function jsonResponse(status: number, data: unknown): Response {
|
|
23
57
|
return new Response(JSON.stringify(data), {
|
|
24
58
|
status,
|
|
@@ -106,16 +140,84 @@ function describeBoardError(mutations: MutationService, error: unknown, requestL
|
|
|
106
140
|
};
|
|
107
141
|
}
|
|
108
142
|
|
|
109
|
-
function buildMutationResponse(
|
|
143
|
+
function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unknown>, status = 200): Response {
|
|
110
144
|
return jsonResponse(status, {
|
|
111
145
|
ok: true,
|
|
112
|
-
data
|
|
113
|
-
...data,
|
|
114
|
-
snapshot: buildBoardSnapshot(domain),
|
|
115
|
-
},
|
|
146
|
+
data,
|
|
116
147
|
});
|
|
117
148
|
}
|
|
118
149
|
|
|
150
|
+
function buildSnapshotDelta(domain: TrackerDomain, selection: SnapshotDeltaSelection): Record<string, unknown> {
|
|
151
|
+
const snapshot = buildBoardSnapshot(domain);
|
|
152
|
+
const epicIdSet = new Set(selection.epicIds ?? []);
|
|
153
|
+
const taskIdSet = new Set(selection.taskIds ?? []);
|
|
154
|
+
const subtaskIdSet = new Set(selection.subtaskIds ?? []);
|
|
155
|
+
const dependencyIdSet = new Set(selection.dependencyIds ?? []);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
generatedAt: snapshot.generatedAt,
|
|
159
|
+
epics: snapshot.epics.filter((epic) => epicIdSet.has(epic.id)),
|
|
160
|
+
tasks: snapshot.tasks.filter((task) => taskIdSet.has(task.id)),
|
|
161
|
+
subtasks: snapshot.subtasks.filter((subtask) => subtaskIdSet.has(subtask.id)),
|
|
162
|
+
dependencies: snapshot.dependencies.filter((dependency) => dependencyIdSet.has(dependency.id)),
|
|
163
|
+
deletedSubtaskIds: [...(selection.deletedSubtaskIds ?? [])],
|
|
164
|
+
deletedDependencyIds: [...(selection.deletedDependencyIds ?? [])],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildMutationDeltaResponse(
|
|
169
|
+
domain: TrackerDomain,
|
|
170
|
+
data: Record<string, unknown>,
|
|
171
|
+
selection: SnapshotDeltaSelection,
|
|
172
|
+
status = 200,
|
|
173
|
+
): Response {
|
|
174
|
+
return buildMutationResponse(domain, {
|
|
175
|
+
...data,
|
|
176
|
+
snapshotDelta: buildSnapshotDelta(domain, selection),
|
|
177
|
+
}, status);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readRecordId(value: unknown): string | null {
|
|
181
|
+
if (!value || typeof value !== "object") {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const id = (value as { id?: unknown }).id;
|
|
186
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function withFreshReplaySnapshotDelta(
|
|
190
|
+
domain: TrackerDomain,
|
|
191
|
+
responseData: Record<string, unknown>,
|
|
192
|
+
selection: SnapshotDeltaSelection,
|
|
193
|
+
): Record<string, unknown> {
|
|
194
|
+
return {
|
|
195
|
+
...responseData,
|
|
196
|
+
snapshotDelta: buildSnapshotDelta(domain, selection),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readSnapshotDelta(responseData: Record<string, unknown>): Record<string, unknown> | null {
|
|
201
|
+
const snapshotDelta = responseData.snapshotDelta;
|
|
202
|
+
return snapshotDelta && typeof snapshotDelta === "object" ? snapshotDelta as Record<string, unknown> : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function readRecordIds(value: unknown): string[] {
|
|
206
|
+
if (!Array.isArray(value)) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return value.map((item) => readRecordId(item)).filter((id): id is string => id !== null);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function readStringArray(value: unknown): string[] {
|
|
214
|
+
if (!Array.isArray(value)) {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
|
219
|
+
}
|
|
220
|
+
|
|
119
221
|
async function parseJsonBody(request: Request): Promise<Record<string, unknown>> {
|
|
120
222
|
const contentType: string = request.headers.get("content-type") ?? "";
|
|
121
223
|
if (!contentType.includes("application/json")) {
|
|
@@ -175,6 +277,41 @@ function readRequiredString(body: Record<string, unknown>, field: string): strin
|
|
|
175
277
|
return value;
|
|
176
278
|
}
|
|
177
279
|
|
|
280
|
+
function readOptionalNullableString(body: Record<string, unknown>, field: string): string | null | undefined {
|
|
281
|
+
const value = body[field];
|
|
282
|
+
if (value === undefined) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (value === null) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (typeof value !== "string") {
|
|
291
|
+
throw new DomainError({
|
|
292
|
+
code: "invalid_input",
|
|
293
|
+
message: `${field} must be a string or null`,
|
|
294
|
+
details: { field },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function readIdempotencyKey(request: Request, body: Record<string, unknown>): string | null {
|
|
302
|
+
const headerKey = request.headers.get("x-trekoon-idempotency-key");
|
|
303
|
+
if (typeof headerKey === "string" && headerKey.trim().length > 0) {
|
|
304
|
+
return headerKey.trim();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const bodyKey = body.clientRequestId;
|
|
308
|
+
if (typeof bodyKey === "string" && bodyKey.trim().length > 0) {
|
|
309
|
+
return bodyKey.trim();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
178
315
|
export function createBoardApiHandler(context: BoardRouteContext): (request: Request) => Promise<Response> {
|
|
179
316
|
return async (request: Request): Promise<Response> => {
|
|
180
317
|
const url = new URL(request.url);
|
|
@@ -203,89 +340,292 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
203
340
|
});
|
|
204
341
|
}
|
|
205
342
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
343
|
+
const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
|
|
344
|
+
if (epicCascadeMatch) {
|
|
345
|
+
const body = await parseJsonBody(request);
|
|
346
|
+
const status = readRequiredString(body, "status");
|
|
347
|
+
const plan = mutations.updateEpicStatusCascade(epicCascadeMatch[1] ?? "", status);
|
|
348
|
+
return buildMutationDeltaResponse(domain, {
|
|
349
|
+
plan,
|
|
350
|
+
}, {
|
|
351
|
+
epicIds: [epicCascadeMatch[1] ?? ""],
|
|
352
|
+
taskIds: plan.orderedChanges.filter((change) => change.kind === "task").map((change) => change.id),
|
|
353
|
+
subtaskIds: plan.orderedChanges.filter((change) => change.kind === "subtask").map((change) => change.id),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
213
356
|
|
|
214
357
|
const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
|
|
215
|
-
|
|
358
|
+
if (epicMatch) {
|
|
216
359
|
const body = await parseJsonBody(request);
|
|
217
360
|
const epic = mutations.updateEpic(epicMatch[1] ?? "", {
|
|
218
361
|
title: readOptionalString(body, "title"),
|
|
219
362
|
description: readOptionalString(body, "description"),
|
|
220
363
|
status: readOptionalString(body, "status"),
|
|
221
364
|
});
|
|
222
|
-
|
|
223
|
-
|
|
365
|
+
return buildMutationDeltaResponse(domain, { epic }, { epicIds: [epic.id] });
|
|
366
|
+
}
|
|
224
367
|
|
|
225
368
|
const taskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/tasks\/([^/]+)$/u) : null;
|
|
226
369
|
if (taskMatch) {
|
|
227
370
|
const body = await parseJsonBody(request);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
371
|
+
const task = mutations.updateTask(taskMatch[1] ?? "", {
|
|
372
|
+
title: readOptionalString(body, "title"),
|
|
373
|
+
description: readOptionalString(body, "description"),
|
|
374
|
+
status: readOptionalString(body, "status"),
|
|
375
|
+
owner: readOptionalNullableString(body, "owner"),
|
|
376
|
+
});
|
|
377
|
+
return buildMutationDeltaResponse(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
|
|
378
|
+
}
|
|
236
379
|
|
|
237
380
|
const subtaskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
238
381
|
if (subtaskMatch) {
|
|
239
382
|
const body = await parseJsonBody(request);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
383
|
+
const subtask = mutations.updateSubtask(subtaskMatch[1] ?? "", {
|
|
384
|
+
title: readOptionalString(body, "title"),
|
|
385
|
+
description: readOptionalString(body, "description"),
|
|
386
|
+
status: readOptionalString(body, "status"),
|
|
387
|
+
owner: readOptionalNullableString(body, "owner"),
|
|
388
|
+
});
|
|
389
|
+
const task = domain.getTaskOrThrow(subtask.taskId);
|
|
390
|
+
return buildMutationDeltaResponse(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
|
|
391
|
+
}
|
|
248
392
|
|
|
249
393
|
if (request.method === "POST" && url.pathname === "/api/subtasks") {
|
|
250
394
|
const body = await parseJsonBody(request);
|
|
251
|
-
const
|
|
395
|
+
const idempotencyKey = readIdempotencyKey(request, body);
|
|
396
|
+
const requestFingerprint = buildCreateSubtaskFingerprint(body);
|
|
397
|
+
if (!idempotencyKey) {
|
|
398
|
+
const subtask = mutations.createSubtask({
|
|
399
|
+
taskId: readRequiredString(body, "taskId"),
|
|
400
|
+
title: readRequiredString(body, "title"),
|
|
401
|
+
description: readOptionalString(body, "description"),
|
|
402
|
+
status: readOptionalString(body, "status"),
|
|
403
|
+
});
|
|
404
|
+
const task = domain.getTaskOrThrow(subtask.taskId);
|
|
405
|
+
const responseData = {
|
|
406
|
+
subtask,
|
|
407
|
+
snapshotDelta: buildSnapshotDelta(domain, {
|
|
408
|
+
epicIds: [task.epicId],
|
|
409
|
+
taskIds: [task.id],
|
|
410
|
+
subtaskIds: [subtask.id],
|
|
411
|
+
}),
|
|
412
|
+
};
|
|
413
|
+
return buildMutationResponse(domain, responseData, 201);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const result = mutations.createSubtaskAtomicallyWithIdempotency({
|
|
252
417
|
taskId: readRequiredString(body, "taskId"),
|
|
253
418
|
title: readRequiredString(body, "title"),
|
|
254
419
|
description: readOptionalString(body, "description"),
|
|
255
420
|
status: readOptionalString(body, "status"),
|
|
421
|
+
claim: {
|
|
422
|
+
scope: "subtask",
|
|
423
|
+
idempotencyKey,
|
|
424
|
+
requestFingerprint,
|
|
425
|
+
conflictMessage: "Idempotency key cannot be reused for a different subtask request",
|
|
426
|
+
},
|
|
427
|
+
buildResponseData: ({ subtask, domain: transactionDomain }) => {
|
|
428
|
+
const task = transactionDomain.getTaskOrThrow(subtask.taskId);
|
|
429
|
+
return {
|
|
430
|
+
subtask,
|
|
431
|
+
snapshotDelta: buildSnapshotDelta(transactionDomain, {
|
|
432
|
+
epicIds: [task.epicId],
|
|
433
|
+
taskIds: [task.id],
|
|
434
|
+
subtaskIds: [subtask.id],
|
|
435
|
+
}),
|
|
436
|
+
};
|
|
437
|
+
},
|
|
256
438
|
});
|
|
257
|
-
|
|
439
|
+
const replaySubtaskId = readRecordId(result.responseData.subtask);
|
|
440
|
+
const replayTaskId = replaySubtaskId ? domain.getSubtask(replaySubtaskId)?.taskId ?? null : null;
|
|
441
|
+
const replayEpicId = replayTaskId ? domain.getTask(replayTaskId)?.epicId ?? null : null;
|
|
442
|
+
return buildMutationResponse(domain, result.state === "replay"
|
|
443
|
+
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
444
|
+
epicIds: replayEpicId ? [replayEpicId] : [],
|
|
445
|
+
taskIds: replayTaskId ? [replayTaskId] : [],
|
|
446
|
+
subtaskIds: replaySubtaskId ? [replaySubtaskId] : [],
|
|
447
|
+
})
|
|
448
|
+
: result.responseData, result.status);
|
|
258
449
|
}
|
|
259
450
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
451
|
+
const deleteSubtaskMatch = request.method === "DELETE" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
452
|
+
if (deleteSubtaskMatch) {
|
|
453
|
+
const subtaskId = deleteSubtaskMatch[1] ?? "";
|
|
454
|
+
const idempotencyKey = request.headers.get("x-trekoon-idempotency-key")?.trim() || null;
|
|
455
|
+
const requestFingerprint = buildDeleteSubtaskFingerprint(subtaskId);
|
|
456
|
+
if (!idempotencyKey) {
|
|
457
|
+
const existingSubtask = domain.getSubtaskOrThrow(subtaskId);
|
|
458
|
+
const task = domain.getTaskOrThrow(existingSubtask.taskId);
|
|
459
|
+
const { deletedDependencyIds } = mutations.deleteSubtask(subtaskId);
|
|
460
|
+
const responseData = {
|
|
461
|
+
subtaskId,
|
|
462
|
+
deleted: true,
|
|
463
|
+
snapshotDelta: buildSnapshotDelta(domain, {
|
|
464
|
+
epicIds: [task.epicId],
|
|
465
|
+
taskIds: [task.id],
|
|
466
|
+
deletedSubtaskIds: [subtaskId],
|
|
467
|
+
deletedDependencyIds,
|
|
468
|
+
}),
|
|
469
|
+
};
|
|
470
|
+
return buildMutationResponse(domain, responseData, 200);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const result = mutations.deleteSubtaskAtomicallyWithIdempotency({
|
|
474
|
+
id: subtaskId,
|
|
475
|
+
claim: {
|
|
476
|
+
scope: "deleted_subtask",
|
|
477
|
+
idempotencyKey,
|
|
478
|
+
requestFingerprint,
|
|
479
|
+
conflictMessage: "Idempotency key cannot be reused for a different subtask delete request",
|
|
480
|
+
},
|
|
481
|
+
buildResponseData: ({ subtaskId: deletedSubtaskId, deletedDependencyIds, domain: transactionDomain, taskId, epicId }) => ({
|
|
482
|
+
subtaskId: deletedSubtaskId,
|
|
483
|
+
deleted: true,
|
|
484
|
+
snapshotDelta: buildSnapshotDelta(transactionDomain, {
|
|
485
|
+
epicIds: [epicId],
|
|
486
|
+
taskIds: [taskId],
|
|
487
|
+
deletedSubtaskIds: [deletedSubtaskId],
|
|
488
|
+
deletedDependencyIds,
|
|
489
|
+
}),
|
|
490
|
+
}),
|
|
491
|
+
});
|
|
492
|
+
const replaySnapshotDelta = readSnapshotDelta(result.responseData);
|
|
493
|
+
return buildMutationResponse(domain, result.state === "replay"
|
|
494
|
+
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
495
|
+
epicIds: readRecordIds(replaySnapshotDelta?.epics),
|
|
496
|
+
taskIds: readRecordIds(replaySnapshotDelta?.tasks),
|
|
497
|
+
deletedSubtaskIds: readStringArray(replaySnapshotDelta?.deletedSubtaskIds),
|
|
498
|
+
deletedDependencyIds: readStringArray(replaySnapshotDelta?.deletedDependencyIds),
|
|
499
|
+
})
|
|
500
|
+
: result.responseData, result.status);
|
|
501
|
+
}
|
|
266
502
|
|
|
267
503
|
if (request.method === "POST" && url.pathname === "/api/dependencies") {
|
|
268
504
|
const body = await parseJsonBody(request);
|
|
269
|
-
const
|
|
270
|
-
|
|
505
|
+
const sourceId = readRequiredString(body, "sourceId");
|
|
506
|
+
const dependsOnId = readRequiredString(body, "dependsOnId");
|
|
507
|
+
const idempotencyKey = readIdempotencyKey(request, body);
|
|
508
|
+
const requestFingerprint = buildCreateDependencyFingerprint(sourceId, dependsOnId);
|
|
509
|
+
if (!idempotencyKey) {
|
|
510
|
+
const dependency = mutations.addDependency(sourceId, dependsOnId);
|
|
511
|
+
const responseData = {
|
|
512
|
+
dependency,
|
|
513
|
+
snapshotDelta: buildSnapshotDelta(domain, {
|
|
514
|
+
taskIds: compactIds([dependency.sourceKind === "task" ? dependency.sourceId : "", dependency.dependsOnKind === "task" ? dependency.dependsOnId : ""]),
|
|
515
|
+
subtaskIds: compactIds([dependency.sourceKind === "subtask" ? dependency.sourceId : "", dependency.dependsOnKind === "subtask" ? dependency.dependsOnId : ""]),
|
|
516
|
+
dependencyIds: [dependency.id],
|
|
517
|
+
}),
|
|
518
|
+
};
|
|
519
|
+
return buildMutationResponse(domain, responseData, 201);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const result = mutations.addDependencyAtomicallyWithIdempotency({
|
|
523
|
+
sourceId,
|
|
524
|
+
dependsOnId,
|
|
525
|
+
claim: {
|
|
526
|
+
scope: "dependency",
|
|
527
|
+
idempotencyKey,
|
|
528
|
+
requestFingerprint,
|
|
529
|
+
conflictMessage: "Idempotency key cannot be reused for a different dependency request",
|
|
530
|
+
},
|
|
531
|
+
buildResponseData: ({ dependency, domain: transactionDomain }) => ({
|
|
532
|
+
dependency,
|
|
533
|
+
snapshotDelta: buildSnapshotDelta(transactionDomain, {
|
|
534
|
+
taskIds: compactIds([dependency.sourceKind === "task" ? dependency.sourceId : "", dependency.dependsOnKind === "task" ? dependency.dependsOnId : ""]),
|
|
535
|
+
subtaskIds: compactIds([dependency.sourceKind === "subtask" ? dependency.sourceId : "", dependency.dependsOnKind === "subtask" ? dependency.dependsOnId : ""]),
|
|
536
|
+
dependencyIds: [dependency.id],
|
|
537
|
+
}),
|
|
538
|
+
}),
|
|
539
|
+
});
|
|
540
|
+
const replayDependency = result.responseData.dependency;
|
|
541
|
+
const replayDependencyId = readRecordId(replayDependency);
|
|
542
|
+
const replaySelection = replayDependency && typeof replayDependency === "object"
|
|
543
|
+
? {
|
|
544
|
+
taskIds: compactIds([
|
|
545
|
+
(replayDependency as { sourceKind?: unknown; sourceId?: unknown }).sourceKind === "task"
|
|
546
|
+
? ((replayDependency as { sourceId?: unknown }).sourceId as string ?? "")
|
|
547
|
+
: "",
|
|
548
|
+
(replayDependency as { dependsOnKind?: unknown; dependsOnId?: unknown }).dependsOnKind === "task"
|
|
549
|
+
? ((replayDependency as { dependsOnId?: unknown }).dependsOnId as string ?? "")
|
|
550
|
+
: "",
|
|
551
|
+
]),
|
|
552
|
+
subtaskIds: compactIds([
|
|
553
|
+
(replayDependency as { sourceKind?: unknown; sourceId?: unknown }).sourceKind === "subtask"
|
|
554
|
+
? ((replayDependency as { sourceId?: unknown }).sourceId as string ?? "")
|
|
555
|
+
: "",
|
|
556
|
+
(replayDependency as { dependsOnKind?: unknown; dependsOnId?: unknown }).dependsOnKind === "subtask"
|
|
557
|
+
? ((replayDependency as { dependsOnId?: unknown }).dependsOnId as string ?? "")
|
|
558
|
+
: "",
|
|
559
|
+
]),
|
|
560
|
+
dependencyIds: replayDependencyId ? [replayDependencyId] : [],
|
|
561
|
+
}
|
|
562
|
+
: { dependencyIds: [] };
|
|
563
|
+
return buildMutationResponse(domain, result.state === "replay"
|
|
564
|
+
? withFreshReplaySnapshotDelta(domain, result.responseData, replaySelection)
|
|
565
|
+
: result.responseData, result.status);
|
|
271
566
|
}
|
|
272
567
|
|
|
273
568
|
if (request.method === "DELETE" && url.pathname === "/api/dependencies") {
|
|
274
569
|
const sourceId = url.searchParams.get("sourceId") ?? "";
|
|
275
570
|
const dependsOnId = url.searchParams.get("dependsOnId") ?? "";
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
571
|
+
const idempotencyKey = request.headers.get("x-trekoon-idempotency-key")?.trim() || null;
|
|
572
|
+
const requestFingerprint = buildDeleteDependencyFingerprint(sourceId, dependsOnId);
|
|
573
|
+
if (!idempotencyKey) {
|
|
574
|
+
const existingDependencyIds = domain.listDependencies(sourceId)
|
|
575
|
+
.filter((dependency) => dependency.dependsOnId === dependsOnId)
|
|
576
|
+
.map((dependency) => dependency.id);
|
|
577
|
+
const removed = mutations.removeDependency(sourceId, dependsOnId);
|
|
578
|
+
if (removed === 0) {
|
|
579
|
+
throw new DomainError({
|
|
580
|
+
code: "not_found",
|
|
581
|
+
message: "Dependency edge not found",
|
|
582
|
+
details: {
|
|
583
|
+
sourceId,
|
|
584
|
+
dependsOnId,
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
const responseData = {
|
|
589
|
+
sourceId,
|
|
590
|
+
dependsOnId,
|
|
591
|
+
removed,
|
|
592
|
+
snapshotDelta: buildSnapshotDelta(domain, {
|
|
593
|
+
taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
|
|
594
|
+
subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
|
|
595
|
+
deletedDependencyIds: existingDependencyIds,
|
|
596
|
+
}),
|
|
597
|
+
};
|
|
598
|
+
return buildMutationResponse(domain, responseData, 200);
|
|
286
599
|
}
|
|
287
600
|
|
|
288
|
-
|
|
601
|
+
const result = mutations.removeDependencyAtomicallyWithIdempotency({
|
|
602
|
+
sourceId,
|
|
603
|
+
dependsOnId,
|
|
604
|
+
claim: {
|
|
605
|
+
scope: "deleted_dependency",
|
|
606
|
+
idempotencyKey,
|
|
607
|
+
requestFingerprint,
|
|
608
|
+
conflictMessage: "Idempotency key cannot be reused for a different dependency delete request",
|
|
609
|
+
},
|
|
610
|
+
buildResponseData: ({ sourceId: deletedSourceId, dependsOnId: deletedDependsOnId, removed, existingDependencyIds, domain: transactionDomain }) => ({
|
|
611
|
+
sourceId: deletedSourceId,
|
|
612
|
+
dependsOnId: deletedDependsOnId,
|
|
613
|
+
removed,
|
|
614
|
+
snapshotDelta: buildSnapshotDelta(transactionDomain, {
|
|
615
|
+
taskIds: compactIds([transactionDomain.getTask(deletedSourceId)?.id ?? "", transactionDomain.getTask(deletedDependsOnId)?.id ?? ""]),
|
|
616
|
+
subtaskIds: compactIds([transactionDomain.getSubtask(deletedSourceId)?.id ?? "", transactionDomain.getSubtask(deletedDependsOnId)?.id ?? ""]),
|
|
617
|
+
deletedDependencyIds: existingDependencyIds,
|
|
618
|
+
}),
|
|
619
|
+
}),
|
|
620
|
+
});
|
|
621
|
+
const replaySnapshotDelta = readSnapshotDelta(result.responseData);
|
|
622
|
+
return buildMutationResponse(domain, result.state === "replay"
|
|
623
|
+
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
624
|
+
taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
|
|
625
|
+
subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
|
|
626
|
+
deletedDependencyIds: readStringArray(replaySnapshotDelta?.deletedDependencyIds),
|
|
627
|
+
})
|
|
628
|
+
: result.responseData, result.status);
|
|
289
629
|
}
|
|
290
630
|
|
|
291
631
|
return jsonResponse(404, {
|