trekoon 0.4.1 → 0.4.2
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 +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -1
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +45 -13
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +42 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +79 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +650 -147
|
@@ -166,7 +166,7 @@ function renderWorkspaceHeader(props) {
|
|
|
166
166
|
// ---------------------------------------------------------------------------
|
|
167
167
|
|
|
168
168
|
function renderKanbanColumns(props) {
|
|
169
|
-
const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter } = props;
|
|
169
|
+
const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter, dragFeedback } = props;
|
|
170
170
|
const filter = taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
171
171
|
|
|
172
172
|
const columnsMarkup = STATUS_ORDER
|
|
@@ -178,6 +178,12 @@ function renderKanbanColumns(props) {
|
|
|
178
178
|
? renderEmptyState(`No ${columnTitle.toLowerCase()} work`, "Adjust search or switch epics to inspect more tasks.")
|
|
179
179
|
: columnTasks.map((task) => renderTaskCard({ task, selected: selectedTaskId === task.id, isMutating })).join("");
|
|
180
180
|
|
|
181
|
+
// Re-apply drag-feedback class from store so a rerender during drag does
|
|
182
|
+
// not silently wipe the visual feedback that was set by handleDragover.
|
|
183
|
+
const feedbackClass = dragFeedback?.targetStatus === status
|
|
184
|
+
? (dragFeedback.kind === "valid" ? " board-drop-valid" : " board-drop-invalid")
|
|
185
|
+
: "";
|
|
186
|
+
|
|
181
187
|
return `
|
|
182
188
|
<section class="board-column board-column--dense ${secondaryPanelClasses("flex min-h-[20rem] min-w-0 flex-col p-3")}" aria-labelledby="column-${status}">
|
|
183
189
|
<header class="board-column__header flex items-start justify-between gap-3 border-b border-[var(--board-border)] pb-3">
|
|
@@ -190,7 +196,7 @@ function renderKanbanColumns(props) {
|
|
|
190
196
|
</div>
|
|
191
197
|
${columnTasks.length > 0 ? `<span class="board-column__count text-xs font-medium text-[var(--board-text-soft)]">${columnTasks.length === 1 ? "1 task" : `${columnTasks.length} tasks`}</span>` : ""}
|
|
192
198
|
</header>
|
|
193
|
-
<div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
|
|
199
|
+
<div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain${feedbackClass}" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
|
|
194
200
|
</section>
|
|
195
201
|
`;
|
|
196
202
|
}).join("");
|
|
@@ -292,7 +298,7 @@ function render(props) {
|
|
|
292
298
|
});
|
|
293
299
|
|
|
294
300
|
const contentMarkup = store.view === "kanban"
|
|
295
|
-
? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter })
|
|
301
|
+
? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter, dragFeedback: store.dragFeedback ?? null })
|
|
296
302
|
: renderListView({ visibleTasks, selectedTaskId });
|
|
297
303
|
|
|
298
304
|
return `
|
|
@@ -255,6 +255,10 @@ export function shouldUseTaskModal(boardState, store) {
|
|
|
255
255
|
return Boolean(boardState?.taskModalOpen && boardState?.selectedTask);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
export function shouldUseSubtaskModal(boardState) {
|
|
259
|
+
return Boolean(boardState?.subtaskModalOpen && boardState?.selectedSubtask);
|
|
260
|
+
}
|
|
261
|
+
|
|
258
262
|
export function lookupNode(snapshot, id) {
|
|
259
263
|
return snapshot.tasks.find((task) => task.id === id)
|
|
260
264
|
?? snapshot.subtasks.find((subtask) => subtask.id === id)
|
|
@@ -310,6 +310,8 @@ export function createDelegation(rootElement, actions) {
|
|
|
310
310
|
for (const el of rootElement.querySelectorAll(".board-drop-valid, .board-drop-invalid")) {
|
|
311
311
|
el.classList.remove("board-drop-valid", "board-drop-invalid");
|
|
312
312
|
}
|
|
313
|
+
// Clear feedback in store so rerenders do not re-apply stale classes.
|
|
314
|
+
actions.setDragFeedback?.(null);
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
function handleDragstart(event) {
|
|
@@ -336,11 +338,15 @@ export function createDelegation(rootElement, actions) {
|
|
|
336
338
|
const targetStatus = column.dataset.dropStatus;
|
|
337
339
|
if (draggedTaskStatus && isValidTransition(draggedTaskStatus, targetStatus)) {
|
|
338
340
|
event.preventDefault();
|
|
341
|
+
// Fast path: apply directly to DOM for immediate visual feedback.
|
|
339
342
|
column.classList.add("board-drop-valid");
|
|
340
343
|
column.classList.remove("board-drop-invalid");
|
|
344
|
+
// Also persist in store so a rerender during drag restores the class.
|
|
345
|
+
actions.setDragFeedback?.({ targetStatus, kind: "valid" });
|
|
341
346
|
} else if (draggedTaskStatus && targetStatus !== draggedTaskStatus) {
|
|
342
347
|
column.classList.add("board-drop-invalid");
|
|
343
348
|
column.classList.remove("board-drop-valid");
|
|
349
|
+
actions.setDragFeedback?.({ targetStatus, kind: "invalid" });
|
|
344
350
|
}
|
|
345
351
|
}
|
|
346
352
|
|
|
@@ -348,6 +354,8 @@ export function createDelegation(rootElement, actions) {
|
|
|
348
354
|
const column = event.target.closest("[data-drop-status]");
|
|
349
355
|
if (column && !column.contains(event.relatedTarget)) {
|
|
350
356
|
column.classList.remove("board-drop-valid", "board-drop-invalid");
|
|
357
|
+
// Clear store feedback when leaving the column.
|
|
358
|
+
actions.setDragFeedback?.(null);
|
|
351
359
|
}
|
|
352
360
|
}
|
|
353
361
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy overlay focus-trap controller.
|
|
3
|
+
*
|
|
4
|
+
* Attaches the document-level keydown/focusin listeners only while an overlay
|
|
5
|
+
* is actually open, and detaches them on close. Without this, plain Tab outside
|
|
6
|
+
* any overlay can be intercepted by a stale handler in the microtask window
|
|
7
|
+
* between overlay close and rerender.
|
|
8
|
+
*
|
|
9
|
+
* @param {{
|
|
10
|
+
* doc?: Document,
|
|
11
|
+
* onTabKey: (event: KeyboardEvent) => void,
|
|
12
|
+
* onFocusIn: (event: FocusEvent) => void,
|
|
13
|
+
* }} options
|
|
14
|
+
*/
|
|
15
|
+
export function createOverlayFocusTrap(options) {
|
|
16
|
+
const doc = options.doc ?? (typeof document !== "undefined" ? document : null);
|
|
17
|
+
if (!doc) {
|
|
18
|
+
return {
|
|
19
|
+
attach() {},
|
|
20
|
+
detach() {},
|
|
21
|
+
isAttached() { return false; },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const onKeyDown = options.onTabKey;
|
|
26
|
+
const onFocusIn = options.onFocusIn;
|
|
27
|
+
let attached = false;
|
|
28
|
+
|
|
29
|
+
function attach() {
|
|
30
|
+
if (attached) return;
|
|
31
|
+
doc.addEventListener("keydown", onKeyDown, true);
|
|
32
|
+
doc.addEventListener("focusin", onFocusIn, true);
|
|
33
|
+
attached = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function detach() {
|
|
37
|
+
if (!attached) return;
|
|
38
|
+
doc.removeEventListener("keydown", onKeyDown, true);
|
|
39
|
+
doc.removeEventListener("focusin", onFocusIn, true);
|
|
40
|
+
attached = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
attach,
|
|
45
|
+
detach,
|
|
46
|
+
isAttached() { return attached; },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -142,6 +142,13 @@ export function createBoardActions(options) {
|
|
|
142
142
|
searchFocusKeys,
|
|
143
143
|
} = options;
|
|
144
144
|
const { store, persist, getBoardState, getTaskById, syncState } = model;
|
|
145
|
+
// Direct mutations of `store.*` (filter toggles, notice changes, drag
|
|
146
|
+
// feedback) bypass setState/syncState, so `getBoardState()`'s memo would
|
|
147
|
+
// otherwise return a stale reference until the next syncState/notify.
|
|
148
|
+
// Route these through invalidateBoardStateMemo() before rerender.
|
|
149
|
+
const invalidateMemo = typeof model.invalidateBoardStateMemo === "function"
|
|
150
|
+
? () => model.invalidateBoardStateMemo()
|
|
151
|
+
: () => {};
|
|
145
152
|
|
|
146
153
|
const transition = (patch = {}, options = {}) => {
|
|
147
154
|
const { persistState = true, rerenderBoard = true } = options;
|
|
@@ -348,13 +355,21 @@ export function createBoardActions(options) {
|
|
|
348
355
|
});
|
|
349
356
|
},
|
|
350
357
|
closeTask() {
|
|
351
|
-
transition({
|
|
358
|
+
transition({
|
|
359
|
+
selectedTaskId: null,
|
|
360
|
+
selectedSubtaskId: null,
|
|
361
|
+
taskModalOpen: false,
|
|
362
|
+
subtaskModalOpen: false,
|
|
363
|
+
});
|
|
352
364
|
},
|
|
353
365
|
openSubtask(subtaskId) {
|
|
354
|
-
transition(
|
|
366
|
+
transition(
|
|
367
|
+
{ selectedSubtaskId: subtaskId || null, subtaskModalOpen: Boolean(subtaskId) },
|
|
368
|
+
{ persistState: false },
|
|
369
|
+
);
|
|
355
370
|
},
|
|
356
371
|
closeSubtask() {
|
|
357
|
-
transition({ selectedSubtaskId: null }, { persistState: false });
|
|
372
|
+
transition({ selectedSubtaskId: null, subtaskModalOpen: false }, { persistState: false });
|
|
358
373
|
},
|
|
359
374
|
submitTaskForm(taskId, formData) {
|
|
360
375
|
const updates = {
|
|
@@ -413,12 +428,31 @@ export function createBoardActions(options) {
|
|
|
413
428
|
const task = getTaskById(taskId);
|
|
414
429
|
return task?.status ?? null;
|
|
415
430
|
},
|
|
431
|
+
/**
|
|
432
|
+
* Record the current drag-over feedback in store state so it survives a
|
|
433
|
+
* rerender triggered by an unrelated event (e.g. a server SSE push).
|
|
434
|
+
* Pass null to clear feedback (dragend / drop / dragleave).
|
|
435
|
+
*
|
|
436
|
+
* @param {{ targetStatus: string, kind: 'valid'|'invalid' }|null} feedback
|
|
437
|
+
*/
|
|
438
|
+
setDragFeedback(feedback) {
|
|
439
|
+
// Reference equality on freshly-allocated `{ targetStatus, kind }` always
|
|
440
|
+
// fails, so dragover would rerender on every move. Compare by primitive
|
|
441
|
+
// key so repeats over the same column become a no-op.
|
|
442
|
+
const prev = store.dragFeedback;
|
|
443
|
+
const prevKey = prev ? `${prev.targetStatus}|${prev.kind}` : null;
|
|
444
|
+
const nextKey = feedback ? `${feedback.targetStatus}|${feedback.kind}` : null;
|
|
445
|
+
if (prevKey === nextKey) return;
|
|
446
|
+
store.dragFeedback = feedback;
|
|
447
|
+
rerender();
|
|
448
|
+
},
|
|
416
449
|
dropTaskStatus(taskId, nextStatus) {
|
|
417
450
|
const task = getTaskById(taskId);
|
|
418
451
|
if (!task || !nextStatus || task.status === nextStatus) {
|
|
419
452
|
return;
|
|
420
453
|
}
|
|
421
|
-
|
|
454
|
+
// Drag/drop is a status change only; do not mutate selection or modal state,
|
|
455
|
+
// otherwise dropping a card while another task modal is open hijacks it.
|
|
422
456
|
api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
|
|
423
457
|
},
|
|
424
458
|
changeEpicStatus(epicId, newStatus) {
|
|
@@ -438,22 +472,26 @@ export function createBoardActions(options) {
|
|
|
438
472
|
toggleEpicStatusFilter(status) {
|
|
439
473
|
const current = store.epicStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
440
474
|
store.epicStatusFilter = { ...current, [status]: !current[status] };
|
|
475
|
+
invalidateMemo();
|
|
441
476
|
persist();
|
|
442
477
|
rerender();
|
|
443
478
|
},
|
|
444
479
|
toggleTaskStatusFilter(status) {
|
|
445
480
|
const current = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
446
481
|
store.taskStatusFilter = { ...current, [status]: !current[status] };
|
|
482
|
+
invalidateMemo();
|
|
447
483
|
persist();
|
|
448
484
|
rerender();
|
|
449
485
|
},
|
|
450
486
|
resetEpicFilter() {
|
|
451
487
|
store.epicStatusFilter = { ...DEFAULT_STATUS_FILTER };
|
|
488
|
+
invalidateMemo();
|
|
452
489
|
persist();
|
|
453
490
|
rerender();
|
|
454
491
|
},
|
|
455
492
|
resetTaskFilter() {
|
|
456
493
|
store.taskStatusFilter = { ...DEFAULT_STATUS_FILTER };
|
|
494
|
+
invalidateMemo();
|
|
457
495
|
persist();
|
|
458
496
|
rerender();
|
|
459
497
|
},
|
|
@@ -13,6 +13,133 @@ function cloneSnapshot(snapshot) {
|
|
|
13
13
|
return JSON.parse(JSON.stringify(snapshot));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const SNAPSHOT_COLLECTIONS = ["epics", "tasks", "subtasks", "dependencies"];
|
|
17
|
+
|
|
18
|
+
const COLLECTION_TO_DELETED_KEY = {
|
|
19
|
+
epics: "deletedEpicIds",
|
|
20
|
+
tasks: "deletedTaskIds",
|
|
21
|
+
subtasks: "deletedSubtaskIds",
|
|
22
|
+
dependencies: "deletedDependencyIds",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function arraysShallowEqual(left, right) {
|
|
26
|
+
if (left === right) return true;
|
|
27
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
28
|
+
if (left.length !== right.length) return false;
|
|
29
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
30
|
+
if (left[i] !== right[i]) return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shallow equality on plain board records.
|
|
37
|
+
*
|
|
38
|
+
* Board records are flat objects whose values are primitives or arrays of
|
|
39
|
+
* primitives (e.g. dependency-id arrays). A field-by-field shallow comparison
|
|
40
|
+
* is therefore equivalent to a structural deep-equal but avoids the
|
|
41
|
+
* O(snapshot) JSON.stringify cost the previous implementation paid on every
|
|
42
|
+
* rollback. Cross-realm values and odd nested objects fall back to
|
|
43
|
+
* reference equality, which is a safe over-restore (worst case: an unchanged
|
|
44
|
+
* record is included in the inverse delta).
|
|
45
|
+
*/
|
|
46
|
+
function recordsShallowEqual(left, right) {
|
|
47
|
+
if (left === right) return true;
|
|
48
|
+
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
|
|
49
|
+
const leftKeys = Object.keys(left);
|
|
50
|
+
const rightKeys = Object.keys(right);
|
|
51
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
52
|
+
for (const key of leftKeys) {
|
|
53
|
+
const leftValue = left[key];
|
|
54
|
+
const rightValue = right[key];
|
|
55
|
+
if (leftValue === rightValue) continue;
|
|
56
|
+
if (Array.isArray(leftValue) || Array.isArray(rightValue)) {
|
|
57
|
+
if (!arraysShallowEqual(leftValue, rightValue)) return false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function indexById(records) {
|
|
66
|
+
const map = new Map();
|
|
67
|
+
if (!Array.isArray(records)) {
|
|
68
|
+
return map;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const record of records) {
|
|
72
|
+
if (record && typeof record === "object" && typeof record.id === "string" && record.id.length > 0) {
|
|
73
|
+
map.set(record.id, record);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return map;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute the inverse delta needed to revert an optimistic mutation.
|
|
82
|
+
*
|
|
83
|
+
* The inverse is built by diffing the snapshot _before_ the optimistic patch
|
|
84
|
+
* against the snapshot _after_ the patch. We only describe entities the
|
|
85
|
+
* optimistic patch actually touched so that concurrent deltas pushed by the
|
|
86
|
+
* server (for unrelated entities) are preserved when we apply the inverse.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} previousSnapshot - Snapshot prior to optimistic apply.
|
|
89
|
+
* @param {object} optimisticSnapshot - Snapshot after optimistic apply.
|
|
90
|
+
* @returns {{
|
|
91
|
+
* epics?: object[], tasks?: object[], subtasks?: object[], dependencies?: object[],
|
|
92
|
+
* deletedEpicIds?: string[], deletedTaskIds?: string[], deletedSubtaskIds?: string[], deletedDependencyIds?: string[],
|
|
93
|
+
* }}
|
|
94
|
+
*/
|
|
95
|
+
export function computeInverseDelta(previousSnapshot, optimisticSnapshot) {
|
|
96
|
+
const inverse = {};
|
|
97
|
+
for (const collection of SNAPSHOT_COLLECTIONS) {
|
|
98
|
+
const before = indexById(previousSnapshot?.[collection]);
|
|
99
|
+
const after = indexById(optimisticSnapshot?.[collection]);
|
|
100
|
+
|
|
101
|
+
const restored = [];
|
|
102
|
+
const deletedIds = [];
|
|
103
|
+
|
|
104
|
+
// Entities that the optimistic patch deleted -> restore them.
|
|
105
|
+
for (const [id, beforeRecord] of before) {
|
|
106
|
+
if (!after.has(id)) {
|
|
107
|
+
restored.push(beforeRecord);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Entities present in both but mutated -> restore the previous version.
|
|
112
|
+
// cloneSnapshot/normalizeSnapshot always produce fresh references for
|
|
113
|
+
// every record in the optimistic snapshot, so plain reference inequality
|
|
114
|
+
// would flag every entity. Use a shallow field-by-field equality check
|
|
115
|
+
// instead — equivalent to a structural compare for these flat records but
|
|
116
|
+
// O(field) per record rather than O(snapshot) JSON.stringify on each
|
|
117
|
+
// rollback (P2 perf finding).
|
|
118
|
+
for (const [id, afterRecord] of after) {
|
|
119
|
+
const beforeRecord = before.get(id);
|
|
120
|
+
if (beforeRecord && beforeRecord !== afterRecord && !recordsShallowEqual(beforeRecord, afterRecord)) {
|
|
121
|
+
restored.push(beforeRecord);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Entities the optimistic patch added -> mark for deletion.
|
|
126
|
+
for (const id of after.keys()) {
|
|
127
|
+
if (!before.has(id)) {
|
|
128
|
+
deletedIds.push(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (restored.length > 0) {
|
|
133
|
+
inverse[collection] = restored;
|
|
134
|
+
}
|
|
135
|
+
if (deletedIds.length > 0) {
|
|
136
|
+
inverse[COLLECTION_TO_DELETED_KEY[collection]] = deletedIds;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return inverse;
|
|
141
|
+
}
|
|
142
|
+
|
|
16
143
|
async function readJsonPayload(response) {
|
|
17
144
|
const text = await response.text();
|
|
18
145
|
if (text.length === 0) {
|
|
@@ -119,14 +246,27 @@ export function createMutationQueue(model, rerender) {
|
|
|
119
246
|
|
|
120
247
|
while (queue.length > 0) {
|
|
121
248
|
const mutation = queue.shift();
|
|
122
|
-
const previousSnapshot = cloneSnapshot(model.store.snapshot);
|
|
123
249
|
if (model.store.notice?.retryMutationId !== mutation.id) {
|
|
124
250
|
model.store.notice = null;
|
|
125
251
|
}
|
|
126
252
|
|
|
253
|
+
// Capture per-mutation inverse delta if the optimistic patch ran.
|
|
254
|
+
// Using an inverse delta (rather than wholesale replaceSnapshot) means
|
|
255
|
+
// concurrent server-pushed deltas applied to unrelated entities while
|
|
256
|
+
// the request was in flight survive a rollback.
|
|
257
|
+
let inverseDelta = null;
|
|
258
|
+
|
|
127
259
|
try {
|
|
128
260
|
if (typeof mutation.optimistic === "function") {
|
|
129
|
-
|
|
261
|
+
const previousSnapshot = cloneSnapshot(model.store.snapshot);
|
|
262
|
+
const optimisticSnapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
|
|
263
|
+
inverseDelta = computeInverseDelta(previousSnapshot, optimisticSnapshot);
|
|
264
|
+
model.store.snapshot = optimisticSnapshot;
|
|
265
|
+
// Direct snapshot mutation bypasses setState/syncState; invalidate
|
|
266
|
+
// the memo so the next getBoardState() reflects the optimistic write.
|
|
267
|
+
if (typeof model.invalidateBoardStateMemo === "function") {
|
|
268
|
+
model.invalidateBoardStateMemo();
|
|
269
|
+
}
|
|
130
270
|
rerender();
|
|
131
271
|
}
|
|
132
272
|
|
|
@@ -146,8 +286,11 @@ export function createMutationQueue(model, rerender) {
|
|
|
146
286
|
? { type: "success", message: mutation.successMessage }
|
|
147
287
|
: null;
|
|
148
288
|
} catch (error) {
|
|
149
|
-
// Revert
|
|
150
|
-
|
|
289
|
+
// Revert only the entities this mutation touched. Any unrelated
|
|
290
|
+
// entities updated by concurrent server deltas remain intact.
|
|
291
|
+
if (inverseDelta) {
|
|
292
|
+
model.applySnapshotDelta(inverseDelta);
|
|
293
|
+
}
|
|
151
294
|
|
|
152
295
|
const message = error instanceof Error ? error.message : String(error);
|
|
153
296
|
model.store.notice = {
|
|
@@ -166,6 +309,9 @@ export function createMutationQueue(model, rerender) {
|
|
|
166
309
|
|
|
167
310
|
processing = false;
|
|
168
311
|
model.store.isMutating = false;
|
|
312
|
+
if (typeof model.invalidateBoardStateMemo === "function") {
|
|
313
|
+
model.invalidateBoardStateMemo();
|
|
314
|
+
}
|
|
169
315
|
rerender();
|
|
170
316
|
resolveFlushes();
|
|
171
317
|
}
|
|
@@ -204,20 +350,26 @@ export function createApi(model, options) {
|
|
|
204
350
|
let lastFailedMutation = null;
|
|
205
351
|
|
|
206
352
|
function enqueueMutation(definition) {
|
|
353
|
+
// Assign a stable identity token so success callbacks can clear
|
|
354
|
+
// lastFailedMutation by id rather than by function-reference equality
|
|
355
|
+
// (inline arrow functions are never the same reference across retries).
|
|
356
|
+
const mutationId = crypto.randomUUID();
|
|
357
|
+
const tagged = { ...definition, mutationId };
|
|
358
|
+
|
|
207
359
|
queue.enqueue({
|
|
208
|
-
...
|
|
360
|
+
...tagged,
|
|
209
361
|
onSuccess(data) {
|
|
210
|
-
if (lastFailedMutation?.
|
|
362
|
+
if (lastFailedMutation?.mutationId === mutationId) {
|
|
211
363
|
lastFailedMutation = null;
|
|
212
364
|
}
|
|
213
|
-
if (typeof
|
|
214
|
-
|
|
365
|
+
if (typeof tagged.onSuccess === "function") {
|
|
366
|
+
tagged.onSuccess(data);
|
|
215
367
|
}
|
|
216
368
|
},
|
|
217
369
|
onError(error) {
|
|
218
|
-
lastFailedMutation =
|
|
219
|
-
if (typeof
|
|
220
|
-
|
|
370
|
+
lastFailedMutation = tagged;
|
|
371
|
+
if (typeof tagged.onError === "function") {
|
|
372
|
+
tagged.onError(error);
|
|
221
373
|
}
|
|
222
374
|
},
|
|
223
375
|
});
|
|
@@ -403,3 +555,124 @@ export function createApi(model, options) {
|
|
|
403
555
|
},
|
|
404
556
|
};
|
|
405
557
|
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Subscribe the board client to /api/snapshot/stream.
|
|
561
|
+
*
|
|
562
|
+
* Receives `snapshotDelta` events emitted by the per-server-instance event bus
|
|
563
|
+
* (own server mutations + WAL-watcher-derived deltas from external CLI writes)
|
|
564
|
+
* and applies them via `model.applySnapshotDelta`. Idempotent merge means
|
|
565
|
+
* re-applying a delta we already saw via the mutation response is harmless.
|
|
566
|
+
*
|
|
567
|
+
* Returns a `dispose()` function that closes the EventSource and stops
|
|
568
|
+
* processing further events.
|
|
569
|
+
*
|
|
570
|
+
* @param {object} model - Store with `applySnapshotDelta` method
|
|
571
|
+
* @param {object} options
|
|
572
|
+
* @param {string} options.sessionToken - Auth token (forwarded as ?token=)
|
|
573
|
+
* @param {function} options.rerender - Trigger UI rerender after applying deltas
|
|
574
|
+
* @param {typeof EventSource} [options.EventSourceCtor] - Constructor override for tests
|
|
575
|
+
* @param {string} [options.path] - Override stream path; default /api/snapshot/stream
|
|
576
|
+
* @returns {{ dispose: () => void, eventSource: EventSource | null }}
|
|
577
|
+
*/
|
|
578
|
+
export function subscribeSnapshotStream(model, options) {
|
|
579
|
+
const {
|
|
580
|
+
sessionToken,
|
|
581
|
+
rerender,
|
|
582
|
+
EventSourceCtor = typeof EventSource !== "undefined" ? EventSource : null,
|
|
583
|
+
path = "/api/snapshot/stream",
|
|
584
|
+
} = options ?? {};
|
|
585
|
+
|
|
586
|
+
if (!EventSourceCtor) {
|
|
587
|
+
return { dispose: () => {}, eventSource: null };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// EventSource cannot set custom headers, so the auth token rides as a query
|
|
591
|
+
// parameter. Server `extractToken` already accepts ?token=.
|
|
592
|
+
const url = sessionToken && sessionToken.length > 0
|
|
593
|
+
? `${path}?token=${encodeURIComponent(sessionToken)}`
|
|
594
|
+
: path;
|
|
595
|
+
|
|
596
|
+
let disposed = false;
|
|
597
|
+
const eventSource = new EventSourceCtor(url);
|
|
598
|
+
|
|
599
|
+
const handleSnapshotDelta = (event) => {
|
|
600
|
+
if (disposed) return;
|
|
601
|
+
const raw = typeof event?.data === "string" ? event.data : "";
|
|
602
|
+
if (raw.length === 0) return;
|
|
603
|
+
let payload;
|
|
604
|
+
try {
|
|
605
|
+
payload = JSON.parse(raw);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
608
|
+
// Surface malformed payloads so operators can spot serialization bugs.
|
|
609
|
+
// Avoid logging the raw text (may include sensitive content) — just length + error.
|
|
610
|
+
console.warn(`subscribeSnapshotStream: malformed snapshotDelta JSON (${raw.length} bytes): ${message}`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const delta = payload?.snapshotDelta;
|
|
614
|
+
if (!delta || typeof delta !== "object") return;
|
|
615
|
+
model.applySnapshotDelta(delta);
|
|
616
|
+
if (typeof rerender === "function") rerender();
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const handleSnapshot = (event) => {
|
|
620
|
+
if (disposed) return;
|
|
621
|
+
const raw = typeof event?.data === "string" ? event.data : "";
|
|
622
|
+
if (raw.length === 0) return;
|
|
623
|
+
let payload;
|
|
624
|
+
try {
|
|
625
|
+
payload = JSON.parse(raw);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
628
|
+
console.warn(`subscribeSnapshotStream: malformed snapshot JSON (${raw.length} bytes): ${message}`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const snapshot = payload?.snapshot;
|
|
632
|
+
if (!snapshot || typeof snapshot !== "object") return;
|
|
633
|
+
if (typeof model.replaceSnapshot === "function") {
|
|
634
|
+
model.replaceSnapshot(snapshot);
|
|
635
|
+
if (typeof rerender === "function") rerender();
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const handleError = () => {
|
|
640
|
+
if (disposed) return;
|
|
641
|
+
// Surface the disconnect to the user so they don't silently miss live
|
|
642
|
+
// updates. EventSource will keep auto-reconnecting; once a snapshot/
|
|
643
|
+
// snapshotDelta event lands again, the regular notice clearing flow
|
|
644
|
+
// (e.g. on the next mutation) replaces this notice.
|
|
645
|
+
if (model.store && typeof model.store === "object") {
|
|
646
|
+
const existing = model.store.notice;
|
|
647
|
+
if (!existing || existing.code !== "live_updates_disconnected") {
|
|
648
|
+
model.store.notice = {
|
|
649
|
+
type: "warning",
|
|
650
|
+
code: "live_updates_disconnected",
|
|
651
|
+
title: "Live updates disconnected",
|
|
652
|
+
message: "Reconnecting to the server. Changes from other sessions may be delayed.",
|
|
653
|
+
};
|
|
654
|
+
if (typeof rerender === "function") rerender();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
eventSource.addEventListener("snapshotDelta", handleSnapshotDelta);
|
|
660
|
+
eventSource.addEventListener("snapshot", handleSnapshot);
|
|
661
|
+
// EventSource calls .onerror on disconnect (and will continue auto-reconnecting).
|
|
662
|
+
// Use the onerror property rather than addEventListener("error") so tests can
|
|
663
|
+
// trigger it via `instance.onerror?.()` without a full event object.
|
|
664
|
+
eventSource.onerror = handleError;
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
eventSource,
|
|
668
|
+
dispose() {
|
|
669
|
+
if (disposed) return;
|
|
670
|
+
disposed = true;
|
|
671
|
+
try {
|
|
672
|
+
eventSource.close();
|
|
673
|
+
} catch {
|
|
674
|
+
// best-effort
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|