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.
- package/.agents/skills/trekoon/SKILL.md +93 -3
- package/README.md +152 -126
- package/docs/ai-agents.md +105 -104
- package/docs/commands.md +145 -167
- package/docs/machine-contracts.md +240 -68
- package/docs/quickstart.md +78 -148
- package/package.json +1 -1
- package/src/commands/help.ts +249 -253
- package/src/commands/quickstart.ts +73 -77
- package/src/commands/skills.ts +104 -19
- package/src/commands/sync.ts +188 -15
- package/src/domain/tracker-domain.ts +210 -37
- package/src/storage/events-retention.ts +72 -0
- package/src/storage/migrations.ts +28 -0
- package/src/sync/event-writes.ts +8 -6
- package/src/sync/service.ts +299 -64
- package/src/sync/types.ts +36 -0
package/src/sync/service.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
939
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
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
|
-
|
|
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;
|