trekoon 0.3.3 → 0.3.5

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,16 @@ 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";
9
+ import { DomainError } from "../domain/types";
8
10
  import {
9
11
  type PullSummary,
12
+ type ResolveAllOptions,
13
+ type ResolveAllFilters,
14
+ type ResolveAllPreviewSummary,
15
+ type ResolveAllSummary,
16
+ type ResolvePreviewSummary,
10
17
  type ResolveSummary,
11
18
  type SyncConflictDetail,
12
19
  type SyncConflictListItem,
@@ -15,6 +22,13 @@ import {
15
22
  type SyncStatusSummary,
16
23
  } from "./types";
17
24
 
25
+ const SYNC_ALLOWED_FIELDS: Readonly<Record<string, readonly string[]>> = {
26
+ epics: ["title", "description", "status"],
27
+ tasks: ["epic_id", "title", "description", "status"],
28
+ subtasks: ["task_id", "title", "description", "status"],
29
+ dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
30
+ };
31
+
18
32
  function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
19
33
  if (cursorToken === "0:") {
20
34
  return false;
@@ -92,6 +106,16 @@ interface ConflictRow {
92
106
  readonly updated_at: number;
93
107
  }
94
108
 
109
+ interface ResolutionWriteContext {
110
+ readonly branchName: string | null;
111
+ readonly headSha: string | null;
112
+ }
113
+
114
+ interface ResolveAllQueryFilters {
115
+ readonly entityId?: string;
116
+ readonly fieldName?: string;
117
+ }
118
+
95
119
  interface EventPayload {
96
120
  readonly fields: Record<string, unknown>;
97
121
  }
@@ -301,14 +325,7 @@ function currentEntityFieldValue(db: Database, entityKind: string, entityId: str
301
325
  return undefined;
302
326
  }
303
327
 
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] ?? [];
328
+ const validFields = SYNC_ALLOWED_FIELDS[tableName] ?? [];
312
329
  if (!validFields.includes(fieldName)) {
313
330
  return undefined;
314
331
  }
@@ -564,18 +581,11 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
564
581
  return false;
565
582
  }
566
583
 
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
584
  if (!rowExists(db, tableName, event.entity_id)) {
575
585
  return false;
576
586
  }
577
587
 
578
- const allowed = new Set(allowedFields[tableName] ?? []);
588
+ const allowed = new Set(SYNC_ALLOWED_FIELDS[tableName] ?? []);
579
589
  const entries = Object.entries(fields).filter(([fieldName, value]) => allowed.has(fieldName) && typeof value === "string");
580
590
 
581
591
  if (entries.length === 0) {
@@ -905,7 +915,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
905
915
  }
906
916
 
