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.
@@ -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 { type PullSummary, type ResolveSummary, type SyncResolution, type SyncStatusSummary } from "./types";
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?: Record<string, unknown>;
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): EventPayload {
64
+ function parsePayload(rawPayload: string): PayloadValidation {
49
65
  try {
50
66
  const parsed: unknown = JSON.parse(rawPayload);
51
67
 
52
- if (isObjectRecord(parsed)) {
53
- if ("fields" in parsed && !isObjectRecord(parsed.fields)) {
54
- return {};
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
- return parsed as EventPayload;
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
- // Fall back to empty payload.
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 listRemoteEventIds(remoteDb: Database): Set<string> {
191
- const rows = remoteDb.query("SELECT id FROM events;").all() as Array<{ id: string }>;
192
- return new Set(rows.map((row) => row.id));
193
- }
228
+ function countAhead(localDb: Database, currentBranch: string | null, remoteDbPath: string): number {
229
+ if (!currentBranch) {
230
+ return 0;
231
+ }
194
232
 
195
- function countAhead(localDb: Database, currentBranch: string | null, remoteEventIds: Set<string>): number {
196
- const rows = localDb
197
- .query("SELECT id, git_branch FROM events WHERE git_branch = ?;")
198
- .all(currentBranch) as Array<{ id: string; git_branch: string | null }>;
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
- return rows.filter((row) => !remoteEventIds.has(row.id)).length;
251
+ return row?.count ?? 0;
252
+ } finally {
253
+ localDb.query("DETACH DATABASE sync_remote;").run();
254
+ }
201
255
  }
202
256
 
203
- function readFieldValue(payload: EventPayload, field: string): unknown {
204
- const fields = payload.fields;
205
- if (!fields) {
206
- return undefined;
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 fields[field];
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 payload = parsePayload(row.payload);
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 (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, 1);
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 applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
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 (@id, @title, @description, @status, @now, @now, 1)
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 (@id, @epicId, @title, @description, @status, @now, @now, 1)
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 (@id, @taskId, @title, @description, @status, @now, @now, 1)
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
- "@id": event.entity_id,
396
- "@sourceId": typeof fields.source_id === "string" ? fields.source_id : "",
397
- "@sourceKind": typeof fields.source_kind === "string" ? fields.source_kind : "task",
398
- "@dependsOnId": typeof fields.depends_on_id === "string" ? fields.depends_on_id : "",
399
- "@dependsOnKind": typeof fields.depends_on_kind === "string" ? fields.depends_on_kind : "task",
400
- "@now": now,
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, listRemoteEventIds(remote.db)),
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 payload = parsePayload(incoming.payload);
483
- const incomingFields: Record<string, unknown> = payload.fields ?? {};
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(incomingFields)) {
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 (hasAppliedField && applyEntityFields(storage.db, incoming, fieldsToApply)) {
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;