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.
- package/.agents/skills/trekoon/SKILL.md +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +39 -14
- package/docs/quickstart.md +21 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +19 -25
- 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 +155 -31
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +123 -30
- package/src/board/routes.ts +397 -54
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +205 -173
- package/src/commands/board.ts +1 -1
- package/src/commands/events.ts +17 -11
- package/src/commands/quickstart.ts +10 -0
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +452 -54
- package/src/domain/tracker-domain.ts +185 -7
- package/src/storage/migrations.ts +123 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +18 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +47 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +759 -151
package/src/sync/service.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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,
|
|
450
|
+
SELECT payload, created_at, id
|
|
359
451
|
FROM events
|
|
360
|
-
WHERE entity_kind = ?
|
|
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
|
|
460
|
+
LIMIT ?;
|
|
363
461
|
`,
|
|
364
462
|
)
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
if (
|
|
370
|
-
|
|
473
|
+
const incomingDependencyIdentity = dependencyEventIdentity(event);
|
|
474
|
+
|
|
475
|
+
if (rows.length === 0) {
|
|
476
|
+
return null;
|
|
371
477
|
}
|
|
372
478
|
|
|
373
|
-
const
|
|
374
|
-
|
|
479
|
+
for (const row of rows) {
|
|
480
|
+
const payloadValidation = parsePayload(row.payload);
|
|
481
|
+
if (!payloadValidation.ok) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
375
484
|
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
381
|
-
|
|
498
|
+
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
499
|
+
const localValue: unknown = readFieldValue(payload, fieldName);
|
|
382
500
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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(
|
|
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]) =>
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
843
|
-
|
|
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
|
-
|
|
846
|
-
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
857
|
-
|
|
1431
|
+
const fieldsToApply: Record<string, unknown> = {};
|
|
1432
|
+
let withheldConflictCount = 0;
|
|
858
1433
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
1818
|
+
function queryPendingConflictIds(
|
|
1214
1819
|
db: Database,
|
|
1215
1820
|
filters: ResolveAllQueryFilters,
|
|
1216
|
-
): readonly
|
|
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
|
|
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
|
|
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
|
|
1281
|
-
? queryPendingConflictsByIds(storage.db, expectedConflictIds)
|
|
1282
|
-
: queryPendingConflicts(storage.db, filters);
|
|
1885
|
+
const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters);
|
|
1283
1886
|
|
|
1284
|
-
if (
|
|
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 (
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
|
1942
|
+
const conflictIds = queryPendingConflictIds(storage.db, filters);
|
|
1335
1943
|
|
|
1336
|
-
if (
|
|
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:
|
|
1347
|
-
matchedIds:
|
|
1954
|
+
matchedCount: conflictIds.length,
|
|
1955
|
+
matchedIds: conflictIds,
|
|
1348
1956
|
filters: normalizedFilters,
|
|
1349
1957
|
dryRun: true,
|
|
1350
1958
|
};
|