trekoon 0.1.5 → 0.1.6
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 +11 -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 +210 -29
- package/src/commands/subtask.ts +7 -5
- package/src/commands/sync.ts +98 -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/sync/service.ts +350 -64
- package/src/sync/types.ts +35 -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 {
|
|
@@ -201,12 +239,7 @@ function countAhead(localDb: Database, currentBranch: string | null, remoteEvent
|
|
|
201
239
|
}
|
|
202
240
|
|
|
203
241
|
function readFieldValue(payload: EventPayload, field: string): unknown {
|
|
204
|
-
|
|
205
|
-
if (!fields) {
|
|
206
|
-
return undefined;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return fields[field];
|
|
242
|
+
return payload.fields[field];
|
|
210
243
|
}
|
|
211
244
|
|
|
212
245
|
function serializeValue(value: unknown): string | null {
|
|
@@ -217,6 +250,31 @@ function serializeValue(value: unknown): string | null {
|
|
|
217
250
|
return JSON.stringify(value);
|
|
218
251
|
}
|
|
219
252
|
|
|
253
|
+
function currentEntityFieldValue(db: Database, entityKind: string, entityId: string, fieldName: string): unknown {
|
|
254
|
+
const tableName = tableForEntityKind(entityKind);
|
|
255
|
+
if (!tableName) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const allowedFields: Record<string, readonly string[]> = {
|
|
260
|
+
epics: ["title", "description", "status"],
|
|
261
|
+
tasks: ["epic_id", "title", "description", "status"],
|
|
262
|
+
subtasks: ["task_id", "title", "description", "status"],
|
|
263
|
+
dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const validFields = allowedFields[tableName] ?? [];
|
|
267
|
+
if (!validFields.includes(fieldName)) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const row = db.query(`SELECT ${fieldName} AS value FROM ${tableName} WHERE id = ? LIMIT 1;`).get(entityId) as
|
|
272
|
+
| { value: string }
|
|
273
|
+
| null;
|
|
274
|
+
|
|
275
|
+
return row?.value;
|
|
276
|
+
}
|
|
277
|
+
|
|
220
278
|
function entityFieldConflict(
|
|
221
279
|
localDb: Database,
|
|
222
280
|
sourceBranch: string,
|
|
@@ -224,6 +282,11 @@ function entityFieldConflict(
|
|
|
224
282
|
fieldName: string,
|
|
225
283
|
incomingValue: unknown,
|
|
226
284
|
): { oursValue: string | null; theirsValue: string | null } | null {
|
|
285
|
+
const currentValue = currentEntityFieldValue(localDb, event.entity_kind, event.entity_id, fieldName);
|
|
286
|
+
if (serializeValue(currentValue) === serializeValue(incomingValue)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
227
290
|
const rows = localDb
|
|
228
291
|
.query(
|
|
229
292
|
`
|
|
@@ -241,7 +304,12 @@ function entityFieldConflict(
|
|
|
241
304
|
continue;
|
|
242
305
|
}
|
|
243
306
|
|
|
244
|
-
const
|
|
307
|
+
const payloadValidation = parsePayload(row.payload);
|
|
308
|
+
if (!payloadValidation.ok) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
245
313
|
const localValue: unknown = readFieldValue(payload, fieldName);
|
|
246
314
|
|
|
247
315
|
if (typeof localValue === "undefined") {
|
|
@@ -268,6 +336,7 @@ function createConflict(
|
|
|
268
336
|
fieldName: string,
|
|
269
337
|
oursValue: string | null,
|
|
270
338
|
theirsValue: string | null,
|
|
339
|
+
resolution: string = "pending",
|
|
271
340
|
): void {
|
|
272
341
|
const now: number = Date.now();
|
|
273
342
|
db.query(
|
|
@@ -284,12 +353,26 @@ function createConflict(
|
|
|
284
353
|
created_at,
|
|
285
354
|
updated_at,
|
|
286
355
|
version
|
|
287
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?,
|
|
356
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1);
|
|
288
357
|
`,
|
|
289
|
-
).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, now, now);
|
|
358
|
+
).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, resolution, now, now);
|
|
290
359
|
}
|
|
291
360
|
|
|
292
|
-
function
|
|
361
|
+
function rowExists(db: Database, tableName: string, id: string): boolean {
|
|
362
|
+
const row = db.query(`SELECT id FROM ${tableName} WHERE id = ? LIMIT 1;`).get(id) as { id: string } | null;
|
|
363
|
+
return row !== null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function validateRequiredStringField(fields: Record<string, unknown>, fieldName: string): string | null {
|
|
367
|
+
const value: unknown = fields[fieldName];
|
|
368
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function applyCreate(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
293
376
|
const tableName = tableForEntityKind(event.entity_kind);
|
|
294
377
|
if (!tableName) {
|
|
295
378
|
return false;
|
|
@@ -298,10 +381,17 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
298
381
|
const now: number = Date.now();
|
|
299
382
|
|
|
300
383
|
if (tableName === "epics") {
|
|
384
|
+
const title = validateRequiredStringField(fields, "title");
|
|
385
|
+
const status = validateRequiredStringField(fields, "status");
|
|
386
|
+
if (!title || !status) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const description = typeof fields.description === "string" ? fields.description : "";
|
|
301
391
|
db.query(
|
|
302
392
|
`
|
|
303
393
|
INSERT INTO epics (id, title, description, status, created_at, updated_at, version)
|
|
304
|
-
VALUES (
|
|
394
|
+
VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
305
395
|
ON CONFLICT(id) DO UPDATE SET
|
|
306
396
|
title = excluded.title,
|
|
307
397
|
description = excluded.description,
|
|
@@ -309,22 +399,24 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
309
399
|
updated_at = excluded.updated_at,
|
|
310
400
|
version = epics.version + 1;
|
|
311
401
|
`,
|
|
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
|
-
});
|
|
402
|
+
).run(event.entity_id, title, description, status, now, now);
|
|
319
403
|
|
|
320
404
|
return true;
|
|
321
405
|
}
|
|
322
406
|
|
|
323
407
|
if (tableName === "tasks") {
|
|
408
|
+
const epicId = validateRequiredStringField(fields, "epic_id");
|
|
409
|
+
const title = validateRequiredStringField(fields, "title");
|
|
410
|
+
const status = validateRequiredStringField(fields, "status");
|
|
411
|
+
if (!epicId || !title || !status || !rowExists(db, "epics", epicId)) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const description = typeof fields.description === "string" ? fields.description : "";
|
|
324
416
|
db.query(
|
|
325
417
|
`
|
|
326
418
|
INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version)
|
|
327
|
-
VALUES (
|
|
419
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
328
420
|
ON CONFLICT(id) DO UPDATE SET
|
|
329
421
|
epic_id = excluded.epic_id,
|
|
330
422
|
title = excluded.title,
|
|
@@ -333,23 +425,24 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
333
425
|
updated_at = excluded.updated_at,
|
|
334
426
|
version = tasks.version + 1;
|
|
335
427
|
`,
|
|
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
|
-
});
|
|
428
|
+
).run(event.entity_id, epicId, title, description, status, now, now);
|
|
344
429
|
|
|
345
430
|
return true;
|
|
346
431
|
}
|
|
347
432
|
|
|
348
433
|
if (tableName === "subtasks") {
|
|
434
|
+
const taskId = validateRequiredStringField(fields, "task_id");
|
|
435
|
+
const title = validateRequiredStringField(fields, "title");
|
|
436
|
+
const status = validateRequiredStringField(fields, "status");
|
|
437
|
+
if (!taskId || !title || !status || !rowExists(db, "tasks", taskId)) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const description = typeof fields.description === "string" ? fields.description : "";
|
|
349
442
|
db.query(
|
|
350
443
|
`
|
|
351
444
|
INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version)
|
|
352
|
-
VALUES (
|
|
445
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
353
446
|
ON CONFLICT(id) DO UPDATE SET
|
|
354
447
|
task_id = excluded.task_id,
|
|
355
448
|
title = excluded.title,
|
|
@@ -358,18 +451,20 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
358
451
|
updated_at = excluded.updated_at,
|
|
359
452
|
version = subtasks.version + 1;
|
|
360
453
|
`,
|
|
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
|
-
});
|
|
454
|
+
).run(event.entity_id, taskId, title, description, status, now, now);
|
|
369
455
|
|
|
370
456
|
return true;
|
|
371
457
|
}
|
|
372
458
|
|
|
459
|
+
const sourceId = validateRequiredStringField(fields, "source_id");
|
|
460
|
+
const sourceKind = validateRequiredStringField(fields, "source_kind");
|
|
461
|
+
const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
|
|
462
|
+
const dependsOnKind = validateRequiredStringField(fields, "depends_on_kind");
|
|
463
|
+
|
|
464
|
+
if (!sourceId || !sourceKind || !dependsOnId || !dependsOnKind) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
|
|
373
468
|
db.query(
|
|
374
469
|
`
|
|
375
470
|
INSERT INTO dependencies (
|
|
@@ -381,8 +476,7 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
381
476
|
created_at,
|
|
382
477
|
updated_at,
|
|
383
478
|
version
|
|
384
|
-
)
|
|
385
|
-
VALUES (@id, @sourceId, @sourceKind, @dependsOnId, @dependsOnKind, @now, @now, 1)
|
|
479
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
386
480
|
ON CONFLICT(id) DO UPDATE SET
|
|
387
481
|
source_id = excluded.source_id,
|
|
388
482
|
source_kind = excluded.source_kind,
|
|
@@ -391,18 +485,108 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
391
485
|
updated_at = excluded.updated_at,
|
|
392
486
|
version = dependencies.version + 1;
|
|
393
487
|
`,
|
|
394
|
-
).run(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
488
|
+
).run(event.entity_id, sourceId, sourceKind, dependsOnId, dependsOnKind, now, now);
|
|
489
|
+
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
494
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
495
|
+
if (!tableName) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const allowedFields: Record<string, readonly string[]> = {
|
|
500
|
+
epics: ["title", "description", "status"],
|
|
501
|
+
tasks: ["epic_id", "title", "description", "status"],
|
|
502
|
+
subtasks: ["task_id", "title", "description", "status"],
|
|
503
|
+
dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
if (!rowExists(db, tableName, event.entity_id)) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const allowed = new Set(allowedFields[tableName] ?? []);
|
|
511
|
+
const entries = Object.entries(fields).filter(([fieldName, value]) => allowed.has(fieldName) && typeof value === "string");
|
|
512
|
+
|
|
513
|
+
if (entries.length === 0) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (tableName === "tasks") {
|
|
518
|
+
const epicIdEntry = entries.find(([field]) => field === "epic_id");
|
|
519
|
+
if (epicIdEntry && !rowExists(db, "epics", epicIdEntry[1] as string)) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (tableName === "subtasks") {
|
|
525
|
+
const taskIdEntry = entries.find(([field]) => field === "task_id");
|
|
526
|
+
if (taskIdEntry && !rowExists(db, "tasks", taskIdEntry[1] as string)) {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const now = Date.now();
|
|
532
|
+
const setClause = entries.map(([field]) => `${field} = ?`).join(", ");
|
|
533
|
+
const values = entries.map(([, value]) => value as string);
|
|
534
|
+
|
|
535
|
+
db.query(`UPDATE ${tableName} SET ${setClause}, updated_at = ?, version = version + 1 WHERE id = ?;`).run(
|
|
536
|
+
...values,
|
|
537
|
+
now,
|
|
538
|
+
event.entity_id,
|
|
539
|
+
);
|
|
402
540
|
|
|
403
541
|
return true;
|
|
404
542
|
}
|
|
405
543
|
|
|
544
|
+
function applyDelete(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
545
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
546
|
+
if (!tableName) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (event.operation === "dependency.removed") {
|
|
551
|
+
const sourceId = validateRequiredStringField(fields, "source_id");
|
|
552
|
+
const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
|
|
553
|
+
if (!sourceId || !dependsOnId) {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
db.query("DELETE FROM dependencies WHERE source_id = ? AND depends_on_id = ?;").run(sourceId, dependsOnId);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(event.entity_id);
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
566
|
+
if (event.operation.endsWith(".deleted") || event.operation === "dependency.removed") {
|
|
567
|
+
return applyDelete(db, event, fields);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (event.operation.endsWith(".created") || event.operation === "dependency.added") {
|
|
571
|
+
return applyCreate(db, event, fields);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (event.operation.endsWith(".updated")) {
|
|
575
|
+
return applyUpdatePatch(db, event, fields);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Backward-compatible fallback for old upsert events.
|
|
579
|
+
if (event.operation === "upsert") {
|
|
580
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
581
|
+
if (!tableName || !rowExists(db, tableName, event.entity_id)) {
|
|
582
|
+
return applyCreate(db, event, fields);
|
|
583
|
+
}
|
|
584
|
+
return applyUpdatePatch(db, event, fields);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
406
590
|
function storeEvent(db: Database, event: StoredEvent): void {
|
|
407
591
|
db.query(
|
|
408
592
|
`
|
|
@@ -479,12 +663,28 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
479
663
|
|
|
480
664
|
storage.db.transaction((): void => {
|
|
481
665
|
for (const incoming of incomingEvents) {
|
|
482
|
-
const
|
|
483
|
-
|
|
666
|
+
const payloadValidation = parsePayload(incoming.payload);
|
|
667
|
+
|
|
668
|
+
if (!payloadValidation.ok) {
|
|
669
|
+
createConflict(
|
|
670
|
+
storage.db,
|
|
671
|
+
incoming,
|
|
672
|
+
"__payload__",
|
|
673
|
+
null,
|
|
674
|
+
payloadValidation.reason ?? "Invalid payload",
|
|
675
|
+
"invalid",
|
|
676
|
+
);
|
|
677
|
+
createdConflicts += 1;
|
|
678
|
+
storeEvent(storage.db, incoming);
|
|
679
|
+
lastToken = cursorTokenFromEvent(incoming);
|
|
680
|
+
lastEventAt = incoming.created_at;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
484
685
|
const fieldsToApply: Record<string, unknown> = {};
|
|
485
|
-
let hasAppliedField = false;
|
|
486
686
|
|
|
487
|
-
for (const [fieldName, value] of Object.entries(
|
|
687
|
+
for (const [fieldName, value] of Object.entries(payload.fields)) {
|
|
488
688
|
const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
|
|
489
689
|
|
|
490
690
|
if (conflict) {
|
|
@@ -494,11 +694,20 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
494
694
|
}
|
|
495
695
|
|
|
496
696
|
fieldsToApply[fieldName] = value;
|
|
497
|
-
hasAppliedField = true;
|
|
498
697
|
}
|
|
499
698
|
|
|
500
|
-
if (
|
|
699
|
+
if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
|
|
501
700
|
appliedEvents += 1;
|
|
701
|
+
} else if (incoming.operation !== "resolve_conflict") {
|
|
702
|
+
createConflict(
|
|
703
|
+
storage.db,
|
|
704
|
+
incoming,
|
|
705
|
+
"__apply__",
|
|
706
|
+
null,
|
|
707
|
+
`Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
|
|
708
|
+
"invalid",
|
|
709
|
+
);
|
|
710
|
+
createdConflicts += 1;
|
|
502
711
|
}
|
|
503
712
|
|
|
504
713
|
storeEvent(storage.db, incoming);
|
|
@@ -604,6 +813,83 @@ function appendResolutionEvent(
|
|
|
604
813
|
);
|
|
605
814
|
}
|
|
606
815
|
|
|
816
|
+
export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConflictListItem[] {
|
|
817
|
+
const storage = openTrekoonDatabase(cwd);
|
|
818
|
+
|
|
819
|
+
try {
|
|
820
|
+
const whereClause = mode === "pending" ? "WHERE resolution = 'pending'" : "";
|
|
821
|
+
return storage.db
|
|
822
|
+
.query(
|
|
823
|
+
`
|
|
824
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
825
|
+
FROM sync_conflicts
|
|
826
|
+
${whereClause}
|
|
827
|
+
ORDER BY created_at ASC;
|
|
828
|
+
`,
|
|
829
|
+
)
|
|
830
|
+
.all() as SyncConflictListItem[];
|
|
831
|
+
} finally {
|
|
832
|
+
storage.close();
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDetail {
|
|
837
|
+
const storage = openTrekoonDatabase(cwd);
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
const conflict = storage.db
|
|
841
|
+
.query(
|
|
842
|
+
`
|
|
843
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
844
|
+
FROM sync_conflicts
|
|
845
|
+
WHERE id = ?
|
|
846
|
+
LIMIT 1;
|
|
847
|
+
`,
|
|
848
|
+
)
|
|
849
|
+
.get(conflictId) as ConflictRow | null;
|
|
850
|
+
|
|
851
|
+
if (!conflict) {
|
|
852
|
+
throw new Error(`Conflict '${conflictId}' not found.`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const event = storage.db
|
|
856
|
+
.query(
|
|
857
|
+
`
|
|
858
|
+
SELECT id, operation, payload, git_branch, git_head, created_at
|
|
859
|
+
FROM events
|
|
860
|
+
WHERE id = ?
|
|
861
|
+
LIMIT 1;
|
|
862
|
+
`,
|
|
863
|
+
)
|
|
864
|
+
.get(conflict.event_id) as
|
|
865
|
+
| {
|
|
866
|
+
id: string;
|
|
867
|
+
operation: string;
|
|
868
|
+
payload: string;
|
|
869
|
+
git_branch: string | null;
|
|
870
|
+
git_head: string | null;
|
|
871
|
+
created_at: number;
|
|
872
|
+
}
|
|
873
|
+
| null;
|
|
874
|
+
|
|
875
|
+
return {
|
|
876
|
+
id: conflict.id,
|
|
877
|
+
eventId: conflict.event_id,
|
|
878
|
+
entityKind: conflict.entity_kind,
|
|
879
|
+
entityId: conflict.entity_id,
|
|
880
|
+
fieldName: conflict.field_name,
|
|
881
|
+
oursValue: parseConflictValue(conflict.ours_value),
|
|
882
|
+
theirsValue: parseConflictValue(conflict.theirs_value),
|
|
883
|
+
resolution: conflict.resolution,
|
|
884
|
+
createdAt: conflict.created_at,
|
|
885
|
+
updatedAt: conflict.updated_at,
|
|
886
|
+
event,
|
|
887
|
+
};
|
|
888
|
+
} finally {
|
|
889
|
+
storage.close();
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
607
893
|
export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
|
|
608
894
|
const storage = openTrekoonDatabase(cwd);
|
|
609
895
|
const git = resolveGitContext(cwd);
|
|
@@ -614,7 +900,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
614
900
|
const conflict = storage.db
|
|
615
901
|
.query(
|
|
616
902
|
`
|
|
617
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution
|
|
903
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
618
904
|
FROM sync_conflicts
|
|
619
905
|
WHERE id = ?
|
|
620
906
|
LIMIT 1;
|
package/src/sync/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type SyncResolution = "ours" | "theirs";
|
|
2
|
+
export type SyncConflictMode = "pending" | "all";
|
|
2
3
|
|
|
3
4
|
export interface GitContextSnapshot {
|
|
4
5
|
readonly worktreePath: string;
|
|
@@ -29,3 +30,37 @@ export interface ResolveSummary {
|
|
|
29
30
|
readonly entityId: string;
|
|
30
31
|
readonly fieldName: string;
|
|
31
32
|
}
|
|
33
|
+
|
|
34
|
+
export interface SyncConflictListItem {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly event_id: string;
|
|
37
|
+
readonly entity_kind: string;
|
|
38
|
+
readonly entity_id: string;
|
|
39
|
+
readonly field_name: string;
|
|
40
|
+
readonly ours_value: string | null;
|
|
41
|
+
readonly theirs_value: string | null;
|
|
42
|
+
readonly resolution: string;
|
|
43
|
+
readonly created_at: number;
|
|
44
|
+
readonly updated_at: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SyncConflictDetail {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly eventId: string;
|
|
50
|
+
readonly entityKind: string;
|
|
51
|
+
readonly entityId: string;
|
|
52
|
+
readonly fieldName: string;
|
|
53
|
+
readonly oursValue: unknown;
|
|
54
|
+
readonly theirsValue: unknown;
|
|
55
|
+
readonly resolution: string;
|
|
56
|
+
readonly createdAt: number;
|
|
57
|
+
readonly updatedAt: number;
|
|
58
|
+
readonly event: {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly operation: string;
|
|
61
|
+
readonly payload: string;
|
|
62
|
+
readonly git_branch: string | null;
|
|
63
|
+
readonly git_head: string | null;
|
|
64
|
+
readonly created_at: number;
|
|
65
|
+
} | null;
|
|
66
|
+
}
|