trekoon 0.1.4 → 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.
@@ -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 {
@@ -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
- const fields = payload.fields;
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 payload = parsePayload(row.payload);
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 (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, 1);
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 applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
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 (@id, @title, @description, @status, @now, @now, 1)
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 (@id, @epicId, @title, @description, @status, @now, @now, 1)
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 (@id, @taskId, @title, @description, @status, @now, @now, 1)
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
- "@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
- });
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 payload = parsePayload(incoming.payload);
483
- const incomingFields: Record<string, unknown> = payload.fields ?? {};
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(incomingFields)) {
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 (hasAppliedField && applyEntityFields(storage.db, incoming, fieldsToApply)) {
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
+ }