trekoon 0.4.5 → 0.4.6
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 +37 -36
- package/.agents/skills/trekoon/reference/execution.md +73 -69
- package/.agents/skills/trekoon/reference/harness-primitives.md +37 -0
- package/.agents/skills/trekoon/reference/planning.md +37 -67
- package/README.md +28 -0
- package/docs/commands.md +19 -6
- package/docs/machine-contracts.md +1 -0
- package/package.json +1 -1
- package/src/board/assets/state/actions.js +32 -10
- package/src/board/assets/state/api.js +234 -35
- package/src/board/assets/state/utils.js +18 -0
- package/src/board/routes.ts +27 -14
- package/src/board/snapshot.ts +9 -19
- package/src/board/wal-watcher.ts +637 -74
- package/src/commands/epic.ts +4 -4
- package/src/commands/help.ts +18 -6
- package/src/commands/quickstart.ts +5 -2
- package/src/commands/session.ts +161 -1
- package/src/commands/subtask.ts +2 -2
- package/src/commands/suggest.ts +1 -1
- package/src/commands/task.ts +2 -2
- package/src/domain/mutation-service.ts +83 -9
- package/src/domain/tracker-domain.ts +109 -6
- package/src/io/output.ts +1 -1
- package/src/storage/database.ts +67 -2
- package/src/storage/migrations.ts +149 -2
- package/src/storage/schema.ts +6 -1
- package/src/sync/event-writes.ts +24 -2
- package/.agents/skills/trekoon/reference/execution-with-team.md +0 -170
|
@@ -377,7 +377,14 @@ export function createBoardActions(options) {
|
|
|
377
377
|
description: String(formData.get("description") || "").trim(),
|
|
378
378
|
status: normalizeStatus(String(formData.get("status") || "todo")),
|
|
379
379
|
};
|
|
380
|
-
api.patchTask
|
|
380
|
+
// No eager version capture: api.patchTask resolves the If-Match version
|
|
381
|
+
// lazily at queue fire time so back-to-back edits on the same task see
|
|
382
|
+
// the post-success version landed via mutation response or SSE delta.
|
|
383
|
+
api.patchTask(
|
|
384
|
+
taskId,
|
|
385
|
+
updates,
|
|
386
|
+
(snapshot) => updateTaskInSnapshot(snapshot, taskId, updates, normalizeSnapshot),
|
|
387
|
+
);
|
|
381
388
|
},
|
|
382
389
|
submitSubtaskForm(subtaskId, formData) {
|
|
383
390
|
const updates = {
|
|
@@ -386,7 +393,11 @@ export function createBoardActions(options) {
|
|
|
386
393
|
status: normalizeStatus(String(formData.get("status") || "todo")),
|
|
387
394
|
};
|
|
388
395
|
syncState({ selectedSubtaskId: subtaskId });
|
|
389
|
-
api.patchSubtask(
|
|
396
|
+
api.patchSubtask(
|
|
397
|
+
subtaskId,
|
|
398
|
+
updates,
|
|
399
|
+
(snapshot) => updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeSnapshot),
|
|
400
|
+
);
|
|
390
401
|
},
|
|
391
402
|
submitCreateSubtask(taskId, formData) {
|
|
392
403
|
const input = {
|
|
@@ -456,20 +467,31 @@ export function createBoardActions(options) {
|
|
|
456
467
|
}
|
|
457
468
|
// Drag/drop is a status change only; do not mutate selection or modal state,
|
|
458
469
|
// otherwise dropping a card while another task modal is open hijacks it.
|
|
459
|
-
|
|
470
|
+
// If-Match version resolved lazily by api.patchTask at fire time.
|
|
471
|
+
api.patchTask(
|
|
472
|
+
taskId,
|
|
473
|
+
{ status: nextStatus },
|
|
474
|
+
(snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot),
|
|
475
|
+
);
|
|
460
476
|
},
|
|
461
477
|
changeEpicStatus(epicId, newStatus) {
|
|
462
478
|
const normalizedStatus = normalizeStatus(newStatus);
|
|
463
|
-
api.patchEpic(
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
479
|
+
api.patchEpic(
|
|
480
|
+
epicId,
|
|
481
|
+
{ status: normalizedStatus },
|
|
482
|
+
(snapshot) => {
|
|
483
|
+
const epic = snapshot.epics.find(e => e.id === epicId);
|
|
484
|
+
if (epic) epic.status = normalizedStatus;
|
|
485
|
+
return snapshot;
|
|
486
|
+
},
|
|
487
|
+
);
|
|
468
488
|
},
|
|
469
489
|
bulkSetStatus(epicId, newStatus) {
|
|
470
490
|
const normalizedStatus = normalizeStatus(newStatus);
|
|
471
|
-
api.cascadeEpicStatus(
|
|
472
|
-
|
|
491
|
+
api.cascadeEpicStatus(
|
|
492
|
+
epicId,
|
|
493
|
+
normalizedStatus,
|
|
494
|
+
(snapshot) => cascadeEpicStatusInSnapshot(snapshot, epicId, normalizedStatus, normalizeSnapshot),
|
|
473
495
|
);
|
|
474
496
|
},
|
|
475
497
|
toggleEpicStatusFilter(status) {
|
|
@@ -140,6 +140,57 @@ export function computeInverseDelta(previousSnapshot, optimisticSnapshot) {
|
|
|
140
140
|
return inverse;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Filter an inverse delta produced by computeInverseDelta so we don't undo
|
|
145
|
+
* concurrent SSE-pushed advances that landed on the same entity while a
|
|
146
|
+
* mutation was in flight. Any record whose live snapshot `version` is strictly
|
|
147
|
+
* greater than the optimistic `ifMatchVersion` we sent is dropped from the
|
|
148
|
+
* `restored` arrays — the server-pushed state stays.
|
|
149
|
+
*
|
|
150
|
+
* If `optimisticVersion` is undefined/null/not-a-number we conservatively
|
|
151
|
+
* return the inverse delta unchanged (back-compat: legacy mutations without an
|
|
152
|
+
* If-Match version can't be reasoned about, so the original behavior wins).
|
|
153
|
+
*
|
|
154
|
+
* @param {object} inverseDelta - Inverse delta from computeInverseDelta.
|
|
155
|
+
* @param {object|null|undefined} currentSnapshot - Latest store snapshot.
|
|
156
|
+
* @param {number|null|undefined} optimisticVersion - The version we sent as If-Match.
|
|
157
|
+
* @returns {object}
|
|
158
|
+
*/
|
|
159
|
+
export function stripUpToDateEntitiesFromInverse(inverseDelta, currentSnapshot, optimisticVersion) {
|
|
160
|
+
if (typeof optimisticVersion !== "number" || !Number.isFinite(optimisticVersion)) {
|
|
161
|
+
return inverseDelta;
|
|
162
|
+
}
|
|
163
|
+
if (!inverseDelta || typeof inverseDelta !== "object") {
|
|
164
|
+
return inverseDelta;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const next = { ...inverseDelta };
|
|
168
|
+
for (const collection of SNAPSHOT_COLLECTIONS) {
|
|
169
|
+
const restored = inverseDelta[collection];
|
|
170
|
+
if (!Array.isArray(restored) || restored.length === 0) continue;
|
|
171
|
+
|
|
172
|
+
const liveRecords = Array.isArray(currentSnapshot?.[collection]) ? currentSnapshot[collection] : [];
|
|
173
|
+
const liveById = indexById(liveRecords);
|
|
174
|
+
const filtered = restored.filter((record) => {
|
|
175
|
+
if (!record || typeof record !== "object" || typeof record.id !== "string") return true;
|
|
176
|
+
const live = liveById.get(record.id);
|
|
177
|
+
const liveVersion = typeof live?.version === "number" ? live.version : null;
|
|
178
|
+
if (liveVersion === null) return true;
|
|
179
|
+
// Drop the restore when the live snapshot's version has already advanced
|
|
180
|
+
// past what we sent — an SSE delta or another mutation reconciliation
|
|
181
|
+
// moved this record forward and the user must see that.
|
|
182
|
+
return !(liveVersion > optimisticVersion);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (filtered.length === 0) {
|
|
186
|
+
delete next[collection];
|
|
187
|
+
} else if (filtered.length !== restored.length) {
|
|
188
|
+
next[collection] = filtered;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return next;
|
|
192
|
+
}
|
|
193
|
+
|
|
143
194
|
async function readJsonPayload(response) {
|
|
144
195
|
const text = await response.text();
|
|
145
196
|
if (text.length === 0) {
|
|
@@ -208,6 +259,61 @@ function createTimeoutError(method, path, timeoutMs) {
|
|
|
208
259
|
return error;
|
|
209
260
|
}
|
|
210
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Normalize a caller-supplied entity version into a bare integer suitable for
|
|
264
|
+
* the If-Match header. The server accepts a bare integer, a quoted ETag, or a
|
|
265
|
+
* W/-prefixed weak ETag (RFC 7232 §3.1) — we emit the bare integer form for
|
|
266
|
+
* simplicity. Returns null when the caller didn't pass a usable version
|
|
267
|
+
* (preserves back-compat with older callers that omit the argument).
|
|
268
|
+
*
|
|
269
|
+
* Logs a single console.warn when a non-null/non-undefined input is rejected so
|
|
270
|
+
* a regression silently dropping the If-Match header is easy to spot in dev.
|
|
271
|
+
*/
|
|
272
|
+
function normalizeIfMatchVersion(version) {
|
|
273
|
+
if (version === undefined || version === null) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
if (typeof version === "number" && Number.isFinite(version) && version >= 0 && Number.isInteger(version)) {
|
|
277
|
+
return String(version);
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
console.warn(`normalizeIfMatchVersion: ignoring non-integer/negative version ${JSON.stringify(version)} — If-Match header will be omitted`);
|
|
281
|
+
} catch {
|
|
282
|
+
// best-effort logging only
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const ENTITY_KIND_COLLECTION = {
|
|
288
|
+
epic: "epics",
|
|
289
|
+
task: "tasks",
|
|
290
|
+
subtask: "subtasks",
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Look up the current version of an entity from the live store snapshot.
|
|
295
|
+
*
|
|
296
|
+
* Resolved lazily at queue-executor fire time (rather than at action-enqueue
|
|
297
|
+
* time) so a second mutation enqueued while the first is in flight reads the
|
|
298
|
+
* post-success version — the server-acked snapshot landed via mutation
|
|
299
|
+
* response or SSE delta before processNext shifts the next mutation off the
|
|
300
|
+
* queue. Without this lazy read, rapid double-edits would carry stale
|
|
301
|
+
* If-Match versions and 409.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} storeState - The live `model.store` mutable state object.
|
|
304
|
+
* @param {"epic"|"task"|"subtask"} kind
|
|
305
|
+
* @param {string} id
|
|
306
|
+
* @returns {number|undefined}
|
|
307
|
+
*/
|
|
308
|
+
function getCurrentVersion(storeState, kind, id) {
|
|
309
|
+
const collection = ENTITY_KIND_COLLECTION[kind];
|
|
310
|
+
if (!collection) return undefined;
|
|
311
|
+
const records = storeState?.snapshot?.[collection];
|
|
312
|
+
if (!Array.isArray(records)) return undefined;
|
|
313
|
+
const record = records.find((entry) => entry?.id === id);
|
|
314
|
+
return typeof record?.version === "number" ? record.version : undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
211
317
|
/**
|
|
212
318
|
* Create a serial mutation queue.
|
|
213
319
|
*
|
|
@@ -222,7 +328,7 @@ function createTimeoutError(method, path, timeoutMs) {
|
|
|
222
328
|
* }}
|
|
223
329
|
*/
|
|
224
330
|
export function createMutationQueue(model, rerender) {
|
|
225
|
-
/** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
|
|
331
|
+
/** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string, resolveIfMatch?: function }>} */
|
|
226
332
|
const queue = [];
|
|
227
333
|
let processing = false;
|
|
228
334
|
/** @type {Array<() => void>} */
|
|
@@ -255,6 +361,19 @@ export function createMutationQueue(model, rerender) {
|
|
|
255
361
|
// the request was in flight survive a rollback.
|
|
256
362
|
let inverseDelta = null;
|
|
257
363
|
|
|
364
|
+
// Resolve the If-Match version LAZILY at fire-time so a queued
|
|
365
|
+
// second mutation on the same entity sees the post-success version that
|
|
366
|
+
// landed via mutation response or SSE delta. Capturing at enqueue time
|
|
367
|
+
// would 409 every rapid double-edit.
|
|
368
|
+
let ifMatchVersion;
|
|
369
|
+
if (typeof mutation.resolveIfMatch === "function") {
|
|
370
|
+
try {
|
|
371
|
+
ifMatchVersion = mutation.resolveIfMatch();
|
|
372
|
+
} catch {
|
|
373
|
+
ifMatchVersion = undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
258
377
|
try {
|
|
259
378
|
if (typeof mutation.optimistic === "function") {
|
|
260
379
|
const previousSnapshot = model.store.snapshot;
|
|
@@ -269,7 +388,7 @@ export function createMutationQueue(model, rerender) {
|
|
|
269
388
|
rerender();
|
|
270
389
|
}
|
|
271
390
|
|
|
272
|
-
const data = await mutation.request();
|
|
391
|
+
const data = await mutation.request({ ifMatchVersion });
|
|
273
392
|
|
|
274
393
|
if (data?.snapshot) {
|
|
275
394
|
model.replaceSnapshot(data.snapshot);
|
|
@@ -285,20 +404,42 @@ export function createMutationQueue(model, rerender) {
|
|
|
285
404
|
? { type: "success", message: mutation.successMessage }
|
|
286
405
|
: null;
|
|
287
406
|
} catch (error) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
407
|
+
const isStaleVersion = error?.code === "precondition_failed";
|
|
408
|
+
|
|
409
|
+
// Revert only the entities this mutation touched, but ALSO drop any
|
|
410
|
+
// entity whose live store version has already advanced past the
|
|
411
|
+
// optimistic version we sent: that means an SSE delta (or another
|
|
412
|
+
// queued mutation reconciliation) landed mid-flight and clobbering it
|
|
413
|
+
// with the pre-optimistic record would lose the user's most recent
|
|
414
|
+
// server-authoritative state.
|
|
415
|
+
const adjustedInverseDelta = inverseDelta
|
|
416
|
+
? stripUpToDateEntitiesFromInverse(inverseDelta, model.store?.snapshot, ifMatchVersion)
|
|
417
|
+
: null;
|
|
418
|
+
|
|
419
|
+
if (adjustedInverseDelta) {
|
|
420
|
+
model.applySnapshotDelta(adjustedInverseDelta);
|
|
292
421
|
}
|
|
293
422
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
423
|
+
if (isStaleVersion) {
|
|
424
|
+
// Typed `stale_version` notice — no retry button because replaying
|
|
425
|
+
// the optimistic payload against the post-advance state would 409
|
|
426
|
+
// again. The user needs to refresh to compose against latest.
|
|
427
|
+
model.store.notice = {
|
|
428
|
+
type: "warning",
|
|
429
|
+
code: "stale_version",
|
|
430
|
+
title: "Stale update",
|
|
431
|
+
message: "Updated by another session — refresh to load the latest version.",
|
|
432
|
+
};
|
|
433
|
+
} else {
|
|
434
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
435
|
+
model.store.notice = {
|
|
436
|
+
type: "error",
|
|
437
|
+
title: "Action failed",
|
|
438
|
+
message,
|
|
439
|
+
retryLabel: "Retry",
|
|
440
|
+
retryMutationId: mutation.mutationId,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
302
443
|
|
|
303
444
|
if (typeof mutation.onError === "function") {
|
|
304
445
|
mutation.onError(error);
|
|
@@ -368,7 +509,18 @@ export function createApi(model, options) {
|
|
|
368
509
|
}
|
|
369
510
|
},
|
|
370
511
|
onError(error) {
|
|
371
|
-
|
|
512
|
+
// Stale-version 409s are NOT retryable — the captured optimistic
|
|
513
|
+
// payload was composed against pre-advance state and replaying it
|
|
514
|
+
// would 409 again. Skip lastFailedMutation so the retry path can't
|
|
515
|
+
// even be invoked, and surface the typed stale_version notice (which
|
|
516
|
+
// does not carry a retry button) so the UI stays consistent.
|
|
517
|
+
if (error?.code === "precondition_failed") {
|
|
518
|
+
if (lastFailedMutation?.mutationId === mutationId) {
|
|
519
|
+
lastFailedMutation = null;
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
lastFailedMutation = tagged;
|
|
523
|
+
}
|
|
372
524
|
if (typeof tagged.onError === "function") {
|
|
373
525
|
tagged.onError(error);
|
|
374
526
|
}
|
|
@@ -431,47 +583,94 @@ export function createApi(model, options) {
|
|
|
431
583
|
return true;
|
|
432
584
|
},
|
|
433
585
|
|
|
434
|
-
patchEpic(epicId, updates, optimistic) {
|
|
586
|
+
patchEpic(epicId, updates, optimistic, options) {
|
|
587
|
+
// Per-call override wins; otherwise read the live store at fire-time so
|
|
588
|
+
// queued back-to-back edits on the same entity carry the post-success
|
|
589
|
+
// version rather than a stale enqueue-time snapshot.
|
|
590
|
+
const explicitVersion = options?.ifMatchVersion;
|
|
591
|
+
const resolveIfMatch = explicitVersion !== undefined
|
|
592
|
+
? () => explicitVersion
|
|
593
|
+
: () => getCurrentVersion(model.store, "epic", epicId);
|
|
435
594
|
enqueueMutation({
|
|
436
595
|
optimistic,
|
|
437
596
|
successMessage: "Epic saved.",
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
})
|
|
597
|
+
entityKind: "epic",
|
|
598
|
+
entityId: epicId,
|
|
599
|
+
resolveIfMatch,
|
|
600
|
+
request: ({ ifMatchVersion } = {}) => {
|
|
601
|
+
const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
|
|
602
|
+
return request(`/api/epics/${encodeURIComponent(epicId)}`, {
|
|
603
|
+
method: "PATCH",
|
|
604
|
+
headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
|
|
605
|
+
body: JSON.stringify(updates),
|
|
606
|
+
});
|
|
607
|
+
},
|
|
442
608
|
});
|
|
443
609
|
},
|
|
444
610
|
|
|
445
|
-
patchTask(taskId, updates, optimistic) {
|
|
611
|
+
patchTask(taskId, updates, optimistic, options) {
|
|
612
|
+
const explicitVersion = options?.ifMatchVersion;
|
|
613
|
+
const resolveIfMatch = explicitVersion !== undefined
|
|
614
|
+
? () => explicitVersion
|
|
615
|
+
: () => getCurrentVersion(model.store, "task", taskId);
|
|
446
616
|
enqueueMutation({
|
|
447
617
|
optimistic,
|
|
448
618
|
successMessage: "Task saved.",
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
})
|
|
619
|
+
entityKind: "task",
|
|
620
|
+
entityId: taskId,
|
|
621
|
+
resolveIfMatch,
|
|
622
|
+
request: ({ ifMatchVersion } = {}) => {
|
|
623
|
+
const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
|
|
624
|
+
return request(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
625
|
+
method: "PATCH",
|
|
626
|
+
headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
|
|
627
|
+
body: JSON.stringify(updates),
|
|
628
|
+
});
|
|
629
|
+
},
|
|
453
630
|
});
|
|
454
631
|
},
|
|
455
632
|
|
|
456
|
-
patchSubtask(subtaskId, updates, optimistic) {
|
|
633
|
+
patchSubtask(subtaskId, updates, optimistic, options) {
|
|
634
|
+
const explicitVersion = options?.ifMatchVersion;
|
|
635
|
+
const resolveIfMatch = explicitVersion !== undefined
|
|
636
|
+
? () => explicitVersion
|
|
637
|
+
: () => getCurrentVersion(model.store, "subtask", subtaskId);
|
|
457
638
|
enqueueMutation({
|
|
458
639
|
optimistic,
|
|
459
640
|
successMessage: "Subtask saved.",
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
})
|
|
641
|
+
entityKind: "subtask",
|
|
642
|
+
entityId: subtaskId,
|
|
643
|
+
resolveIfMatch,
|
|
644
|
+
request: ({ ifMatchVersion } = {}) => {
|
|
645
|
+
const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
|
|
646
|
+
return request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
|
|
647
|
+
method: "PATCH",
|
|
648
|
+
headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
|
|
649
|
+
body: JSON.stringify(updates),
|
|
650
|
+
});
|
|
651
|
+
},
|
|
464
652
|
});
|
|
465
653
|
},
|
|
466
654
|
|
|
467
|
-
cascadeEpicStatus(epicId, status, optimistic) {
|
|
655
|
+
cascadeEpicStatus(epicId, status, optimistic, options) {
|
|
656
|
+
const explicitVersion = options?.ifMatchVersion;
|
|
657
|
+
const resolveIfMatch = explicitVersion !== undefined
|
|
658
|
+
? () => explicitVersion
|
|
659
|
+
: () => getCurrentVersion(model.store, "epic", epicId);
|
|
468
660
|
enqueueMutation({
|
|
469
661
|
optimistic,
|
|
470
662
|
successMessage: "Epic cascade status updated.",
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
})
|
|
663
|
+
entityKind: "epic",
|
|
664
|
+
entityId: epicId,
|
|
665
|
+
resolveIfMatch,
|
|
666
|
+
request: ({ ifMatchVersion } = {}) => {
|
|
667
|
+
const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
|
|
668
|
+
return request(`/api/epics/${encodeURIComponent(epicId)}/cascade`, {
|
|
669
|
+
method: "PATCH",
|
|
670
|
+
headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
|
|
671
|
+
body: JSON.stringify({ status }),
|
|
672
|
+
});
|
|
673
|
+
},
|
|
475
674
|
});
|
|
476
675
|
},
|
|
477
676
|
|
|
@@ -46,6 +46,21 @@ function normalizeOwner(value) {
|
|
|
46
46
|
return typeof value === "string" ? value : null;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a server-provided version into a non-negative integer, or null if
|
|
51
|
+
* the value is missing/invalid. The board uses this to populate If-Match
|
|
52
|
+
* headers; non-integers and negative numbers are silently dropped here so the
|
|
53
|
+
* mutation queue treats the entity as version-less (back-compat path).
|
|
54
|
+
* @param {unknown} value
|
|
55
|
+
* @returns {number|null}
|
|
56
|
+
*/
|
|
57
|
+
function normalizeVersion(value) {
|
|
58
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
/**
|
|
50
65
|
* @param {any[]} tasks
|
|
51
66
|
* @returns {Record<string, number>}
|
|
@@ -89,6 +104,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
89
104
|
owner: normalizeOwner(task.owner),
|
|
90
105
|
createdAt,
|
|
91
106
|
updatedAt: normalizeTimestamp(task.updatedAt, createdAt),
|
|
107
|
+
version: normalizeVersion(task.version),
|
|
92
108
|
blockedBy: [],
|
|
93
109
|
blocks: [],
|
|
94
110
|
dependencyIds: [],
|
|
@@ -118,6 +134,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
118
134
|
owner: normalizeOwner(subtask.owner),
|
|
119
135
|
createdAt,
|
|
120
136
|
updatedAt: normalizeTimestamp(subtask.updatedAt, createdAt),
|
|
137
|
+
version: normalizeVersion(subtask.version),
|
|
121
138
|
blockedBy: [],
|
|
122
139
|
blocks: [],
|
|
123
140
|
dependencyIds: [],
|
|
@@ -188,6 +205,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
188
205
|
status: normalizeStatus(String(epic.status ?? "todo")),
|
|
189
206
|
createdAt,
|
|
190
207
|
updatedAt: normalizeTimestamp(epic.updatedAt, createdAt),
|
|
208
|
+
version: normalizeVersion(epic.version),
|
|
191
209
|
taskIds: epicTasks.map((task) => task.id),
|
|
192
210
|
counts: deriveCounts(epicTasks),
|
|
193
211
|
searchText: "",
|
package/src/board/routes.ts
CHANGED
|
@@ -612,6 +612,16 @@ function preconditionFailedResponse(details: PreconditionFailedDetails): Respons
|
|
|
612
612
|
});
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
+
function preconditionRequiredResponse(): Response {
|
|
616
|
+
return jsonResponse(428, {
|
|
617
|
+
ok: false,
|
|
618
|
+
error: {
|
|
619
|
+
code: "precondition_required",
|
|
620
|
+
message: "If-Match header is required for PATCH requests",
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
615
625
|
function readIdempotencyKey(request: Request, body: Record<string, unknown>): string | null {
|
|
616
626
|
const headerKey = request.headers.get("x-trekoon-idempotency-key");
|
|
617
627
|
if (typeof headerKey === "string" && headerKey.trim().length > 0) {
|
|
@@ -705,12 +715,12 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
705
715
|
const body = await parseJsonBody(request);
|
|
706
716
|
const status = readRequiredString(body, "status");
|
|
707
717
|
const ifMatch = parseIfMatchHeader(request);
|
|
718
|
+
if (ifMatch === null) {
|
|
719
|
+
return preconditionRequiredResponse();
|
|
720
|
+
}
|
|
708
721
|
// CAS path: precondition is enforced inside the write transaction
|
|
709
|
-
// (see PreconditionFailedError catch below).
|
|
710
|
-
|
|
711
|
-
const plan = ifMatch !== null
|
|
712
|
-
? mutations.updateEpicStatusCascadeWithIfMatch(epicId, ifMatch, status)
|
|
713
|
-
: mutations.updateEpicStatusCascade(epicId, status);
|
|
722
|
+
// (see PreconditionFailedError catch below).
|
|
723
|
+
const plan = mutations.updateEpicStatusCascadeWithIfMatch(epicId, ifMatch, status);
|
|
714
724
|
return respondWithMutationDelta(domain, {
|
|
715
725
|
plan,
|
|
716
726
|
}, {
|
|
@@ -725,14 +735,15 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
725
735
|
const epicId = epicMatch[1] ?? "";
|
|
726
736
|
const body = await parseJsonBody(request);
|
|
727
737
|
const ifMatch = parseIfMatchHeader(request);
|
|
738
|
+
if (ifMatch === null) {
|
|
739
|
+
return preconditionRequiredResponse();
|
|
740
|
+
}
|
|
728
741
|
const epicInput = {
|
|
729
742
|
title: readOptionalString(body, "title"),
|
|
730
743
|
description: readOptionalString(body, "description"),
|
|
731
744
|
status: readOptionalString(body, "status"),
|
|
732
745
|
};
|
|
733
|
-
const epic = ifMatch
|
|
734
|
-
? mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput)
|
|
735
|
-
: mutations.updateEpic(epicId, epicInput);
|
|
746
|
+
const epic = mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput);
|
|
736
747
|
return respondWithMutationDelta(domain, { epic }, { epicIds: [epic.id] });
|
|
737
748
|
}
|
|
738
749
|
|
|
@@ -741,15 +752,16 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
741
752
|
const taskId = taskMatch[1] ?? "";
|
|
742
753
|
const body = await parseJsonBody(request);
|
|
743
754
|
const ifMatch = parseIfMatchHeader(request);
|
|
755
|
+
if (ifMatch === null) {
|
|
756
|
+
return preconditionRequiredResponse();
|
|
757
|
+
}
|
|
744
758
|
const taskInput = {
|
|
745
759
|
title: readOptionalString(body, "title"),
|
|
746
760
|
description: readOptionalString(body, "description"),
|
|
747
761
|
status: readOptionalString(body, "status"),
|
|
748
762
|
owner: readOptionalNullableString(body, "owner"),
|
|
749
763
|
};
|
|
750
|
-
const task = ifMatch
|
|
751
|
-
? mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput)
|
|
752
|
-
: mutations.updateTask(taskId, taskInput);
|
|
764
|
+
const task = mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput);
|
|
753
765
|
return respondWithMutationDelta(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
|
|
754
766
|
}
|
|
755
767
|
|
|
@@ -758,15 +770,16 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
758
770
|
const subtaskId = subtaskMatch[1] ?? "";
|
|
759
771
|
const body = await parseJsonBody(request);
|
|
760
772
|
const ifMatch = parseIfMatchHeader(request);
|
|
773
|
+
if (ifMatch === null) {
|
|
774
|
+
return preconditionRequiredResponse();
|
|
775
|
+
}
|
|
761
776
|
const subtaskInput = {
|
|
762
777
|
title: readOptionalString(body, "title"),
|
|
763
778
|
description: readOptionalString(body, "description"),
|
|
764
779
|
status: readOptionalString(body, "status"),
|
|
765
780
|
owner: readOptionalNullableString(body, "owner"),
|
|
766
781
|
};
|
|
767
|
-
const subtask = ifMatch
|
|
768
|
-
? mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput)
|
|
769
|
-
: mutations.updateSubtask(subtaskId, subtaskInput);
|
|
782
|
+
const subtask = mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput);
|
|
770
783
|
const task = domain.getTaskOrThrow(subtask.taskId);
|
|
771
784
|
return respondWithMutationDelta(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
|
|
772
785
|
}
|
package/src/board/snapshot.ts
CHANGED
|
@@ -93,6 +93,8 @@ interface SnapshotDeltaSelection {
|
|
|
93
93
|
readonly taskIds?: readonly string[];
|
|
94
94
|
readonly subtaskIds?: readonly string[];
|
|
95
95
|
readonly dependencyIds?: readonly string[];
|
|
96
|
+
readonly deletedEpicIds?: readonly string[];
|
|
97
|
+
readonly deletedTaskIds?: readonly string[];
|
|
96
98
|
readonly deletedSubtaskIds?: readonly string[];
|
|
97
99
|
readonly deletedDependencyIds?: readonly string[];
|
|
98
100
|
}
|
|
@@ -253,6 +255,8 @@ export function buildBoardSnapshotDelta(domain: TrackerDomain, selection: Snapsh
|
|
|
253
255
|
tasks: snapshotTasks.filter((task) => requestedTaskIds.includes(task.id)),
|
|
254
256
|
subtasks: allSubtasks.map((subtask) => mapSnapshotSubtask(subtask, indexes)).filter((subtask) => requestedSubtaskIds.includes(subtask.id)),
|
|
255
257
|
dependencies: indexes.dependencies.filter((dependency) => requestedDependencyIds.has(dependency.id)),
|
|
258
|
+
deletedEpicIds: [...(selection.deletedEpicIds ?? [])],
|
|
259
|
+
deletedTaskIds: [...(selection.deletedTaskIds ?? [])],
|
|
256
260
|
deletedSubtaskIds: [...(selection.deletedSubtaskIds ?? [])],
|
|
257
261
|
deletedDependencyIds: [...(selection.deletedDependencyIds ?? [])],
|
|
258
262
|
};
|
|
@@ -265,22 +269,8 @@ export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
|
|
|
265
269
|
const subtasks = domain.listSubtasks();
|
|
266
270
|
const sourceIds = [...tasks.map((task) => task.id), ...subtasks.map((subtask) => subtask.id)];
|
|
267
271
|
const dependenciesBySourceId = domain.listDependenciesBySourceIds(sourceIds);
|
|
268
|
-
const subtasksByTaskId = new Map<string, SubtaskRecord[]>();
|
|
269
|
-
const tasksByEpicId = new Map<string, TaskRecord[]>();
|
|
270
272
|
const indexes = buildDependencyIndexes(dependenciesBySourceId, sourceIds);
|
|
271
273
|
|
|
272
|
-
for (const task of tasks) {
|
|
273
|
-
const existing = tasksByEpicId.get(task.epicId) ?? [];
|
|
274
|
-
existing.push(task);
|
|
275
|
-
tasksByEpicId.set(task.epicId, existing);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
for (const subtask of subtasks) {
|
|
279
|
-
const existing = subtasksByTaskId.get(subtask.taskId) ?? [];
|
|
280
|
-
existing.push(subtask);
|
|
281
|
-
subtasksByTaskId.set(subtask.taskId, existing);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
274
|
const snapshotSubtasks: BoardSnapshotSubtask[] = subtasks.map((subtask) => mapSnapshotSubtask(subtask, indexes));
|
|
285
275
|
const snapshotSubtasksByTaskId = new Map<string, BoardSnapshotSubtask[]>();
|
|
286
276
|
for (const subtask of snapshotSubtasks) {
|
|
@@ -290,14 +280,14 @@ export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
|
|
|
290
280
|
}
|
|
291
281
|
|
|
292
282
|
const snapshotTasks: BoardSnapshotTask[] = tasks.map((task) => mapSnapshotTask(task, snapshotSubtasksByTaskId.get(task.id) ?? [], indexes));
|
|
293
|
-
const
|
|
283
|
+
const snapshotTasksByEpicId = new Map<string, BoardSnapshotTask[]>();
|
|
294
284
|
for (const task of snapshotTasks) {
|
|
295
|
-
const existing =
|
|
296
|
-
existing.push(task
|
|
297
|
-
|
|
285
|
+
const existing = snapshotTasksByEpicId.get(task.epicId) ?? [];
|
|
286
|
+
existing.push(task);
|
|
287
|
+
snapshotTasksByEpicId.set(task.epicId, existing);
|
|
298
288
|
}
|
|
299
289
|
|
|
300
|
-
const snapshotEpics: BoardSnapshotEpic[] = epics.map((epic) => mapSnapshotEpic(epic,
|
|
290
|
+
const snapshotEpics: BoardSnapshotEpic[] = epics.map((epic) => mapSnapshotEpic(epic, snapshotTasksByEpicId.get(epic.id) ?? []));
|
|
301
291
|
|
|
302
292
|
return {
|
|
303
293
|
generatedAt,
|