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.
@@ -4,7 +4,16 @@ import { safeErrorMessage } from "../commands/error-utils";
4
4
  import { MutationService } from "../domain/mutation-service";
5
5
  import { TrackerDomain } from "../domain/tracker-domain";
6
6
  import { DomainError } from "../domain/types";
7
- import { buildBoardSnapshot } from "./snapshot";
7
+ import { buildBoardSnapshot, buildBoardSnapshotDelta } from "./snapshot";
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
+ }
8
17
 
9
18
  interface BoardRouteContext {
10
19
  readonly db: Database;
@@ -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,
@@ -29,6 +63,25 @@ function jsonResponse(status: number, data: unknown): Response {
29
63
  });
30
64
  }
31
65
 
66
+ function readCookieToken(request: Request): string | null {
67
+ const rawCookie = request.headers.get("cookie");
68
+ if (!rawCookie) {
69
+ return null;
70
+ }
71
+
72
+ for (const part of rawCookie.split(";")) {
73
+ const [name, ...valueParts] = part.split("=");
74
+ if (name?.trim() !== "trekoon_board_session") {
75
+ continue;
76
+ }
77
+
78
+ const value = valueParts.join("=").trim();
79
+ return value.length > 0 ? decodeURIComponent(value) : null;
80
+ }
81
+
82
+ return null;
83
+ }
84
+
32
85
  function extractToken(request: Request, url: URL): string | null {
33
86
  const authorization: string | null = request.headers.get("authorization");
34
87
  if (authorization?.startsWith("Bearer ")) {
@@ -45,7 +98,7 @@ function extractToken(request: Request, url: URL): string | null {
45
98
  return queryToken.trim();
46
99
  }
47
100
 
48
- return null;
101
+ return readCookieToken(request);
49
102
  }
50
103
 
51
104
  function isSqliteBusyMessage(message: string): boolean {
@@ -106,16 +159,68 @@ function describeBoardError(mutations: MutationService, error: unknown, requestL
106
159
  };
107
160
  }
108
161
 
109
- function buildMutationResponse(domain: TrackerDomain, data: Record<string, unknown>, status = 200): Response {
162
+ function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unknown>, status = 200): Response {
110
163
  return jsonResponse(status, {
111
164
  ok: true,
112
- data: {
113
- ...data,
114
- snapshot: buildBoardSnapshot(domain),
115
- },
165
+ data,
116
166
  });
117
167
  }
118
168
 
169
+ const buildSnapshotDelta = buildBoardSnapshotDelta;
170
+
171
+ function buildMutationDeltaResponse(
172
+ domain: TrackerDomain,
173
+ data: Record<string, unknown>,
174
+ selection: SnapshotDeltaSelection,
175
+ status = 200,
176
+ ): Response {
177
+ return buildMutationResponse(domain, {
178
+ ...data,
179
+ snapshotDelta: buildSnapshotDelta(domain, selection),
180
+ }, status);
181
+ }
182
+
183
+ function readRecordId(value: unknown): string | null {
184
+ if (!value || typeof value !== "object") {
185
+ return null;
186
+ }
187
+
188
+ const id = (value as { id?: unknown }).id;
189
+ return typeof id === "string" && id.length > 0 ? id : null;
190
+ }
191
+
192
+ function withFreshReplaySnapshotDelta(
193
+ domain: TrackerDomain,
194
+ responseData: Record<string, unknown>,
195
+ selection: SnapshotDeltaSelection,
196
+ ): Record<string, unknown> {
197
+ return {
198
+ ...responseData,
199
+ snapshotDelta: buildSnapshotDelta(domain, selection),
200
+ };
201
+ }
202
+
203
+ function readSnapshotDelta(responseData: Record<string, unknown>): Record<string, unknown> | null {
204
+ const snapshotDelta = responseData.snapshotDelta;
205
+ return snapshotDelta && typeof snapshotDelta === "object" ? snapshotDelta as Record<string, unknown> : null;
206
+ }
207
+
208
+ function readRecordIds(value: unknown): string[] {
209
+ if (!Array.isArray(value)) {
210
+ return [];
211
+ }
212
+
213
+ return value.map((item) => readRecordId(item)).filter((id): id is string => id !== null);
214
+ }
215
+
216
+ function readStringArray(value: unknown): string[] {
217
+ if (!Array.isArray(value)) {
218
+ return [];
219
+ }
220
+
221
+ return value.filter((item): item is string => typeof item === "string" && item.length > 0);
222
+ }
223
+
119
224
  async function parseJsonBody(request: Request): Promise<Record<string, unknown>> {
120
225
  const contentType: string = request.headers.get("content-type") ?? "";
121
226
  if (!contentType.includes("application/json")) {
@@ -175,6 +280,41 @@ function readRequiredString(body: Record<string, unknown>, field: string): strin
175
280
  return value;
176
281
  }
177
282
 
283
+ function readOptionalNullableString(body: Record<string, unknown>, field: string): string | null | undefined {
284
+ const value = body[field];
285
+ if (value === undefined) {
286
+ return undefined;
287
+ }
288
+
289
+ if (value === null) {
290
+ return null;
291
+ }
292
+
293
+ if (typeof value !== "string") {
294
+ throw new DomainError({
295
+ code: "invalid_input",
296
+ message: `${field} must be a string or null`,
297
+ details: { field },
298
+ });
299
+ }
300
+
301
+ return value;
302
+ }
303
+
304
+ function readIdempotencyKey(request: Request, body: Record<string, unknown>): string | null {
305
+ const headerKey = request.headers.get("x-trekoon-idempotency-key");
306
+ if (typeof headerKey === "string" && headerKey.trim().length > 0) {
307
+ return headerKey.trim();
308
+ }
309
+
310
+ const bodyKey = body.clientRequestId;
311
+ if (typeof bodyKey === "string" && bodyKey.trim().length > 0) {
312
+ return bodyKey.trim();
313
+ }
314
+
315
+ return null;
316
+ }
317
+
178
318
  export function createBoardApiHandler(context: BoardRouteContext): (request: Request) => Promise<Response> {
179
319
  return async (request: Request): Promise<Response> => {
180
320
  const url = new URL(request.url);
@@ -203,89 +343,292 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
203
343
  });
204
344
  }
205
345
 
206
- const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
207
- if (epicCascadeMatch) {
208
- const body = await parseJsonBody(request);
209
- const status = readRequiredString(body, "status");
210
- const plan = mutations.updateEpicStatusCascade(epicCascadeMatch[1] ?? "", status);
211
- return buildMutationResponse(domain, { plan });
212
- }
346
+ const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
347
+ if (epicCascadeMatch) {
348
+ const body = await parseJsonBody(request);
349
+ const status = readRequiredString(body, "status");
350
+ const plan = mutations.updateEpicStatusCascade(epicCascadeMatch[1] ?? "", status);
351
+ return buildMutationDeltaResponse(domain, {
352
+ plan,
353
+ }, {
354
+ epicIds: [epicCascadeMatch[1] ?? ""],
355
+ taskIds: plan.orderedChanges.filter((change) => change.kind === "task").map((change) => change.id),
356
+ subtaskIds: plan.orderedChanges.filter((change) => change.kind === "subtask").map((change) => change.id),
357
+ });
358
+ }
213
359
 
214
360
  const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
215
- if (epicMatch) {
361
+ if (epicMatch) {
216
362
  const body = await parseJsonBody(request);
217
363
  const epic = mutations.updateEpic(epicMatch[1] ?? "", {
218
364
  title: readOptionalString(body, "title"),
219
365
  description: readOptionalString(body, "description"),
220
366
  status: readOptionalString(body, "status"),
221
367
  });
222
- return buildMutationResponse(domain, { epic });
223
- }
368
+ return buildMutationDeltaResponse(domain, { epic }, { epicIds: [epic.id] });
369
+ }
224
370
 
225
371
  const taskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/tasks\/([^/]+)$/u) : null;