907
917
  function parseConflictValue(value: string | null): unknown {
908
- if (!value) {
918
+ if (value === null) {
909
919
  return null;
910
920
  }
911
921
 
@@ -919,27 +929,86 @@ function parseConflictValue(value: string | null): unknown {
919
929
  function updateSingleField(db: Database, entityKind: string, entityId: string, fieldName: string, value: unknown): void {
920
930
  const tableName = tableForEntityKind(entityKind);
921
931
  if (!tableName) {
922
- return;
932
+ throw new DomainError({
933
+ code: "unsupported_entity_kind",
934
+ message: `No table mapping for entity kind: ${entityKind}`,
935
+ details: { entityKind },
936
+ });
923
937
  }
924
938
 
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] ?? [];
939
+ const validFields: readonly string[] = SYNC_ALLOWED_FIELDS[tableName] ?? [];
933
940
  if (!validFields.includes(fieldName)) {
934
- return;
941
+ throw new DomainError({
942
+ code: "disallowed_field",
943
+ message: `Field '${fieldName}' is not allowed for table '${tableName}'`,
944
+ details: { tableName, fieldName },
945
+ });
935
946
  }
936
947
 
937
948
  const now: number = Date.now();
938
- db.query(`UPDATE ${tableName} SET ${fieldName} = ?, updated_at = ?, version = version + 1 WHERE id = ?;`).run(
939
- typeof value === "string" ? value : JSON.stringify(value),
949
+ const result = db
950
+ .query(`UPDATE ${tableName} SET ${fieldName} = ?, updated_at = ?, version = version + 1 WHERE id = ?;`)
951
+ .run(typeof value === "string" ? value : JSON.stringify(value), now, entityId);
952
+
953
+ if (result.changes === 0) {
954
+ throw new DomainError({
955
+ code: "row_not_found",
956
+ message: `No row updated: entity '${entityKind}' with id '${entityId}' not found in table '${tableName}'`,
957
+ details: { tableName, entityKind, entityId },
958
+ });
959
+ }
960
+ }
961
+
962
+ function deleteSingleEntity(db: Database, entityKind: string, entityId: string): void {
963
+ const tableName = tableForEntityKind(entityKind);
964
+ if (!tableName) {
965
+ throw new DomainError({
966
+ code: "unsupported_entity_kind",
967
+ message: `No table mapping for entity kind: ${entityKind}`,
968
+ details: { entityKind },
969
+ });
970
+ }
971
+
972
+ const result = db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(entityId);
973
+
974
+ if (result.changes === 0) {
975
+ throw new DomainError({
976
+ code: "row_not_found",
977
+ message: `No row deleted: entity '${entityKind}' with id '${entityId}' not found in table '${tableName}'`,
978
+ details: { tableName, entityKind, entityId },
979
+ });
980
+ }
981
+ }
982
+
983
+ function normalizeResolveAllFilters(filters: ResolveAllQueryFilters): ResolveAllFilters {
984
+ return {
985
+ entity: filters.entityId ?? null,
986
+ field: filters.fieldName ?? null,
987
+ };
988
+ }
989
+
990
+ function resolveConflictRow(
991
+ db: Database,
992
+ conflict: ConflictRow,
993
+ resolution: SyncResolution,
994
+ git: ResolutionWriteContext,
995
+ ): void {
996
+ if (resolution === "theirs") {
997
+ if (conflict.field_name === "__delete__") {
998
+ deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id);
999
+ } else {
1000
+ updateSingleField(db, conflict.entity_kind, conflict.entity_id, conflict.field_name, parseConflictValue(conflict.theirs_value));
1001
+ }
1002
+ }
1003
+
1004
+ const now: number = nextEventTimestamp(db);
1005
+ db.query("UPDATE sync_conflicts SET resolution = ?, updated_at = ?, version = version + 1 WHERE id = ?;").run(
1006
+ resolution,
940
1007
  now,
941
- entityId,
1008
+ conflict.id,
942
1009
  );
1010
+
1011
+ appendResolutionEvent(db, git.branchName, git.headSha, conflict, resolution, now);
943
1012
  }
944
1013
 
945
1014
  function appendResolutionEvent(
@@ -948,8 +1017,9 @@ function appendResolutionEvent(
948
1017
  gitHead: string | null,
949
1018
  conflict: ConflictRow,
950
1019
  resolution: SyncResolution,
1020
+ timestamp?: number,
951
1021
  ): void {
952
- const now: number = Date.now();
1022
+ const now: number = timestamp ?? nextEventTimestamp(db);
953
1023
  const resolvedValue: string | null = resolution === "theirs" ? conflict.theirs_value : conflict.ours_value;
954
1024
 
955
1025
  db.query(
@@ -1061,6 +1131,29 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
1061
1131
  }
1062
1132
  }
1063
1133
 
1134
+ function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
1135
+ const conflict = db
1136
+ .query(
1137
+ `
1138
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
1139
+ FROM sync_conflicts
1140
+ WHERE id = ?
1141
+ LIMIT 1;
1142
+ `,
1143
+ )
1144
+ .get(conflictId) as ConflictRow | null;
1145
+
1146
+ if (!conflict) {
1147
+ throw new Error(`Conflict '${conflictId}' not found.`);
1148
+ }
1149
+
1150
+ if (conflict.resolution !== "pending") {
1151
+ throw new Error(`Conflict '${conflictId}' already resolved.`);
1152
+ }
1153
+
1154
+ return conflict;
1155
+ }
1156
+
1064
1157
  export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
1065
1158
  const storage = openTrekoonDatabase(cwd);
1066
1159
  const git = resolveGitContext(cwd);
@@ -1068,43 +1161,38 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1068
1161
  try {
1069
1162
  persistGitContext(storage.db, git);
1070
1163
 
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
- }
1164
+ // lookupPendingConflict is inside the writeTransaction so that the
1165
+ // "is this still pending?" check and the resolution mutation are
1166
+ // atomic. Without this, two concurrent resolves could both pass
1167
+ // the check and double-resolve the same conflict.
1168
+ const conflict = writeTransaction(storage.db, (): ConflictRow => {
1169
+ const row = lookupPendingConflict(storage.db, conflictId);
1170
+ resolveConflictRow(storage.db, row, resolution, git);
1171
+ return row;
1172
+ });
1085
1173
 
1086
- if (conflict.resolution !== "pending") {
1087
- throw new Error(`Conflict '${conflictId}' already resolved.`);
1088
- }
1174
+ return {
1175
+ conflictId,
1176
+ resolution,
1177
+ entityKind: conflict.entity_kind,
1178
+ entityId: conflict.entity_id,
1179
+ fieldName: conflict.field_name,
1180
+ };
1181
+ } finally {
1182
+ storage.close();
1183
+ }
1184
+ }
1089
1185
 
