@jacebenson/jsn 4.3.13-dev.89.1 → 4.3.13-dev.91.1
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/package.json +2 -1
- package/src/capabilities.js +5 -0
- package/src/cli.js +2 -1
- package/src/commands/updatesets.js +62 -11
- package/src/mutations.js +2 -1
- package/src/record-profiles.js +5 -0
- package/src/sdk.js +67 -1
- package/src/update-set-xml.js +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "4.3.13-dev.
|
|
3
|
+
"version": "4.3.13-dev.91.1",
|
|
4
4
|
"description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"@inquirer/prompts": "^7.5.1",
|
|
33
33
|
"better-sqlite3": "^13.0.3",
|
|
34
34
|
"js-yaml": "^5.5.0",
|
|
35
|
+
"saxes": "^6.0.0",
|
|
35
36
|
"turndown": "^7.2.4"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
package/src/capabilities.js
CHANGED
|
@@ -134,3 +134,8 @@ export function mutationPaths(caps = collectCapabilities()) {
|
|
|
134
134
|
export function _resetCapabilities() {
|
|
135
135
|
REGISTRY.clear();
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
/** Declared local-only previews bypass instance access, including context headers. */
|
|
139
|
+
export function isLocalPreview(argv, caps = collectCapabilities()) {
|
|
140
|
+
return argv.dryRun === true && caps.get(argv._?.[0])?.localPreviewSubcommands?.includes(argv._?.[1]) === true;
|
|
141
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { resolveSession, applySession } from './session.js';
|
|
|
10
10
|
import { renderHelp, renderTips } from './help.js';
|
|
11
11
|
import { isMutationCommand, refreshMutationCommands } from './mutations.js';
|
|
12
12
|
import { skippedUpdatesCmd } from './commands/skipped-updates.js';
|
|
13
|
-
import { noInstanceCommands, dailyCheckSkipCommands, contextHeaderSkipCommands } from './capabilities.js';
|
|
13
|
+
import { isLocalPreview, noInstanceCommands, dailyCheckSkipCommands, contextHeaderSkipCommands } from './capabilities.js';
|
|
14
14
|
import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
|
|
15
15
|
import { formatCommandList } from './helpers.js';
|
|
16
16
|
|
|
@@ -258,6 +258,7 @@ export function buildCLI() {
|
|
|
258
258
|
.middleware(async (argv) => {
|
|
259
259
|
// Session (incl. --instance/--profile overrides) was already applied
|
|
260
260
|
// by the middleware above — nothing to re-apply here.
|
|
261
|
+
if (isLocalPreview(argv)) return;
|
|
261
262
|
const cmd = (argv._[0] || '').toString();
|
|
262
263
|
// Daily-check + context-header skip-list is derived from command
|
|
263
264
|
// capability declarations (skipDailyChecks: true).
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { inspectUpdateSetXML, updateSetFilename, MAX_UPDATE_SET_BYTES } from '../update-set-xml.js';
|
|
2
4
|
import { editor, confirm } from '../prompts.js';
|
|
3
5
|
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack, quoteShellArg } from '../helpers.js';
|
|
4
6
|
import { getActiveProfile } from '../config.js';
|
|
5
7
|
import { FormatAuto } from '../output.js';
|
|
6
8
|
import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../context.js';
|
|
7
|
-
import { errAPI, errUsage, errConfirmationRequired } from '../errors.js';
|
|
9
|
+
import { AppError, CodeConfirmationRequired, errAPI, errUsage, errConfirmationRequired } from '../errors.js';
|
|
8
10
|
import { declareCapabilities } from '../capabilities.js';
|
|
9
11
|
import { resolveScope } from './scopes.js';
|
|
10
12
|
import { unwrapSysId } from '../resolve-record.js';
|
|
11
13
|
|
|
12
14
|
declareCapabilities('updatesets', {
|
|
13
|
-
mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent'],
|
|
14
|
-
mutationContextExemptSubcommands: ['create', 'set'],
|
|
15
|
+
mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent', 'import', 'reopen'],
|
|
16
|
+
mutationContextExemptSubcommands: ['create', 'set', 'import', 'reopen'],
|
|
17
|
+
localPreviewSubcommands: ['import'],
|
|
15
18
|
});
|
|
16
19
|
|
|
17
20
|
/**
|
|
@@ -382,7 +385,7 @@ export async function formatUpdateSetDetail(app, record) {
|
|
|
382
385
|
});
|
|
383
386
|
hints.push({
|
|
384
387
|
action: 'export',
|
|
385
|
-
cmd: `jsn updatesets export "${name}" --out ${
|
|
388
|
+
cmd: `jsn updatesets export "${name}" --out ${quoteShellArg(updateSetFilename(name))}`,
|
|
386
389
|
description: 'Export this update set to XML',
|
|
387
390
|
});
|
|
388
391
|
if (!isClosed) {
|
|
@@ -478,11 +481,14 @@ export function updateSetsCmd(wrap) {
|
|
|
478
481
|
})
|
|
479
482
|
.command({
|
|
480
483
|
command: 'export <name>',
|
|
481
|
-
describe: 'Export
|
|
484
|
+
describe: 'Export XML without changing state; auto-name a file in a terminal, preserve piped stdout',
|
|
482
485
|
builder: (y) => y
|
|
483
486
|
.positional('name', { describe: 'Update set name', type: 'string' })
|
|
484
|
-
.option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
|
|
487
|
+
.option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
|
|
488
|
+
.option('out-dir', { type: 'string', describe: 'Write an automatically named XML file in this directory' })
|
|
489
|
+
.option('stdout', { type: 'boolean', describe: 'Write raw XML to stdout, including in a terminal' }),
|
|
485
490
|
handler: wrap(async (argv, app) => {
|
|
491
|
+
if ([Boolean(argv.out), Boolean(argv.outDir), Boolean(argv.stdout)].filter(Boolean).length > 1) throw errUsage('Choose one of --out, --out-dir, or --stdout');
|
|
486
492
|
app.requireInstance();
|
|
487
493
|
const record = await resolveUpdateSetByName(app, argv.name);
|
|
488
494
|
const sysID = getStringField(record, 'sys_id');
|
|
@@ -504,14 +510,16 @@ export function updateSetsCmd(wrap) {
|
|
|
504
510
|
}
|
|
505
511
|
|
|
506
512
|
const bytes = Buffer.byteLength(xml, 'utf-8');
|
|
507
|
-
|
|
508
|
-
|
|
513
|
+
const out = argv.out || (argv.outDir || (process.stdout.isTTY && !argv.stdout && !argv.json && argv.format !== 'json')
|
|
514
|
+
? path.join(argv.outDir || '.', updateSetFilename(getStringField(record, 'name'))) : null);
|
|
515
|
+
if (out) {
|
|
516
|
+
await fs.promises.writeFile(out, xml, { flag: 'wx' });
|
|
509
517
|
app.ok({
|
|
510
518
|
update_set: getStringField(record, 'name'),
|
|
511
519
|
sys_id: sysID,
|
|
512
|
-
out
|
|
520
|
+
out,
|
|
513
521
|
bytes,
|
|
514
|
-
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${
|
|
522
|
+
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${out}` });
|
|
515
523
|
return;
|
|
516
524
|
}
|
|
517
525
|
|
|
@@ -519,7 +527,7 @@ export function updateSetsCmd(wrap) {
|
|
|
519
527
|
// (`jsn updatesets export "X" > set.xml`). Only wrap in the JSON
|
|
520
528
|
// envelope when the user explicitly asks for it — auto format
|
|
521
529
|
// detection must NOT hijack the payload for piped consumers.
|
|
522
|
-
if (argv.json || argv.format === 'json') {
|
|
530
|
+
if (!argv.stdout && (argv.json || argv.format === 'json')) {
|
|
523
531
|
app.ok({
|
|
524
532
|
update_set: getStringField(record, 'name'),
|
|
525
533
|
sys_id: sysID,
|
|
@@ -531,6 +539,47 @@ export function updateSetsCmd(wrap) {
|
|
|
531
539
|
process.stdout.write(xml + (xml.endsWith('\n') ? '' : '\n'));
|
|
532
540
|
}),
|
|
533
541
|
})
|
|
542
|
+
.command({
|
|
543
|
+
command: 'import <file>',
|
|
544
|
+
describe: 'Load XML as a retrieved update set; never preview, commit, or select it automatically',
|
|
545
|
+
builder: y => y
|
|
546
|
+
.positional('file', { type: 'string', describe: 'Update set XML export' })
|
|
547
|
+
.option('dry-run', { type: 'boolean', describe: 'Inspect locally without any instance requests' })
|
|
548
|
+
.option('yes', { type: 'boolean', describe: 'Confirm loading the displayed XML into the selected target' }),
|
|
549
|
+
handler: wrap(async (argv, app) => {
|
|
550
|
+
const stat = await fs.promises.stat(argv.file);
|
|
551
|
+
if (!stat.isFile() || stat.size > MAX_UPDATE_SET_BYTES) throw errUsage('Expected an XML file no larger than 50 MiB');
|
|
552
|
+
const xml = await fs.promises.readFile(argv.file, 'utf8');
|
|
553
|
+
const inspection = inspectUpdateSetXML(xml);
|
|
554
|
+
const { members, ...summary } = inspection;
|
|
555
|
+
if (argv.dryRun) return app.ok({ ...summary, members, dry_run: true }, { summary: `XML: ${inspection.name}; ${inspection.count} updates (local inspection only)` });
|
|
556
|
+
app.requireInstance();
|
|
557
|
+
const question = `Load "${inspection.name}" (${inspection.count} updates) into ${app.getEffectiveInstance()} as a retrieved update set? This does not commit its changes.`;
|
|
558
|
+
if (!argv.yes) {
|
|
559
|
+
if (!canPrompt() || argv.json || argv.get || (argv.format && argv.format !== FormatAuto)) {
|
|
560
|
+
throw new AppError(CodeConfirmationRequired, `${question} — confirmation required. Pass --yes after approving this target.`,
|
|
561
|
+
`Rerun: ${buildRerunCommand(argv, { rerun: ['updatesets', 'import', argv.file, '--yes'] })}`);
|
|
562
|
+
}
|
|
563
|
+
if (!await confirm({ message: question, default: false })) return;
|
|
564
|
+
}
|
|
565
|
+
const result = await app.sdk.importUpdateSet(xml, inspection, path.basename(argv.file));
|
|
566
|
+
app.ok({ ...summary, ...result, committed: false }, { summary: `Loaded retrieved update set "${inspection.name}" (${result.count} updates); not committed` });
|
|
567
|
+
}),
|
|
568
|
+
})
|
|
569
|
+
.command({
|
|
570
|
+
command: 'reopen <name>',
|
|
571
|
+
describe: 'Explicitly return a complete or ignored local update set to in progress; does not select it',
|
|
572
|
+
handler: wrap(async (argv, app) => {
|
|
573
|
+
const target = await resolveCompletionTarget(app, argv.name);
|
|
574
|
+
const record = await fetchUpdateSetById(app, target.sys_id);
|
|
575
|
+
const state = String(record?.state?.value ?? record?.state ?? '').toLowerCase();
|
|
576
|
+
if (!['complete', 'ignore'].includes(state)) throw errUsage(`Cannot reopen an update set in state "${state}"; expected complete or ignore`);
|
|
577
|
+
await app.sdk.update('sys_update_set', target.sys_id, { state: 'in progress' });
|
|
578
|
+
const mismatches = await verifyWriteBack(app, 'sys_update_set', target.sys_id, { state: 'in progress' });
|
|
579
|
+
if (mismatches.length) throw errAPI(200, 'Reopen could not be verified; inspect the update set before retrying');
|
|
580
|
+
app.ok({ update_set: target.name, sys_id: target.sys_id, state: 'in progress' }, { summary: `Reopened update set: ${target.name}` });
|
|
581
|
+
}),
|
|
582
|
+
})
|
|
534
583
|
.command({
|
|
535
584
|
command: 'set [name]',
|
|
536
585
|
describe: 'Set the current update set (interactive picker with scope when run bare)',
|
|
@@ -738,6 +787,8 @@ export function updateSetsCmd(wrap) {
|
|
|
738
787
|
console.log(' list List update sets (shows scope)');
|
|
739
788
|
console.log(' show <name> Show an update set (type counts + risky items)');
|
|
740
789
|
console.log(' export <name> Export an update set to XML (stdout or --out)');
|
|
790
|
+
console.log(' import <file> Load retrieved XML (--dry-run inspects locally; --yes confirms)');
|
|
791
|
+
console.log(' reopen <name> Explicitly return a closed set to in progress');
|
|
741
792
|
console.log(' set [name] Set the current update set (picker when run bare)');
|
|
742
793
|
console.log(' create Create a new update set (auto-sets as current)');
|
|
743
794
|
console.log(' complete [name] Complete a named or current set with --notes (editor when omitted)');
|
package/src/mutations.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// create` (read-only bypass), and listed `restmethods`, a command that was
|
|
9
9
|
// never registered. Derived data can't drift that way.
|
|
10
10
|
|
|
11
|
-
import { mutationPaths } from './capabilities.js';
|
|
11
|
+
import { mutationPaths, isLocalPreview } from './capabilities.js';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Back-compat export: the derived mutation path list, refreshed by
|
|
@@ -42,6 +42,7 @@ function currentPaths(paths) {
|
|
|
42
42
|
* @returns {boolean}
|
|
43
43
|
*/
|
|
44
44
|
export function isMutationCommand(argv, paths) {
|
|
45
|
+
if (isLocalPreview(argv)) return false;
|
|
45
46
|
const cmd = argv._ || [];
|
|
46
47
|
for (const pattern of currentPaths(paths)) {
|
|
47
48
|
if (pattern.length > cmd.length) continue;
|
package/src/record-profiles.js
CHANGED
|
@@ -84,5 +84,10 @@ export function buildRecordProfile(definition, wrap, hooks = {}) {
|
|
|
84
84
|
: buildDevCmd(profile.name, profile.table, profile.aliases, profile.columns, wrap, options);
|
|
85
85
|
command.aliases = [...profile.aliases];
|
|
86
86
|
command.recordProfile = profile;
|
|
87
|
+
command.discovery.benefits.push(
|
|
88
|
+
`Reference output: ${profile.output.references}; list projection: ${profile.output.listProjection}`,
|
|
89
|
+
...profile.relationships.essential.map(relationship =>
|
|
90
|
+
`Essential ${relationship.name} records from ${relationship.table} on show (disable with --no-essential)`),
|
|
91
|
+
);
|
|
87
92
|
return command;
|
|
88
93
|
}
|
package/src/sdk.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { batchRead } from './sdk-batch.js';
|
|
4
4
|
import { gunzipSync } from 'node:zlib';
|
|
5
|
-
import { CodeAuth, errAuth, errAPI, errNetwork } from './errors.js';
|
|
5
|
+
import { AppError, CodeAPI, CodeAuth, errAuth, errAPI, errNetwork, errUsage } from './errors.js';
|
|
6
6
|
import { getStringField } from './helpers.js';
|
|
7
7
|
import { getAction, getStepTypes, createAction, updateAction, testAction } from './processflow-actions.js';
|
|
8
8
|
import { readSkippedBaseline, revertSkippedUpdate } from './skipped-update-provider.js';
|
|
@@ -768,6 +768,72 @@ export class SDKClient {
|
|
|
768
768
|
});
|
|
769
769
|
}
|
|
770
770
|
|
|
771
|
+
/** Load through the native XML upload processor, then verify the exact envelope. */
|
|
772
|
+
async importUpdateSet(xml, inspection, filename = 'update-set.xml') {
|
|
773
|
+
const { sys_id: id, remote_sys_id: sourceID } = inspection;
|
|
774
|
+
const params = new URLSearchParams({
|
|
775
|
+
sysparm_query: `sys_id=${id}^ORremote_sys_id=${sourceID}`,
|
|
776
|
+
sysparm_fields: 'sys_id,name,state', sysparm_limit: '2', sysparm_display_value: 'false',
|
|
777
|
+
});
|
|
778
|
+
if ((await this.list('sys_remote_update_set', params)).length) {
|
|
779
|
+
throw errUsage('This retrieved update set already exists on the target; refusing to overwrite it. Inspect it before retrying.');
|
|
780
|
+
}
|
|
781
|
+
// Native INSERT_OR_UPDATE must not move/overwrite a member belonging to another set.
|
|
782
|
+
for (let offset = 0; offset < inspection.members.length; offset += 50) {
|
|
783
|
+
const ids = inspection.members.slice(offset, offset + 50).map(member => member.sys_id).join(',');
|
|
784
|
+
const existing = await this.list('sys_update_xml', new URLSearchParams({
|
|
785
|
+
sysparm_query: `sys_idIN${ids}`, sysparm_fields: 'sys_id', sysparm_limit: '1',
|
|
786
|
+
}));
|
|
787
|
+
if (existing.length) throw errUsage('An XML member already exists on the target; refusing to overwrite or move existing updates.');
|
|
788
|
+
}
|
|
789
|
+
const cookies = await this._warmSession();
|
|
790
|
+
const formURL = `${this.baseURL}/upload.do?sysparm_target=sys_remote_update_set`;
|
|
791
|
+
const html = await this.rawRequest(formURL, { method: 'GET', headers: cookies ? { Cookie: cookies } : {} });
|
|
792
|
+
const csrf = html.match(/name=["']sysparm_ck["'][^>]*value=["']([^"']+)["']/i)?.[1];
|
|
793
|
+
if (!csrf || !/action=["']\/sys_upload\.do["']/i.test(html) || !/name=["']attachFile["']/i.test(html)) {
|
|
794
|
+
throw errUsage('The native XML upload form is unavailable; no XML was uploaded. Check admin access and session authentication.');
|
|
795
|
+
}
|
|
796
|
+
const form = new FormData();
|
|
797
|
+
form.set('sysparm_ck', csrf);
|
|
798
|
+
form.set('sysparm_target', 'sys_remote_update_set');
|
|
799
|
+
form.set('sysparm_upload_prefix', '');
|
|
800
|
+
form.set('sysparm_referring_url', 'sys_remote_update_set_list.do');
|
|
801
|
+
form.set('attachFile', new Blob([xml], { type: 'application/xml' }), filename);
|
|
802
|
+
let outcome = 'unknown';
|
|
803
|
+
try {
|
|
804
|
+
await this.rawRequest(`${this.baseURL}/sys_upload.do`, { method: 'POST', body: form,
|
|
805
|
+
headers: { ...(cookies ? { Cookie: cookies } : {}), 'X-UserToken': csrf, Referer: formURL },
|
|
806
|
+
});
|
|
807
|
+
const records = await this.list('sys_remote_update_set', params);
|
|
808
|
+
if (records.length !== 1) throw new Error('Could not identify exactly one uploaded retrieved update set');
|
|
809
|
+
const record = records[0];
|
|
810
|
+
const importedID = getStringField(record, 'sys_id');
|
|
811
|
+
const state = getStringField(record, 'state');
|
|
812
|
+
if (state !== 'loaded' || getStringField(record, 'name') !== inspection.name) throw new Error('Uploaded update set name/state did not match the loaded export');
|
|
813
|
+
const members = [];
|
|
814
|
+
for (let offset = 0; ; offset += 500) {
|
|
815
|
+
const page = await this.list('sys_update_xml', new URLSearchParams({
|
|
816
|
+
sysparm_query: `remote_update_set=${importedID}^ORDERBYsys_id`, sysparm_limit: '500',
|
|
817
|
+
sysparm_offset: String(offset), sysparm_display_value: 'false', sysparm_fields: 'sys_id,name,action',
|
|
818
|
+
}));
|
|
819
|
+
members.push(...page);
|
|
820
|
+
if (members.length > inspection.count) throw new Error('Uploaded update set contains unexpected extra members');
|
|
821
|
+
if (page.length < 500) break;
|
|
822
|
+
}
|
|
823
|
+
const actual = new Map(members.map(member => [getStringField(member, 'sys_id'), member]));
|
|
824
|
+
if (actual.size !== inspection.count || inspection.members.some(expected => {
|
|
825
|
+
const member = actual.get(expected.sys_id);
|
|
826
|
+
return !member || getStringField(member, 'name') !== expected.name || getStringField(member, 'action') !== expected.action;
|
|
827
|
+
})) throw new Error('Uploaded update set members did not match the XML export');
|
|
828
|
+
outcome = 'succeeded';
|
|
829
|
+
return { sys_id: importedID, state, count: members.length, link: `${this.baseURL}/sys_remote_update_set.do?sys_id=${importedID}` };
|
|
830
|
+
} catch (error) {
|
|
831
|
+
throw new AppError(CodeAPI, `XML import could not be verified: ${error.message}. It may have partially loaded; inspect Retrieved Update Sets before retrying. No automatic retry was attempted.`, 'Inspect Retrieved Update Sets before retrying.', 0, error);
|
|
832
|
+
} finally {
|
|
833
|
+
if (this.onMutation) await this._reportMutation({ table: 'sys_remote_update_set', sys_id: id, operation: 'create', outcome });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
771
837
|
/**
|
|
772
838
|
* Execute a background script on the ServiceNow instance via sys.scripts.do.
|
|
773
839
|
* Uses a 3-step session-establishment flow compatible with OAuth tokens:
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Parse the export envelope without evaluating its payload or resolving entities.
|
|
2
|
+
import { SaxesParser } from 'saxes';
|
|
3
|
+
import { errUsage } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export const MAX_UPDATE_SET_BYTES = 50 * 1024 * 1024;
|
|
6
|
+
const ID = /^[a-f0-9]{32}$/i;
|
|
7
|
+
|
|
8
|
+
export function updateSetFilename(name) {
|
|
9
|
+
const stem = String(name).normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, 180);
|
|
10
|
+
return `${stem || 'update-set'}.xml`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function inspectUpdateSetXML(xml) {
|
|
14
|
+
if (Buffer.byteLength(xml, 'utf8') > MAX_UPDATE_SET_BYTES) throw errUsage('Update set XML exceeds the 50 MiB limit');
|
|
15
|
+
const parser = new SaxesParser();
|
|
16
|
+
const stack = [], sets = [], updates = [];
|
|
17
|
+
let record;
|
|
18
|
+
parser.on('doctype', () => { throw errUsage('DOCTYPE and external entities are not allowed in update set XML'); });
|
|
19
|
+
parser.on('opentag', tag => {
|
|
20
|
+
stack.push(tag.name);
|
|
21
|
+
if (stack.length === 1 && tag.name !== 'unload') throw errUsage('Expected an update set export with an <unload> root');
|
|
22
|
+
if (stack.length === 2) {
|
|
23
|
+
if (!['sys_remote_update_set', 'sys_update_xml'].includes(tag.name) || tag.attributes.action !== 'INSERT_OR_UPDATE') {
|
|
24
|
+
throw errUsage('Only INSERT_OR_UPDATE sys_remote_update_set and sys_update_xml envelope records may be imported');
|
|
25
|
+
}
|
|
26
|
+
record = {};
|
|
27
|
+
}
|
|
28
|
+
if (stack.length === 3) {
|
|
29
|
+
if (Object.hasOwn(record, tag.name)) throw errUsage(`Duplicate XML field: ${tag.name}`);
|
|
30
|
+
record[tag.name] = '';
|
|
31
|
+
}
|
|
32
|
+
if (stack.length > 3) throw errUsage('Update payloads must be XML text or CDATA, not nested envelope elements');
|
|
33
|
+
});
|
|
34
|
+
const text = value => { if (stack.length === 3) record[stack[2]] += value; };
|
|
35
|
+
parser.on('text', text); parser.on('cdata', text);
|
|
36
|
+
parser.on('closetag', () => {
|
|
37
|
+
if (stack.length === 2) (stack[1] === 'sys_remote_update_set' ? sets : updates).push(record);
|
|
38
|
+
stack.pop();
|
|
39
|
+
});
|
|
40
|
+
try { parser.write(xml).close(); } catch (error) { throw errUsage(`Invalid update set XML: ${error.message}`); }
|
|
41
|
+
if (sets.length !== 1) throw errUsage('Import exactly one retrieved update set per XML file');
|
|
42
|
+
const set = sets[0];
|
|
43
|
+
if (!ID.test(set.sys_id || '') || !ID.test(set.remote_sys_id || '') || !set.name?.trim()) {
|
|
44
|
+
throw errUsage('Update set requires a name, sys_id, and remote_sys_id');
|
|
45
|
+
}
|
|
46
|
+
if (set.sys_class_name && set.sys_class_name !== 'sys_remote_update_set') throw errUsage('Invalid retrieved update set class');
|
|
47
|
+
if (set.state !== 'loaded' || set.update_set?.trim()) throw errUsage('Expected a loaded, uncommitted retrieved update set export');
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const summary = new Map(), payloadOperations = new Map();
|
|
50
|
+
for (const update of updates) {
|
|
51
|
+
if (!ID.test(update.sys_id || '') || seen.has(update.sys_id.toLowerCase()) || update.remote_update_set !== set.sys_id
|
|
52
|
+
|| update.update_set?.trim() || !update.name || !update.payload?.trim()
|
|
53
|
+
|| !['INSERT_OR_UPDATE', 'DELETE'].includes(update.action)) {
|
|
54
|
+
throw errUsage('Invalid, duplicate, or unrelated update XML member');
|
|
55
|
+
}
|
|
56
|
+
if (update.sys_class_name && update.sys_class_name !== 'sys_update_xml') throw errUsage('Invalid update XML class');
|
|
57
|
+
seen.add(update.sys_id.toLowerCase());
|
|
58
|
+
// Payload XML stays inert; parse it solely to describe the affected table.
|
|
59
|
+
const payload = new SaxesParser(); let table = '', depth = 0, primary = '';
|
|
60
|
+
payload.on('doctype', () => { throw errUsage('DOCTYPE is not allowed in update payloads'); });
|
|
61
|
+
payload.on('opentag', tag => {
|
|
62
|
+
depth++;
|
|
63
|
+
if (depth === 1 && tag.name !== 'record_update') throw errUsage('Expected a record_update payload');
|
|
64
|
+
if (depth === 1) table = tag.attributes.table || '';
|
|
65
|
+
if (depth === 2) {
|
|
66
|
+
if (!primary) {
|
|
67
|
+
primary = tag.name;
|
|
68
|
+
if (table && table !== primary) throw errUsage('Payload table does not match its primary record');
|
|
69
|
+
table = primary;
|
|
70
|
+
if (tag.attributes.action && tag.attributes.action !== update.action) throw errUsage('Payload action does not match the update member action');
|
|
71
|
+
}
|
|
72
|
+
const action = tag.attributes.action || 'platform_default';
|
|
73
|
+
if (!['INSERT_OR_UPDATE', 'INSERT', 'UPDATE', 'DELETE', 'delete_multiple', 'platform_default'].includes(action)) throw errUsage('Unsupported payload record action');
|
|
74
|
+
const key = `${tag.name}:${action}`;
|
|
75
|
+
const operation = payloadOperations.get(key) || { table: tag.name, action, count: 0 };
|
|
76
|
+
operation.count++; payloadOperations.set(key, operation);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
payload.on('closetag', () => { depth--; });
|
|
80
|
+
try { payload.write(update.payload).close(); } catch (error) { throw errUsage(`Invalid update payload: ${error.message}`); }
|
|
81
|
+
if (!primary || !/^[a-z][a-z0-9_]*$/i.test(table)) throw errUsage(`Missing or invalid payload table for ${update.name}`);
|
|
82
|
+
const key = `${table}:${update.action}`;
|
|
83
|
+
const row = summary.get(key) || { table, action: update.action, count: 0 };
|
|
84
|
+
row.count++; summary.set(key, row);
|
|
85
|
+
}
|
|
86
|
+
return { name: set.name, sys_id: set.sys_id, remote_sys_id: set.remote_sys_id,
|
|
87
|
+
state: set.state, count: updates.length, summary: [...summary.values()], payload_operations: [...payloadOperations.values()],
|
|
88
|
+
members: updates.map(update => ({ sys_id: update.sys_id, name: update.name, target_name: update.target_name || '', action: update.action })) };
|
|
89
|
+
}
|