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.
@@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { type Database } from "bun:sqlite";
4
4
 
5
+ import { ENTITY_OPERATIONS } from "../domain/mutation-operations";
5
6
  import { openTrekoonDatabase, writeTransaction } from "../storage/database";
6
- import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
7
+ import { countBranchEventsSince, queryBranchEventsSinceBatch } from "./branch-db";
7
8
  import { nextEventTimestamp } from "./event-writes";
8
9
  import { persistGitContext, resolveGitContext } from "./git-context";
9
10
  import { DomainError } from "../domain/types";
@@ -24,11 +25,25 @@ import {
24
25
 
25
26
  const SYNC_ALLOWED_FIELDS: Readonly<Record<string, readonly string[]>> = {
26
27
  epics: ["title", "description", "status"],
27
- tasks: ["epic_id", "title", "description", "status"],
28
- subtasks: ["task_id", "title", "description", "status"],
28
+ tasks: ["epic_id", "title", "description", "status", "owner"],
29
+ subtasks: ["task_id", "title", "description", "status", "owner"],
29
30
  dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
30
31
  };
31
32
 
33
+ const SYNC_EVENT_METADATA_FIELDS = new Set(["dependency_id", "source_event_id"]);
34
+
35
+ function isSyncNullableStringField(tableName: string, fieldName: string): boolean {
36
+ return (tableName === "tasks" || tableName === "subtasks") && fieldName === "owner";
37
+ }
38
+
39
+ function isSyncFieldValueSupported(tableName: string, fieldName: string, value: unknown): boolean {
40
+ if (typeof value === "string") {
41
+ return true;
42
+ }
43
+
44
+ return value === null && isSyncNullableStringField(tableName, fieldName);
45
+ }
46
+
32
47
  function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
33
48
  if (cursorToken === "0:") {
34
49
  return false;
@@ -106,6 +121,14 @@ interface ConflictRow {
106
121
  readonly updated_at: number;
107
122
  }
108
123
 
124
+ interface ResolutionEventPayload {
125
+ readonly conflict_id?: string;
126
+ readonly source_event_id?: string;
127
+ readonly field: string;
128
+ readonly resolution: string;
129
+ readonly value?: string | null;
130
+ }
131
+
109
132
  interface ResolutionWriteContext {
110
133
  readonly branchName: string | null;
111
134
  readonly headSha: string | null;
@@ -120,6 +143,34 @@ interface EventPayload {
120
143
  readonly fields: Record<string, unknown>;
121
144
  }
122
145
 
146
+ interface DeleteCascadeResolutionRow {
147
+ readonly id: string;
148
+ readonly source_id: string;
149
+ readonly depends_on_id: string;
150
+ }
151
+
152
+ interface ConflictOrderRow {
153
+ readonly id: string;
154
+ }
155
+
156
+ interface LocalEntityEventRow {
157
+ readonly payload: string;
158
+ readonly created_at: number;
159
+ readonly id: string;
160
+ }
161
+
162
+ interface DependencyEventIdentity {
163
+ readonly sourceId: string;
164
+ readonly sourceKind: string;
165
+ readonly dependsOnId: string;
166
+ readonly dependsOnKind: string;
167
+ }
168
+
169
+ const SYNC_PULL_BATCH_SIZE = 250;
170
+ const CONFLICT_HISTORY_SCAN_BATCH_SIZE = 250;
171
+ const RESOLVE_ALL_CHUNK_SIZE = 200;
172
+ const DELETE_CONFLICT_DEPENDENCY_SCAN_CHUNK_SIZE = 400;
173
+
123
174
  interface PayloadValidation {
124
175
  readonly ok: boolean;
125
176
  readonly fields: Record<string, unknown>;
@@ -130,6 +181,15 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
130
181
  return typeof value === "object" && value !== null && !Array.isArray(value);
131
182
  }
132
183
 
184
+ function parseJsonObject(rawPayload: string): Record<string, unknown> | null {
185
+ try {
186
+ const parsed: unknown = JSON.parse(rawPayload);
187
+ return isObjectRecord(parsed) ? parsed : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
133
193
  function parsePayload(rawPayload: string): PayloadValidation {
134
194
  try {
135
195
  const parsed: unknown = JSON.parse(rawPayload);
@@ -331,12 +391,43 @@ function currentEntityFieldValue(db: Database, entityKind: string, entityId: str
331
391
  }
332
392
 
333
393
  const row = db.query(`SELECT ${fieldName} AS value FROM ${tableName} WHERE id = ? LIMIT 1;`).get(entityId) as
334
- | { value: string }
394
+ | { value: string | null }
335
395
  | null;
336
396
 
337
397
  return row?.value;
338
398
  }
339
399
 
400
+ function dependencyEventIdentityFromFields(fields: Record<string, unknown>): DependencyEventIdentity | null {
401
+ const sourceId = validateRequiredStringField(fields, "source_id");
402
+ const sourceKind = validateRequiredStringField(fields, "source_kind");
403
+ const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
404
+ const dependsOnKind = validateRequiredStringField(fields, "depends_on_kind");
405
+
406
+ if (!sourceId || !sourceKind || !dependsOnId || !dependsOnKind) {
407
+ return null;
408
+ }
409
+
410
+ return {
411
+ sourceId,
412
+ sourceKind,
413
+ dependsOnId,
414
+ dependsOnKind,
415
+ };
416
+ }
417
+
418
+ function dependencyEventIdentity(event: StoredEvent): DependencyEventIdentity | null {
419
+ if (event.entity_kind !== "dependency") {
420
+ return null;
421
+ }
422
+
423
+ const payloadValidation = parsePayload(event.payload);
424
+ if (!payloadValidation.ok) {
425
+ return null;
426
+ }
427
+
428
+ return dependencyEventIdentityFromFields(payloadValidation.fields);
429
+ }
430
+
340
431
  function entityFieldConflict(
341
432
  localDb: Database,
342
433
  sourceBranch: string,
@@ -349,46 +440,83 @@ function entityFieldConflict(
349
440
  return null;
350
441
  }
351
442
 
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
443
+ let beforeCreatedAt = Number.MAX_SAFE_INTEGER;
444
+ let beforeId = "\uffff";
445
+
446
+ while (true) {
447
+ const rows = localDb
356
448
  .query(
357
449
  `
358
- SELECT payload, git_branch
450
+ SELECT payload, created_at, id
359
451
  FROM events
360
- WHERE entity_kind = ? AND entity_id = ? AND git_branch != ?
452
+ WHERE entity_kind = ?
453
+ AND entity_id = ?
454
+ AND (git_branch IS NULL OR git_branch != ?)
455
+ AND (
456
+ created_at < ?
457
+ OR (created_at = ? AND id < ?)
458
+ )
361
459
  ORDER BY created_at DESC, id DESC
362
- LIMIT 500;
460
+ LIMIT ?;
363
461
  `,
364
462
  )
365
- .all(event.entity_kind, event.entity_id, sourceBranch) as Array<{ payload: string; git_branch: string | null }>;
463
+ .all(
464
+ event.entity_kind,
465
+ event.entity_id,
466
+ sourceBranch,
467
+ beforeCreatedAt,
468
+ beforeCreatedAt,
469
+ beforeId,
470
+ CONFLICT_HISTORY_SCAN_BATCH_SIZE,
471
+ ) as LocalEntityEventRow[];
366
472
 
367
- for (const row of rows) {
368
- const payloadValidation = parsePayload(row.payload);
369
- if (!payloadValidation.ok) {
370
- continue;
473
+ const incomingDependencyIdentity = dependencyEventIdentity(event);
474
+
475
+ if (rows.length === 0) {
476
+ return null;
371
477
  }
372
478
 
373
- const payload: EventPayload = { fields: payloadValidation.fields };
374
- const localValue: unknown = readFieldValue(payload, fieldName);
479
+ for (const row of rows) {
480
+ const payloadValidation = parsePayload(row.payload);
481
+ if (!payloadValidation.ok) {
482
+ continue;
483
+ }
375
484
 
376
- if (typeof localValue === "undefined") {
377
- continue;
378
- }
485
+ if (incomingDependencyIdentity !== null) {
486
+ const localDependencyIdentity = dependencyEventIdentityFromFields(payloadValidation.fields);
487
+ if (
488
+ localDependencyIdentity === null ||
489
+ localDependencyIdentity.sourceId !== incomingDependencyIdentity.sourceId ||
490
+ localDependencyIdentity.sourceKind !== incomingDependencyIdentity.sourceKind ||
491
+ localDependencyIdentity.dependsOnId !== incomingDependencyIdentity.dependsOnId ||
492
+ localDependencyIdentity.dependsOnKind !== incomingDependencyIdentity.dependsOnKind
493
+ ) {
494
+ continue;
495
+ }
496
+ }
379
497
 
380
- const oursValue = serializeValue(localValue);
381
- const theirsValue = serializeValue(incomingValue);
498
+ const payload: EventPayload = { fields: payloadValidation.fields };
499
+ const localValue: unknown = readFieldValue(payload, fieldName);
382
500
 
383
- if (oursValue !== theirsValue) {
384
- return {
385
- oursValue,
386
- theirsValue,
387
- };
501
+ if (typeof localValue === "undefined") {
502
+ continue;
503
+ }
504
+
505
+ const oursValue = serializeValue(localValue);
506
+ const theirsValue = serializeValue(incomingValue);
507
+
508
+ if (oursValue !== theirsValue) {
509
+ return {
510
+ oursValue,
511
+ theirsValue,
512
+ };
513
+ }
388
514
  }
389
- }
390
515
 
391
- return null;
516
+ const lastRow = rows.at(-1)!;
517
+ beforeCreatedAt = lastRow.created_at;
518
+ beforeId = lastRow.id;
519
+ }
392
520
  }
393
521
 
394
522
  function createConflict(
@@ -400,6 +528,45 @@ function createConflict(
400
528
  resolution: string = "pending",
401
529
  ): void {
402
530
  const now: number = Date.now();
531
+ const existing = db
532
+ .query(
533
+ `
534
+ SELECT id, resolution, ours_value, theirs_value
535
+ FROM sync_conflicts
536
+ WHERE event_id = ? AND entity_kind = ? AND entity_id = ? AND field_name = ?
537
+ ORDER BY CASE WHEN resolution = 'pending' THEN 0 ELSE 1 END, created_at ASC, id ASC
538
+ LIMIT 1;
539
+ `,
540
+ )
541
+ .get(event.id, event.entity_kind, event.entity_id, fieldName) as
542
+ | { id: string; resolution: string; ours_value: string | null; theirs_value: string | null }
543
+ | null;
544
+
545
+ if (existing) {
546
+ const nextResolution = existing.resolution === "pending" ? resolution : existing.resolution;
547
+ const unchanged =
548
+ existing.ours_value === oursValue &&
549
+ existing.theirs_value === theirsValue &&
550
+ existing.resolution === nextResolution;
551
+
552
+ if (unchanged) {
553
+ return;
554
+ }
555
+
556
+ db.query(
557
+ `
558
+ UPDATE sync_conflicts
559
+ SET ours_value = ?,
560
+ theirs_value = ?,
561
+ resolution = ?,
562
+ updated_at = ?,
563
+ version = version + 1
564
+ WHERE id = ?;
565
+ `,
566
+ ).run(oursValue, theirsValue, nextResolution, now, existing.id);
567
+ return;
568
+ }
569
+
403
570
  db.query(
404
571
  `
405
572
  INSERT INTO sync_conflicts (
@@ -419,15 +586,345 @@ function createConflict(
419
586
  ).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, resolution, now, now);
420
587
  }
421
588
 
589
+ function findConflictForResolutionEvent(
590
+ db: Database,
591
+ event: StoredEvent,
592
+ payload: ResolutionEventPayload,
593
+ ): ConflictRow | null {
594
+ if (typeof payload.source_event_id === "string" && payload.source_event_id.length > 0) {
595
+ const bySourceEvent = db
596
+ .query(
597
+ `
598
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
599
+ FROM sync_conflicts
600
+ WHERE event_id = ?
601
+ AND entity_kind = ?
602
+ AND entity_id = ?
603
+ AND field_name = ?
604
+ ORDER BY CASE WHEN resolution = 'pending' THEN 0 ELSE 1 END, created_at ASC, id ASC
605
+ LIMIT 1;
606
+ `,
607
+ )
608
+ .get(payload.source_event_id, event.entity_kind, event.entity_id, payload.field) as ConflictRow | null;
609
+
610
+ if (bySourceEvent) {
611
+ return bySourceEvent;
612
+ }
613
+ }
614
+
615
+ if (typeof payload.conflict_id !== "string" || payload.conflict_id.length === 0) {
616
+ return null;
617
+ }
618
+
619
+ return db
620
+ .query(
621
+ `
622
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
623
+ FROM sync_conflicts
624
+ WHERE id = ?
625
+ AND entity_kind = ?
626
+ AND entity_id = ?
627
+ AND field_name = ?
628
+ LIMIT 1;
629
+ `,
630
+ )
631
+ .get(payload.conflict_id, event.entity_kind, event.entity_id, payload.field) as ConflictRow | null;
632
+ }
633
+
634
+ function removeDependenciesTouchingNode(db: Database, nodeId: string): void {
635
+ db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(nodeId, nodeId);
636
+ }
637
+
638
+ function removeTaskSubtree(db: Database, taskId: string): void {
639
+ const subtasks = db
640
+ .query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
641
+ .all(taskId) as Array<{ id: string }>;
642
+
643
+ for (const subtask of subtasks) {
644
+ removeDependenciesTouchingNode(db, subtask.id);
645
+ }
646
+
647
+ db.query("DELETE FROM subtasks WHERE task_id = ?;").run(taskId);
648
+ removeDependenciesTouchingNode(db, taskId);
649
+ }
650
+
651
+ function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow): void {
652
+ const rows = db
653
+ .query(
654
+ `
655
+ 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
656
+ FROM events e
657
+ WHERE e.operation = 'dependency.removed'
658
+ AND json_extract(e.payload, '$.fields.source_event_id') = ?
659
+ ORDER BY e.created_at ASC, e.id ASC;
660
+ `,
661
+ )
662
+ .all(conflict.event_id) as DeleteCascadeResolutionRow[];
663
+
664
+ for (const row of rows) {
665
+ if (typeof row.source_id !== "string" || typeof row.depends_on_id !== "string") {
666
+ continue;
667
+ }
668
+
669
+ db.query("DELETE FROM dependencies WHERE source_id = ? AND depends_on_id = ?;").run(row.source_id, row.depends_on_id);
670
+ }
671
+ }
672
+
673
+ function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): void {
674
+ if (conflict.field_name === "__delete__") {
675
+ if (conflict.entity_kind === "task") {
676
+ removeTaskSubtree(db, conflict.entity_id);
677
+ } else if (conflict.entity_kind === "subtask") {
678
+ removeDependenciesTouchingNode(db, conflict.entity_id);
679
+ }
680
+ applyPendingDeleteCascadeResolution(db, conflict);
681
+ deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id, { allowMissing: true });
682
+ return;
683
+ }
684
+
685
+ updateSingleField(db, conflict.entity_kind, conflict.entity_id, conflict.field_name, parseConflictValue(conflict.theirs_value), {
686
+ allowMissing: true,
687
+ });
688
+ }
689
+
690
+ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean {
691
+ const parsed = parseJsonObject(event.payload);
692
+ if (!parsed) {
693
+ return false;
694
+ }
695
+
696
+ const resolutionPayload = parsed as unknown as ResolutionEventPayload;
697
+ const fieldName = resolutionPayload.field;
698
+ const resolution = resolutionPayload.resolution;
699
+
700
+ if (
701
+ typeof fieldName !== "string" ||
702
+ (resolution !== "ours" && resolution !== "theirs")
703
+ ) {
704
+ return false;
705
+ }
706
+
707
+ const conflict = findConflictForResolutionEvent(db, event, resolutionPayload);
708
+ if (!conflict) {
709
+ return false;
710
+ }
711
+
712
+ if (conflict.resolution === "pending" && resolution === "theirs") {
713
+ applyConflictTheirsResolution(db, conflict);
714
+ }
715
+
716
+ const now = nextEventTimestamp(db);
717
+ const updated = db
718
+ .query(
719
+ `
720
+ UPDATE sync_conflicts
721
+ SET resolution = CASE WHEN resolution = 'pending' THEN @resolution ELSE resolution END,
722
+ updated_at = CASE WHEN resolution = 'pending' THEN @now ELSE updated_at END,
723
+ version = CASE WHEN resolution = 'pending' THEN version + 1 ELSE version END
724
+ WHERE id = @conflictId
725
+ AND entity_kind = @entityKind
726
+ AND entity_id = @entityId
727
+ AND field_name = @fieldName;
728
+ `,
729
+ )
730
+ .run({
731
+ "@resolution": resolution,
732
+ "@now": now,
733
+ "@conflictId": conflict.id,
734
+ "@entityKind": event.entity_kind,
735
+ "@entityId": event.entity_id,
736
+ "@fieldName": fieldName,
737
+ });
738
+
739
+ return updated.changes > 0;
740
+ }
741
+
422
742
  function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, sourceBranch: string): boolean {
423
743
  const row = db
424
744
  .query(
425
- `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch != ? LIMIT 1;`,
745
+ `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND (git_branch IS NULL OR git_branch != ?) LIMIT 1;`,
426
746
  )
427
747
  .get(entityKind, entityId, sourceBranch);
428
748
  return row !== null;
429
749
  }
430
750
 
751
+ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[], sourceBranch: string): boolean {
752
+ if (nodeIds.length === 0) {
753
+ return false;
754
+ }
755
+
756
+ for (let offset = 0; offset < nodeIds.length; offset += DELETE_CONFLICT_DEPENDENCY_SCAN_CHUNK_SIZE) {
757
+ const chunk = nodeIds.slice(offset, offset + DELETE_CONFLICT_DEPENDENCY_SCAN_CHUNK_SIZE);
758
+ const placeholders = chunk.map(() => "?").join(", ");
759
+ const row = db
760
+ .query(
761
+ `
762
+ SELECT 1
763
+ FROM events
764
+ WHERE entity_kind = 'dependency'
765
+ AND (git_branch IS NULL OR git_branch != ?)
766
+ AND (
767
+ json_extract(payload, '$.fields.source_id') IN (${placeholders})
768
+ OR json_extract(payload, '$.fields.depends_on_id') IN (${placeholders})
769
+ )
770
+ LIMIT 1;
771
+ `,
772
+ )
773
+ .get(sourceBranch, ...chunk, ...chunk);
774
+
775
+ if (row !== null) {
776
+ return true;
777
+ }
778
+ }
779
+
780
+ return false;
781
+ }
782
+
783
+ function hasLocalDependencyEditsForIdentity(
784
+ db: Database,
785
+ sourceBranch: string,
786
+ identity: DependencyEventIdentity,
787
+ ): boolean {
788
+ const row = db
789
+ .query(
790
+ `
791
+ SELECT 1
792
+ FROM events
793
+ WHERE entity_kind = 'dependency'
794
+ AND (git_branch IS NULL OR git_branch != ?)
795
+ AND json_extract(payload, '$.fields.source_id') = ?
796
+ AND json_extract(payload, '$.fields.source_kind') = ?
797
+ AND json_extract(payload, '$.fields.depends_on_id') = ?
798
+ AND json_extract(payload, '$.fields.depends_on_kind') = ?
799
+ LIMIT 1;
800
+ `,
801
+ )
802
+ .get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
803
+
804
+ return row !== null;
805
+ }
806
+
807
+ function dependencyRowExistsForIdentity(db: Database, identity: DependencyEventIdentity): boolean {
808
+ const row = db
809
+ .query(
810
+ `
811
+ SELECT 1
812
+ FROM dependencies
813
+ WHERE source_id = ?
814
+ AND source_kind = ?
815
+ AND depends_on_id = ?
816
+ AND depends_on_kind = ?
817
+ LIMIT 1;
818
+ `,
819
+ )
820
+ .get(identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
821
+
822
+ return row !== null;
823
+ }
824
+
825
+ function latestLocalDependencyOperationForIdentity(
826
+ db: Database,
827
+ sourceBranch: string,
828
+ identity: DependencyEventIdentity,
829
+ ): string | null {
830
+ const row = db
831
+ .query(
832
+ `
833
+ SELECT operation
834
+ FROM events
835
+ WHERE entity_kind = 'dependency'
836
+ AND (git_branch IS NULL OR git_branch != ?)
837
+ AND json_extract(payload, '$.fields.source_id') = ?
838
+ AND json_extract(payload, '$.fields.source_kind') = ?
839
+ AND json_extract(payload, '$.fields.depends_on_id') = ?
840
+ AND json_extract(payload, '$.fields.depends_on_kind') = ?
841
+ ORDER BY created_at DESC, id DESC
842
+ LIMIT 1;
843
+ `,
844
+ )
845
+ .get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
846
+ | { operation: string }
847
+ | null;
848
+
849
+ return row?.operation ?? null;
850
+ }
851
+
852
+ function hasLocalDependencyRemovalForIdentity(
853
+ db: Database,
854
+ sourceBranch: string,
855
+ identity: DependencyEventIdentity,
856
+ ): boolean {
857
+ const row = db
858
+ .query(
859
+ `
860
+ SELECT 1
861
+ FROM events
862
+ WHERE entity_kind = 'dependency'
863
+ AND operation = 'dependency.removed'
864
+ AND (git_branch IS NULL OR git_branch != ?)
865
+ AND json_extract(payload, '$.fields.source_id') = ?
866
+ AND json_extract(payload, '$.fields.depends_on_id') = ?
867
+ AND (
868
+ json_extract(payload, '$.fields.source_kind') IS NULL
869
+ OR json_extract(payload, '$.fields.source_kind') = ?
870
+ )
871
+ AND (
872
+ json_extract(payload, '$.fields.depends_on_kind') IS NULL
873
+ OR json_extract(payload, '$.fields.depends_on_kind') = ?
874
+ )
875
+ LIMIT 1;
876
+ `,
877
+ )
878
+ .get(sourceBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
879
+
880
+ return row !== null;
881
+ }
882
+
883
+ function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sourceBranch: string): boolean {
884
+ const identity = dependencyEventIdentity(event);
885
+ if (identity === null) {
886
+ return false;
887
+ }
888
+
889
+ if (!dependencyRowExistsForIdentity(db, identity)) {
890
+ return false;
891
+ }
892
+
893
+ const latestOperation = latestLocalDependencyOperationForIdentity(db, sourceBranch, identity);
894
+ if (latestOperation === ENTITY_OPERATIONS.dependency.removed) {
895
+ return false;
896
+ }
897
+
898
+ return hasLocalDependencyEditsForIdentity(db, sourceBranch, identity);
899
+ }
900
+
901
+ function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBranch: string): boolean {
902
+ if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, sourceBranch)) {
903
+ return true;
904
+ }
905
+
906
+ if (event.entity_kind === "subtask") {
907
+ return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id], sourceBranch);
908
+ }
909
+
910
+ if (event.entity_kind !== "task") {
911
+ return false;
912
+ }
913
+
914
+ const subtaskRows = db
915
+ .query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
916
+ .all(event.entity_id) as Array<{ id: string }>;
917
+ const subtaskIds = subtaskRows.map((row) => row.id);
918
+
919
+ for (const subtaskId of subtaskIds) {
920
+ if (hasLocalEntityEdits(db, "subtask", subtaskId, sourceBranch)) {
921
+ return true;
922
+ }
923
+ }
924
+
925
+ return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds], sourceBranch);
926
+ }
927
+
431
928
  function rowExists(db: Database, tableName: string, id: string): boolean {
432
929
  const row = db.query(`SELECT id FROM ${tableName} WHERE id = ? LIMIT 1;`).get(id) as { id: string } | null;
433
930
  return row !== null;
@@ -495,19 +992,21 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
495
992
  }
496
993
 
497
994
  const description = typeof fields.description === "string" ? fields.description : "";
995
+ const owner = isSyncFieldValueSupported(tableName, "owner", fields.owner) ? (fields.owner as string | null) : null;
498
996
  db.query(
499
997
  `
500
- INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version)
501
- VALUES (?, ?, ?, ?, ?, ?, ?, 1)
998
+ INSERT INTO tasks (id, epic_id, title, description, status, owner, created_at, updated_at, version)
999
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
502
1000
  ON CONFLICT(id) DO UPDATE SET
503
1001
  epic_id = excluded.epic_id,
504
1002
  title = excluded.title,
505
1003
  description = excluded.description,
506
1004
  status = excluded.status,
1005
+ owner = excluded.owner,
507
1006
  updated_at = excluded.updated_at,
508
1007
  version = tasks.version + 1;
509
1008
  `,
510
- ).run(event.entity_id, epicId, title, description, status, now, now);
1009
+ ).run(event.entity_id, epicId, title, description, status, owner, now, now);
511
1010
 
512
1011
  return true;
513
1012
  }
@@ -521,19 +1020,21 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
521
1020
  }
522
1021
 
523
1022
  const description = typeof fields.description === "string" ? fields.description : "";
1023
+ const owner = isSyncFieldValueSupported(tableName, "owner", fields.owner) ? (fields.owner as string | null) : null;
524
1024
  db.query(
525
1025
  `
526
- INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version)
527
- VALUES (?, ?, ?, ?, ?, ?, ?, 1)
1026
+ INSERT INTO subtasks (id, task_id, title, description, status, owner, created_at, updated_at, version)
1027
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
528
1028
  ON CONFLICT(id) DO UPDATE SET
529
1029
  task_id = excluded.task_id,
530
1030
  title = excluded.title,
531
1031
  description = excluded.description,
532
1032
  status = excluded.status,
1033
+ owner = excluded.owner,
533
1034
  updated_at = excluded.updated_at,
534
1035
  version = subtasks.version + 1;
535
1036
  `,
536
- ).run(event.entity_id, taskId, title, description, status, now, now);
1037
+ ).run(event.entity_id, taskId, title, description, status, owner, now, now);
537
1038
 
538
1039
  return true;
539
1040
  }
@@ -542,6 +1043,7 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
542
1043
  const sourceKind = validateRequiredStringField(fields, "source_kind");
543
1044
  const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
544
1045
  const dependsOnKind = validateRequiredStringField(fields, "depends_on_kind");
1046
+ const dependencyId = validateRequiredStringField(fields, "dependency_id") ?? event.entity_id;
545
1047
 
546
1048
  if (!sourceId || !sourceKind || !dependsOnId || !dependsOnKind) {
547
1049
  return false;
@@ -564,13 +1066,12 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
564
1066
  version
565
1067
  ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
566
1068
  ON CONFLICT(source_id, depends_on_id) DO UPDATE SET
567
- id = excluded.id,
568
1069
  source_kind = excluded.source_kind,
569
1070
  depends_on_kind = excluded.depends_on_kind,
570
1071
  updated_at = excluded.updated_at,
571
1072
  version = dependencies.version + 1;
572
1073
  `,
573
- ).run(event.entity_id, sourceId, sourceKind, dependsOnId, dependsOnKind, now, now);
1074
+ ).run(dependencyId, sourceId, sourceKind, dependsOnId, dependsOnKind, now, now);
574
1075
 
575
1076
  return true;
576
1077
  }
@@ -586,7 +1087,9 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
586
1087
  }
587
1088
 
588
1089
  const allowed = new Set(SYNC_ALLOWED_FIELDS[tableName] ?? []);
589
- const entries = Object.entries(fields).filter(([fieldName, value]) => allowed.has(fieldName) && typeof value === "string");
1090
+ const entries = Object.entries(fields).filter(([fieldName, value]) =>
1091
+ allowed.has(fieldName) && isSyncFieldValueSupported(tableName, fieldName, value)
1092
+ );
590
1093
 
591
1094
  if (entries.length === 0) {
592
1095
  return false;
@@ -608,7 +1111,7 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
608
1111
 
609
1112
  const now = Date.now();
610
1113
  const setClause = entries.map(([field]) => `${field} = ?`).join(", ");
611
- const values = entries.map(([, value]) => value as string);
1114
+ const values = entries.map(([, value]) => value as string | null);
612
1115
 
613
1116
  db.query(`UPDATE ${tableName} SET ${setClause}, updated_at = ?, version = version + 1 WHERE id = ?;`).run(
614
1117
  ...values,
@@ -636,10 +1139,52 @@ function applyDelete(db: Database, event: StoredEvent, fields: Record<string, un
636
1139
  return true;
637
1140
  }
638
1141
 
1142
+ if (event.entity_kind === "task") {
1143
+ removeTaskSubtree(db, event.entity_id);
1144
+ } else if (event.entity_kind === "subtask") {
1145
+ removeDependenciesTouchingNode(db, event.entity_id);
1146
+ }
1147
+
639
1148
  db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(event.entity_id);
640
1149
  return true;
641
1150
  }
642
1151
 
1152
+ function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean {
1153
+ const row = db
1154
+ .query(
1155
+ `
1156
+ SELECT 1
1157
+ FROM sync_conflicts
1158
+ WHERE event_id = ?
1159
+ AND field_name = '__delete__'
1160
+ AND resolution = 'pending'
1161
+ LIMIT 1;
1162
+ `,
1163
+ )
1164
+ .get(sourceEventId);
1165
+
1166
+ return row !== null;
1167
+ }
1168
+
1169
+ function pendingDeleteConflictSourceEventId(fields: Record<string, unknown>): string | null {
1170
+ const sourceEventId = fields.source_event_id;
1171
+ return typeof sourceEventId === "string" && sourceEventId.length > 0 ? sourceEventId : null;
1172
+ }
1173
+
1174
+ function shouldWithholdDeleteCascadeEvent(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
1175
+ const sourceEventId = pendingDeleteConflictSourceEventId(fields);
1176
+ if (!sourceEventId) {
1177
+ return false;
1178
+ }
1179
+
1180
+ const isDeleteCascadeEvent = event.operation === "dependency.removed" || event.operation === "subtask.deleted";
1181
+ if (!isDeleteCascadeEvent) {
1182
+ return false;
1183
+ }
1184
+
1185
+ return hasPendingDeleteConflict(db, sourceEventId);
1186
+ }
1187
+
643
1188
  function applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
644
1189
  if (event.operation.endsWith(".deleted") || event.operation === "dependency.removed") {
645
1190
  return applyDelete(db, event, fields);
@@ -750,19 +1295,34 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
750
1295
  const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
751
1296
  const cursorToken = cursor?.cursor_token ?? "0:";
752
1297
  const staleCursor: boolean = cursor !== null && isCursorStale(storage.db, cursorToken, sourceBranch);
753
- const incomingEvents: StoredEvent[] = queryBranchEventsSince(storage.db, sourceBranch, cursorToken) as StoredEvent[];
754
1298
 
755
1299
  // Same-branch fast path: skip conflict detection when already on sourceBranch.
756
1300
  // Null branchName (detached HEAD) falls through to the normal path.
757
1301
  if (git.branchName !== null && git.branchName === sourceBranch) {
758
1302
  let lastToken: string | null = null;
759
1303
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
1304
+ let scannedEvents = 0;
760
1305
 
761
1306
  writeTransaction(storage.db, (): void => {
762
- for (const incoming of incomingEvents) {
763
- storeEvent(storage.db, incoming);
764
- lastToken = cursorTokenFromEvent(incoming);
765
- lastEventAt = incoming.created_at;
1307
+ while (true) {
1308
+ const incomingEvents = queryBranchEventsSinceBatch(
1309
+ storage.db,
1310
+ sourceBranch,
1311
+ lastToken ?? cursorToken,
1312
+ SYNC_PULL_BATCH_SIZE,
1313
+ ) as StoredEvent[];
1314
+
1315
+ if (incomingEvents.length === 0) {
1316
+ break;
1317
+ }
1318
+
1319
+ scannedEvents += incomingEvents.length;
1320
+
1321
+ for (const incoming of incomingEvents) {
1322
+ storeEvent(storage.db, incoming);
1323
+ lastToken = cursorTokenFromEvent(incoming);
1324
+ lastEventAt = incoming.created_at;
1325
+ }
766
1326
  }
767
1327
 
768
1328
  if (lastToken) {
@@ -772,7 +1332,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
772
1332
 
773
1333
  return {
774
1334
  sourceBranch,
775
- scannedEvents: incomingEvents.length,
1335
+ scannedEvents,
776
1336
  appliedEvents: 0,
777
1337
  createdConflicts: 0,
778
1338
  cursorToken: lastToken,
@@ -798,85 +1358,120 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
798
1358
  let conflictEvents = 0;
799
1359
  let lastToken: string | null = null;
800
1360
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
1361
+ let scannedEvents = 0;
801
1362
 
802
1363
  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;
1364
+ while (true) {
1365
+ const incomingEvents = queryBranchEventsSinceBatch(
1366
+ storage.db,
1367
+ sourceBranch,
1368
+ lastToken ?? cursorToken,
1369
+ SYNC_PULL_BATCH_SIZE,
1370
+ ) as StoredEvent[];
1371
+
1372
+ if (incomingEvents.length === 0) {
1373
+ break;
822
1374
  }
823
1375
 
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
- }
1376
+ scannedEvents += incomingEvents.length;
841
1377
 
842
- const fieldsToApply: Record<string, unknown> = {};
843
- let withheldConflictCount = 0;
1378
+ for (const incoming of incomingEvents) {
1379
+ if (incoming.operation === "resolve_conflict") {
1380
+ if (applyIncomingResolutionEvent(storage.db, incoming)) {
1381
+ appliedEvents += 1;
1382
+ }
1383
+ storeEvent(storage.db, incoming);
1384
+ lastToken = cursorTokenFromEvent(incoming);
1385
+ lastEventAt = incoming.created_at;
1386
+ continue;
1387
+ }
844
1388
 
845
- for (const [fieldName, value] of Object.entries(payload.fields)) {
846
- const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
1389
+ const payloadValidation = parsePayload(incoming.payload);
1390
+
1391
+ if (!payloadValidation.ok) {
1392
+ malformedPayloadEvents += 1;
1393
+ quarantinedEvents += 1;
1394
+ createConflict(
1395
+ storage.db,
1396
+ incoming,
1397
+ "__payload__",
1398
+ null,
1399
+ payloadValidation.reason ?? "Invalid payload",
1400
+ "invalid",
1401
+ );
1402
+ createdConflicts += 1;
1403
+ storeEvent(storage.db, incoming);
1404
+ lastToken = cursorTokenFromEvent(incoming);
1405
+ lastEventAt = incoming.created_at;
1406
+ continue;
1407
+ }
847
1408
 
848
- if (conflict) {
849
- withheldConflictCount += 1;
850
- conflictEvents += 1;
851
- createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
1409
+ const payload: EventPayload = { fields: payloadValidation.fields };
1410
+
1411
+ if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields)) {
1412
+ storeEvent(storage.db, incoming);
1413
+ lastToken = cursorTokenFromEvent(incoming);
1414
+ lastEventAt = incoming.created_at;
1415
+ continue;
1416
+ }
1417
+
1418
+ const isDeleteWithLocalEdits =
1419
+ (incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, sourceBranch)) ||
1420
+ (incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, sourceBranch));
1421
+ if (isDeleteWithLocalEdits) {
1422
+ createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
852
1423
  createdConflicts += 1;
1424
+ conflictEvents += 1;
1425
+ storeEvent(storage.db, incoming);
1426
+ lastToken = cursorTokenFromEvent(incoming);
1427
+ lastEventAt = incoming.created_at;
853
1428
  continue;
854
1429
  }
855
1430
 
856
- fieldsToApply[fieldName] = value;
857
- }
1431
+ const fieldsToApply: Record<string, unknown> = {};
1432
+ let withheldConflictCount = 0;
858
1433
 
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
- }
1434
+ for (const [fieldName, value] of Object.entries(payload.fields)) {
1435
+ if (SYNC_EVENT_METADATA_FIELDS.has(fieldName)) {
1436
+ fieldsToApply[fieldName] = value;
1437
+ continue;
1438
+ }
1439
+
1440
+ const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
1441
+
1442
+ if (conflict) {
1443
+ withheldConflictCount += 1;
1444
+ conflictEvents += 1;
1445
+ createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
1446
+ createdConflicts += 1;
1447
+ continue;
1448
+ }
1449
+
1450
+ fieldsToApply[fieldName] = value;
1451
+ }
1452
+
1453
+ if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
1454
+ appliedEvents += 1;
1455
+ } else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
1456
+ appliedEvents += 1;
1457
+ } else {
1458
+ applyRejectedEvents += 1;
1459
+ quarantinedEvents += 1;
1460
+ createConflict(
1461
+ storage.db,
1462
+ incoming,
1463
+ "__apply__",
1464
+ null,
1465
+ `Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
1466
+ "invalid",
1467
+ );
1468
+ createdConflicts += 1;
1469
+ }
876
1470
 
877
- storeEvent(storage.db, incoming);
878
- lastToken = cursorTokenFromEvent(incoming);
879
- lastEventAt = incoming.created_at;
1471
+ storeEvent(storage.db, incoming);
1472
+ lastToken = cursorTokenFromEvent(incoming);
1473
+ lastEventAt = incoming.created_at;
1474
+ }
880
1475
  }
881
1476
 
882
1477
  if (lastToken) {
@@ -895,7 +1490,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
895
1490
 
896
1491
  return {
897
1492
  sourceBranch,
898
- scannedEvents: incomingEvents.length,
1493
+ scannedEvents,
899
1494
  appliedEvents,
900
1495
  createdConflicts,
901
1496
  cursorToken: lastToken,
@@ -926,7 +1521,14 @@ function parseConflictValue(value: string | null): unknown {
926
1521
  }
927
1522
  }
928
1523
 
929
- function updateSingleField(db: Database, entityKind: string, entityId: string, fieldName: string, value: unknown): void {
1524
+ function updateSingleField(
1525
+ db: Database,
1526
+ entityKind: string,
1527
+ entityId: string,
1528
+ fieldName: string,
1529
+ value: unknown,
1530
+ options: { allowMissing?: boolean } = {},
1531
+ ): void {
930
1532
  const tableName = tableForEntityKind(entityKind);
931
1533
  if (!tableName) {
932
1534
  throw new DomainError({
@@ -946,11 +1548,12 @@ function updateSingleField(db: Database, entityKind: string, entityId: string, f
946
1548
  }
947
1549
 
948
1550
  const now: number = Date.now();
1551
+ const normalizedValue = typeof value === "string" || value === null ? value : JSON.stringify(value);
949
1552
  const result = db
950
1553
  .query(`UPDATE ${tableName} SET ${fieldName} = ?, updated_at = ?, version = version + 1 WHERE id = ?;`)
951
- .run(typeof value === "string" ? value : JSON.stringify(value), now, entityId);
1554
+ .run(normalizedValue, now, entityId);
952
1555
 
953
- if (result.changes === 0) {
1556
+ if (result.changes === 0 && !options.allowMissing) {
954
1557
  throw new DomainError({
955
1558
  code: "row_not_found",
956
1559
  message: `No row updated: entity '${entityKind}' with id '${entityId}' not found in table '${tableName}'`,
@@ -959,7 +1562,12 @@ function updateSingleField(db: Database, entityKind: string, entityId: string, f
959
1562
  }
960
1563
  }
961
1564
 
962
- function deleteSingleEntity(db: Database, entityKind: string, entityId: string): void {
1565
+ function deleteSingleEntity(
1566
+ db: Database,
1567
+ entityKind: string,
1568
+ entityId: string,
1569
+ options: { allowMissing?: boolean } = {},
1570
+ ): void {
963
1571
  const tableName = tableForEntityKind(entityKind);
964
1572
  if (!tableName) {
965
1573
  throw new DomainError({
@@ -971,7 +1579,7 @@ function deleteSingleEntity(db: Database, entityKind: string, entityId: string):
971
1579
 
972
1580
  const result = db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(entityId);
973
1581
 
974
- if (result.changes === 0) {
1582
+ if (result.changes === 0 && !options.allowMissing) {
975
1583
  throw new DomainError({
976
1584
  code: "row_not_found",
977
1585
  message: `No row deleted: entity '${entityKind}' with id '${entityId}' not found in table '${tableName}'`,
@@ -994,11 +1602,7 @@ function resolveConflictRow(
994
1602
  git: ResolutionWriteContext,
995
1603
  ): void {
996
1604
  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
- }
1605
+ applyConflictTheirsResolution(db, conflict);
1002
1606
  }
1003
1607
 
1004
1608
  const now: number = nextEventTimestamp(db);
@@ -1043,6 +1647,7 @@ function appendResolutionEvent(
1043
1647
  conflict.entity_id,
1044
1648
  JSON.stringify({
1045
1649
  conflict_id: conflict.id,
1650
+ source_event_id: conflict.event_id,
1046
1651
  field: conflict.field_name,
1047
1652
  resolution,
1048
1653
  value: resolvedValue,
@@ -1210,10 +1815,10 @@ export function syncResolvePreview(cwd: string, conflictId: string, resolution:
1210
1815
  }
1211
1816
  }
1212
1817
 
1213
- function queryPendingConflicts(
1818
+ function queryPendingConflictIds(
1214
1819
  db: Database,
1215
1820
  filters: ResolveAllQueryFilters,
1216
- ): readonly ConflictRow[] {
1821
+ ): readonly string[] {
1217
1822
  const conditions: string[] = ["resolution = 'pending'"];
1218
1823
  const params: string[] = [];
1219
1824
 
@@ -1228,14 +1833,14 @@ function queryPendingConflicts(
1228
1833
  }
1229
1834
 
1230
1835
  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
1836
+ SELECT c.id
1232
1837
  FROM sync_conflicts c
1233
1838
  LEFT JOIN events e ON e.id = c.event_id
1234
1839
  WHERE ${conditions.map((condition) => condition.replaceAll("resolution", "c.resolution").replaceAll("entity_id", "c.entity_id").replaceAll("field_name", "c.field_name")).join(" AND ")}
1235
1840
  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
1841
  `;
1237
1842
 
1238
- return db.query(sql).all(...params) as ConflictRow[];
1843
+ return (db.query(sql).all(...params) as ConflictOrderRow[]).map((row) => row.id);
1239
1844
  }
1240
1845
 
1241
1846
  function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]): readonly ConflictRow[] {
@@ -1277,11 +1882,9 @@ export function syncResolveAll(
1277
1882
 
1278
1883
  const resolvedIds: string[] = writeTransaction(storage.db, (): string[] => {
1279
1884
  const expectedConflictIds = options.expectedConflictIds;
1280
- const conflicts = expectedConflictIds
1281
- ? queryPendingConflictsByIds(storage.db, expectedConflictIds)
1282
- : queryPendingConflicts(storage.db, filters);
1885
+ const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters);
1283
1886
 
1284
- if (conflicts.length === 0) {
1887
+ if (orderedConflictIds.length === 0) {
1285
1888
  throw new DomainError({
1286
1889
  code: "no_matching_conflicts",
1287
1890
  message: "No pending conflicts match the given filters.",
@@ -1289,23 +1892,28 @@ export function syncResolveAll(
1289
1892
  });
1290
1893
  }
1291
1894
 
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
1895
  const ids: string[] = [];
1305
1896
 
1306
- for (const conflict of conflicts) {
1307
- resolveConflictRow(storage.db, conflict, resolution, git);
1308
- ids.push(conflict.id);
1897
+ for (let offset = 0; offset < orderedConflictIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
1898
+ const chunkIds = orderedConflictIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
1899
+ const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds);
1900
+
1901
+ if (chunkConflicts.length !== chunkIds.length) {
1902
+ throw new DomainError({
1903
+ code: "conflict_set_changed",
1904
+ message: "Pending conflicts changed before batch resolution could be applied.",
1905
+ details: {
1906
+ filters: normalizedFilters,
1907
+ expectedConflictIds: chunkIds,
1908
+ availableConflictIds: chunkConflicts.map((conflict) => conflict.id),
1909
+ },
1910
+ });
1911
+ }
1912
+
1913
+ for (const conflict of chunkConflicts) {
1914
+ resolveConflictRow(storage.db, conflict, resolution, git);
1915
+ ids.push(conflict.id);
1916
+ }
1309
1917
  }
1310
1918
 
1311
1919
  return ids;
@@ -1331,9 +1939,9 @@ export function syncResolveAllPreview(
1331
1939
  const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
1332
1940
 
1333
1941
  try {
1334
- const conflicts = queryPendingConflicts(storage.db, filters);
1942
+ const conflictIds = queryPendingConflictIds(storage.db, filters);
1335
1943
 
1336
- if (conflicts.length === 0) {
1944
+ if (conflictIds.length === 0) {
1337
1945
  throw new DomainError({
1338
1946
  code: "no_matching_conflicts",
1339
1947
  message: "No pending conflicts match the given filters.",
@@ -1343,8 +1951,8 @@ export function syncResolveAllPreview(
1343
1951
 
1344
1952
  return {
1345
1953
  resolution,
1346
- matchedCount: conflicts.length,
1347
- matchedIds: conflicts.map((c) => c.id),
1954
+ matchedCount: conflictIds.length,
1955
+ matchedIds: conflictIds,
1348
1956
  filters: normalizedFilters,
1349
1957
  dryRun: true,
1350
1958
  };