trekoon 0.3.6 → 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.
- package/package.json +1 -1
- package/src/board/assets/app.js +11 -0
- package/src/board/assets/components/Notice.js +18 -4
- package/src/board/assets/state/actions.js +6 -6
- package/src/board/assets/state/api.js +151 -26
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +73 -13
- package/src/board/routes.ts +392 -52
- package/src/board/snapshot.ts +151 -168
- package/src/commands/events.ts +17 -11
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +310 -26
- package/src/domain/tracker-domain.ts +169 -5
- package/src/storage/migrations.ts +98 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +17 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +43 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +578 -149
package/src/sync/service.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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,
|
|
409
|
+
SELECT payload, created_at, id
|
|
359
410
|
FROM events
|
|
360
|
-
WHERE entity_kind = ?
|
|
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
|
|
419
|
+
LIMIT ?;
|
|
363
420
|
`,
|
|
364
421
|
)
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
if (!payloadValidation.ok) {
|
|
370
|
-
continue;
|
|
432
|
+
if (rows.length === 0) {
|
|
433
|
+
return null;
|
|
371
434
|
}
|
|
372
435
|
|
|
373
|
-
const
|
|
374
|
-
|
|
436
|
+
for (const row of rows) {
|
|
437
|
+
const payloadValidation = parsePayload(row.payload);
|
|
438
|
+
if (!payloadValidation.ok) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
375
441
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
442
|
+
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
443
|
+
const localValue: unknown = readFieldValue(payload, fieldName);
|
|
379
444
|
|
|
380
|
-
|
|
381
|
-
|
|
445
|
+
if (typeof localValue === "undefined") {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
382
448
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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]) =>
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
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
|
-
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
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
|
|
843
|
-
|
|
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
|
-
|
|
846
|
-
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
857
|
-
|
|
1257
|
+
const fieldsToApply: Record<string, unknown> = {};
|
|
1258
|
+
let withheldConflictCount = 0;
|
|
858
1259
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
1639
|
+
function queryPendingConflictIds(
|
|
1214
1640
|
db: Database,
|
|
1215
1641
|
filters: ResolveAllQueryFilters,
|
|
1216
|
-
): readonly
|
|
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
|
|
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
|
|
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
|
|
1281
|
-
? queryPendingConflictsByIds(storage.db, expectedConflictIds)
|
|
1282
|
-
: queryPendingConflicts(storage.db, filters);
|
|
1706
|
+
const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters);
|
|
1283
1707
|
|
|
1284
|
-
if (
|
|
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 (
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
|
1763
|
+
const conflictIds = queryPendingConflictIds(storage.db, filters);
|
|
1335
1764
|
|
|
1336
|
-
if (
|
|
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:
|
|
1347
|
-
matchedIds:
|
|
1775
|
+
matchedCount: conflictIds.length,
|
|
1776
|
+
matchedIds: conflictIds,
|
|
1348
1777
|
filters: normalizedFilters,
|
|
1349
1778
|
dryRun: true,
|
|
1350
1779
|
};
|