1090
- writeTransaction(storage.db, (): void => {
1091
- if (resolution === "theirs") {
1092
- updateSingleField(
1093
- storage.db,
1094
- conflict.entity_kind,
1095
- conflict.entity_id,
1096
- conflict.field_name,
1097
- parseConflictValue(conflict.theirs_value),
1098
- );
1099
- }
1186
+ // Preview is read-only — no git context persistence needed.
1187
+ export function syncResolvePreview(cwd: string, conflictId: string, resolution: SyncResolution): ResolvePreviewSummary {
1188
+ const storage = openTrekoonDatabase(cwd);
1100
1189
 
1101
- const now: number = Date.now();
1102
- storage.db
1103
- .query("UPDATE sync_conflicts SET resolution = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
1104
- .run(resolution, now, conflict.id);
1190
+ try {
1191
+ const conflict = lookupPendingConflict(storage.db, conflictId);
1105
1192
 
1106
- appendResolutionEvent(storage.db, git.branchName, git.headSha, conflict, resolution);
1107
- });
1193
+ const oursValue: unknown = parseConflictValue(conflict.ours_value);
1194
+ const theirsValue: unknown = parseConflictValue(conflict.theirs_value);
1195
+ const wouldWrite: unknown = resolution === "theirs" ? theirsValue : oursValue;
1108
1196
 
1109
1197
  return {
1110
1198
  conflictId,
@@ -1112,6 +1200,153 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1112
1200
  entityKind: conflict.entity_kind,
1113
1201
  entityId: conflict.entity_id,
1114
1202
  fieldName: conflict.field_name,
1203
+ oursValue,
1204
+ theirsValue,
1205
+ wouldWrite,
1206
+ dryRun: true,
1207
+ };
1208
+ } finally {
1209
+ storage.close();
1210
+ }
1211
+ }
1212
+
1213
+ function queryPendingConflicts(
1214
+ db: Database,
1215
+ filters: ResolveAllQueryFilters,
1216
+ ): readonly ConflictRow[] {
1217
+ const conditions: string[] = ["resolution = 'pending'"];
1218
+ const params: string[] = [];
1219
+
1220
+ if (filters.entityId !== undefined) {
1221
+ conditions.push("entity_id = ?");
1222
+ params.push(filters.entityId);
1223
+ }
1224
+
1225
+ if (filters.fieldName !== undefined) {
1226
+ conditions.push("field_name = ?");
1227
+ params.push(filters.fieldName);
1228
+ }
1229
+
1230
+ const sql = `
1231
+ SELECT c.id, c.event_id, c.entity_kind, c.entity_id, c.field_name, c.ours_value, c.theirs_value, c.resolution, c.created_at, c.updated_at
1232
+ FROM sync_conflicts c
1233
+ LEFT JOIN events e ON e.id = c.event_id
1234
+ WHERE ${conditions.map((condition) => condition.replaceAll("resolution", "c.resolution").replaceAll("entity_id", "c.entity_id").replaceAll("field_name", "c.field_name")).join(" AND ")}
1235
+ ORDER BY COALESCE(e.created_at, c.created_at) ASC, COALESCE(e.id, c.event_id) ASC, c.created_at ASC, c.id ASC;
1236
+ `;
1237
+
1238
+ return db.query(sql).all(...params) as ConflictRow[];
1239
+ }
1240
+
1241
+ function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]): readonly ConflictRow[] {
1242
+ if (conflictIds.length === 0) {
1243
+ return [];
1244
+ }
1245
+
1246
+ const placeholders = conflictIds.map(() => "?").join(", ");
1247
+ const rows = db
1248
+ .query(
1249
+ `
1250
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
1251
+ FROM sync_conflicts
1252
+ WHERE resolution = 'pending' AND id IN (${placeholders});
1253
+ `,
1254
+ )
1255
+ .all(...conflictIds) as ConflictRow[];
1256
+
1257
+ const rowById = new Map(rows.map((row) => [row.id, row]));
1258
+
1259
+ return conflictIds.flatMap((conflictId) => {
1260
+ const row = rowById.get(conflictId);
1261
+ return row ? [row] : [];
1262
+ });
1263
+ }
1264
+
1265
+ export function syncResolveAll(
1266
+ cwd: string,
1267
+ resolution: SyncResolution,
1268
+ filters: ResolveAllQueryFilters,
1269
+ options: ResolveAllOptions = {},
1270
+ ): ResolveAllSummary {
1271
+ const storage = openTrekoonDatabase(cwd);
1272
+ const git = resolveGitContext(cwd);
1273
+ const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
1274
+
1275
+ try {
1276
+ persistGitContext(storage.db, git);
1277
+
1278
+ const resolvedIds: string[] = writeTransaction(storage.db, (): string[] => {
1279
+ const expectedConflictIds = options.expectedConflictIds;
1280
+ const conflicts = expectedConflictIds
1281
+ ? queryPendingConflictsByIds(storage.db, expectedConflictIds)
1282
+ : queryPendingConflicts(storage.db, filters);
1283
+
1284
+ if (conflicts.length === 0) {
1285
+ throw new DomainError({
1286
+ code: "no_matching_conflicts",
1287
+ message: "No pending conflicts match the given filters.",
1288
+ details: { filters: normalizedFilters },
1289
+ });
1290
+ }
1291
+
1292
+ if (expectedConflictIds && conflicts.length !== expectedConflictIds.length) {
1293
+ throw new DomainError({
1294
+ code: "conflict_set_changed",
1295
+ message: "Pending conflicts changed before batch resolution could be applied.",
1296
+ details: {
1297
+ filters: normalizedFilters,
1298
+ expectedConflictIds,
1299
+ availableConflictIds: conflicts.map((conflict) => conflict.id),
1300
+ },
1301
+ });
1302
+ }
1303
+
1304
+ const ids: string[] = [];
1305
+
1306
+ for (const conflict of conflicts) {
1307
+ resolveConflictRow(storage.db, conflict, resolution, git);
1308
+ ids.push(conflict.id);
1309
+ }
1310
+
1311
+ return ids;
1312
+ });
1313
+
1314
+ return {
1315
+ resolution,
1316
+ resolvedCount: resolvedIds.length,
1317
+ resolvedIds,
1318
+ filters: normalizedFilters,
1319
+ };
1320
+ } finally {
1321
+ storage.close();
1322
+ }
1323
+ }
1324
+
1325
+ export function syncResolveAllPreview(
1326
+ cwd: string,
1327
+ resolution: SyncResolution,
1328
+ filters: ResolveAllQueryFilters,
1329
+ ): ResolveAllPreviewSummary {
1330
+ const storage = openTrekoonDatabase(cwd);
1331
+ const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
1332
+
1333
+ try {
1334
+ const conflicts = queryPendingConflicts(storage.db, filters);
1335
+
1336
+ if (conflicts.length === 0) {
1337
+ throw new DomainError({
1338
+ code: "no_matching_conflicts",
1339
+ message: "No pending conflicts match the given filters.",
1340
+ details: { filters: normalizedFilters },
1341
+ });
1342
+ }
1343
+
1344
+ return {
1345
+ resolution,
1346
+ matchedCount: conflicts.length,
1347
+ matchedIds: conflicts.map((c) => c.id),
1348
+ filters: normalizedFilters,
1349
+ dryRun: true,
1115
1350
  };
1116
1351
  } finally {
1117
1352
  storage.close();
package/src/sync/types.ts CHANGED
@@ -43,6 +43,42 @@ 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
+
58
+ export interface ResolveAllFilters {
59
+ readonly entity: string | null;
60
+ readonly field: string | null;
61
+ }
62
+
63
+ export interface ResolveAllSummary {
64
+ readonly resolution: SyncResolution;
65
+ readonly resolvedCount: number;
66
+ readonly resolvedIds: readonly string[];
67
+ readonly filters: ResolveAllFilters;
68
+ }
69
+
70
+ export interface ResolveAllPreviewSummary {
71
+ readonly resolution: SyncResolution;
72
+ readonly matchedCount: number;
73
+ readonly matchedIds: readonly string[];
74
+ readonly filters: ResolveAllFilters;
75
+ readonly dryRun: true;
76
+ }
77
+
78
+ export interface ResolveAllOptions {
79
+ readonly expectedConflictIds?: readonly string[];
80
+ }
81
+
46
82
  export interface SyncConflictListItem {
47
83
  readonly id: string;
48
84
  readonly event_id: string;