@tenonhq/dovetail-core 0.0.105 → 0.0.107
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/appUtils.js +22 -16
- 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/snClient.js +10 -0
- package/dist/tests/reconcileApplyPlan.test.js +2 -2
- package/dist/tests/reconcileMultiPass.test.js +74 -0
- package/dist/tests/updateSetConfig.test.js +126 -0
- package/dist/updateSetCommands.js +61 -0
- package/dist/updateSetConfig.js +55 -0
- package/package.json +3 -3
- package/release-manifest.json +190 -225
- package/skills/dove-manage-update-sets.md +6 -0
- package/dist/reconcile/deleteOrder.js +0 -52
- package/dist/tests/reconcileDeleteOrder.test.js +0 -70
package/dist/appUtils.js
CHANGED
|
@@ -38,30 +38,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.createAndAssignUpdateSet = exports.swapScope = exports.buildFiles = exports.summarizeRecord = exports.pushFiles = exports.getAppFileList = exports.groupAppFiles = exports.processMissingFiles = exports.refreshAllFiles = exports.findMissingFiles = exports.syncManifest = exports.processManifest = exports.normalizeManifestKeys = exports.toSafeFolderName = exports.stampMetadataContent = void 0;
|
|
40
40
|
const path_1 = __importDefault(require("path"));
|
|
41
|
-
const fs_1 = __importDefault(require("fs"));
|
|
42
41
|
const progress_1 = __importDefault(require("progress"));
|
|
43
42
|
const fUtils = __importStar(require("./FileUtils"));
|
|
44
43
|
const ConfigManager = __importStar(require("./config"));
|
|
45
44
|
const constants_1 = require("./constants");
|
|
46
45
|
const PluginManager_1 = __importDefault(require("./PluginManager"));
|
|
47
46
|
const FileLogger_1 = require("./FileLogger");
|
|
48
|
-
const
|
|
47
|
+
const updateSetConfig_1 = require("./updateSetConfig");
|
|
49
48
|
const snClient_1 = require("./snClient");
|
|
50
49
|
const benchmark_1 = require("./benchmark");
|
|
51
50
|
const Logger_1 = require("./Logger");
|
|
52
51
|
const genericUtils_1 = require("./genericUtils");
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (fs_1.default.existsSync(configPath)) {
|
|
57
|
-
return JSON.parse(fs_1.default.readFileSync(configPath, "utf8"));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
catch (e) {
|
|
61
|
-
Logger_1.logger.warn(`Failed to parse update set config at ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
62
|
-
}
|
|
63
|
-
return {};
|
|
64
|
-
};
|
|
52
|
+
// Delegate to the shared reader so the push hot-path and the
|
|
53
|
+
// createUpdateSet/switchUpdateSet writers stay on one parse contract.
|
|
54
|
+
const getUpdateSetConfig = () => (0, updateSetConfig_1.readUpdateSetConfig)();
|
|
65
55
|
// Strip the ServiceNow instance host from an absolute _record_link, leaving the
|
|
66
56
|
// instance-relative path. The CADSO metadata endpoint returns an absolute URL
|
|
67
57
|
// (https://<instance>.service-now.com/<table>.do?sys_id=...); persisting that
|
|
@@ -762,9 +752,11 @@ const pushFiles = async (recs) => {
|
|
|
762
752
|
const hasUpdateSets = Object.keys(updateSetConfig).length > 0;
|
|
763
753
|
if (hasUpdateSets) {
|
|
764
754
|
const activeScopes = Object.entries(updateSetConfig)
|
|
765
|
-
.map(([scope, us]) => `${scope} -> ${us.name}`)
|
|
755
|
+
.map(([scope, us]) => `${scope} -> ${us.name} (${us.sys_id})`)
|
|
766
756
|
.join(", ");
|
|
767
|
-
|
|
757
|
+
// Name the source file and show the sys_id so the operator can see exactly
|
|
758
|
+
// where captures land — push routes by this file, not the active set (#182).
|
|
759
|
+
Logger_1.logger.info(`Update set routing (from .dove-update-sets.json): ${activeScopes}`);
|
|
768
760
|
}
|
|
769
761
|
// Pre-resolve read-only table sets for every scope present in this batch.
|
|
770
762
|
// Cheaper than re-resolving per record. Tables flagged in _readOnlyTables
|
|
@@ -971,6 +963,20 @@ const createAndAssignUpdateSet = async (updateSetName = "", scope) => {
|
|
|
971
963
|
else {
|
|
972
964
|
await client.createCurrentUpdateSetUserPref(updateSetSysId, userSysId);
|
|
973
965
|
}
|
|
966
|
+
// Route this scope's captures to the new set. pushFiles() routes by
|
|
967
|
+
// .dove-update-sets.json, not the active set, so without this a `dove push
|
|
968
|
+
// --updateSet` could create+activate a set yet capture into a stale one
|
|
969
|
+
// (TenonHQ/Dovetail#182). Only possible when the scope name is known.
|
|
970
|
+
if (scope) {
|
|
971
|
+
const routed = (0, updateSetConfig_1.writeUpdateSetRouting)({
|
|
972
|
+
scope,
|
|
973
|
+
sysId: updateSetSysId,
|
|
974
|
+
name: updateSetName,
|
|
975
|
+
});
|
|
976
|
+
if (routed) {
|
|
977
|
+
Logger_1.logger.info(`Push routing updated: ${scope} -> ${updateSetName} (${updateSetSysId})`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
974
980
|
return {
|
|
975
981
|
name: updateSetName,
|
|
976
982
|
id: updateSetSysId,
|
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) {
|
package/dist/snClient.js
CHANGED
|
@@ -334,6 +334,15 @@ const snClient = (baseURL, username, password) => {
|
|
|
334
334
|
},
|
|
335
335
|
});
|
|
336
336
|
};
|
|
337
|
+
const getScopeById = (scopeSysId) => {
|
|
338
|
+
const endpoint = "api/now/table/sys_scope";
|
|
339
|
+
return client.get(endpoint, {
|
|
340
|
+
params: {
|
|
341
|
+
sysparm_query: `sys_id=${scopeSysId}`,
|
|
342
|
+
sysparm_fields: "scope",
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
};
|
|
337
346
|
const getUserSysId = (userName = process.env.SN_USER) => {
|
|
338
347
|
const endpoint = "api/now/table/sys_user";
|
|
339
348
|
return client.get(endpoint, {
|
|
@@ -504,6 +513,7 @@ const snClient = (baseURL, username, password) => {
|
|
|
504
513
|
getAppList,
|
|
505
514
|
updateRecord,
|
|
506
515
|
getScopeId,
|
|
516
|
+
getScopeById,
|
|
507
517
|
getUserSysId,
|
|
508
518
|
getCurrentAppUserPrefSysId,
|
|
509
519
|
updateCurrentAppUserPref,
|
|
@@ -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
|
+
});
|