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.
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { type Database } from "bun:sqlite";
4
4
 
5
5
  import { openTrekoonDatabase, writeTransaction } from "../storage/database";
6
- import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
6
+ import { countBranchEventsSince, queryBranchEventsSinceBatch } from "./branch-db";
7
7
  import { nextEventTimestamp } from "./event-writes";
8
8
  import { persistGitContext, resolveGitContext } from "./git-context";
9
9
  import { DomainError } from "../domain/types";
@@ -24,11 +24,23 @@ import {
24
24
 
25
25
  const SYNC_ALLOWED_FIELDS: Readonly<Record<string, readonly string[]>> = {
26
26
  epics: ["title", "description", "status"],
27
- tasks: ["epic_id", "title", "description", "status"],
28
- subtasks: ["task_id", "title", "description", "status"],
27
+ tasks: ["epic_id", "title", "description", "status", "owner"],
28
+ subtasks: ["task_id", "title", "description", "status", "owner"],
29
29
  dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
30
30
  };
31
31
 
32
+ function isSyncNullableStringField(tableName: string, fieldName: string): boolean {
33
+ return (tableName === "tasks" || tableName === "subtasks") && fieldName === "owner";
34
+ }
35
+
36
+ function isSyncFieldValueSupported(tableName: string, fieldName: string, value: unknown): boolean {
37
+ if (typeof value === "string") {
38
+ return true;
39
+ }
40
+
41
+ return value === null && isSyncNullableStringField(tableName, fieldName);
42
+ }
43
+
32
44
  function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
33
45
  if (cursorToken === "0:") {
34
46
  return false;
@@ -106,6 +118,14 @@ interface ConflictRow {
106
118
  readonly updated_at: number;
107
119
  }
108
120
 
121
+ interface ResolutionEventPayload {
122
+ readonly conflict_id?: string;
123
+ readonly source_event_id?: string;
124
+ readonly field: string;
125
+ readonly resolution: string;
126
+ readonly value?: string | null;
127
+ }
128
+
109
129
  interface ResolutionWriteContext {
110
130
  readonly branchName: string | null;
111
131
  readonly headSha: string | null;
@@ -120,6 +140,27 @@ interface EventPayload {
120
140
  readonly fields: Record<string, unknown>;
121
141
  }
122
142
 
143
+ interface DeleteCascadeResolutionRow {
144
+ readonly id: string;
145
+ readonly source_id: string;
146
+ readonly depends_on_id: string;
147
+ }
148
+
149
+ interface ConflictOrderRow {
150
+ readonly id: string;
151
+ }
152
+
153
+ interface LocalEntityEventRow {
154
+ readonly payload: string;
155
+ readonly created_at: number;
156
+ readonly id: string;
157
+ }
158
+
159
+ const SYNC_PULL_BATCH_SIZE = 250;
160
+ const CONFLICT_HISTORY_SCAN_BATCH_SIZE = 250;
161
+ const RESOLVE_ALL_CHUNK_SIZE = 200;
162
+ const DELETE_CONFLICT_DEPENDENCY_SCAN_CHUNK_SIZE = 400;
163
+
123
164
  interface PayloadValidation {
124
165
  readonly ok: boolean;
125
166
  readonly fields: Record<string, unknown>;
@@ -130,6 +171,15 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
130
171
  return typeof value === "object" && value !== null && !Array.isArray(value);
131
172
  }
132
173
 
174
+ function parseJsonObject(rawPayload: string): Record<string, unknown> | null {
175
+ try {
176
+ const parsed: unknown = JSON.parse(rawPayload);
177
+ return isObjectRecord(parsed) ? parsed : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
133
183
  function parsePayload(rawPayload: string): PayloadValidation {
134
184
  try {
135
185
  const parsed: unknown = JSON.parse(rawPayload);
@@ -331,7 +381,7 @@ function currentEntityFieldValue(db: Database, entityKind: string, entityId: str
331
381
  }
332
382
 
333
383
  const row = db.query(`SELECT ${fieldName} AS value FROM ${tableName} WHERE id = ? LIMIT 1;`).get(entityId) as
334
- | { value: string }
384
+ | { value: string | null }
335
385
  | null;
336
386
 
337
387
  return row?.value;
@@ -349,46 +399,68 @@ function entityFieldConflict(
349
399
  return null;
350
400
  }
351
401
 
352
- // Note: loads all matching events into memory. For entities with very large
353
- // event histories, consider a cursor-based scan. The idx_events_entity index
354
- // keeps the scan narrow by (entity_kind, entity_id).
355
- const rows = localDb
402
+ let beforeCreatedAt = Number.MAX_SAFE_INTEGER;
403
+ let beforeId = "\uffff";
404
+
405
+ while (true) {
406
+ const rows = localDb
356
407
  .query(
357
408
  `
358
- SELECT payload, git_branch
409
+ SELECT payload, created_at, id
359
410
  FROM events
360
- WHERE entity_kind = ? AND entity_id = ? AND git_branch != ?
411
+ WHERE entity_kind = ?
412
+ AND entity_id = ?
413
+ AND (git_branch IS NULL OR git_branch != ?)
414
+ AND (
415
+ created_at < ?
416
+ OR (created_at = ? AND id < ?)
417
+ )
361
418
  ORDER BY created_at DESC, id DESC
362
- LIMIT 500;
419
+ LIMIT ?;
363
420
  `,
364
421
  )
365
- .all(event.entity_kind, event.entity_id, sourceBranch) as Array<{ payload: string; git_branch: string | null }>;
422
+ .all(
423
+ event.entity_kind,
424
+ event.entity_id,
425
+ sourceBranch,
426
+ beforeCreatedAt,
427
+ beforeCreatedAt,
428
+ beforeId,
429
+ CONFLICT_HISTORY_SCAN_BATCH_SIZE,
430
+ ) as LocalEntityEventRow[];
366
431
 
367
- for (const row of rows) {
368
- const payloadValidation = parsePayload(row.payload);
369
- if (!payloadValidation.ok) {
370
- continue;
432
+ if (rows.length === 0) {
433
+ return null;
371
434
  }
372
435
 
373
- const payload: EventPayload = { fields: payloadValidation.fields };
374
- const localValue: unknown = readFieldValue(payload, fieldName);
436
+ for (const row of rows) {
437
+ const payloadValidation = parsePayload(row.payload);
438
+ if (!payloadValidation.ok) {
439
+ continue;
440
+ }
375
441
 
376
- if (typeof localValue === "undefined") {
377
- continue;
378
- }
442
+ const payload: EventPayload = { fields: payloadValidation.fields };
443
+ const localValue: unknown = readFieldValue(payload, fieldName);
379
444
 
380
- const oursValue = serializeValue(localValue);
381
- const theirsValue = serializeValue(incomingValue);
445
+ if (typeof localValue === "undefined") {
446
+ continue;
447
+ }
382
448
 
383
- if (oursValue !== theirsValue) {
384
- return {
385
- oursValue,
386
- theirsValue,
387
- };
449
+ const oursValue = serializeValue(localValue);
450
+ const theirsValue = serializeValue(incomingValue);
451
+
452
+ if (oursValue !== theirsValue) {
453
+ return {
454
+ oursValue,
455
+ theirsValue,
456
+ };
457
+ }
388
458
  }
389
- }
390
459
 
391
- return null;
460
+ const lastRow = rows.at(-1)!;
461
+ beforeCreatedAt = lastRow.created_at;
462
+ beforeId = lastRow.id;
463
+ }
392
464
  }
393
465
 
394
466
  function createConflict(
@@ -400,6 +472,45 @@ function createConflict(
400
472
  resolution: string = "pending",
401
473
  ): void {
402
474
  const now: number = Date.now();
475
+ const existing = db
476
+ .query(
477
+ `
478
+ SELECT id, resolution, ours_value, theirs_value
479
+ FROM sync_conflicts
480
+ WHERE event_id = ? AND entity_kind = ? AND entity_id = ? AND field_name = ?
481
+ ORDER BY CASE WHEN resolution = 'pending' THEN 0 ELSE 1 END, created_at ASC, id ASC
482
+ LIMIT 1;
483
+ `,
484
+ )
485
+ .get(event.id, event.entity_kind, event.entity_id, fieldName) as
486
+ | { id: string; resolution: string; ours_value: string | null; theirs_value: string | null }
487
+ | null;
488
+
489
+ if (existing) {
490
+ const nextResolution = existing.resolution === "pending" ? resolution : existing.resolution;
491
+ const unchanged =
492
+ existing.ours_value === oursValue &&
493
+ existing.theirs_value === theirsValue &&
494
+ existing.resolution === nextResolution;
495
+
496
+ if (unchanged) {
497
+ return;
498
+ }
499
+
500
+ db.query(
501
+ `
502
+ UPDATE sync_conflicts
503
+ SET ours_value = ?,
504
+ theirs_value = ?,
505
+ resolution = ?,
506
+ updated_at = ?,
507
+ version = version + 1
508
+ WHERE id = ?;
509
+ `,
510
+ ).run(oursValue, theirsValue, nextResolution, now, existing.id);
511
+ return;
512
+ }
513
+
403
514
  db.query(
404
515
  `
405
516
  INSERT INTO sync_conflicts (
@@ -419,15 +530,227 @@ function createConflict(
419
530
  ).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, resolution, now, now);
420
531
  }
421
532
 
533
+ function findConflictForResolutionEvent(
534
+ db: Database,
535
+ event: StoredEvent,
536
+ payload: ResolutionEventPayload,
537
+ ): ConflictRow | null {
538
+ if (typeof payload.source_event_id === "string" && payload.source_event_id.length > 0) {
539
+ const bySourceEvent = db
540
+ .query(
541
+ `
542
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
543
+ FROM sync_conflicts
544
+ WHERE event_id = ?
545
+ AND entity_kind = ?
546
+ AND entity_id = ?
547
+ AND field_name = ?
548
+ ORDER BY CASE WHEN resolution = 'pending' THEN 0 ELSE 1 END, created_at ASC, id ASC
549
+ LIMIT 1;
550
+ `,
551
+ )
552
+ .get(payload.source_event_id, event.entity_kind, event.entity_id, payload.field) as ConflictRow | null;
553
+
554
+ if (bySourceEvent) {
555
+ return bySourceEvent;
556
+ }
557
+ }
558
+
559
+ if (typeof payload.conflict_id !== "string" || payload.conflict_id.length === 0) {
560
+ return null;
561
+ }
562
+
563
+ return db
564
+ .query(
565
+ `
566
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
567
+ FROM sync_conflicts
568
+ WHERE id = ?
569
+ AND entity_kind = ?
570
+ AND entity_id = ?
571
+ AND field_name = ?
572
+ LIMIT 1;
573
+ `,
574
+ )
575
+ .get(payload.conflict_id, event.entity_kind, event.entity_id, payload.field) as ConflictRow | null;
576
+ }
577
+
578
+ function removeDependenciesTouchingNode(db: Database, nodeId: string): void {
579
+ db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(nodeId, nodeId);
580
+ }
581
+
582
+ function removeTaskSubtree(db: Database, taskId: string): void {
583
+ const subtasks = db
584
+ .query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
585
+ .all(taskId) as Array<{ id: string }>;
586
+
587
+ for (const subtask of subtasks) {
588
+ removeDependenciesTouchingNode(db, subtask.id);
589
+ }
590
+
591
+ db.query("DELETE FROM subtasks WHERE task_id = ?;").run(taskId);
592
+ removeDependenciesTouchingNode(db, taskId);
593
+ }
594
+
595
+ function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow): void {
596
+ const rows = db
597
+ .query(
598
+ `
599
+ SELECT e.id, json_extract(e.payload, '$.fields.source_id') AS source_id, json_extract(e.payload, '$.fields.depends_on_id') AS depends_on_id
600
+ FROM events e
601
+ WHERE e.operation = 'dependency.removed'
602
+ AND json_extract(e.payload, '$.fields.source_event_id') = ?
603
+ ORDER BY e.created_at ASC, e.id ASC;
604
+ `,
605
+ )
606
+ .all(conflict.event_id) as DeleteCascadeResolutionRow[];
607
+
608
+ for (const row of rows) {
609
+ if (typeof row.source_id !== "string" || typeof row.depends_on_id !== "string") {
610
+ continue;
611
+ }
612
+
613
+ db.query("DELETE FROM dependencies WHERE source_id = ? AND depends_on_id = ?;").run(row.source_id, row.depends_on_id);
614
+ }
615
+ }
616
+
617
+ function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): void {
618
+ if (conflict.field_name === "__delete__") {
619
+ if (conflict.entity_kind === "task") {
620
+ removeTaskSubtree(db, conflict.entity_id);
621
+ } else if (conflict.entity_kind === "subtask") {
622
+ removeDependenciesTouchingNode(db, conflict.entity_id);
623
+ }
624
+ applyPendingDeleteCascadeResolution(db, conflict);
625
+ deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id, { allowMissing: true });
626
+ return;
627
+ }
628
+
629
+ updateSingleField(db, conflict.entity_kind, conflict.entity_id, conflict.field_name, parseConflictValue(conflict.theirs_value), {
630
+ allowMissing: true,
631
+ });
632
+ }
633
+
634
+ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean {
635
+ const parsed = parseJsonObject(event.payload);
636
+ if (!parsed) {
637
+ return false;
638
+ }
639
+
640
+ const resolutionPayload = parsed as unknown as ResolutionEventPayload;
641
+ const fieldName = resolutionPayload.field;
642
+ const resolution = resolutionPayload.resolution;
643
+
644
+ if (
645
+ typeof fieldName !== "string" ||
646
+ (resolution !== "ours" && resolution !== "theirs")
647
+ ) {
648
+ return false;
649
+ }
650
+
651
+ const conflict = findConflictForResolutionEvent(db, event, resolutionPayload);
652
+ if (!conflict) {
653
+ return false;
654
+ }
655
+
656
+ if (conflict.resolution === "pending" && resolution === "theirs") {
657
+ applyConflictTheirsResolution(db, conflict);
658
+ }
659
+
660
+ const now = nextEventTimestamp(db);
661
+ const updated = db
662
+ .query(
663
+ `
664
+ UPDATE sync_conflicts
665
+ SET resolution = CASE WHEN resolution = 'pending' THEN @resolution ELSE resolution END,
666
+ updated_at = CASE WHEN resolution = 'pending' THEN @now ELSE updated_at END,
667
+ version = CASE WHEN resolution = 'pending' THEN version + 1 ELSE version END
668
+ WHERE id = @conflictId
669
+ AND entity_kind = @entityKind
670
+ AND entity_id = @entityId
671
+ AND field_name = @fieldName;
672
+ `,
673
+ )
674
+ .run({
675
+ "@resolution": resolution,
676
+ "@now": now,
677
+ "@conflictId": conflict.id,
678
+ "@entityKind": event.entity_kind,
679
+ "@entityId": event.entity_id,
680
+ "@fieldName": fieldName,
681
+ });
682
+
683
+ return updated.changes > 0;
684
+ }
685
+
422
686
  function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, sourceBranch: string): boolean {
423
687
  const row = db
424
688
  .query(
425
- `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch != ? LIMIT 1;`,
689
+ `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND (git_branch IS NULL OR git_branch != ?) LIMIT 1;`,
426
690
  )
427
691
  .get(entityKind, entityId, sourceBranch);
428
692
  return row !== null;
429
693
  }
430
694
 
695
+ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[], sourceBranch: string): boolean {
696
+ if (nodeIds.length === 0) {
697
+ return false;
698
+ }
699
+
700
+ for (let offset = 0; offset < nodeIds.length; offset += DELETE_CONFLICT_DEPENDENCY_SCAN_CHUNK_SIZE) {
701
+ const chunk = nodeIds.slice(offset, offset + DELETE_CONFLICT_DEPENDENCY_SCAN_CHUNK_SIZE);
702
+ const placeholders = chunk.map(() => "?").join(", ");
703
+ const row = db
704
+ .query(
705
+ `
706
+ SELECT 1
707
+ FROM events
708
+ WHERE entity_kind = 'dependency'
709
+ AND (git_branch IS NULL OR git_branch != ?)
710
+ AND (
711
+ json_extract(payload, '$.fields.source_id') IN (${placeholders})
712
+ OR json_extract(payload, '$.fields.depends_on_id') IN (${placeholders})
713
+ )
714
+ LIMIT 1;
715
+ `,
716
+ )
717
+ .get(sourceBranch, ...chunk, ...chunk);
718
+
719
+ if (row !== null) {
720
+ return true;
721
+ }
722
+ }
723
+
724
+ return false;
725
+ }
726
+
727
+ function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBranch: string): boolean {
728
+ if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, sourceBranch)) {
729
+ return true;
730
+ }
731
+
732
+ if (event.entity_kind === "subtask") {
733
+ return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id], sourceBranch);
734
+ }
735
+
736
+ if (event.entity_kind !== "task") {
737
+ return false;
738
+ }
739
+
740
+ const subtaskRows = db
741
+ .query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
742
+ .all(event.entity_id) as Array<{ id: string }>;
743
+ const subtaskIds = subtaskRows.map((row) => row.id);
744
+
745
+ for (const subtaskId of subtaskIds) {
746
+ if (hasLocalEntityEdits(db, "subtask", subtaskId, sourceBranch)) {
747
+ return true;
748
+ }
749
+ }
750
+
751
+ return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds], sourceBranch);
752
+ }
753
+
431
754
  function rowExists(db: Database, tableName: string, id: string): boolean {
432
755
  const row = db.query(`SELECT id FROM ${tableName} WHERE id = ? LIMIT 1;`).get(id) as { id: string } | null;
433
756
  return row !== null;
@@ -495,19 +818,21 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
495
818
  }
496
819
 
497
820
  const description = typeof fields.description === "string" ? fields.description : "";
821
+ const owner = isSyncFieldValueSupported(tableName, "owner", fields.owner) ? (fields.owner as string | null) : null;
498
822
  db.query(
499
823
  `
500
- INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version)
501
- VALUES (?, ?, ?, ?, ?, ?, ?, 1)
824
+ INSERT INTO tasks (id, epic_id, title, description, status, owner, created_at, updated_at, version)
825
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
502
826
  ON CONFLICT(id) DO UPDATE SET
503
827
  epic_id = excluded.epic_id,
504
828
  title = excluded.title,
505
829
  description = excluded.description,
506
830
  status = excluded.status,
831
+ owner = excluded.owner,
507
832
  updated_at = excluded.updated_at,
508
833
  version = tasks.version + 1;
509
834
  `,
510
- ).run(event.entity_id, epicId, title, description, status, now, now);
835
+ ).run(event.entity_id, epicId, title, description, status, owner, now, now);
511
836
 
512
837
  return true;
513
838
  }
@@ -521,19 +846,21 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
521
846
  }
522
847
 
523
848
  const description = typeof fields.description === "string" ? fields.description : "";
849
+ const owner = isSyncFieldValueSupported(tableName, "owner", fields.owner) ? (fields.owner as string | null) : null;
524
850
  db.query(
525
851
  `
526
- INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version)
527
- VALUES (?, ?, ?, ?, ?, ?, ?, 1)
852
+ INSERT INTO subtasks (id, task_id, title, description, status, owner, created_at, updated_at, version)
853
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
528
854
  ON CONFLICT(id) DO UPDATE SET
529
855
  task_id = excluded.task_id,
530
856
  title = excluded.title,
531
857
  description = excluded.description,
532
858
  status = excluded.status,
859
+ owner = excluded.owner,
533
860
  updated_at = excluded.updated_at,
534
861
  version = subtasks.version + 1;
535
862
  `,
536
- ).run(event.entity_id, taskId, title, description, status, now, now);
863
+ ).run(event.entity_id, taskId, title, description, status, owner, now, now);
537
864
 
538
865
  return true;
539
866
  }
@@ -586,7 +913,9 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
586
913
  }
587
914
 
588
915
  const allowed = new Set(SYNC_ALLOWED_FIELDS[tableName] ?? []);
589
- const entries = Object.entries(fields).filter(([fieldName, value]) => allowed.has(fieldName) && typeof value === "string");
916
+ const entries = Object.entries(fields).filter(([fieldName, value]) =>
917
+ allowed.has(fieldName) && isSyncFieldValueSupported(tableName, fieldName, value)
918
+ );
590
919
 
591
920
  if (entries.length === 0) {
592
921
  return false;
@@ -608,7 +937,7 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
608
937
 
609
938
  const now = Date.now();
610
939
  const setClause = entries.map(([field]) => `${field} = ?`).join(", ");
611
- const values = entries.map(([, value]) => value as string);
940
+ const values = entries.map(([, value]) => value as string | null);
612
941
 
613
942
  db.query(`UPDATE ${tableName} SET ${setClause}, updated_at = ?, version = version + 1 WHERE id = ?;`).run(
614
943
  ...values,
@@ -636,10 +965,52 @@ function applyDelete(db: Database, event: StoredEvent, fields: Record<string, un
636
965
  return true;
637
966
  }
638
967
 
968
+ if (event.entity_kind === "task") {
969
+ removeTaskSubtree(db, event.entity_id);
970
+ } else if (event.entity_kind === "subtask") {
971
+ removeDependenciesTouchingNode(db, event.entity_id);
972
+ }
973
+
639
974
  db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(event.entity_id);
640
975
  return true;
641
976
  }
642
977
 
978
+ function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean {
979
+ const row = db
980
+ .query(
981
+ `
982
+ SELECT 1
983
+ FROM sync_conflicts
984
+ WHERE event_id = ?
985
+ AND field_name = '__delete__'
986
+ AND resolution = 'pending'
987
+ LIMIT 1;
988
+ `,
989
+ )
990
+ .get(sourceEventId);
991
+
992
+ return row !== null;
993
+ }
994
+
995
+ function pendingDeleteConflictSourceEventId(fields: Record<string, unknown>): string | null {
996
+ const sourceEventId = fields.source_event_id;
997
+ return typeof sourceEventId === "string" && sourceEventId.length > 0 ? sourceEventId : null;
998
+ }
999
+
1000
+ function shouldWithholdDeleteCascadeEvent(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
1001
+ const sourceEventId = pendingDeleteConflictSourceEventId(fields);
1002
+ if (!sourceEventId) {
1003
+ return false;
1004
+ }
1005
+
1006
+ const isDeleteCascadeEvent = event.operation === "dependency.removed" || event.operation === "subtask.deleted";
1007
+ if (!isDeleteCascadeEvent) {
1008
+ return false;
1009
+ }
1010
+
1011
+ return hasPendingDeleteConflict(db, sourceEventId);
1012
+ }
1013
+
643
1014
  function applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
644
1015
  if (event.operation.endsWith(".deleted") || event.operation === "dependency.removed") {
645
1016
  return applyDelete(db, event, fields);
@@ -750,19 +1121,34 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
750
1121
  const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
751
1122
  const cursorToken = cursor?.cursor_token ?? "0:";
752
1123
  const staleCursor: boolean = cursor !== null && isCursorStale(storage.db, cursorToken, sourceBranch);
753
- const incomingEvents: StoredEvent[] = queryBranchEventsSince(storage.db, sourceBranch, cursorToken) as StoredEvent[];
754
1124
 
755
1125
  // Same-branch fast path: skip conflict detection when already on sourceBranch.
756
1126
  // Null branchName (detached HEAD) falls through to the normal path.
757
1127
  if (git.branchName !== null && git.branchName === sourceBranch) {
758
1128
  let lastToken: string | null = null;
759
1129
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
1130
+ let scannedEvents = 0;
760
1131
 
761
1132
  writeTransaction(storage.db, (): void => {
762
- for (const incoming of incomingEvents) {
763
- storeEvent(storage.db, incoming);
764
- lastToken = cursorTokenFromEvent(incoming);
765
- lastEventAt = incoming.created_at;
1133
+ while (true) {
1134
+ const incomingEvents = queryBranchEventsSinceBatch(
1135
+ storage.db,
1136
+ sourceBranch,
1137
+ lastToken ?? cursorToken,
1138
+ SYNC_PULL_BATCH_SIZE,
1139
+ ) as StoredEvent[];
1140
+
1141
+ if (incomingEvents.length === 0) {
1142
+ break;
1143
+ }
1144
+
1145
+ scannedEvents += incomingEvents.length;
1146
+
1147
+ for (const incoming of incomingEvents) {
1148
+ storeEvent(storage.db, incoming);
1149
+ lastToken = cursorTokenFromEvent(incoming);
1150
+ lastEventAt = incoming.created_at;
1151
+ }
766
1152
  }
767
1153
 
768
1154
  if (lastToken) {
@@ -772,7 +1158,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
772
1158
 
773
1159
  return {
774
1160
  sourceBranch,
775
- scannedEvents: incomingEvents.length,
1161
+ scannedEvents,
776
1162
  appliedEvents: 0,
777
1163
  createdConflicts: 0,
778
1164
  cursorToken: lastToken,
@@ -798,85 +1184,115 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
798
1184
  let conflictEvents = 0;
799
1185
  let lastToken: string | null = null;
800
1186
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
1187
+ let scannedEvents = 0;
801
1188
 
802
1189
  writeTransaction(storage.db, (): void => {
803
- for (const incoming of incomingEvents) {
804
- const payloadValidation = parsePayload(incoming.payload);
805
-
806
- if (!payloadValidation.ok) {
807
- malformedPayloadEvents += 1;
808
- quarantinedEvents += 1;
809
- createConflict(
810
- storage.db,
811
- incoming,
812
- "__payload__",
813
- null,
814
- payloadValidation.reason ?? "Invalid payload",
815
- "invalid",
816
- );
817
- createdConflicts += 1;
818
- storeEvent(storage.db, incoming);
819
- lastToken = cursorTokenFromEvent(incoming);
820
- lastEventAt = incoming.created_at;
821
- continue;
1190
+ while (true) {
1191
+ const incomingEvents = queryBranchEventsSinceBatch(
1192
+ storage.db,
1193
+ sourceBranch,
1194
+ lastToken ?? cursorToken,
1195
+ SYNC_PULL_BATCH_SIZE,
1196
+ ) as StoredEvent[];
1197
+
1198
+ if (incomingEvents.length === 0) {
1199
+ break;
822
1200
  }
823
1201
 
824
- const payload: EventPayload = { fields: payloadValidation.fields };
825
-
826
- const isDeleteWithLocalEdits =
827
- incoming.operation.endsWith(".deleted") &&
828
- hasLocalEntityEdits(storage.db, incoming.entity_kind, incoming.entity_id, sourceBranch);
829
- if (isDeleteWithLocalEdits) {
830
- // Note: dependency.removed is intentionally excluded — dependencies are
831
- // edges (not entities with local edit history), so conflict detection
832
- // does not apply to them.
833
- createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
834
- createdConflicts += 1;
835
- conflictEvents += 1;
836
- storeEvent(storage.db, incoming);
837
- lastToken = cursorTokenFromEvent(incoming);
838
- lastEventAt = incoming.created_at;
839
- continue;
840
- }
1202
+ scannedEvents += incomingEvents.length;
841
1203
 
842
- const fieldsToApply: Record<string, unknown> = {};
843
- let withheldConflictCount = 0;
1204
+ for (const incoming of incomingEvents) {
1205
+ if (incoming.operation === "resolve_conflict") {
1206
+ if (applyIncomingResolutionEvent(storage.db, incoming)) {
1207
+ appliedEvents += 1;
1208
+ }
1209
+ storeEvent(storage.db, incoming);
1210
+ lastToken = cursorTokenFromEvent(incoming);
1211
+ lastEventAt = incoming.created_at;
1212
+ continue;
1213
+ }
844
1214
 
845
- for (const [fieldName, value] of Object.entries(payload.fields)) {
846
- const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
1215
+ const payloadValidation = parsePayload(incoming.payload);
1216
+
1217
+ if (!payloadValidation.ok) {
1218
+ malformedPayloadEvents += 1;
1219
+ quarantinedEvents += 1;
1220
+ createConflict(
1221
+ storage.db,
1222
+ incoming,
1223
+ "__payload__",
1224
+ null,
1225
+ payloadValidation.reason ?? "Invalid payload",
1226
+ "invalid",
1227
+ );
1228
+ createdConflicts += 1;
1229
+ storeEvent(storage.db, incoming);
1230
+ lastToken = cursorTokenFromEvent(incoming);
1231
+ lastEventAt = incoming.created_at;
1232
+ continue;
1233
+ }
847
1234
 
848
- if (conflict) {
849
- withheldConflictCount += 1;
850
- conflictEvents += 1;
851
- createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
1235
+ const payload: EventPayload = { fields: payloadValidation.fields };
1236
+
1237
+ if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields)) {
1238
+ storeEvent(storage.db, incoming);
1239
+ lastToken = cursorTokenFromEvent(incoming);
1240
+ lastEventAt = incoming.created_at;
1241
+ continue;
1242
+ }
1243
+
1244
+ const isDeleteWithLocalEdits =
1245
+ incoming.operation.endsWith(".deleted") &&
1246
+ hasLocalDeleteCascadeEdits(storage.db, incoming, sourceBranch);
1247
+ if (isDeleteWithLocalEdits) {
1248
+ createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
852
1249
  createdConflicts += 1;
1250
+ conflictEvents += 1;
1251
+ storeEvent(storage.db, incoming);
1252
+ lastToken = cursorTokenFromEvent(incoming);
1253
+ lastEventAt = incoming.created_at;
853
1254
  continue;
854
1255
  }
855
1256
 
856
- fieldsToApply[fieldName] = value;
857
- }
1257
+ const fieldsToApply: Record<string, unknown> = {};
1258
+ let withheldConflictCount = 0;
858
1259
 
859
- if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
860
- appliedEvents += 1;
861
- } else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
862
- appliedEvents += 1;
863
- } else if (incoming.operation !== "resolve_conflict") {
864
- applyRejectedEvents += 1;
865
- quarantinedEvents += 1;
866
- createConflict(
867
- storage.db,
868
- incoming,
869
- "__apply__",
870
- null,
871
- `Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
872
- "invalid",
873
- );
874
- createdConflicts += 1;
875
- }
1260
+ for (const [fieldName, value] of Object.entries(payload.fields)) {
1261
+ const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
1262
+
1263
+ if (conflict) {
1264
+ withheldConflictCount += 1;
1265
+ conflictEvents += 1;
1266
+ createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
1267
+ createdConflicts += 1;
1268
+ continue;
1269
+ }
876
1270
 
877
- storeEvent(storage.db, incoming);
878
- lastToken = cursorTokenFromEvent(incoming);
879
- lastEventAt = incoming.created_at;
1271
+ fieldsToApply[fieldName] = value;
1272
+ }
1273
+
1274
+ if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
1275
+ appliedEvents += 1;
1276
+ } else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
1277
+ appliedEvents += 1;
1278
+ } else {
1279
+ applyRejectedEvents += 1;
1280
+ quarantinedEvents += 1;
1281
+ createConflict(
1282
+ storage.db,
1283
+ incoming,
1284
+ "__apply__",
1285
+ null,
1286
+ `Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
1287
+ "invalid",
1288
+ );
1289
+ createdConflicts += 1;
1290
+ }
1291
+
1292
+ storeEvent(storage.db, incoming);
1293
+ lastToken = cursorTokenFromEvent(incoming);
1294
+ lastEventAt = incoming.created_at;
1295
+ }
880
1296
  }
881
1297
 
882
1298
  if (lastToken) {
@@ -895,7 +1311,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
895
1311
 
896
1312
  return {
897
1313
  sourceBranch,
898
- scannedEvents: incomingEvents.length,
1314
+ scannedEvents,
899
1315
  appliedEvents,
900
1316
  createdConflicts,
901
1317
  cursorToken: lastToken,
@@ -926,7 +1342,14 @@ function parseConflictValue(value: string | null): unknown {
926
1342
  }
927
1343
  }
928
1344
 
929
- function updateSingleField(db: Database, entityKind: string, entityId: string, fieldName: string, value: unknown): void {
1345
+ function updateSingleField(
1346
+ db: Database,
1347
+ entityKind: string,
1348
+ entityId: string,
1349
+ fieldName: string,
1350
+ value: unknown,
1351
+ options: { allowMissing?: boolean } = {},
1352
+ ): void {
930
1353
  const tableName = tableForEntityKind(entityKind);
931
1354
  if (!tableName) {
932
1355
  throw new DomainError({
@@ -946,11 +1369,12 @@ function updateSingleField(db: Database, entityKind: string, entityId: string, f
946
1369
  }
947
1370
 
948
1371
  const now: number = Date.now();
1372
+ const normalizedValue = typeof value === "string" || value === null ? value : JSON.stringify(value);
949
1373
  const result = db
950
1374
  .query(`UPDATE ${tableName} SET ${fieldName} = ?, updated_at = ?, version = version + 1 WHERE id = ?;`)
951
- .run(typeof value === "string" ? value : JSON.stringify(value), now, entityId);
1375
+ .run(normalizedValue, now, entityId);
952
1376
 
953
- if (result.changes === 0) {
1377
+ if (result.changes === 0 && !options.allowMissing) {
954
1378
  throw new DomainError({
955
1379
  code: "row_not_found",
956
1380
  message: `No row updated: entity '${entityKind}' with id '${entityId}' not found in table '${tableName}'`,
@@ -959,7 +1383,12 @@ function updateSingleField(db: Database, entityKind: string, entityId: string, f
959
1383
  }
960
1384
  }
961
1385
 
962
- function deleteSingleEntity(db: Database, entityKind: string, entityId: string): void {
1386
+ function deleteSingleEntity(
1387
+ db: Database,
1388
+ entityKind: string,
1389
+ entityId: string,
1390
+ options: { allowMissing?: boolean } = {},
1391
+ ): void {
963
1392
  const tableName = tableForEntityKind(entityKind);
964
1393
  if (!tableName) {
965
1394
  throw new DomainError({
@@ -971,7 +1400,7 @@ function deleteSingleEntity(db: Database, entityKind: string, entityId: string):
971
1400
 
972
1401
  const result = db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(entityId);
973
1402
 
974
- if (result.changes === 0) {
1403
+ if (result.changes === 0 && !options.allowMissing) {
975
1404
  throw new DomainError({
976
1405
  code: "row_not_found",
977
1406
  message: `No row deleted: entity '${entityKind}' with id '${entityId}' not found in table '${tableName}'`,
@@ -994,11 +1423,7 @@ function resolveConflictRow(
994
1423
  git: ResolutionWriteContext,
995
1424
  ): void {
996
1425
  if (resolution === "theirs") {
997
- if (conflict.field_name === "__delete__") {
998
- deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id);
999
- } else {
1000
- updateSingleField(db, conflict.entity_kind, conflict.entity_id, conflict.field_name, parseConflictValue(conflict.theirs_value));
1001
- }
1426
+ applyConflictTheirsResolution(db, conflict);
1002
1427
  }
1003
1428
 
1004
1429
  const now: number = nextEventTimestamp(db);
@@ -1043,6 +1468,7 @@ function appendResolutionEvent(
1043
1468
  conflict.entity_id,
1044
1469
  JSON.stringify({
1045
1470
  conflict_id: conflict.id,
1471
+ source_event_id: conflict.event_id,
1046
1472
  field: conflict.field_name,
1047
1473
  resolution,
1048
1474
  value: resolvedValue,
@@ -1210,10 +1636,10 @@ export function syncResolvePreview(cwd: string, conflictId: string, resolution:
1210
1636
  }
1211
1637
  }
1212
1638
 
1213
- function queryPendingConflicts(
1639
+ function queryPendingConflictIds(
1214
1640
  db: Database,
1215
1641
  filters: ResolveAllQueryFilters,
1216
- ): readonly ConflictRow[] {
1642
+ ): readonly string[] {
1217
1643
  const conditions: string[] = ["resolution = 'pending'"];
1218
1644
  const params: string[] = [];
1219
1645
 
@@ -1228,14 +1654,14 @@ function queryPendingConflicts(
1228
1654
  }
1229
1655
 
1230
1656
  const sql = `
1231
- SELECT c.id, c.event_id, c.entity_kind, c.entity_id, c.field_name, c.ours_value, c.theirs_value, c.resolution, c.created_at, c.updated_at
1657
+ SELECT c.id
1232
1658
  FROM sync_conflicts c
1233
1659
  LEFT JOIN events e ON e.id = c.event_id
1234
1660
  WHERE ${conditions.map((condition) => condition.replaceAll("resolution", "c.resolution").replaceAll("entity_id", "c.entity_id").replaceAll("field_name", "c.field_name")).join(" AND ")}
1235
1661
  ORDER BY COALESCE(e.created_at, c.created_at) ASC, COALESCE(e.id, c.event_id) ASC, c.created_at ASC, c.id ASC;
1236
1662
  `;
1237
1663
 
1238
- return db.query(sql).all(...params) as ConflictRow[];
1664
+ return (db.query(sql).all(...params) as ConflictOrderRow[]).map((row) => row.id);
1239
1665
  }
1240
1666
 
1241
1667
  function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]): readonly ConflictRow[] {
@@ -1277,11 +1703,9 @@ export function syncResolveAll(
1277
1703
 
1278
1704
  const resolvedIds: string[] = writeTransaction(storage.db, (): string[] => {
1279
1705
  const expectedConflictIds = options.expectedConflictIds;
1280
- const conflicts = expectedConflictIds
1281
- ? queryPendingConflictsByIds(storage.db, expectedConflictIds)
1282
- : queryPendingConflicts(storage.db, filters);
1706
+ const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters);
1283
1707
 
1284
- if (conflicts.length === 0) {
1708
+ if (orderedConflictIds.length === 0) {
1285
1709
  throw new DomainError({
1286
1710
  code: "no_matching_conflicts",
1287
1711
  message: "No pending conflicts match the given filters.",
@@ -1289,23 +1713,28 @@ export function syncResolveAll(
1289
1713
  });
1290
1714
  }
1291
1715
 
1292
- if (expectedConflictIds && conflicts.length !== expectedConflictIds.length) {
1293
- throw new DomainError({
1294
- code: "conflict_set_changed",
1295
- message: "Pending conflicts changed before batch resolution could be applied.",
1296
- details: {
1297
- filters: normalizedFilters,
1298
- expectedConflictIds,
1299
- availableConflictIds: conflicts.map((conflict) => conflict.id),
1300
- },
1301
- });
1302
- }
1303
-
1304
1716
  const ids: string[] = [];
1305
1717
 
1306
- for (const conflict of conflicts) {
1307
- resolveConflictRow(storage.db, conflict, resolution, git);
1308
- ids.push(conflict.id);
1718
+ for (let offset = 0; offset < orderedConflictIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
1719
+ const chunkIds = orderedConflictIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
1720
+ const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds);
1721
+
1722
+ if (chunkConflicts.length !== chunkIds.length) {
1723
+ throw new DomainError({
1724
+ code: "conflict_set_changed",
1725
+ message: "Pending conflicts changed before batch resolution could be applied.",
1726
+ details: {
1727
+ filters: normalizedFilters,
1728
+ expectedConflictIds: chunkIds,
1729
+ availableConflictIds: chunkConflicts.map((conflict) => conflict.id),
1730
+ },
1731
+ });
1732
+ }
1733
+
1734
+ for (const conflict of chunkConflicts) {
1735
+ resolveConflictRow(storage.db, conflict, resolution, git);
1736
+ ids.push(conflict.id);
1737
+ }
1309
1738
  }
1310
1739
 
1311
1740
  return ids;
@@ -1331,9 +1760,9 @@ export function syncResolveAllPreview(
1331
1760
  const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
1332
1761
 
1333
1762
  try {
1334
- const conflicts = queryPendingConflicts(storage.db, filters);
1763
+ const conflictIds = queryPendingConflictIds(storage.db, filters);
1335
1764
 
1336
- if (conflicts.length === 0) {
1765
+ if (conflictIds.length === 0) {
1337
1766
  throw new DomainError({
1338
1767
  code: "no_matching_conflicts",
1339
1768
  message: "No pending conflicts match the given filters.",
@@ -1343,8 +1772,8 @@ export function syncResolveAllPreview(
1343
1772
 
1344
1773
  return {
1345
1774
  resolution,
1346
- matchedCount: conflicts.length,
1347
- matchedIds: conflicts.map((c) => c.id),
1775
+ matchedCount: conflictIds.length,
1776
+ matchedIds: conflictIds,
1348
1777
  filters: normalizedFilters,
1349
1778
  dryRun: true,
1350
1779
  };