trekoon 0.1.5 → 0.1.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/README.md +24 -2
- package/package.json +1 -1
- package/src/commands/dep.ts +5 -3
- package/src/commands/epic.ts +7 -5
- package/src/commands/help.ts +1 -1
- package/src/commands/skills.ts +383 -39
- package/src/commands/subtask.ts +7 -5
- package/src/commands/sync.ts +102 -3
- package/src/commands/task.ts +7 -5
- package/src/domain/mutation-operations.ts +27 -0
- package/src/domain/mutation-service.ts +169 -0
- package/src/storage/migrations.ts +48 -1
- package/src/sync/service.ts +417 -73
- package/src/sync/types.ts +44 -0
package/src/sync/service.ts
CHANGED
|
@@ -5,7 +5,15 @@ import { type Database } from "bun:sqlite";
|
|
|
5
5
|
import { openTrekoonDatabase } from "../storage/database";
|
|
6
6
|
import { openBranchDatabaseSnapshot } from "./branch-db";
|
|
7
7
|
import { persistGitContext, resolveGitContext } from "./git-context";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type PullSummary,
|
|
10
|
+
type ResolveSummary,
|
|
11
|
+
type SyncConflictDetail,
|
|
12
|
+
type SyncConflictListItem,
|
|
13
|
+
type SyncConflictMode,
|
|
14
|
+
type SyncResolution,
|
|
15
|
+
type SyncStatusSummary,
|
|
16
|
+
} from "./types";
|
|
9
17
|
|
|
10
18
|
interface StoredEvent {
|
|
11
19
|
readonly id: string;
|
|
@@ -35,32 +43,62 @@ interface ConflictRow {
|
|
|
35
43
|
readonly ours_value: string | null;
|
|
36
44
|
readonly theirs_value: string | null;
|
|
37
45
|
readonly resolution: string;
|
|
46
|
+
readonly created_at: number;
|
|
47
|
+
readonly updated_at: number;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
interface EventPayload {
|
|
41
|
-
readonly fields
|
|
51
|
+
readonly fields: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PayloadValidation {
|
|
55
|
+
readonly ok: boolean;
|
|
56
|
+
readonly fields: Record<string, unknown>;
|
|
57
|
+
readonly reason?: string;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
45
61
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
function parsePayload(rawPayload: string):
|
|
64
|
+
function parsePayload(rawPayload: string): PayloadValidation {
|
|
49
65
|
try {
|
|
50
66
|
const parsed: unknown = JSON.parse(rawPayload);
|
|
51
67
|
|
|
52
|
-
if (isObjectRecord(parsed)) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
if (!isObjectRecord(parsed)) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
fields: {},
|
|
72
|
+
reason: "payload must be a JSON object",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
56
75
|
|
|
57
|
-
|
|
76
|
+
if (!("fields" in parsed)) {
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
fields: {},
|
|
80
|
+
};
|
|
58
81
|
}
|
|
82
|
+
|
|
83
|
+
if (!isObjectRecord(parsed.fields)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
fields: {},
|
|
87
|
+
reason: "payload.fields must be an object",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
fields: parsed.fields,
|
|
94
|
+
};
|
|
59
95
|
} catch {
|
|
60
|
-
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
fields: {},
|
|
99
|
+
reason: "payload is not valid JSON",
|
|
100
|
+
};
|
|
61
101
|
}
|
|
62
|
-
|
|
63
|
-
return {};
|
|
64
102
|
}
|
|
65
103
|
|
|
66
104
|
function tableForEntityKind(entityKind: string): "epics" | "tasks" | "subtasks" | "dependencies" | null {
|
|
@@ -187,26 +225,59 @@ function countBehind(remoteDb: Database, cursorToken: string): number {
|
|
|
187
225
|
return row?.count ?? 0;
|
|
188
226
|
}
|
|
189
227
|
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
228
|
+
function countAhead(localDb: Database, currentBranch: string | null, remoteDbPath: string): number {
|
|
229
|
+
if (!currentBranch) {
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
194
232
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
233
|
+
localDb.query("ATTACH DATABASE ? AS sync_remote;").run(remoteDbPath);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const row = localDb
|
|
237
|
+
.query(
|
|
238
|
+
`
|
|
239
|
+
SELECT COUNT(*) AS count
|
|
240
|
+
FROM events AS local_events
|
|
241
|
+
WHERE local_events.git_branch = @branch
|
|
242
|
+
AND NOT EXISTS (
|
|
243
|
+
SELECT 1
|
|
244
|
+
FROM sync_remote.events AS remote_events
|
|
245
|
+
WHERE remote_events.id = local_events.id
|
|
246
|
+
);
|
|
247
|
+
`,
|
|
248
|
+
)
|
|
249
|
+
.get({ "@branch": currentBranch }) as { count: number } | null;
|
|
199
250
|
|
|
200
|
-
|
|
251
|
+
return row?.count ?? 0;
|
|
252
|
+
} finally {
|
|
253
|
+
localDb.query("DETACH DATABASE sync_remote;").run();
|
|
254
|
+
}
|
|
201
255
|
}
|
|
202
256
|
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
257
|
+
function buildSyncErrorHints(diagnostics: {
|
|
258
|
+
malformedPayloadEvents: number;
|
|
259
|
+
applyRejectedEvents: number;
|
|
260
|
+
conflictEvents: number;
|
|
261
|
+
}): string[] {
|
|
262
|
+
const hints: string[] = [];
|
|
263
|
+
|
|
264
|
+
if (diagnostics.malformedPayloadEvents > 0) {
|
|
265
|
+
hints.push("Malformed event payloads were quarantined; inspect sync conflicts with field '__payload__'.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (diagnostics.applyRejectedEvents > 0) {
|
|
269
|
+
hints.push("Some events were quarantined as invalid; inspect sync conflicts with field '__apply__'.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (diagnostics.conflictEvents > 0) {
|
|
273
|
+
hints.push("Field-level conflicts detected; run 'trekoon sync conflicts list' and resolve pending entries.");
|
|
207
274
|
}
|
|
208
275
|
|
|
209
|
-
return
|
|
276
|
+
return hints;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readFieldValue(payload: EventPayload, field: string): unknown {
|
|
280
|
+
return payload.fields[field];
|
|
210
281
|
}
|
|
211
282
|
|
|
212
283
|
function serializeValue(value: unknown): string | null {
|
|
@@ -217,6 +288,31 @@ function serializeValue(value: unknown): string | null {
|
|
|
217
288
|
return JSON.stringify(value);
|
|
218
289
|
}
|
|
219
290
|
|
|
291
|
+
function currentEntityFieldValue(db: Database, entityKind: string, entityId: string, fieldName: string): unknown {
|
|
292
|
+
const tableName = tableForEntityKind(entityKind);
|
|
293
|
+
if (!tableName) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const allowedFields: Record<string, readonly string[]> = {
|
|
298
|
+
epics: ["title", "description", "status"],
|
|
299
|
+
tasks: ["epic_id", "title", "description", "status"],
|
|
300
|
+
subtasks: ["task_id", "title", "description", "status"],
|
|
301
|
+
dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const validFields = allowedFields[tableName] ?? [];
|
|
305
|
+
if (!validFields.includes(fieldName)) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const row = db.query(`SELECT ${fieldName} AS value FROM ${tableName} WHERE id = ? LIMIT 1;`).get(entityId) as
|
|
310
|
+
| { value: string }
|
|
311
|
+
| null;
|
|
312
|
+
|
|
313
|
+
return row?.value;
|
|
314
|
+
}
|
|
315
|
+
|
|
220
316
|
function entityFieldConflict(
|
|
221
317
|
localDb: Database,
|
|
222
318
|
sourceBranch: string,
|
|
@@ -224,6 +320,11 @@ function entityFieldConflict(
|
|
|
224
320
|
fieldName: string,
|
|
225
321
|
incomingValue: unknown,
|
|
226
322
|
): { oursValue: string | null; theirsValue: string | null } | null {
|
|
323
|
+
const currentValue = currentEntityFieldValue(localDb, event.entity_kind, event.entity_id, fieldName);
|
|
324
|
+
if (serializeValue(currentValue) === serializeValue(incomingValue)) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
227
328
|
const rows = localDb
|
|
228
329
|
.query(
|
|
229
330
|
`
|
|
@@ -241,7 +342,12 @@ function entityFieldConflict(
|
|
|
241
342
|
continue;
|
|
242
343
|
}
|
|
243
344
|
|
|
244
|
-
const
|
|
345
|
+
const payloadValidation = parsePayload(row.payload);
|
|
346
|
+
if (!payloadValidation.ok) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
245
351
|
const localValue: unknown = readFieldValue(payload, fieldName);
|
|
246
352
|
|
|
247
353
|
if (typeof localValue === "undefined") {
|
|
@@ -268,6 +374,7 @@ function createConflict(
|
|
|
268
374
|
fieldName: string,
|
|
269
375
|
oursValue: string | null,
|
|
270
376
|
theirsValue: string | null,
|
|
377
|
+
resolution: string = "pending",
|
|
271
378
|
): void {
|
|
272
379
|
const now: number = Date.now();
|
|
273
380
|
db.query(
|
|
@@ -284,12 +391,26 @@ function createConflict(
|
|
|
284
391
|
created_at,
|
|
285
392
|
updated_at,
|
|
286
393
|
version
|
|
287
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?,
|
|
394
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1);
|
|
288
395
|
`,
|
|
289
|
-
).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, now, now);
|
|
396
|
+
).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, resolution, now, now);
|
|
290
397
|
}
|
|
291
398
|
|
|
292
|
-
function
|
|
399
|
+
function rowExists(db: Database, tableName: string, id: string): boolean {
|
|
400
|
+
const row = db.query(`SELECT id FROM ${tableName} WHERE id = ? LIMIT 1;`).get(id) as { id: string } | null;
|
|
401
|
+
return row !== null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function validateRequiredStringField(fields: Record<string, unknown>, fieldName: string): string | null {
|
|
405
|
+
const value: unknown = fields[fieldName];
|
|
406
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function applyCreate(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
293
414
|
const tableName = tableForEntityKind(event.entity_kind);
|
|
294
415
|
if (!tableName) {
|
|
295
416
|
return false;
|
|
@@ -298,10 +419,17 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
298
419
|
const now: number = Date.now();
|
|
299
420
|
|
|
300
421
|
if (tableName === "epics") {
|
|
422
|
+
const title = validateRequiredStringField(fields, "title");
|
|
423
|
+
const status = validateRequiredStringField(fields, "status");
|
|
424
|
+
if (!title || !status) {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const description = typeof fields.description === "string" ? fields.description : "";
|
|
301
429
|
db.query(
|
|
302
430
|
`
|
|
303
431
|
INSERT INTO epics (id, title, description, status, created_at, updated_at, version)
|
|
304
|
-
VALUES (
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
305
433
|
ON CONFLICT(id) DO UPDATE SET
|
|
306
434
|
title = excluded.title,
|
|
307
435
|
description = excluded.description,
|
|
@@ -309,22 +437,24 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
309
437
|
updated_at = excluded.updated_at,
|
|
310
438
|
version = epics.version + 1;
|
|
311
439
|
`,
|
|
312
|
-
).run(
|
|
313
|
-
"@id": event.entity_id,
|
|
314
|
-
"@title": typeof fields.title === "string" ? fields.title : "Untitled epic",
|
|
315
|
-
"@description": typeof fields.description === "string" ? fields.description : "",
|
|
316
|
-
"@status": typeof fields.status === "string" ? fields.status : "open",
|
|
317
|
-
"@now": now,
|
|
318
|
-
});
|
|
440
|
+
).run(event.entity_id, title, description, status, now, now);
|
|
319
441
|
|
|
320
442
|
return true;
|
|
321
443
|
}
|
|
322
444
|
|
|
323
445
|
if (tableName === "tasks") {
|
|
446
|
+
const epicId = validateRequiredStringField(fields, "epic_id");
|
|
447
|
+
const title = validateRequiredStringField(fields, "title");
|
|
448
|
+
const status = validateRequiredStringField(fields, "status");
|
|
449
|
+
if (!epicId || !title || !status || !rowExists(db, "epics", epicId)) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const description = typeof fields.description === "string" ? fields.description : "";
|
|
324
454
|
db.query(
|
|
325
455
|
`
|
|
326
456
|
INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version)
|
|
327
|
-
VALUES (
|
|
457
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
328
458
|
ON CONFLICT(id) DO UPDATE SET
|
|
329
459
|
epic_id = excluded.epic_id,
|
|
330
460
|
title = excluded.title,
|
|
@@ -333,23 +463,24 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
333
463
|
updated_at = excluded.updated_at,
|
|
334
464
|
version = tasks.version + 1;
|
|
335
465
|
`,
|
|
336
|
-
).run(
|
|
337
|
-
"@id": event.entity_id,
|
|
338
|
-
"@epicId": typeof fields.epic_id === "string" ? fields.epic_id : "missing-epic",
|
|
339
|
-
"@title": typeof fields.title === "string" ? fields.title : "Untitled task",
|
|
340
|
-
"@description": typeof fields.description === "string" ? fields.description : "",
|
|
341
|
-
"@status": typeof fields.status === "string" ? fields.status : "open",
|
|
342
|
-
"@now": now,
|
|
343
|
-
});
|
|
466
|
+
).run(event.entity_id, epicId, title, description, status, now, now);
|
|
344
467
|
|
|
345
468
|
return true;
|
|
346
469
|
}
|
|
347
470
|
|
|
348
471
|
if (tableName === "subtasks") {
|
|
472
|
+
const taskId = validateRequiredStringField(fields, "task_id");
|
|
473
|
+
const title = validateRequiredStringField(fields, "title");
|
|
474
|
+
const status = validateRequiredStringField(fields, "status");
|
|
475
|
+
if (!taskId || !title || !status || !rowExists(db, "tasks", taskId)) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const description = typeof fields.description === "string" ? fields.description : "";
|
|
349
480
|
db.query(
|
|
350
481
|
`
|
|
351
482
|
INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version)
|
|
352
|
-
VALUES (
|
|
483
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
353
484
|
ON CONFLICT(id) DO UPDATE SET
|
|
354
485
|
task_id = excluded.task_id,
|
|
355
486
|
title = excluded.title,
|
|
@@ -358,18 +489,20 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
358
489
|
updated_at = excluded.updated_at,
|
|
359
490
|
version = subtasks.version + 1;
|
|
360
491
|
`,
|
|
361
|
-
).run(
|
|
362
|
-
"@id": event.entity_id,
|
|
363
|
-
"@taskId": typeof fields.task_id === "string" ? fields.task_id : "missing-task",
|
|
364
|
-
"@title": typeof fields.title === "string" ? fields.title : "Untitled subtask",
|
|
365
|
-
"@description": typeof fields.description === "string" ? fields.description : "",
|
|
366
|
-
"@status": typeof fields.status === "string" ? fields.status : "open",
|
|
367
|
-
"@now": now,
|
|
368
|
-
});
|
|
492
|
+
).run(event.entity_id, taskId, title, description, status, now, now);
|
|
369
493
|
|
|
370
494
|
return true;
|
|
371
495
|
}
|
|
372
496
|
|
|
497
|
+
const sourceId = validateRequiredStringField(fields, "source_id");
|
|
498
|
+
const sourceKind = validateRequiredStringField(fields, "source_kind");
|
|
499
|
+
const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
|
|
500
|
+
const dependsOnKind = validateRequiredStringField(fields, "depends_on_kind");
|
|
501
|
+
|
|
502
|
+
if (!sourceId || !sourceKind || !dependsOnId || !dependsOnKind) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
373
506
|
db.query(
|
|
374
507
|
`
|
|
375
508
|
INSERT INTO dependencies (
|
|
@@ -381,8 +514,7 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
381
514
|
created_at,
|
|
382
515
|
updated_at,
|
|
383
516
|
version
|
|
384
|
-
)
|
|
385
|
-
VALUES (@id, @sourceId, @sourceKind, @dependsOnId, @dependsOnKind, @now, @now, 1)
|
|
517
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
386
518
|
ON CONFLICT(id) DO UPDATE SET
|
|
387
519
|
source_id = excluded.source_id,
|
|
388
520
|
source_kind = excluded.source_kind,
|
|
@@ -391,18 +523,108 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
391
523
|
updated_at = excluded.updated_at,
|
|
392
524
|
version = dependencies.version + 1;
|
|
393
525
|
`,
|
|
394
|
-
).run(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
526
|
+
).run(event.entity_id, sourceId, sourceKind, dependsOnId, dependsOnKind, now, now);
|
|
527
|
+
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
532
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
533
|
+
if (!tableName) {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const allowedFields: Record<string, readonly string[]> = {
|
|
538
|
+
epics: ["title", "description", "status"],
|
|
539
|
+
tasks: ["epic_id", "title", "description", "status"],
|
|
540
|
+
subtasks: ["task_id", "title", "description", "status"],
|
|
541
|
+
dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
if (!rowExists(db, tableName, event.entity_id)) {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const allowed = new Set(allowedFields[tableName] ?? []);
|
|
549
|
+
const entries = Object.entries(fields).filter(([fieldName, value]) => allowed.has(fieldName) && typeof value === "string");
|
|
550
|
+
|
|
551
|
+
if (entries.length === 0) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (tableName === "tasks") {
|
|
556
|
+
const epicIdEntry = entries.find(([field]) => field === "epic_id");
|
|
557
|
+
if (epicIdEntry && !rowExists(db, "epics", epicIdEntry[1] as string)) {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (tableName === "subtasks") {
|
|
563
|
+
const taskIdEntry = entries.find(([field]) => field === "task_id");
|
|
564
|
+
if (taskIdEntry && !rowExists(db, "tasks", taskIdEntry[1] as string)) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const now = Date.now();
|
|
570
|
+
const setClause = entries.map(([field]) => `${field} = ?`).join(", ");
|
|
571
|
+
const values = entries.map(([, value]) => value as string);
|
|
572
|
+
|
|
573
|
+
db.query(`UPDATE ${tableName} SET ${setClause}, updated_at = ?, version = version + 1 WHERE id = ?;`).run(
|
|
574
|
+
...values,
|
|
575
|
+
now,
|
|
576
|
+
event.entity_id,
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function applyDelete(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
583
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
584
|
+
if (!tableName) {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
402
587
|
|
|
588
|
+
if (event.operation === "dependency.removed") {
|
|
589
|
+
const sourceId = validateRequiredStringField(fields, "source_id");
|
|
590
|
+
const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
|
|
591
|
+
if (!sourceId || !dependsOnId) {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
db.query("DELETE FROM dependencies WHERE source_id = ? AND depends_on_id = ?;").run(sourceId, dependsOnId);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(event.entity_id);
|
|
403
600
|
return true;
|
|
404
601
|
}
|
|
405
602
|
|
|
603
|
+
function applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
604
|
+
if (event.operation.endsWith(".deleted") || event.operation === "dependency.removed") {
|
|
605
|
+
return applyDelete(db, event, fields);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (event.operation.endsWith(".created") || event.operation === "dependency.added") {
|
|
609
|
+
return applyCreate(db, event, fields);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (event.operation.endsWith(".updated")) {
|
|
613
|
+
return applyUpdatePatch(db, event, fields);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Backward-compatible fallback for old upsert events.
|
|
617
|
+
if (event.operation === "upsert") {
|
|
618
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
619
|
+
if (!tableName || !rowExists(db, tableName, event.entity_id)) {
|
|
620
|
+
return applyCreate(db, event, fields);
|
|
621
|
+
}
|
|
622
|
+
return applyUpdatePatch(db, event, fields);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
406
628
|
function storeEvent(db: Database, event: StoredEvent): void {
|
|
407
629
|
db.query(
|
|
408
630
|
`
|
|
@@ -447,7 +669,7 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
|
|
|
447
669
|
try {
|
|
448
670
|
return {
|
|
449
671
|
sourceBranch,
|
|
450
|
-
ahead: countAhead(storage.db, git.branchName,
|
|
672
|
+
ahead: countAhead(storage.db, git.branchName, remote.path),
|
|
451
673
|
behind: countBehind(remote.db, cursorToken),
|
|
452
674
|
pendingConflicts: countPendingConflicts(storage.db),
|
|
453
675
|
git,
|
|
@@ -474,31 +696,65 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
474
696
|
|
|
475
697
|
let appliedEvents = 0;
|
|
476
698
|
let createdConflicts = 0;
|
|
699
|
+
let malformedPayloadEvents = 0;
|
|
700
|
+
let applyRejectedEvents = 0;
|
|
701
|
+
let quarantinedEvents = 0;
|
|
702
|
+
let conflictEvents = 0;
|
|
477
703
|
let lastToken: string | null = null;
|
|
478
704
|
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
479
705
|
|
|
480
706
|
storage.db.transaction((): void => {
|
|
481
707
|
for (const incoming of incomingEvents) {
|
|
482
|
-
const
|
|
483
|
-
|
|
708
|
+
const payloadValidation = parsePayload(incoming.payload);
|
|
709
|
+
|
|
710
|
+
if (!payloadValidation.ok) {
|
|
711
|
+
malformedPayloadEvents += 1;
|
|
712
|
+
quarantinedEvents += 1;
|
|
713
|
+
createConflict(
|
|
714
|
+
storage.db,
|
|
715
|
+
incoming,
|
|
716
|
+
"__payload__",
|
|
717
|
+
null,
|
|
718
|
+
payloadValidation.reason ?? "Invalid payload",
|
|
719
|
+
"invalid",
|
|
720
|
+
);
|
|
721
|
+
createdConflicts += 1;
|
|
722
|
+
storeEvent(storage.db, incoming);
|
|
723
|
+
lastToken = cursorTokenFromEvent(incoming);
|
|
724
|
+
lastEventAt = incoming.created_at;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
484
729
|
const fieldsToApply: Record<string, unknown> = {};
|
|
485
|
-
let hasAppliedField = false;
|
|
486
730
|
|
|
487
|
-
for (const [fieldName, value] of Object.entries(
|
|
731
|
+
for (const [fieldName, value] of Object.entries(payload.fields)) {
|
|
488
732
|
const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
|
|
489
733
|
|
|
490
734
|
if (conflict) {
|
|
735
|
+
conflictEvents += 1;
|
|
491
736
|
createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
|
|
492
737
|
createdConflicts += 1;
|
|
493
738
|
continue;
|
|
494
739
|
}
|
|
495
740
|
|
|
496
741
|
fieldsToApply[fieldName] = value;
|
|
497
|
-
hasAppliedField = true;
|
|
498
742
|
}
|
|
499
743
|
|
|
500
|
-
if (
|
|
744
|
+
if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
|
|
501
745
|
appliedEvents += 1;
|
|
746
|
+
} else if (incoming.operation !== "resolve_conflict") {
|
|
747
|
+
applyRejectedEvents += 1;
|
|
748
|
+
quarantinedEvents += 1;
|
|
749
|
+
createConflict(
|
|
750
|
+
storage.db,
|
|
751
|
+
incoming,
|
|
752
|
+
"__apply__",
|
|
753
|
+
null,
|
|
754
|
+
`Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
|
|
755
|
+
"invalid",
|
|
756
|
+
);
|
|
757
|
+
createdConflicts += 1;
|
|
502
758
|
}
|
|
503
759
|
|
|
504
760
|
storeEvent(storage.db, incoming);
|
|
@@ -517,6 +773,17 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
517
773
|
appliedEvents,
|
|
518
774
|
createdConflicts,
|
|
519
775
|
cursorToken: lastToken,
|
|
776
|
+
diagnostics: {
|
|
777
|
+
malformedPayloadEvents,
|
|
778
|
+
applyRejectedEvents,
|
|
779
|
+
quarantinedEvents,
|
|
780
|
+
conflictEvents,
|
|
781
|
+
errorHints: buildSyncErrorHints({
|
|
782
|
+
malformedPayloadEvents,
|
|
783
|
+
applyRejectedEvents,
|
|
784
|
+
conflictEvents,
|
|
785
|
+
}),
|
|
786
|
+
},
|
|
520
787
|
};
|
|
521
788
|
} finally {
|
|
522
789
|
remote.close();
|
|
@@ -604,6 +871,83 @@ function appendResolutionEvent(
|
|
|
604
871
|
);
|
|
605
872
|
}
|
|
606
873
|
|
|
874
|
+
export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConflictListItem[] {
|
|
875
|
+
const storage = openTrekoonDatabase(cwd);
|
|
876
|
+
|
|
877
|
+
try {
|
|
878
|
+
const whereClause = mode === "pending" ? "WHERE resolution = 'pending'" : "";
|
|
879
|
+
return storage.db
|
|
880
|
+
.query(
|
|
881
|
+
`
|
|
882
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
883
|
+
FROM sync_conflicts
|
|
884
|
+
${whereClause}
|
|
885
|
+
ORDER BY created_at ASC;
|
|
886
|
+
`,
|
|
887
|
+
)
|
|
888
|
+
.all() as SyncConflictListItem[];
|
|
889
|
+
} finally {
|
|
890
|
+
storage.close();
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDetail {
|
|
895
|
+
const storage = openTrekoonDatabase(cwd);
|
|
896
|
+
|
|
897
|
+
try {
|
|
898
|
+
const conflict = storage.db
|
|
899
|
+
.query(
|
|
900
|
+
`
|
|
901
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
902
|
+
FROM sync_conflicts
|
|
903
|
+
WHERE id = ?
|
|
904
|
+
LIMIT 1;
|
|
905
|
+
`,
|
|
906
|
+
)
|
|
907
|
+
.get(conflictId) as ConflictRow | null;
|
|
908
|
+
|
|
909
|
+
if (!conflict) {
|
|
910
|
+
throw new Error(`Conflict '${conflictId}' not found.`);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const event = storage.db
|
|
914
|
+
.query(
|
|
915
|
+
`
|
|
916
|
+
SELECT id, operation, payload, git_branch, git_head, created_at
|
|
917
|
+
FROM events
|
|
918
|
+
WHERE id = ?
|
|
919
|
+
LIMIT 1;
|
|
920
|
+
`,
|
|
921
|
+
)
|
|
922
|
+
.get(conflict.event_id) as
|
|
923
|
+
| {
|
|
924
|
+
id: string;
|
|
925
|
+
operation: string;
|
|
926
|
+
payload: string;
|
|
927
|
+
git_branch: string | null;
|
|
928
|
+
git_head: string | null;
|
|
929
|
+
created_at: number;
|
|
930
|
+
}
|
|
931
|
+
| null;
|
|
932
|
+
|
|
933
|
+
return {
|
|
934
|
+
id: conflict.id,
|
|
935
|
+
eventId: conflict.event_id,
|
|
936
|
+
entityKind: conflict.entity_kind,
|
|
937
|
+
entityId: conflict.entity_id,
|
|
938
|
+
fieldName: conflict.field_name,
|
|
939
|
+
oursValue: parseConflictValue(conflict.ours_value),
|
|
940
|
+
theirsValue: parseConflictValue(conflict.theirs_value),
|
|
941
|
+
resolution: conflict.resolution,
|
|
942
|
+
createdAt: conflict.created_at,
|
|
943
|
+
updatedAt: conflict.updated_at,
|
|
944
|
+
event,
|
|
945
|
+
};
|
|
946
|
+
} finally {
|
|
947
|
+
storage.close();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
607
951
|
export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
|
|
608
952
|
const storage = openTrekoonDatabase(cwd);
|
|
609
953
|
const git = resolveGitContext(cwd);
|
|
@@ -614,7 +958,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
614
958
|
const conflict = storage.db
|
|
615
959
|
.query(
|
|
616
960
|
`
|
|
617
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution
|
|
961
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
618
962
|
FROM sync_conflicts
|
|
619
963
|
WHERE id = ?
|
|
620
964
|
LIMIT 1;
|