@tenonhq/dovetail-core 0.0.104 → 0.0.105

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.
@@ -0,0 +1,409 @@
1
+ "use strict";
2
+ // `dove reconcile` — make a developer's personal ServiceNow instance match the
3
+ // git branch they just checked out: the deliberate, diff-gated inverse of
4
+ // `dove watch`.
5
+ //
6
+ // Modes:
7
+ // (default) read-only dry run — snapshot branch (on-disk) + live
8
+ // instance, classify create/update/delete, surface drift,
9
+ // report schema drift (report-only), print the report.
10
+ // --write-baseline establish the per-instance merge-base from current live
11
+ // state (no record changes). Run this once before --apply.
12
+ // --apply [--force] apply the safe subset: record UPDATE (branch -> instance)
13
+ // and tracked DELETE. Refuses if the instance drifted since
14
+ // baseline (unless --force). CREATE is deferred to Phase 3;
15
+ // schema stays report-only (a ServiceNow ceiling).
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ var __importDefault = (this && this.__importDefault) || function (mod) {
50
+ return (mod && mod.__esModule) ? mod : { "default": mod };
51
+ };
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.reconcileCommand = reconcileCommand;
54
+ const path_1 = __importDefault(require("path"));
55
+ const os_1 = __importDefault(require("os"));
56
+ const fs_1 = require("fs");
57
+ const child_process_1 = require("child_process");
58
+ const util_1 = require("util");
59
+ const dovetail_schema_1 = require("@tenonhq/dovetail-schema");
60
+ const Logger_1 = require("./Logger");
61
+ const FileLogger_1 = require("./FileLogger");
62
+ const commands_1 = require("./commands");
63
+ const ConfigManager = __importStar(require("./config"));
64
+ const snClient_1 = require("./snClient");
65
+ const appUtils_1 = require("./appUtils");
66
+ const recordSource_1 = require("./reconcile/recordSource");
67
+ const recordDiff_1 = require("./reconcile/recordDiff");
68
+ const baseline_1 = require("./reconcile/baseline");
69
+ const report_1 = require("./reconcile/report");
70
+ const applyPlan_1 = require("./reconcile/applyPlan");
71
+ const deleteOrder_1 = require("./reconcile/deleteOrder");
72
+ const gitignore_1 = require("./reconcile/gitignore");
73
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
74
+ function requireCreds() {
75
+ const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "" } = process.env;
76
+ if (!SN_USER || !SN_PASSWORD || !SN_INSTANCE) {
77
+ throw new Error("Missing ServiceNow credentials. Ensure SN_INSTANCE, SN_USER, and SN_PASSWORD are set in your .env file or environment.");
78
+ }
79
+ return { SN_USER, SN_PASSWORD, SN_INSTANCE };
80
+ }
81
+ function normalizeInstance(instance) {
82
+ return instance.replace(/^https?:\/\//, "").replace(/\/$/, "");
83
+ }
84
+ function resolveScopes(args) {
85
+ const config = ConfigManager.getConfig();
86
+ const configScopes = config.scopes ? Object.keys(config.scopes) : [];
87
+ if (configScopes.length === 0) {
88
+ throw new Error("No scopes configured in dove.config.js. Add scopes to the 'scopes' object in your configuration.");
89
+ }
90
+ if (args.scope) {
91
+ if (configScopes.indexOf(args.scope) === -1) {
92
+ throw new Error('Scope "' +
93
+ args.scope +
94
+ '" is not configured in dove.config.js. Available scopes: ' +
95
+ configScopes.join(", "));
96
+ }
97
+ return [args.scope];
98
+ }
99
+ return configScopes;
100
+ }
101
+ async function currentBranch() {
102
+ try {
103
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
104
+ return stdout.trim();
105
+ }
106
+ catch (e) {
107
+ return "";
108
+ }
109
+ }
110
+ // Best-effort schema arm: only runs when a snapshot exists for this instance.
111
+ async function buildSchemaDiffs(options) {
112
+ const { creds, instance, scopes } = options;
113
+ const rootDir = ConfigManager.getRootDir();
114
+ const outputDir = path_1.default.join(rootDir, "schema");
115
+ let snapshots = [];
116
+ try {
117
+ snapshots = await (0, dovetail_schema_1.listSnapshots)({ outputDir, instance });
118
+ }
119
+ catch (e) {
120
+ return { diffs: {}, skipped: "schema snapshot lookup failed" };
121
+ }
122
+ if (snapshots.length === 0) {
123
+ return {
124
+ diffs: {},
125
+ skipped: "no schema snapshot for " +
126
+ instance +
127
+ " — run `dove schema pull --snapshot <label>` to include schema drift",
128
+ };
129
+ }
130
+ const fromRef = path_1.default.basename(snapshots[0].dir);
131
+ let liveDir;
132
+ try {
133
+ const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), "dove-reconcile-schema-"));
134
+ await (0, dovetail_schema_1.pullSchema)({
135
+ instance: creds.SN_INSTANCE,
136
+ username: creds.SN_USER,
137
+ password: creds.SN_PASSWORD,
138
+ outputDir: tmpDir,
139
+ scopes,
140
+ });
141
+ liveDir = tmpDir;
142
+ }
143
+ catch (e) {
144
+ return { diffs: {}, skipped: "live schema pull failed" };
145
+ }
146
+ const fromDir = await (0, dovetail_schema_1.resolveSnapshotDir)({ ref: fromRef, outputDir, instance });
147
+ const diffs = {};
148
+ for (const scope of scopes) {
149
+ try {
150
+ const from = await (0, dovetail_schema_1.readSchemaTree)({ dir: fromDir, scope });
151
+ const to = await (0, dovetail_schema_1.readSchemaTree)({ dir: liveDir, scope });
152
+ diffs[scope] = (0, dovetail_schema_1.diffSchemas)({ from, to, fromRef, toRef: "live", scope });
153
+ }
154
+ catch (e) {
155
+ FileLogger_1.fileLogger.debug("reconcile: schema diff failed for scope " + scope);
156
+ }
157
+ }
158
+ return { diffs, skipped: null };
159
+ }
160
+ // Collect the on-disk field file paths for the records an apply will UPDATE, so
161
+ // the existing build + update-set push pipeline can overwrite the instance with
162
+ // branch content.
163
+ async function collectUpdateFilePaths(scope, updates) {
164
+ const sourcePath = ConfigManager.getSourcePathForScope(scope);
165
+ const paths = [];
166
+ for (const change of updates) {
167
+ const recordDir = path_1.default.join(sourcePath, change.table, change.name);
168
+ let entries;
169
+ try {
170
+ entries = await fs_1.promises.readdir(recordDir);
171
+ }
172
+ catch (e) {
173
+ FileLogger_1.fileLogger.debug("reconcile: update record dir missing " + recordDir);
174
+ continue;
175
+ }
176
+ for (const entry of entries) {
177
+ if (entry === "metaData.json") {
178
+ continue;
179
+ }
180
+ const full = path_1.default.join(recordDir, entry);
181
+ try {
182
+ const stat = await fs_1.promises.stat(full);
183
+ if (stat.isFile()) {
184
+ paths.push(full);
185
+ }
186
+ }
187
+ catch (e) {
188
+ // ignore unreadable entry
189
+ }
190
+ }
191
+ }
192
+ return paths;
193
+ }
194
+ async function applyUpdates(scope, updates) {
195
+ if (updates.length === 0) {
196
+ return { pushed: 0, failures: [] };
197
+ }
198
+ const paths = await collectUpdateFilePaths(scope, updates);
199
+ if (paths.length === 0) {
200
+ return { pushed: 0, failures: [] };
201
+ }
202
+ const recs = await (0, appUtils_1.getAppFileList)(paths);
203
+ const results = await (0, appUtils_1.pushFiles)(recs);
204
+ const failures = [];
205
+ let pushed = 0;
206
+ for (const result of results) {
207
+ if (result.success) {
208
+ pushed++;
209
+ }
210
+ else {
211
+ failures.push(result.message);
212
+ }
213
+ }
214
+ return { pushed, failures };
215
+ }
216
+ async function applyDeletes(scope, deletes) {
217
+ if (deletes.length === 0) {
218
+ return [];
219
+ }
220
+ const client = (0, snClient_1.defaultClient)();
221
+ return (0, deleteOrder_1.runMultiPassDeletes)(deletes, async (change) => {
222
+ try {
223
+ const response = await client.deleteRecord({
224
+ table: change.table,
225
+ sys_id: change.sys_id,
226
+ scope,
227
+ });
228
+ let data = response.data;
229
+ if (data && data.result) {
230
+ data = data.result;
231
+ }
232
+ if (data && (data.error || data.success === false)) {
233
+ return { ok: false, error: data.error || "delete rejected" };
234
+ }
235
+ return { ok: true };
236
+ }
237
+ catch (e) {
238
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
239
+ }
240
+ });
241
+ }
242
+ // Capture current live state across every scope into a fresh baseline + ensure
243
+ // the baseline file is gitignored in the consumer project.
244
+ async function recaptureBaseline(options) {
245
+ const all = [];
246
+ for (const scope of options.scopes) {
247
+ try {
248
+ const live = await (0, recordSource_1.loadLiveRecords)(scope);
249
+ for (const record of live) {
250
+ all.push(record);
251
+ }
252
+ }
253
+ catch (e) {
254
+ Logger_1.logger.warn("Baseline capture failed for scope " + scope + " — skipped.");
255
+ }
256
+ }
257
+ (0, baseline_1.writeBaseline)(options.rootDir, (0, baseline_1.baselineFromLive)(options.instance, all));
258
+ const update = (0, gitignore_1.ensureGitignored)(options.rootDir, baseline_1.BASELINE_FILENAME);
259
+ if (update.changed) {
260
+ Logger_1.logger.info("Added " + baseline_1.BASELINE_FILENAME + " to .gitignore.");
261
+ }
262
+ return all.length;
263
+ }
264
+ async function writeBaselineMode(options) {
265
+ Logger_1.logger.info("Establishing reconcile baseline for " + options.instance + "...");
266
+ const count = await recaptureBaseline(options);
267
+ Logger_1.logger.success("Baseline established: " + count + " record(s) captured to " + baseline_1.BASELINE_FILENAME + ".");
268
+ }
269
+ async function reconcileCommand(args) {
270
+ (0, commands_1.setLogLevel)(args);
271
+ const typedArgs = args;
272
+ let creds;
273
+ try {
274
+ creds = requireCreds();
275
+ }
276
+ catch (e) {
277
+ Logger_1.logger.error(e instanceof Error ? e.message : String(e));
278
+ process.exitCode = 1;
279
+ return;
280
+ }
281
+ const instance = normalizeInstance(creds.SN_INSTANCE);
282
+ const scopes = resolveScopes(typedArgs);
283
+ const rootDir = ConfigManager.getRootDir();
284
+ // --write-baseline: establish the merge-base and stop.
285
+ if (typedArgs.writeBaseline) {
286
+ await writeBaselineMode({ rootDir, instance, scopes });
287
+ return;
288
+ }
289
+ const branchRef = await currentBranch();
290
+ const baseline = (0, baseline_1.readBaseline)(rootDir, instance);
291
+ const baselineIds = (0, baseline_1.baselineSysIds)(baseline);
292
+ // Schema arm is on by default; --no-schema disables it.
293
+ let schemaDiffs = {};
294
+ let schemaSkippedReason = null;
295
+ if (typedArgs.schema === false) {
296
+ schemaSkippedReason = "schema diff disabled (--no-schema)";
297
+ }
298
+ else {
299
+ const schemaResult = await buildSchemaDiffs({ creds, instance, scopes });
300
+ schemaDiffs = schemaResult.diffs;
301
+ schemaSkippedReason = schemaResult.skipped;
302
+ }
303
+ const scopeData = [];
304
+ const scopeResults = [];
305
+ for (const scope of scopes) {
306
+ Logger_1.logger.info("Reconciling scope: " + scope + "...");
307
+ let branch;
308
+ let live;
309
+ try {
310
+ branch = await (0, recordSource_1.loadBranchRecords)(scope);
311
+ live = await (0, recordSource_1.loadLiveRecords)(scope);
312
+ }
313
+ catch (e) {
314
+ Logger_1.logger.error("Failed to load records for scope " +
315
+ scope +
316
+ ": " +
317
+ (e instanceof Error ? e.message : String(e)));
318
+ continue;
319
+ }
320
+ const diff = (0, recordDiff_1.diffRecords)({ branch, live, baselineSysIds: baselineIds });
321
+ const dirty = (0, baseline_1.computeDirty)({ baseline, live });
322
+ scopeData.push({ scope, live, diff, dirty });
323
+ scopeResults.push({
324
+ scope,
325
+ diff,
326
+ dirty,
327
+ hasBaseline: baseline !== null,
328
+ schema: schemaDiffs[scope] || null,
329
+ });
330
+ }
331
+ const report = (0, report_1.formatReconcileReport)({
332
+ instanceHost: instance,
333
+ branchRef,
334
+ scopes: scopeResults,
335
+ schemaSkippedReason,
336
+ });
337
+ // console.log so the report's own layout survives the winston line-wrapper.
338
+ console.log(report);
339
+ if (!typedArgs.apply) {
340
+ return;
341
+ }
342
+ await applyMode({ typedArgs, scopeData, baseline: baseline !== null, rootDir, instance, scopes });
343
+ }
344
+ async function applyMode(options) {
345
+ const { typedArgs, scopeData } = options;
346
+ const force = typedArgs.force === true;
347
+ const plans = scopeData.map((data) => ({
348
+ scope: data.scope,
349
+ plan: (0, applyPlan_1.buildApplyPlan)({
350
+ diff: data.diff,
351
+ dirty: data.dirty,
352
+ hasBaseline: options.baseline,
353
+ force,
354
+ }),
355
+ }));
356
+ const refused = plans.filter((entry) => entry.plan.refuse);
357
+ if (refused.length > 0) {
358
+ Logger_1.logger.error("Apply refused:");
359
+ for (const entry of refused) {
360
+ Logger_1.logger.error(" [" + entry.scope + "] " + entry.plan.refuseReason);
361
+ }
362
+ process.exitCode = 1;
363
+ return;
364
+ }
365
+ Logger_1.logger.info("");
366
+ Logger_1.logger.info("Applying reconcile (branch -> instance)...");
367
+ let totalUpdated = 0;
368
+ let totalDeleted = 0;
369
+ const failures = [];
370
+ for (const entry of plans) {
371
+ const { scope, plan } = entry;
372
+ if (plan.deferredCreates.length > 0) {
373
+ Logger_1.logger.info(" [" +
374
+ scope +
375
+ "] " +
376
+ plan.deferredCreates.length +
377
+ " create(s) deferred — CREATE ships in Phase 3.");
378
+ }
379
+ const updateResult = await applyUpdates(scope, plan.updates);
380
+ totalUpdated += updateResult.pushed;
381
+ for (const failure of updateResult.failures) {
382
+ failures.push("[" + scope + "] update: " + failure);
383
+ }
384
+ const deleteOutcomes = await applyDeletes(scope, plan.deletes);
385
+ for (const outcome of deleteOutcomes) {
386
+ if (outcome.ok) {
387
+ totalDeleted++;
388
+ }
389
+ else {
390
+ failures.push("[" + scope + "] delete " + outcome.change.table + "/" + outcome.change.name + ": " + (outcome.error || "failed"));
391
+ }
392
+ }
393
+ }
394
+ // Re-capture the baseline from the now-converged instance.
395
+ await recaptureBaseline({
396
+ rootDir: options.rootDir,
397
+ instance: options.instance,
398
+ scopes: options.scopes,
399
+ });
400
+ Logger_1.logger.info("");
401
+ Logger_1.logger.success("Applied: " + totalUpdated + " updated, " + totalDeleted + " deleted.");
402
+ if (failures.length > 0) {
403
+ Logger_1.logger.error(failures.length + " operation(s) failed:");
404
+ for (const failure of failures) {
405
+ Logger_1.logger.error(" " + failure);
406
+ }
407
+ process.exitCode = 1;
408
+ }
409
+ }
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ // Tests for the pure apply-plan decision: refuse-if-dirty, baseline gating,
3
+ // --force semantics, and delete eligibility by disposition.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ const applyPlan_1 = require("../reconcile/applyPlan");
6
+ const change = (over) => ({
7
+ table: "sys_script",
8
+ scope: "x_cadso_core",
9
+ name: over.sys_id,
10
+ fieldDeltas: [],
11
+ ...over,
12
+ });
13
+ const diff = (over) => ({
14
+ creates: [],
15
+ updates: [],
16
+ deletes: [],
17
+ unchangedCount: 0,
18
+ ...over,
19
+ });
20
+ const dirty = (sys_id) => ({
21
+ sys_id,
22
+ table: "sys_script",
23
+ name: sys_id,
24
+ reason: "changed-since-baseline",
25
+ });
26
+ describe("buildApplyPlan — refusal gating", () => {
27
+ it("refuses with no baseline", () => {
28
+ const plan = (0, applyPlan_1.buildApplyPlan)({ diff: diff({}), dirty: [], hasBaseline: false, force: false });
29
+ expect(plan.refuse).toBe(true);
30
+ expect(plan.refuseReason).toContain("--write-baseline");
31
+ });
32
+ it("refuses on drift without --force", () => {
33
+ const plan = (0, applyPlan_1.buildApplyPlan)({
34
+ diff: diff({}),
35
+ dirty: [dirty("a")],
36
+ hasBaseline: true,
37
+ force: false,
38
+ });
39
+ expect(plan.refuse).toBe(true);
40
+ expect(plan.refuseReason).toContain("changed on the instance since baseline");
41
+ });
42
+ it("does not refuse on drift with --force", () => {
43
+ const plan = (0, applyPlan_1.buildApplyPlan)({
44
+ diff: diff({}),
45
+ dirty: [dirty("a")],
46
+ hasBaseline: true,
47
+ force: true,
48
+ });
49
+ expect(plan.refuse).toBe(false);
50
+ });
51
+ it("does not refuse when clean", () => {
52
+ const plan = (0, applyPlan_1.buildApplyPlan)({ diff: diff({}), dirty: [], hasBaseline: true, force: false });
53
+ expect(plan.refuse).toBe(false);
54
+ });
55
+ });
56
+ describe("buildApplyPlan — what applies", () => {
57
+ it("includes updates and defers creates", () => {
58
+ const plan = (0, applyPlan_1.buildApplyPlan)({
59
+ diff: diff({
60
+ updates: [change({ sys_id: "u", kind: "update" })],
61
+ creates: [change({ sys_id: "c", kind: "create" })],
62
+ }),
63
+ dirty: [],
64
+ hasBaseline: true,
65
+ force: false,
66
+ });
67
+ expect(plan.updates.map((u) => u.sys_id)).toEqual(["u"]);
68
+ expect(plan.deferredCreates.map((c) => c.sys_id)).toEqual(["c"]);
69
+ });
70
+ it("applies only tracked deletes; keeps local-new and no-baseline", () => {
71
+ const plan = (0, applyPlan_1.buildApplyPlan)({
72
+ diff: diff({
73
+ deletes: [
74
+ change({ sys_id: "t", kind: "delete", deleteDisposition: "tracked" }),
75
+ change({ sys_id: "l", kind: "delete", deleteDisposition: "local-new" }),
76
+ change({ sys_id: "n", kind: "delete", deleteDisposition: "no-baseline" }),
77
+ ],
78
+ }),
79
+ dirty: [],
80
+ hasBaseline: true,
81
+ force: false,
82
+ });
83
+ expect(plan.deletes.map((d) => d.sys_id)).toEqual(["t"]);
84
+ expect(plan.skippedDeletes.map((d) => d.sys_id).sort()).toEqual(["l", "n"]);
85
+ });
86
+ it("--force still never deletes local-new records", () => {
87
+ const plan = (0, applyPlan_1.buildApplyPlan)({
88
+ diff: diff({
89
+ deletes: [change({ sys_id: "mine", kind: "delete", deleteDisposition: "local-new" })],
90
+ }),
91
+ dirty: [dirty("other")],
92
+ hasBaseline: true,
93
+ force: true,
94
+ });
95
+ expect(plan.deletes).toHaveLength(0);
96
+ expect(plan.skippedDeletes.map((d) => d.sys_id)).toEqual(["mine"]);
97
+ });
98
+ });
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ // Tests for the reconcile baseline: defensive read (degrades to null, never
3
+ // throws), write round-trip, the pure dirty-check, and live-snapshot capture.
4
+ var __importDefault = (this && this.__importDefault) || function (mod) {
5
+ return (mod && mod.__esModule) ? mod : { "default": mod };
6
+ };
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const os_1 = __importDefault(require("os"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const baseline_1 = require("../reconcile/baseline");
12
+ const live = (sys_id, updatedOn) => ({
13
+ table: "sys_script",
14
+ scope: "x_cadso_core",
15
+ name: sys_id,
16
+ sys_id,
17
+ updatedOn,
18
+ fields: {},
19
+ });
20
+ function tmpRoot() {
21
+ return fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "dove-reconcile-base-"));
22
+ }
23
+ describe("readBaseline — defensive", () => {
24
+ it("returns null when the file is absent", () => {
25
+ expect((0, baseline_1.readBaseline)(tmpRoot(), "tenonworkstudio")).toBeNull();
26
+ });
27
+ it("returns null on malformed JSON", () => {
28
+ const root = tmpRoot();
29
+ fs_1.default.writeFileSync(path_1.default.join(root, baseline_1.BASELINE_FILENAME), "{not json");
30
+ expect((0, baseline_1.readBaseline)(root, "tenonworkstudio")).toBeNull();
31
+ });
32
+ it("returns null on wrong version or non-string record map", () => {
33
+ const root = tmpRoot();
34
+ fs_1.default.writeFileSync(path_1.default.join(root, baseline_1.BASELINE_FILENAME), JSON.stringify({ version: 2, instance: "x", records: {} }));
35
+ expect((0, baseline_1.readBaseline)(root, "x")).toBeNull();
36
+ const root2 = tmpRoot();
37
+ fs_1.default.writeFileSync(path_1.default.join(root2, baseline_1.BASELINE_FILENAME), JSON.stringify({ version: 1, instance: "x", records: { a: 5 } }));
38
+ expect((0, baseline_1.readBaseline)(root2, "x")).toBeNull();
39
+ });
40
+ it("returns null when the baseline was captured against another instance", () => {
41
+ const root = tmpRoot();
42
+ (0, baseline_1.writeBaseline)(root, {
43
+ version: 1,
44
+ instance: "tenonworkshop",
45
+ records: { a: "t" },
46
+ });
47
+ expect((0, baseline_1.readBaseline)(root, "tenonworkstudio")).toBeNull();
48
+ });
49
+ it("round-trips a valid baseline", () => {
50
+ const root = tmpRoot();
51
+ (0, baseline_1.writeBaseline)(root, {
52
+ version: 1,
53
+ instance: "tenonworkstudio",
54
+ records: { a: "2026-06-01 00:00:00" },
55
+ });
56
+ const got = (0, baseline_1.readBaseline)(root, "tenonworkstudio");
57
+ expect(got).not.toBeNull();
58
+ expect(got.records.a).toBe("2026-06-01 00:00:00");
59
+ });
60
+ });
61
+ describe("baselineFromLive + baselineSysIds", () => {
62
+ it("captures sys_id -> updatedOn from the live set", () => {
63
+ const base = (0, baseline_1.baselineFromLive)("tenonworkstudio", [
64
+ live("a", "t1"),
65
+ live("b", "t2"),
66
+ ]);
67
+ expect(base.records).toEqual({ a: "t1", b: "t2" });
68
+ expect(base.instance).toBe("tenonworkstudio");
69
+ });
70
+ it("baselineSysIds is null for a null baseline and a Set otherwise", () => {
71
+ expect((0, baseline_1.baselineSysIds)(null)).toBeNull();
72
+ const set = (0, baseline_1.baselineSysIds)({ version: 1, instance: "x", records: { a: "t" } });
73
+ expect(set).not.toBeNull();
74
+ expect(set.has("a")).toBe(true);
75
+ });
76
+ });
77
+ describe("computeDirty", () => {
78
+ it("returns [] with no baseline", () => {
79
+ expect((0, baseline_1.computeDirty)({ baseline: null, live: [live("a", "t2")] })).toEqual([]);
80
+ });
81
+ it("flags a baseline record whose live updatedOn moved", () => {
82
+ const dirty = (0, baseline_1.computeDirty)({
83
+ baseline: { version: 1, instance: "x", records: { a: "t1", b: "t1" } },
84
+ live: [live("a", "t2"), live("b", "t1")],
85
+ });
86
+ expect(dirty).toHaveLength(1);
87
+ expect(dirty[0]).toMatchObject({ sys_id: "a", reason: "changed-since-baseline" });
88
+ });
89
+ it("does not flag records absent from the baseline (those are deletes/creates, not drift)", () => {
90
+ const dirty = (0, baseline_1.computeDirty)({
91
+ baseline: { version: 1, instance: "x", records: {} },
92
+ live: [live("new", "t9")],
93
+ });
94
+ expect(dirty).toEqual([]);
95
+ });
96
+ it("ignores empty timestamps rather than false-flagging", () => {
97
+ const dirty = (0, baseline_1.computeDirty)({
98
+ baseline: { version: 1, instance: "x", records: { a: "" } },
99
+ live: [live("a", "t2")],
100
+ });
101
+ expect(dirty).toEqual([]);
102
+ });
103
+ });
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ // Tests for the multi-pass delete loop: clean deletes, emergent FK ordering
3
+ // (a record blocked until its dependent is gone), and a genuine failure that
4
+ // survives a no-progress pass and is reported.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const deleteOrder_1 = require("../reconcile/deleteOrder");
7
+ const del = (sys_id) => ({
8
+ kind: "delete",
9
+ table: "sys_script",
10
+ scope: "x_cadso_core",
11
+ sys_id,
12
+ name: sys_id,
13
+ fieldDeltas: [],
14
+ deleteDisposition: "tracked",
15
+ });
16
+ describe("runMultiPassDeletes", () => {
17
+ it("deletes everything in a single clean pass", async () => {
18
+ const calls = [];
19
+ const exec = async (c) => {
20
+ calls.push(c.sys_id);
21
+ return { ok: true };
22
+ };
23
+ const out = await (0, deleteOrder_1.runMultiPassDeletes)([del("a"), del("b")], exec);
24
+ expect(out.every((o) => o.ok)).toBe(true);
25
+ expect(calls.sort()).toEqual(["a", "b"]);
26
+ });
27
+ it("resolves FK ordering: parent succeeds only after the child is gone", async () => {
28
+ // "parent" cannot delete while "child" still exists.
29
+ const deleted = new Set();
30
+ const exec = async (c) => {
31
+ if (c.sys_id === "parent" && !deleted.has("child")) {
32
+ return { ok: false, error: "still referenced by child" };
33
+ }
34
+ deleted.add(c.sys_id);
35
+ return { ok: true };
36
+ };
37
+ // parent is attempted first, fails, then succeeds on the pass after child.
38
+ const out = await (0, deleteOrder_1.runMultiPassDeletes)([del("parent"), del("child")], exec);
39
+ expect(out.filter((o) => o.ok).map((o) => o.change.sys_id).sort()).toEqual([
40
+ "child",
41
+ "parent",
42
+ ]);
43
+ expect(out.every((o) => o.ok)).toBe(true);
44
+ });
45
+ it("reports a record that fails for a non-ordering reason", async () => {
46
+ const exec = async (c) => {
47
+ if (c.sys_id === "locked") {
48
+ return { ok: false, error: "ACL blocked" };
49
+ }
50
+ return { ok: true };
51
+ };
52
+ const out = await (0, deleteOrder_1.runMultiPassDeletes)([del("ok"), del("locked")], exec);
53
+ const failed = out.filter((o) => !o.ok);
54
+ expect(failed).toHaveLength(1);
55
+ expect(failed[0].change.sys_id).toBe("locked");
56
+ expect(failed[0].error).toBe("ACL blocked");
57
+ });
58
+ it("treats a thrown executor error as a failure", async () => {
59
+ const exec = async () => {
60
+ throw new Error("network down");
61
+ };
62
+ const out = await (0, deleteOrder_1.runMultiPassDeletes)([del("a")], exec);
63
+ expect(out[0].ok).toBe(false);
64
+ expect(out[0].error).toBe("network down");
65
+ });
66
+ it("returns [] for no changes", async () => {
67
+ const out = await (0, deleteOrder_1.runMultiPassDeletes)([], async () => ({ ok: true }));
68
+ expect(out).toEqual([]);
69
+ });
70
+ });