@tenonhq/dovetail-core 0.0.105 → 0.0.106
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/dist/commander.js +2 -2
- package/dist/reconcile/applyPlan.js +4 -3
- package/dist/reconcile/multiPass.js +55 -0
- package/dist/reconcileCommand.js +178 -16
- package/dist/tests/reconcileApplyPlan.test.js +2 -2
- package/dist/tests/reconcileMultiPass.test.js +74 -0
- package/package.json +1 -1
- package/dist/reconcile/deleteOrder.js +0 -52
- package/dist/tests/reconcileDeleteOrder.test.js +0 -70
package/dist/commander.js
CHANGED
|
@@ -388,7 +388,7 @@ async function initCommands() {
|
|
|
388
388
|
await (0, schemaCommand_1.schemaSnapshotsCommand)(args);
|
|
389
389
|
}
|
|
390
390
|
})
|
|
391
|
-
.command("reconcile", "Make your personal instance match the checked-out branch (diff/report by default; --apply to apply UPDATE + DELETE)", (cmdArgs) => {
|
|
391
|
+
.command("reconcile", "Make your personal instance match the checked-out branch (diff/report by default; --apply to apply CREATE + UPDATE + DELETE)", (cmdArgs) => {
|
|
392
392
|
cmdArgs.options({
|
|
393
393
|
...sharedOptions,
|
|
394
394
|
scope: {
|
|
@@ -409,7 +409,7 @@ async function initCommands() {
|
|
|
409
409
|
apply: {
|
|
410
410
|
type: "boolean",
|
|
411
411
|
default: false,
|
|
412
|
-
describe: "Apply
|
|
412
|
+
describe: "Apply records branch -> instance: CREATE + UPDATE + tracked DELETE. Refuses on drift unless --force.",
|
|
413
413
|
},
|
|
414
414
|
force: {
|
|
415
415
|
type: "boolean",
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
// since baseline; refresh to keep them or --force.
|
|
13
13
|
// - Clean, or --force -> apply.
|
|
14
14
|
//
|
|
15
|
-
// What applies
|
|
15
|
+
// What applies:
|
|
16
|
+
// - CREATE every branch-only record (created on the instance with the
|
|
17
|
+
// branch's own sys_id — the server honors it via setNewGuidValue).
|
|
16
18
|
// - UPDATE every diff update (branch content overwrites the instance).
|
|
17
19
|
// - DELETE only "tracked" instance-only records (the branch deleted them).
|
|
18
20
|
// "local-new" / "no-baseline" records are the dev's own creations —
|
|
19
21
|
// NEVER deleted, not even with --force (force discards edits to the
|
|
20
22
|
// branch's records, it does not reap records the branch never owned).
|
|
21
|
-
// - CREATE deferred to Phase 3 — reported, never applied here.
|
|
22
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
24
|
exports.buildApplyPlan = buildApplyPlan;
|
|
24
25
|
function buildApplyPlan(options) {
|
|
@@ -51,9 +52,9 @@ function buildApplyPlan(options) {
|
|
|
51
52
|
return {
|
|
52
53
|
refuse,
|
|
53
54
|
refuseReason,
|
|
55
|
+
creates: diff.creates.slice(),
|
|
54
56
|
updates: diff.updates.slice(),
|
|
55
57
|
deletes,
|
|
56
58
|
skippedDeletes,
|
|
57
|
-
deferredCreates: diff.creates.slice(),
|
|
58
59
|
};
|
|
59
60
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// FK-safe record operations without building a dependency graph. ServiceNow
|
|
3
|
+
// refuses to delete a record another still references, and refuses to create a
|
|
4
|
+
// record before the parent it references exists. Both orderings fall out of the
|
|
5
|
+
// same loop: attempt every pending record, keep whatever succeeded, retry the
|
|
6
|
+
// failures next pass, stop when a full pass makes no progress (a genuine,
|
|
7
|
+
// non-ordering failure — e.g. ACL) and report the stragglers.
|
|
8
|
+
// - DELETE: a parent blocked by a still-present child succeeds once the child
|
|
9
|
+
// is gone (children before parents).
|
|
10
|
+
// - CREATE: a child blocked by a missing parent succeeds once the parent
|
|
11
|
+
// exists (parents before children).
|
|
12
|
+
//
|
|
13
|
+
// Pure control flow: the operation is injected, so the loop is unit-tested with
|
|
14
|
+
// a fake executor that encodes a dependency ("B cannot proceed until A has").
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.runMultiPassOps = runMultiPassOps;
|
|
17
|
+
async function runMultiPassOps(changes, attempt) {
|
|
18
|
+
const succeeded = [];
|
|
19
|
+
let pending = changes.slice();
|
|
20
|
+
let lastError = new Map();
|
|
21
|
+
while (pending.length > 0) {
|
|
22
|
+
const stillPending = [];
|
|
23
|
+
let progressed = false;
|
|
24
|
+
lastError = new Map();
|
|
25
|
+
for (const change of pending) {
|
|
26
|
+
let result;
|
|
27
|
+
try {
|
|
28
|
+
result = await attempt(change);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
32
|
+
}
|
|
33
|
+
if (result.ok) {
|
|
34
|
+
succeeded.push({ change, ok: true });
|
|
35
|
+
progressed = true;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
stillPending.push(change);
|
|
39
|
+
lastError.set(change.sys_id, result.error || "operation failed");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
pending = stillPending;
|
|
43
|
+
if (!progressed) {
|
|
44
|
+
// A full pass changed nothing — the remaining failures are not ordering
|
|
45
|
+
// problems. Report them and stop.
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const failures = pending.map((change) => ({
|
|
50
|
+
change,
|
|
51
|
+
ok: false,
|
|
52
|
+
error: lastError.get(change.sys_id) || "operation failed",
|
|
53
|
+
}));
|
|
54
|
+
return succeeded.concat(failures);
|
|
55
|
+
}
|
package/dist/reconcileCommand.js
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
// report schema drift (report-only), print the report.
|
|
10
10
|
// --write-baseline establish the per-instance merge-base from current live
|
|
11
11
|
// state (no record changes). Run this once before --apply.
|
|
12
|
-
// --apply [--force] apply
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// --apply [--force] apply records branch -> instance: CREATE net-new (with
|
|
13
|
+
// the branch's own sys_id, which the server honors),
|
|
14
|
+
// UPDATE changed records, DELETE tracked instance-only
|
|
15
|
+
// records. Refuses if the instance drifted since baseline
|
|
16
|
+
// (unless --force). Schema stays report-only (a SN ceiling).
|
|
17
|
+
// Updates for a scope with no configured update set are
|
|
18
|
+
// skipped, not pushed — a raw PATCH would re-parent scope.
|
|
16
19
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
20
|
if (k2 === undefined) k2 = k;
|
|
18
21
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -53,7 +56,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
53
56
|
exports.reconcileCommand = reconcileCommand;
|
|
54
57
|
const path_1 = __importDefault(require("path"));
|
|
55
58
|
const os_1 = __importDefault(require("os"));
|
|
56
|
-
const fs_1 = require("fs");
|
|
59
|
+
const fs_1 = __importStar(require("fs"));
|
|
57
60
|
const child_process_1 = require("child_process");
|
|
58
61
|
const util_1 = require("util");
|
|
59
62
|
const dovetail_schema_1 = require("@tenonhq/dovetail-schema");
|
|
@@ -63,13 +66,33 @@ const commands_1 = require("./commands");
|
|
|
63
66
|
const ConfigManager = __importStar(require("./config"));
|
|
64
67
|
const snClient_1 = require("./snClient");
|
|
65
68
|
const appUtils_1 = require("./appUtils");
|
|
69
|
+
const projectFiles_1 = require("./projectFiles");
|
|
66
70
|
const recordSource_1 = require("./reconcile/recordSource");
|
|
67
71
|
const recordDiff_1 = require("./reconcile/recordDiff");
|
|
72
|
+
const fields_1 = require("./reconcile/fields");
|
|
68
73
|
const baseline_1 = require("./reconcile/baseline");
|
|
69
74
|
const report_1 = require("./reconcile/report");
|
|
70
75
|
const applyPlan_1 = require("./reconcile/applyPlan");
|
|
71
|
-
const
|
|
76
|
+
const multiPass_1 = require("./reconcile/multiPass");
|
|
72
77
|
const gitignore_1 = require("./reconcile/gitignore");
|
|
78
|
+
// Per-scope update-set routing, read from the same .dove-update-sets.json the
|
|
79
|
+
// push pipeline uses. Missing/parse errors degrade to {} (treated as "no update
|
|
80
|
+
// set configured"), never throw.
|
|
81
|
+
function getUpdateSetConfig() {
|
|
82
|
+
try {
|
|
83
|
+
const configPath = (0, projectFiles_1.getUpdateSetsConfigPath)();
|
|
84
|
+
if (fs_1.default.existsSync(configPath)) {
|
|
85
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(configPath, "utf8"));
|
|
86
|
+
if (parsed && typeof parsed === "object") {
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
FileLogger_1.fileLogger.debug("reconcile: could not read update-set config");
|
|
93
|
+
}
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
73
96
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
74
97
|
function requireCreds() {
|
|
75
98
|
const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "" } = process.env;
|
|
@@ -218,7 +241,7 @@ async function applyDeletes(scope, deletes) {
|
|
|
218
241
|
return [];
|
|
219
242
|
}
|
|
220
243
|
const client = (0, snClient_1.defaultClient)();
|
|
221
|
-
return (0,
|
|
244
|
+
return (0, multiPass_1.runMultiPassOps)(deletes, async (change) => {
|
|
222
245
|
try {
|
|
223
246
|
const response = await client.deleteRecord({
|
|
224
247
|
table: change.table,
|
|
@@ -239,6 +262,117 @@ async function applyDeletes(scope, deletes) {
|
|
|
239
262
|
}
|
|
240
263
|
});
|
|
241
264
|
}
|
|
265
|
+
// Reconstruct the full field set for a CREATE from the record's on-disk
|
|
266
|
+
// representation: the metaData.json snapshot supplies every captured field
|
|
267
|
+
// value, then the canonical field files (script.js, etc.) override their own
|
|
268
|
+
// fields. Volatile bookkeeping and identity columns are excluded — sys_id is
|
|
269
|
+
// passed separately (and honored by the server), sys_scope comes from `scope`.
|
|
270
|
+
async function buildCreateFields(scope, change) {
|
|
271
|
+
const sourcePath = ConfigManager.getSourcePathForScope(scope);
|
|
272
|
+
const recordDir = path_1.default.join(sourcePath, change.table, change.name);
|
|
273
|
+
const fields = {};
|
|
274
|
+
// 1. metaData snapshot — short/non-file fields (active, order, name, ...).
|
|
275
|
+
try {
|
|
276
|
+
const metaRaw = await fs_1.promises.readFile(path_1.default.join(recordDir, "metaData.json"), "utf8");
|
|
277
|
+
const meta = JSON.parse(metaRaw);
|
|
278
|
+
for (const key of Object.keys(meta)) {
|
|
279
|
+
if (key.charAt(0) === "_" || key === "sys_id" || key === "sys_scope") {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (key.indexOf("sys_") === 0) {
|
|
283
|
+
// sys_created_on/by, sys_updated_on/by, sys_mod_count, sys_class_name, ...
|
|
284
|
+
// Skipped: server-managed. (sys_class_name on a base table defaults fine.)
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const value = meta[key];
|
|
288
|
+
if (typeof value === "string") {
|
|
289
|
+
fields[key] = value;
|
|
290
|
+
}
|
|
291
|
+
else if (typeof value === "object" &&
|
|
292
|
+
value !== null &&
|
|
293
|
+
typeof value.value === "string") {
|
|
294
|
+
fields[key] = value.value;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
FileLogger_1.fileLogger.debug("reconcile: no metaData for create " + recordDir);
|
|
300
|
+
}
|
|
301
|
+
// 2. Canonical field files override (the authoritative large-field content).
|
|
302
|
+
let entries = [];
|
|
303
|
+
try {
|
|
304
|
+
entries = await fs_1.promises.readdir(recordDir);
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
FileLogger_1.fileLogger.debug("reconcile: create record dir missing " + recordDir);
|
|
308
|
+
}
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (entry === "metaData.json") {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const dot = entry.lastIndexOf(".");
|
|
314
|
+
if (dot <= 0) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const name = entry.slice(0, dot);
|
|
318
|
+
const type = entry.slice(dot + 1);
|
|
319
|
+
if (!(0, fields_1.isComparableField)({ name, type })) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const stat = await fs_1.promises.stat(path_1.default.join(recordDir, entry));
|
|
324
|
+
if (stat.isFile()) {
|
|
325
|
+
fields[name] = await fs_1.promises.readFile(path_1.default.join(recordDir, entry), "utf8");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
// ignore unreadable entry
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Ensure a name is present (some tables require it).
|
|
333
|
+
if (!fields.name && change.name) {
|
|
334
|
+
fields.name = change.name;
|
|
335
|
+
}
|
|
336
|
+
return fields;
|
|
337
|
+
}
|
|
338
|
+
async function applyCreates(scope, creates, updateSetSysId) {
|
|
339
|
+
if (creates.length === 0) {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
const client = (0, snClient_1.defaultClient)();
|
|
343
|
+
return (0, multiPass_1.runMultiPassOps)(creates, async (change) => {
|
|
344
|
+
try {
|
|
345
|
+
const fields = await buildCreateFields(scope, change);
|
|
346
|
+
const response = await client.createRecord({
|
|
347
|
+
table: change.table,
|
|
348
|
+
fields,
|
|
349
|
+
sys_id: change.sys_id,
|
|
350
|
+
scope,
|
|
351
|
+
update_set_sys_id: updateSetSysId,
|
|
352
|
+
});
|
|
353
|
+
let data = response.data;
|
|
354
|
+
if (data && data.result) {
|
|
355
|
+
data = data.result;
|
|
356
|
+
}
|
|
357
|
+
if (data && data.error) {
|
|
358
|
+
return { ok: false, error: data.error };
|
|
359
|
+
}
|
|
360
|
+
if (data && data.sys_id && data.sys_id !== change.sys_id) {
|
|
361
|
+
// The server reassigned the sys_id — identity would diverge and the
|
|
362
|
+
// diff would never converge. Surface it loudly rather than silently
|
|
363
|
+
// creating a phantom.
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
error: "server reassigned sys_id (" + data.sys_id + " != " + change.sys_id + ")",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return { ok: true };
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
242
376
|
// Capture current live state across every scope into a fresh baseline + ensure
|
|
243
377
|
// the baseline file is gitignored in the consumer project.
|
|
244
378
|
async function recaptureBaseline(options) {
|
|
@@ -364,23 +498,45 @@ async function applyMode(options) {
|
|
|
364
498
|
}
|
|
365
499
|
Logger_1.logger.info("");
|
|
366
500
|
Logger_1.logger.info("Applying reconcile (branch -> instance)...");
|
|
501
|
+
const updateSetConfig = getUpdateSetConfig();
|
|
502
|
+
let totalCreated = 0;
|
|
367
503
|
let totalUpdated = 0;
|
|
368
504
|
let totalDeleted = 0;
|
|
369
505
|
const failures = [];
|
|
370
506
|
for (const entry of plans) {
|
|
371
507
|
const { scope, plan } = entry;
|
|
372
|
-
|
|
373
|
-
|
|
508
|
+
const updateSet = updateSetConfig[scope];
|
|
509
|
+
const updateSetSysId = updateSet ? updateSet.sys_id : undefined;
|
|
510
|
+
// CREATE — scope-correct via the `scope` param; routed into the update set
|
|
511
|
+
// when one is configured.
|
|
512
|
+
const createOutcomes = await applyCreates(scope, plan.creates, updateSetSysId);
|
|
513
|
+
for (const outcome of createOutcomes) {
|
|
514
|
+
if (outcome.ok) {
|
|
515
|
+
totalCreated++;
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
failures.push("[" + scope + "] create " + outcome.change.table + "/" + outcome.change.name + ": " + (outcome.error || "failed"));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// UPDATE — guard against the re-parenting trap: pushFiles falls back to a
|
|
522
|
+
// raw PATCH (which adopts session scope) when no update set is configured.
|
|
523
|
+
// Refuse to push updates for such a scope rather than silently re-parent.
|
|
524
|
+
if (plan.updates.length > 0 && !updateSetSysId) {
|
|
525
|
+
failures.push("[" +
|
|
374
526
|
scope +
|
|
375
527
|
"] " +
|
|
376
|
-
plan.
|
|
377
|
-
"
|
|
528
|
+
plan.updates.length +
|
|
529
|
+
" update(s) skipped — no update set configured for this scope. " +
|
|
530
|
+
"Set one (e.g. `dove switchUpdateSet`) before applying updates, to avoid re-parenting scope.");
|
|
378
531
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
532
|
+
else {
|
|
533
|
+
const updateResult = await applyUpdates(scope, plan.updates);
|
|
534
|
+
totalUpdated += updateResult.pushed;
|
|
535
|
+
for (const failure of updateResult.failures) {
|
|
536
|
+
failures.push("[" + scope + "] update: " + failure);
|
|
537
|
+
}
|
|
383
538
|
}
|
|
539
|
+
// DELETE — tracked instance-only records.
|
|
384
540
|
const deleteOutcomes = await applyDeletes(scope, plan.deletes);
|
|
385
541
|
for (const outcome of deleteOutcomes) {
|
|
386
542
|
if (outcome.ok) {
|
|
@@ -398,7 +554,13 @@ async function applyMode(options) {
|
|
|
398
554
|
scopes: options.scopes,
|
|
399
555
|
});
|
|
400
556
|
Logger_1.logger.info("");
|
|
401
|
-
Logger_1.logger.success("Applied: " +
|
|
557
|
+
Logger_1.logger.success("Applied: " +
|
|
558
|
+
totalCreated +
|
|
559
|
+
" created, " +
|
|
560
|
+
totalUpdated +
|
|
561
|
+
" updated, " +
|
|
562
|
+
totalDeleted +
|
|
563
|
+
" deleted.");
|
|
402
564
|
if (failures.length > 0) {
|
|
403
565
|
Logger_1.logger.error(failures.length + " operation(s) failed:");
|
|
404
566
|
for (const failure of failures) {
|
|
@@ -54,7 +54,7 @@ describe("buildApplyPlan — refusal gating", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
describe("buildApplyPlan — what applies", () => {
|
|
57
|
-
it("includes updates and
|
|
57
|
+
it("includes updates and creates", () => {
|
|
58
58
|
const plan = (0, applyPlan_1.buildApplyPlan)({
|
|
59
59
|
diff: diff({
|
|
60
60
|
updates: [change({ sys_id: "u", kind: "update" })],
|
|
@@ -65,7 +65,7 @@ describe("buildApplyPlan — what applies", () => {
|
|
|
65
65
|
force: false,
|
|
66
66
|
});
|
|
67
67
|
expect(plan.updates.map((u) => u.sys_id)).toEqual(["u"]);
|
|
68
|
-
expect(plan.
|
|
68
|
+
expect(plan.creates.map((c) => c.sys_id)).toEqual(["c"]);
|
|
69
69
|
});
|
|
70
70
|
it("applies only tracked deletes; keeps local-new and no-baseline", () => {
|
|
71
71
|
const plan = (0, applyPlan_1.buildApplyPlan)({
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Tests for the generic multi-pass op loop: clean ops, emergent ordering (a
|
|
3
|
+
// record blocked until its dependency resolves), a genuine failure surviving a
|
|
4
|
+
// no-progress pass, and thrown-error handling. Exercised for both delete
|
|
5
|
+
// ordering (children before parents) and create ordering (parents before
|
|
6
|
+
// children).
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
const multiPass_1 = require("../reconcile/multiPass");
|
|
9
|
+
const rec = (sys_id, kind) => ({
|
|
10
|
+
kind,
|
|
11
|
+
table: "sys_script",
|
|
12
|
+
scope: "x_cadso_core",
|
|
13
|
+
sys_id,
|
|
14
|
+
name: sys_id,
|
|
15
|
+
fieldDeltas: [],
|
|
16
|
+
});
|
|
17
|
+
describe("runMultiPassOps", () => {
|
|
18
|
+
it("completes everything in a single clean pass", async () => {
|
|
19
|
+
const calls = [];
|
|
20
|
+
const exec = async (c) => {
|
|
21
|
+
calls.push(c.sys_id);
|
|
22
|
+
return { ok: true };
|
|
23
|
+
};
|
|
24
|
+
const out = await (0, multiPass_1.runMultiPassOps)([rec("a", "delete"), rec("b", "delete")], exec);
|
|
25
|
+
expect(out.every((o) => o.ok)).toBe(true);
|
|
26
|
+
expect(calls.sort()).toEqual(["a", "b"]);
|
|
27
|
+
});
|
|
28
|
+
it("resolves DELETE ordering: parent succeeds only after child is gone", async () => {
|
|
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
|
+
const out = await (0, multiPass_1.runMultiPassOps)([rec("parent", "delete"), rec("child", "delete")], exec);
|
|
38
|
+
expect(out.every((o) => o.ok)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it("resolves CREATE ordering: child succeeds only after parent exists", async () => {
|
|
41
|
+
const created = new Set();
|
|
42
|
+
const exec = async (c) => {
|
|
43
|
+
if (c.sys_id === "child" && !created.has("parent")) {
|
|
44
|
+
return { ok: false, error: "parent does not exist yet" };
|
|
45
|
+
}
|
|
46
|
+
created.add(c.sys_id);
|
|
47
|
+
return { ok: true };
|
|
48
|
+
};
|
|
49
|
+
// child attempted first, fails, then succeeds on the pass after parent.
|
|
50
|
+
const out = await (0, multiPass_1.runMultiPassOps)([rec("child", "create"), rec("parent", "create")], exec);
|
|
51
|
+
expect(out.every((o) => o.ok)).toBe(true);
|
|
52
|
+
expect(created.has("child")).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it("reports a record that fails for a non-ordering reason", async () => {
|
|
55
|
+
const exec = async (c) => c.sys_id === "locked" ? { ok: false, error: "ACL blocked" } : { ok: true };
|
|
56
|
+
const out = await (0, multiPass_1.runMultiPassOps)([rec("ok", "delete"), rec("locked", "delete")], exec);
|
|
57
|
+
const failed = out.filter((o) => !o.ok);
|
|
58
|
+
expect(failed).toHaveLength(1);
|
|
59
|
+
expect(failed[0].change.sys_id).toBe("locked");
|
|
60
|
+
expect(failed[0].error).toBe("ACL blocked");
|
|
61
|
+
});
|
|
62
|
+
it("treats a thrown executor error as a failure", async () => {
|
|
63
|
+
const exec = async () => {
|
|
64
|
+
throw new Error("network down");
|
|
65
|
+
};
|
|
66
|
+
const out = await (0, multiPass_1.runMultiPassOps)([rec("a", "create")], exec);
|
|
67
|
+
expect(out[0].ok).toBe(false);
|
|
68
|
+
expect(out[0].error).toBe("network down");
|
|
69
|
+
});
|
|
70
|
+
it("returns [] for no changes", async () => {
|
|
71
|
+
const out = await (0, multiPass_1.runMultiPassOps)([], async () => ({ ok: true }));
|
|
72
|
+
expect(out).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/package.json
CHANGED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
// FK-safe deletion without building a dependency graph. ServiceNow refuses to
|
|
3
|
-
// delete a record another record still references; the safe order is therefore
|
|
4
|
-
// children before parents. Rather than model the foreign-key graph, delete in
|
|
5
|
-
// passes: attempt every pending record, keep whatever succeeded, and retry the
|
|
6
|
-
// failures on the next pass. A record blocked by a still-present dependent
|
|
7
|
-
// succeeds once that dependent is gone. Stop when a full pass makes no progress
|
|
8
|
-
// (a genuine, non-ordering failure — e.g. ACL) and report the stragglers.
|
|
9
|
-
//
|
|
10
|
-
// Pure control flow: the actual delete is injected, so the loop is unit-tested
|
|
11
|
-
// with a fake executor that encodes "B cannot delete until A is gone."
|
|
12
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.runMultiPassDeletes = runMultiPassDeletes;
|
|
14
|
-
async function runMultiPassDeletes(changes, attemptDelete) {
|
|
15
|
-
const succeeded = [];
|
|
16
|
-
let pending = changes.slice();
|
|
17
|
-
let lastError = new Map();
|
|
18
|
-
while (pending.length > 0) {
|
|
19
|
-
const stillPending = [];
|
|
20
|
-
let progressed = false;
|
|
21
|
-
lastError = new Map();
|
|
22
|
-
for (const change of pending) {
|
|
23
|
-
let result;
|
|
24
|
-
try {
|
|
25
|
-
result = await attemptDelete(change);
|
|
26
|
-
}
|
|
27
|
-
catch (e) {
|
|
28
|
-
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
29
|
-
}
|
|
30
|
-
if (result.ok) {
|
|
31
|
-
succeeded.push({ change, ok: true });
|
|
32
|
-
progressed = true;
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
stillPending.push(change);
|
|
36
|
-
lastError.set(change.sys_id, result.error || "delete failed");
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
pending = stillPending;
|
|
40
|
-
if (!progressed) {
|
|
41
|
-
// A full pass deleted nothing — the remaining failures are not ordering
|
|
42
|
-
// problems. Report them and stop.
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
const failures = pending.map((change) => ({
|
|
47
|
-
change,
|
|
48
|
-
ok: false,
|
|
49
|
-
error: lastError.get(change.sys_id) || "delete failed",
|
|
50
|
-
}));
|
|
51
|
-
return succeeded.concat(failures);
|
|
52
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
-
});
|