226
372
  if (taskMatch) {
227
373
  const body = await parseJsonBody(request);
228
- const task = mutations.updateTask(taskMatch[1] ?? "", {
229
- title: readOptionalString(body, "title"),
230
- description: readOptionalString(body, "description"),
231
- status: readOptionalString(body, "status"),
232
- owner: readOptionalString(body, "owner"),
233
- });
234
- return buildMutationResponse(domain, { task });
235
- }
374
+ const task = mutations.updateTask(taskMatch[1] ?? "", {
375
+ title: readOptionalString(body, "title"),
376
+ description: readOptionalString(body, "description"),
377
+ status: readOptionalString(body, "status"),
378
+ owner: readOptionalNullableString(body, "owner"),
379
+ });
380
+ return buildMutationDeltaResponse(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
381
+ }
236
382
 
237
383
  const subtaskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
238
384
  if (subtaskMatch) {
239
385
  const body = await parseJsonBody(request);
240
- const subtask = mutations.updateSubtask(subtaskMatch[1] ?? "", {
241
- title: readOptionalString(body, "title"),
242
- description: readOptionalString(body, "description"),
243
- status: readOptionalString(body, "status"),
244
- owner: readOptionalString(body, "owner"),
245
- });
246
- return buildMutationResponse(domain, { subtask });
247
- }
386
+ const subtask = mutations.updateSubtask(subtaskMatch[1] ?? "", {
387
+ title: readOptionalString(body, "title"),
388
+ description: readOptionalString(body, "description"),
389
+ status: readOptionalString(body, "status"),
390
+ owner: readOptionalNullableString(body, "owner"),
391
+ });
392
+ const task = domain.getTaskOrThrow(subtask.taskId);
393
+ return buildMutationDeltaResponse(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
394
+ }
248
395
 
