trekoon 0.3.3 → 0.3.4

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.
@@ -4,9 +4,11 @@ import { type Database } from "bun:sqlite";
4
4
 
5
5
  import { openTrekoonDatabase, writeTransaction } from "../storage/database";
6
6
  import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
7
+ import { nextEventTimestamp } from "./event-writes";
7
8
  import { persistGitContext, resolveGitContext } from "./git-context";
8
9
  import {
9
10
  type PullSummary,
11
+ type ResolvePreviewSummary,
10
12
  type ResolveSummary,
11
13
  type SyncConflictDetail,
12
14
  type SyncConflictListItem,
@@ -15,6 +17,13 @@ import {
15
17
  type SyncStatusSummary,
16
18
  } from "./types";
17
19
 
20
+ const SYNC_ALLOWED_FIELDS: Readonly<Record<string, readonly string[]>> = {
21
+ epics: ["title", "description", "status"],
22
+ tasks: ["epic_id", "title", "description", "status"],
23
+ subtasks: ["task_id", "title", "description", "status"],
24
+ dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
25
+ };
26
+
18
27
  function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
19
28
  if (cursorToken === "0:") {
20
29
  return false;
@@ -301,14 +310,7 @@ function currentEntityFieldValue(db: Database, entityKind: string, entityId: str
301
310
  return undefined;
302
311
  }
303
312
 
304
- const allowedFields: Record<string, readonly string[]> = {
305
- epics: ["title", "description", "status"],
306
- tasks: ["epic_id", "title", "description", "status"],
307
- subtasks: ["task_id", "title", "description", "status"],
308
- dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
309
- };
310
-
311
- const validFields = allowedFields[tableName] ?? [];
313
+ const validFields = SYNC_ALLOWED_FIELDS[tableName] ?? [];
312
314
  if (!validFields.includes(fieldName)) {
313
315
  return undefined;
314
316
  }
@@ -564,18 +566,11 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
564
566
  return false;
565
567
  }
566
568
 
567
- const allowedFields: Record<string, readonly string[]> = {
568
- epics: ["title", "description", "status"],
569
- tasks: ["epic_id", "title", "description", "status"],
570
- subtasks: ["task_id", "title", "description", "status"],
571
- dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
572
- };
573
-
574
569
  if (!rowExists(db, tableName, event.entity_id)) {
575
570
  return false;
576
571
  }
577
572
 
578
- const allowed = new Set(allowedFields[tableName] ?? []);
573
+ const allowed = new Set(SYNC_ALLOWED_FIELDS[tableName] ?? []);
579
574
  const entries = Object.entries(fields).filter(([fieldName, value]) => allowed.has(fieldName) && typeof value === "string");
580
575
 
581
576
  if (entries.length === 0) {
@@ -905,7 +900,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
905
900
  }
906
901
 
907
902
  function parseConflictValue(value: string | null): unknown {
908
- if (!value) {
903
+ if (value === null || value === undefined) {
909
904
  return null;
910
905
  }
911
906
 
@@ -922,14 +917,7 @@ function updateSingleField(db: Database, entityKind: string, entityId: string, f
922
917
  return;
923
918
  }
924
919
 
925
- const allowedFields: Record<string, readonly string[]> = {
926
- epics: ["title", "description", "status"],
927
- tasks: ["epic_id", "title", "description", "status"],
928
- subtasks: ["task_id", "title", "description", "status"],
929
- dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
930
- };
931
-
932
- const validFields: readonly string[] = allowedFields[tableName] ?? [];
920
+ const validFields: readonly string[] = SYNC_ALLOWED_FIELDS[tableName] ?? [];
933
921
  if (!validFields.includes(fieldName)) {
934
922
  return;
935
923
  }
@@ -948,8 +936,9 @@ function appendResolutionEvent(
948
936
  gitHead: string | null,
949
937
  conflict: ConflictRow,
950
938
  resolution: SyncResolution,
939
+ timestamp?: number,
951
940
  ): void {
952
- const now: number = Date.now();
941
+ const now: number = timestamp ?? nextEventTimestamp(db);
953
942
  const resolvedValue: string | null = resolution === "theirs" ? conflict.theirs_value : conflict.ours_value;
954
943
 
955
944
  db.query(
@@ -1061,6 +1050,29 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
1061
1050
  }
1062
1051
  }
1063
1052
 
1053
+ function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
1054
+ const conflict = db
1055
+ .query(
1056
+ `
1057
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
1058
+ FROM sync_conflicts
1059
+ WHERE id = ?
1060
+ LIMIT 1;
1061
+ `,
1062
+ )
1063
+ .get(conflictId) as ConflictRow | null;
1064
+
1065
+ if (!conflict) {
1066
+ throw new Error(`Conflict '${conflictId}' not found.`);
1067
+ }
1068
+
1069
+ if (conflict.resolution !== "pending") {
1070
+ throw new Error(`Conflict '${conflictId}' already resolved.`);
1071
+ }
1072
+
1073
+ return conflict;
1074
+ }
1075
+
1064
1076
  export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
1065
1077
  const storage = openTrekoonDatabase(cwd);
1066
1078
  const git = resolveGitContext(cwd);
@@ -1068,42 +1080,31 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1068
1080
  try {
1069
1081
  persistGitContext(storage.db, git);
1070
1082
 
1071
- const conflict = storage.db
1072
- .query(
1073
- `
1074
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
1075
- FROM sync_conflicts
1076
- WHERE id = ?
1077
- LIMIT 1;
1078
- `,
1079
- )
1080
- .get(conflictId) as ConflictRow | null;
1081
-
1082
- if (!conflict) {
1083
- throw new Error(`Conflict '${conflictId}' not found.`);
1084
- }
1085
-
1086
- if (conflict.resolution !== "pending") {
1087
- throw new Error(`Conflict '${conflictId}' already resolved.`);
1088
- }
1083
+ // lookupPendingConflict is inside the writeTransaction so that the
1084
+ // "is this still pending?" check and the resolution mutation are
1085
+ // atomic. Without this, two concurrent resolves could both pass
1086
+ // the check and double-resolve the same conflict.
1087
+ const conflict = writeTransaction(storage.db, (): ConflictRow => {
1088
+ const row = lookupPendingConflict(storage.db, conflictId);
1089
1089
 
1090
- writeTransaction(storage.db, (): void => {
1091
1090
  if (resolution === "theirs") {
1092
1091
  updateSingleField(
1093
1092
  storage.db,
1094
- conflict.entity_kind,
1095
- conflict.entity_id,
1096
- conflict.field_name,
1097
- parseConflictValue(conflict.theirs_value),
1093
+ row.entity_kind,
1094
+ row.entity_id,
1095
+ row.field_name,
1096
+ parseConflictValue(row.theirs_value),
1098
1097
  );
1099
1098
  }
1100
1099
 
1101
- const now: number = Date.now();
1100
+ const now: number = nextEventTimestamp(storage.db);
1102
1101
  storage.db
1103
1102
  .query("UPDATE sync_conflicts SET resolution = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
1104
- .run(resolution, now, conflict.id);
1103
+ .run(resolution, now, row.id);
1104
+
1105
+ appendResolutionEvent(storage.db, git.branchName, git.headSha, row, resolution, now);
1105
1106
 
1106
- appendResolutionEvent(storage.db, git.branchName, git.headSha, conflict, resolution);
1107
+ return row;
1107
1108
  });
1108
1109
 
1109
1110
  return {
@@ -1117,3 +1118,30 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1117
1118
  storage.close();
1118
1119
  }
1119
1120
  }
1121
+
1122
+ // Preview is read-only — no git context persistence needed.
1123
+ export function syncResolvePreview(cwd: string, conflictId: string, resolution: SyncResolution): ResolvePreviewSummary {
1124
+ const storage = openTrekoonDatabase(cwd);
1125
+
1126
+ try {
1127
+ const conflict = lookupPendingConflict(storage.db, conflictId);
1128
+
1129
+ const oursValue: unknown = parseConflictValue(conflict.ours_value);
1130
+ const theirsValue: unknown = parseConflictValue(conflict.theirs_value);
1131
+ const wouldWrite: unknown = resolution === "theirs" ? theirsValue : oursValue;
1132
+
1133
+ return {
1134
+ conflictId,
1135
+ resolution,
1136
+ entityKind: conflict.entity_kind,
1137
+ entityId: conflict.entity_id,
1138
+ fieldName: conflict.field_name,
1139
+ oursValue,
1140
+ theirsValue,
1141
+ wouldWrite,
1142
+ dryRun: true,
1143
+ };
1144
+ } finally {
1145
+ storage.close();
1146
+ }
1147
+ }
package/src/sync/types.ts CHANGED
@@ -43,6 +43,18 @@ export interface ResolveSummary {
43
43
  readonly fieldName: string;
44
44
  }
45
45
 
46
+ export interface ResolvePreviewSummary {
47
+ readonly conflictId: string;
48
+ readonly resolution: SyncResolution;
49
+ readonly entityKind: string;
50
+ readonly entityId: string;
51
+ readonly fieldName: string;
52
+ readonly oursValue: unknown;
53
+ readonly theirsValue: unknown;
54
+ readonly wouldWrite: unknown;
55
+ readonly dryRun: true;
56
+ }
57
+
46
58
  export interface SyncConflictListItem {
47
59
  readonly id: string;
48
60
  readonly event_id: string;