249
396
  if (request.method === "POST" && url.pathname === "/api/subtasks") {
250
397
  const body = await parseJsonBody(request);
251
- const subtask = mutations.createSubtask({
398
+ const idempotencyKey = readIdempotencyKey(request, body);
399
+ const requestFingerprint = buildCreateSubtaskFingerprint(body);
400
+ if (!idempotencyKey) {
401
+ const subtask = mutations.createSubtask({
402
+ taskId: readRequiredString(body, "taskId"),
403
+ title: readRequiredString(body, "title"),
404
+ description: readOptionalString(body, "description"),
405
+ status: readOptionalString(body, "status"),
406
+ });
407
+ const task = domain.getTaskOrThrow(subtask.taskId);
408
+ const responseData = {
409
+ subtask,
410
+ snapshotDelta: buildSnapshotDelta(domain, {
411
+ epicIds: [task.epicId],
412
+ taskIds: [task.id],
413
+ subtaskIds: [subtask.id],
414
+ }),
415
+ };
416
+ return buildMutationResponse(domain, responseData, 201);
417
+ }
418
+
419
+ const result = mutations.createSubtaskAtomicallyWithIdempotency({
252
420
  taskId: readRequiredString(body, "taskId"),
253
421
  title: readRequiredString(body, "title"),
254
422
  description: readOptionalString(body, "description"),
255
423
  status: readOptionalString(body, "status"),
424
+ claim: {
425
+ scope: "subtask",
426
+ idempotencyKey,
427
+ requestFingerprint,
428
+ conflictMessage: "Idempotency key cannot be reused for a different subtask request",
429
+ },
430
+ buildResponseData: ({ subtask, domain: transactionDomain }) => {
431
+ const task = transactionDomain.getTaskOrThrow(subtask.taskId);
432
+ return {
433
+ subtask,
434
+ snapshotDelta: buildSnapshotDelta(transactionDomain, {
435
+ epicIds: [task.epicId],
436
+ taskIds: [task.id],
437
+ subtaskIds: [subtask.id],
438
+ }),
439
+ };
440
+ },
256
441
  });
257
- return buildMutationResponse(domain, { subtask }, 201);
442
+ const replaySubtaskId = readRecordId(result.responseData.subtask);
443
+ const replayTaskId = replaySubtaskId ? domain.getSubtask(replaySubtaskId)?.taskId ?? null : null;
444
+ const replayEpicId = replayTaskId ? domain.getTask(replayTaskId)?.epicId ?? null : null;
445
+ return buildMutationResponse(domain, result.state === "replay"
446
+ ? withFreshReplaySnapshotDelta(domain, result.responseData, {
447
+ epicIds: replayEpicId ? [replayEpicId] : [],
448
+ taskIds: replayTaskId ? [replayTaskId] : [],
449
+ subtaskIds: replaySubtaskId ? [replaySubtaskId] : [],
450
+ })
451
+ : result.responseData, result.status);
258
452
  }
259
453
 
260
- const deleteSubtaskMatch = request.method === "DELETE" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
261
- if (deleteSubtaskMatch) {
262
- const subtaskId = deleteSubtaskMatch[1] ?? "";
263
- mutations.deleteSubtask(subtaskId);
264
- return buildMutationResponse(domain, { subtaskId, deleted: true });
265
- }
454
+ const deleteSubtaskMatch = request.method === "DELETE" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
455
+ if (deleteSubtaskMatch) {
456
+ const subtaskId = deleteSubtaskMatch[1] ?? "";
457
+ const idempotencyKey = request.headers.get("x-trekoon-idempotency-key")?.trim() || null;
458
+ const requestFingerprint = buildDeleteSubtaskFingerprint(subtaskId);
459
+ if (!idempotencyKey) {
460
+ const existingSubtask = domain.getSubtaskOrThrow(subtaskId);
461
+ const task = domain.getTaskOrThrow(existingSubtask.taskId);
462
+ const { deletedDependencyIds } = mutations.deleteSubtask(subtaskId);
463
+ const responseData = {
464
+ subtaskId,
465
+ deleted: true,
466
+ snapshotDelta: buildSnapshotDelta(domain, {
467
+ epicIds: [task.epicId],
468
+ taskIds: [task.id],
469
+ deletedSubtaskIds: [subtaskId],
470
+ deletedDependencyIds,
471
+ }),
472
+ };
473
+ return buildMutationResponse(domain, responseData, 200);
474
+ }
475
+
476
+ const result = mutations.deleteSubtaskAtomicallyWithIdempotency({
477
+ id: subtaskId,
478
+ claim: {
479
+ scope: "deleted_subtask",
480
+ idempotencyKey,
481
+ requestFingerprint,
482
+ conflictMessage: "Idempotency key cannot be reused for a different subtask delete request",
483
+ },
484
+ buildResponseData: ({ subtaskId: deletedSubtaskId, deletedDependencyIds, domain: transactionDomain, taskId, epicId }) => ({
485
+ subtaskId: deletedSubtaskId,
486
+ deleted: true,
487
+ snapshotDelta: buildSnapshotDelta(transactionDomain, {
488
+ epicIds: [epicId],
489
+ taskIds: [taskId],
490
+ deletedSubtaskIds: [deletedSubtaskId],
491
+ deletedDependencyIds,
492
+ }),
493
+ }),
494
+ });
495
+ const replaySnapshotDelta = readSnapshotDelta(result.responseData);
496
+ return buildMutationResponse(domain, result.state === "replay"
497
+ ? withFreshReplaySnapshotDelta(domain, result.responseData, {
498
+ epicIds: readRecordIds(replaySnapshotDelta?.epics),
499
+ taskIds: readRecordIds(replaySnapshotDelta?.tasks),
500
+ deletedSubtaskIds: readStringArray(replaySnapshotDelta?.deletedSubtaskIds),
501
+ deletedDependencyIds: readStringArray(replaySnapshotDelta?.deletedDependencyIds),
502
+ })
503
+ : result.responseData, result.status);
504
+ }
266
505
 
267
506
  if (request.method === "POST" && url.pathname === "/api/dependencies") {
268
507
  const body = await parseJsonBody(request);
269
- const dependency = mutations.addDependency(readRequiredString(body, "sourceId"), readRequiredString(body, "dependsOnId"));
270
- return buildMutationResponse(domain, { dependency }, 201);
508
+ const sourceId = readRequiredString(body, "sourceId");
509
+ const dependsOnId = readRequiredString(body, "dependsOnId");
510
+ const idempotencyKey = readIdempotencyKey(request, body);
511
+ const requestFingerprint = buildCreateDependencyFingerprint(sourceId, dependsOnId);
512
+ if (!idempotencyKey) {
513
+ const dependency = mutations.addDependency(sourceId, dependsOnId);
514
+ const responseData = {
515
+ dependency,
516
+ snapshotDelta: buildSnapshotDelta(domain, {
517
+ taskIds: compactIds([dependency.sourceKind === "task" ? dependency.sourceId : "", dependency.dependsOnKind === "task" ? dependency.dependsOnId : ""]),
518
+ subtaskIds: compactIds([dependency.sourceKind === "subtask" ? dependency.sourceId : "", dependency.dependsOnKind === "subtask" ? dependency.dependsOnId : ""]),
519
+ dependencyIds: [dependency.id],
520
+ }),
521
+ };
522
+ return buildMutationResponse(domain, responseData, 201);
523
+ }
524
+
525
+ const result = mutations.addDependencyAtomicallyWithIdempotency({
526
+ sourceId,
527
+ dependsOnId,
528
+ claim: {
529
+ scope: "dependency",
530
+ idempotencyKey,
531
+ requestFingerprint,
532
+ conflictMessage: "Idempotency key cannot be reused for a different dependency request",
533
+ },
534
+ buildResponseData: ({ dependency, domain: transactionDomain }) => ({
535
+ dependency,
536
+ snapshotDelta: buildSnapshotDelta(transactionDomain, {
537
+ taskIds: compactIds([dependency.sourceKind === "task" ? dependency.sourceId : "", dependency.dependsOnKind === "task" ? dependency.dependsOnId : ""]),
538
+ subtaskIds: compactIds([dependency.sourceKind === "subtask" ? dependency.sourceId : "", dependency.dependsOnKind === "subtask" ? dependency.dependsOnId : ""]),
539
+ dependencyIds: [dependency.id],
540
+ }),
541
+ }),
542
+ });
543
+ const replayDependency = result.responseData.dependency;
544
+ const replayDependencyId = readRecordId(replayDependency);
545
+ const replaySelection = replayDependency && typeof replayDependency === "object"
546
+ ? {
547
+ taskIds: compactIds([
548
+ (replayDependency as { sourceKind?: unknown; sourceId?: unknown }).sourceKind === "task"
549
+ ? ((replayDependency as { sourceId?: unknown }).sourceId as string ?? "")
550
+ : "",
551
+ (replayDependency as { dependsOnKind?: unknown; dependsOnId?: unknown }).dependsOnKind === "task"
552
+ ? ((replayDependency as { dependsOnId?: unknown }).dependsOnId as string ?? "")
553
+ : "",
554
+ ]),
555
+ subtaskIds: compactIds([
556
+ (replayDependency as { sourceKind?: unknown; sourceId?: unknown }).sourceKind === "subtask"
557
+ ? ((replayDependency as { sourceId?: unknown }).sourceId as string ?? "")
558
+ : "",
559
+ (replayDependency as { dependsOnKind?: unknown; dependsOnId?: unknown }).dependsOnKind === "subtask"
560
+ ? ((replayDependency as { dependsOnId?: unknown }).dependsOnId as string ?? "")
561
+ : "",
562
+ ]),
563
+ dependencyIds: replayDependencyId ? [replayDependencyId] : [],
564
+ }
565
+ : { dependencyIds: [] };
566
+ return buildMutationResponse(domain, result.state === "replay"
567
+ ? withFreshReplaySnapshotDelta(domain, result.responseData, replaySelection)
568
+ : result.responseData, result.status);
271
569
  }
272
570
 
273
571
  if (request.method === "DELETE" && url.pathname === "/api/dependencies") {
274
572
  const sourceId = url.searchParams.get("sourceId") ?? "";
275
573
  const dependsOnId = url.searchParams.get("dependsOnId") ?? "";
276
- const removed = mutations.removeDependency(sourceId, dependsOnId);
277
- if (removed === 0) {
278
- throw new DomainError({
279
- code: "not_found",
280
- message: "Dependency edge not found",
281
- details: {
282
- sourceId,
283
- dependsOnId,
284
- },
285
- });
574
+ const idempotencyKey = request.headers.get("x-trekoon-idempotency-key")?.trim() || null;
575
+ const requestFingerprint = buildDeleteDependencyFingerprint(sourceId, dependsOnId);
576
+ if (!idempotencyKey) {
577
+ const existingDependencyIds = domain.listDependencies(sourceId)
578
+ .filter((dependency) => dependency.dependsOnId === dependsOnId)
579
+ .map((dependency) => dependency.id);
580
+ const removed = mutations.removeDependency(sourceId, dependsOnId);
581
+ if (removed === 0) {
582
+ throw new DomainError({
583
+ code: "not_found",
584
+ message: "Dependency edge not found",
585
+ details: {
586
+ sourceId,
587
+ dependsOnId,
588
+ },
589
+ });
590
+ }
591
+ const responseData = {
592
+ sourceId,
593
+ dependsOnId,
594
+ removed,
595
+ snapshotDelta: buildSnapshotDelta(domain, {
596
+ taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
597
+ subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
598
+ deletedDependencyIds: existingDependencyIds,
599
+ }),
600
+ };
601
+ return buildMutationResponse(domain, responseData, 200);
286
602
  }
287
603
 
288
- return buildMutationResponse(domain, { sourceId, dependsOnId, removed });
604
+ const result = mutations.removeDependencyAtomicallyWithIdempotency({
605
+ sourceId,
606
+ dependsOnId,
607
+ claim: {
608
+ scope: "deleted_dependency",
609
+ idempotencyKey,
610
+ requestFingerprint,
611
+ conflictMessage: "Idempotency key cannot be reused for a different dependency delete request",
612
+ },
613
+ buildResponseData: ({ sourceId: deletedSourceId, dependsOnId: deletedDependsOnId, removed, existingDependencyIds, domain: transactionDomain }) => ({
614
+ sourceId: deletedSourceId,
615
+ dependsOnId: deletedDependsOnId,
616
+ removed,
617
+ snapshotDelta: buildSnapshotDelta(transactionDomain, {
618
+ taskIds: compactIds([transactionDomain.getTask(deletedSourceId)?.id ?? "", transactionDomain.getTask(deletedDependsOnId)?.id ?? ""]),
619
+ subtaskIds: compactIds([transactionDomain.getSubtask(deletedSourceId)?.id ?? "", transactionDomain.getSubtask(deletedDependsOnId)?.id ?? ""]),
620
+ deletedDependencyIds: existingDependencyIds,
621
+ }),
622
+ }),
623
+ });
624
+ const replaySnapshotDelta = readSnapshotDelta(result.responseData);
625
+ return buildMutationResponse(domain, result.state === "replay"
626
+ ? withFreshReplaySnapshotDelta(domain, result.responseData, {
627
+ taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
628
+ subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
629
+ deletedDependencyIds: readStringArray(replaySnapshotDelta?.deletedDependencyIds),
630
+ })
631
+ : result.responseData, result.status);
289
632
  }
290
633
 
291
634
  return jsonResponse(404, {
@@ -3,9 +3,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, extname, resolve } from "node:path";
4
4
 
5
5
  import { createBoardApiHandler } from "./routes";
6
+ import { buildBoardSnapshot } from "./snapshot";
6
7
 
7
8
  import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
8
9
  import { resolveStoragePaths } from "../storage/path";
10
+ import { TrackerDomain } from "../domain/tracker-domain";
9
11
 
10
12
  const CONTENT_TYPES: Record<string, string> = {
11
13
  ".css": "text/css; charset=utf-8",
@@ -87,6 +89,37 @@ function isUnavailablePortError(error: unknown): boolean {
87
89
  return /^(EADDRINUSE|EACCES)$/i.test(errorCode) || /(EADDRINUSE|EACCES|address already in use|permission denied)/i.test(error.message);
88
90
  }
89
91
 
92
+ function buildBoardSessionCookie(token: string): string {
93
+ return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
94
+ }
95
+
96
+ function serializeInlineJson(value: unknown): string {
97
+ return JSON.stringify(value)
98
+ .replace(/</g, "\\u003c")
99
+ .replace(/>/g, "\\u003e")
100
+ .replace(/&/g, "\\u0026")
101
+ .replace(/\u2028/g, "\\u2028")
102
+ .replace(/\u2029/g, "\\u2029");
103
+ }
104
+
105
+ function buildBoardBootstrapPayload(database: TrekoonDatabase, token: string): string {
106
+ const domain = new TrackerDomain(database.db);
107
+ return serializeInlineJson({
108
+ token,
109
+ snapshot: buildBoardSnapshot(domain),
110
+ });
111
+ }
112
+
113
+ function injectBoardBootstrap(html: string, bootstrapJson: string): string {
114
+ const bootstrapTag = `<script id="trekoon-board-bootstrap" type="application/json">${bootstrapJson}</script>`;
115
+ const closingBodyIndex = html.lastIndexOf("</body>");
116
+ if (closingBodyIndex === -1) {
117
+ return `${html}${bootstrapTag}`;
118
+ }
119
+
120
+ return `${html.slice(0, closingBodyIndex)}${bootstrapTag}\n${html.slice(closingBodyIndex)}`;
121
+ }
122
+
90
123
  export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
91
124
  const cwd: string = options.cwd ?? process.cwd();
92
125
  const database: TrekoonDatabase = openTrekoonDatabase(cwd);
@@ -110,6 +143,13 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
110
143
  return apiHandler(request);
111
144
  }
112
145
 
146
+ const responseHeaders: Record<string, string> = {
147
+ "cache-control": "no-store",
148
+ };
149
+ if ((url.searchParams.get("token") ?? "") === token) {
150
+ responseHeaders["set-cookie"] = buildBoardSessionCookie(token);
151
+ }
152
+
113
153
  const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
114
154
  if (assetPath === null) {
115
155
  const fallbackPath = readAssetPath(boardRoot, "/index.html");
@@ -117,9 +157,21 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
117
157
  return new Response("Board assets are not installed", { status: 500 });
118
158
  }
119
159
 
120
- return new Response(readFileSync(fallbackPath), {
160
+ const html = injectBoardBootstrap(readFileSync(fallbackPath, "utf8"), buildBoardBootstrapPayload(database, token));
161
+
162
+ return new Response(html, {
163
+ headers: {
164
+ ...responseHeaders,
165
+ "content-type": "text/html; charset=utf-8",
166
+ },
167
+ });
168
+ }
169
+
170
+ if (assetPath.endsWith("/index.html")) {
171
+ const html = injectBoardBootstrap(readFileSync(assetPath, "utf8"), buildBoardBootstrapPayload(database, token));
172
+ return new Response(html, {
121
173
  headers: {
122
- "cache-control": "no-store",
174
+ ...responseHeaders,
123
175
  "content-type": "text/html; charset=utf-8",
124
176
  },
125
177
  });
@@ -127,7 +179,7 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
127
179
 
128
180
  return new Response(readFileSync(assetPath), {
129
181
  headers: {
130
- "cache-control": "no-store",
182
+ ...responseHeaders,
131
183
  "content-type": guessContentType(assetPath),
132
184
  },
133
185
  });
@@ -169,11 +221,12 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
169
221
 
170
222
  const origin: string = `http://127.0.0.1:${port}`;
171
223
  const url: string = `${origin}/?token=${encodeURIComponent(token)}`;
224
+ const fallbackUrl: string = origin;
172
225
 
173
226
  return {
174
227
  origin,
175
228
  url,
176
- fallbackUrl: url,
229
+ fallbackUrl,
177
230
  token,
178
231
  hostname: "127.0.0.1",
179
232
  port,