@jacebenson/jsn 4.2.4 → 4.3.0

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.
@@ -5,15 +5,73 @@ import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import Database from 'better-sqlite3';
7
7
 
8
- const DOCS_DIR_NAME = 'servicenow-cli/docs';
8
+ const LEGACY_DOCS_DIR_NAME = 'servicenow-cli/docs';
9
+ const DOCS_DIR_NAME = 'docs';
9
10
 
10
11
  function xdgCacheHome() {
11
12
  if (process.env.XDG_CACHE_HOME) return process.env.XDG_CACHE_HOME;
12
13
  return path.join(os.homedir(), '.cache');
13
14
  }
14
15
 
16
+ function jsnDataHome() {
17
+ // JSN_DATA_HOME is an internal test override and useful for isolated runs.
18
+ if (process.env.JSN_DATA_HOME) return process.env.JSN_DATA_HOME;
19
+ return path.join(os.homedir(), '.jsn');
20
+ }
21
+
22
+ function legacyDocsDir() {
23
+ return path.join(xdgCacheHome(), LEGACY_DOCS_DIR_NAME);
24
+ }
25
+
26
+ function verifyMovedFile(sourceStat, destination) {
27
+ const destinationStat = fs.statSync(destination);
28
+ return destinationStat.isFile() && destinationStat.size === sourceStat.size;
29
+ }
30
+
31
+ function migrateLegacyDocs() {
32
+ const sourceDir = legacyDocsDir();
33
+ const destinationDir = path.join(jsnDataHome(), DOCS_DIR_NAME);
34
+ const sourceDb = path.join(sourceDir, 'docs.db');
35
+ const destinationDb = path.join(destinationDir, 'docs.db');
36
+
37
+ if (fs.existsSync(destinationDb) || !fs.existsSync(sourceDb)) return;
38
+
39
+ const sourceDbStat = fs.statSync(sourceDb);
40
+ const moved = [];
41
+ try {
42
+ fs.mkdirSync(destinationDir, { recursive: true });
43
+ for (const entry of fs.readdirSync(sourceDir)) {
44
+ const source = path.join(sourceDir, entry);
45
+ const destination = path.join(destinationDir, entry);
46
+ if (fs.existsSync(destination)) {
47
+ throw new Error(`Cannot migrate docs data: destination already exists: ${destination}`);
48
+ }
49
+ fs.renameSync(source, destination);
50
+ moved.push({ source, destination });
51
+ }
52
+
53
+ if (!fs.existsSync(destinationDb) || !verifyMovedFile(sourceDbStat, destinationDb)) {
54
+ throw new Error('Cannot migrate docs data: destination verification failed');
55
+ }
56
+
57
+ // The source directory should now be empty. Removing only this known docs
58
+ // directory leaves unrelated cache entries alone.
59
+ fs.rmdirSync(sourceDir);
60
+ } catch (err) {
61
+ // Put every moved entry back so a failed migration leaves the old copy
62
+ // usable. The destination directory may already have existed.
63
+ for (const { source, destination } of moved.reverse()) {
64
+ if (fs.existsSync(destination) && !fs.existsSync(source)) {
65
+ fs.renameSync(destination, source);
66
+ }
67
+ }
68
+ throw err;
69
+ }
70
+ }
71
+
15
72
  export function getDocsCacheDir() {
16
- return path.join(xdgCacheHome(), DOCS_DIR_NAME);
73
+ migrateLegacyDocs();
74
+ return path.join(jsnDataHome(), DOCS_DIR_NAME);
17
75
  }
18
76
 
19
77
  export function getDocsDbPath() {
@@ -0,0 +1,143 @@
1
+ import { errUsageHint, errUsage } from '../errors.js';
2
+ import { captureRun, listRuns, getRun, compareRuns, formatRunDetailed, formatRunList, formatComparisonDetailed } from '../perf.js';
3
+ import { paginatedSearch } from '../paginated-search.js';
4
+
5
+ function pickerEnabled(app) {
6
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY && app.output?.effectiveFormat?.() !== 'json' && app.output?.effectiveFormat?.() !== 'quiet');
7
+ }
8
+
9
+ async function pickRun(app, { exclude = new Set(), message = 'Select a performance capture' } = {}) {
10
+ const runs = listRuns({ limit: 1000 }).filter(run => !exclude.has(run.run_id));
11
+ if (!pickerEnabled(app) || runs.length === 0) return null;
12
+ const selected = await paginatedSearch({
13
+ message,
14
+ totalCount: runs.length,
15
+ pageSize: 10,
16
+ source: async (term = '') => {
17
+ const filter = String(term).toLowerCase();
18
+ return runs
19
+ .filter(run => !filter || `${run.run_id} ${run.label || ''} ${run.profile || ''} ${run.instance}`.toLowerCase().includes(filter))
20
+ .map(run => ({
21
+ name: `${run.run_id} ${run.label || '(unlabeled)'} [${run.profile || 'default'}] ${run.instance}`,
22
+ value: run,
23
+ }));
24
+ },
25
+ });
26
+ return selected?.value || null;
27
+ }
28
+
29
+ function requireRun(runId, side) {
30
+ const run = getRun(runId);
31
+ if (!run) throw errUsageHint(`Unknown ${side} run: ${runId}`, 'Run `jsn perf list` to see saved captures.');
32
+ return run;
33
+ }
34
+
35
+ function listHint() {
36
+ return { action: 'list', cmd: 'jsn perf list', description: 'Browse saved performance captures' };
37
+ }
38
+
39
+ function showHint(runId) {
40
+ return { action: 'show', cmd: `jsn perf show ${runId}`, description: 'View this capture again' };
41
+ }
42
+
43
+ function compareHint(runId) {
44
+ return { action: 'compare', cmd: `jsn perf compare ${runId} OTHER_RUN_ID`, description: 'Compare this capture with another run' };
45
+ }
46
+
47
+ export function perfCmd(wrap) {
48
+ return {
49
+ command: 'perf [subcommand]',
50
+ aliases: ['performance'],
51
+ describe: 'Capture and compare read-only ServiceNow performance summaries',
52
+ builder: (y) => y
53
+ .command({
54
+ command: 'capture',
55
+ describe: 'Capture one read-only performance snapshot',
56
+ builder: (b) => b.option('label', { type: 'string', describe: 'Human-readable label for this run' }),
57
+ handler: wrap(async (argv, app) => {
58
+ app.requireInstance();
59
+ const run = await captureRun({
60
+ sdk: app.sdk,
61
+ instance: app.getEffectiveInstance(),
62
+ profile: app.session?.profileName || app.context?.profileName || '',
63
+ username: app.session?.username || '',
64
+ label: argv.label || '',
65
+ options: { label: argv.label || '' },
66
+ });
67
+ app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
68
+ summary: `Performance capture ${run.run_id}: ${run.status}`,
69
+ breadcrumbs: [listHint(), showHint(run.run_id), compareHint(run.run_id)],
70
+ });
71
+ }),
72
+ })
73
+ .command({
74
+ command: 'list',
75
+ aliases: ['ls'],
76
+ describe: 'List saved performance captures',
77
+ builder: (b) => b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Maximum runs to show' }),
78
+ handler: wrap(async (argv, app) => {
79
+ const runs = listRuns({ limit: argv.limit });
80
+ const picked = await pickRun(app, { message: 'Select a performance capture' });
81
+ if (picked) {
82
+ const run = getRun(picked.run_id);
83
+ app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
84
+ summary: `Performance capture ${picked.run_id}`,
85
+ breadcrumbs: [listHint(), compareHint(run.run_id)],
86
+ });
87
+ return;
88
+ }
89
+ app.ok({ runs, count: runs.length, _formatted: formatRunList(runs) }, {
90
+ summary: `${runs.length} performance capture(s)`,
91
+ breadcrumbs: [
92
+ { action: 'capture', cmd: 'jsn perf capture --label LABEL', description: 'Create another read-only capture' },
93
+ ...(runs[0] ? [showHint(runs[0].run_id)] : []),
94
+ ],
95
+ });
96
+ }),
97
+ })
98
+ .command({
99
+ command: 'show [run_id]',
100
+ describe: 'Show one saved performance capture',
101
+ handler: wrap(async (argv, app) => {
102
+ const run = argv.run_id ? requireRun(argv.run_id, 'target') : await pickRun(app);
103
+ if (!run) return;
104
+ app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
105
+ summary: `Performance capture ${run.run_id}`,
106
+ breadcrumbs: [listHint(), compareHint(run.run_id)],
107
+ });
108
+ }),
109
+ })
110
+ .command({
111
+ command: 'compare [baseline] [new]',
112
+ describe: 'Compare exactly two saved captures',
113
+ handler: wrap(async (argv, app) => {
114
+ if (!argv.baseline && !argv.new) {
115
+ const baseline = await pickRun(app, { message: 'Select the baseline capture' });
116
+ if (!baseline) return;
117
+ const newer = await pickRun(app, { exclude: new Set([baseline.run_id]), message: 'Select the new capture' });
118
+ if (!newer) return;
119
+ const baselineRun = getRun(baseline.run_id);
120
+ const newerRun = getRun(newer.run_id);
121
+ const result = compareRuns(baselineRun, newerRun);
122
+ app.ok({ ...result, _formatted: formatComparisonDetailed(result) }, {
123
+ summary: `Performance comparison: ${result.status}`,
124
+ breadcrumbs: [listHint(), showHint(baseline.run_id), showHint(newer.run_id)],
125
+ });
126
+ return;
127
+ }
128
+ if (!argv.baseline || !argv.new) throw errUsage('Compare requires exactly two run IDs');
129
+ if (argv.baseline === argv.new) throw errUsage('Compare requires two different run IDs');
130
+ const baseline = requireRun(argv.baseline, 'baseline');
131
+ const newer = requireRun(argv.new, 'new');
132
+ const result = compareRuns(baseline, newer);
133
+ app.ok({ ...result, _formatted: formatComparisonDetailed(result) }, {
134
+ summary: `Performance comparison: ${result.status}`,
135
+ breadcrumbs: [listHint(), showHint(baseline.run_id), showHint(newer.run_id)],
136
+ });
137
+ }),
138
+ }),
139
+ handler: wrap(async (_argv, app) => {
140
+ app.ok({ command: 'perf', subcommands: ['capture', 'list', 'show', 'compare'] }, { summary: 'Use `jsn perf <subcommand> --help` for details.' });
141
+ }),
142
+ };
143
+ }
@@ -0,0 +1,266 @@
1
+ const INPUT_TYPE_RUNTIME = {
2
+ String: 'string', Integer: 'integer', 'True/False': 'true/false', Choice: 'choice', Reference: 'reference', Date: 'date', 'Date/Time': 'date_time',
3
+ };
4
+ const ANSWER_TYPE_RUNTIME = {
5
+ Boolean: 'boolean', Choice: 'choice', Currency: 'currency', Decimal: 'decimal', Due_date: 'due_date', Glide_date: 'glide_date', Glide_date_time: 'glide_date_time', Glide_duration: 'glide_duration', Integer: 'integer', Longint: 'longint', Reference: 'reference', String: 'string',
6
+ };
7
+ const INPUT_TYPES = new Set([...Object.keys(INPUT_TYPE_RUNTIME), ...Object.values(INPUT_TYPE_RUNTIME)]);
8
+ const ANSWER_TYPES = new Set([...Object.keys(ANSWER_TYPE_RUNTIME), ...Object.values(ANSWER_TYPE_RUNTIME)]);
9
+ const ACCESS = new Set(['package_private', 'public']);
10
+ const CHILD_KINDS = ['inputs', 'answerElements', 'inputChoices', 'answerElementChoices', 'conditions', 'questions'];
11
+
12
+ function fail(message) { throw new TypeError(`Invalid decision table payload: ${message}`); }
13
+ function object(value, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be an object`); return value; }
14
+ function nonEmptyString(value, label) { if (typeof value !== 'string' || value.trim() === '') fail(`${label} is required`); return value; }
15
+ function rowId(value, label) { nonEmptyString(value, label); if (!/^[0-9a-f]{32}$/i.test(value)) fail(`${label} must be a 32-character hexadecimal sys_id`); return value; }
16
+ function deleting(row) { return row._delete || row.deleted || row.remove; }
17
+ const CREATE_FIELDS = {
18
+ parent: ['accessibleFrom', 'name', 'scope'],
19
+ input: ['label', 'maxsize', 'order', 'reference', 'type'],
20
+ answerElement: ['label', 'maxsize', 'order', 'reference', 'type'],
21
+ inputChoice: ['inputID', 'label', 'order', 'value'],
22
+ answerElementChoice: ['answerElementID', 'label', 'order', 'value'],
23
+ condition: ['decisionInput', 'defaultOperator', 'label', 'description', 'order'],
24
+ question: ['active', 'answer', 'condition', 'defaultAnswer', 'order'],
25
+ };
26
+ const UPDATE_FIELDS = {
27
+ parent: ['accessibleFrom', 'name'],
28
+ input: ['active', 'defaultValue', 'label', 'mandatory', 'maxsize', 'order', 'readonly'],
29
+ answerElement: ['comments', 'label', 'maxsize'],
30
+ inputChoice: ['label', 'order', 'value'],
31
+ answerElementChoice: ['label', 'order', 'value'],
32
+ choice: ['label', 'order', 'value'],
33
+ condition: ['defaultOperator', 'description', 'label'],
34
+ question: ['active', 'answer', 'condition', 'defaultAnswer', 'label', 'order'],
35
+ };
36
+ function allowFields(row, label, kind, update) {
37
+ const existing = update && Boolean(row.sys_id);
38
+ const fields = existing ? UPDATE_FIELDS[kind] : CREATE_FIELDS[kind];
39
+ const aliases = !existing && kind === 'inputChoice' ? ['input'] : (!existing && kind === 'answerElementChoice' ? ['answerElement'] : []);
40
+ const allowed = new Set([...fields, 'sys_id', '_key', '_delete', 'deleted', 'remove', ...aliases]);
41
+ for (const key of Object.keys(row)) if (!allowed.has(key)) fail(`${label}.${key} is not documented`);
42
+ }
43
+ function validateRow(row, label, required = [], kind, update = false) {
44
+ object(row, label);
45
+ allowFields(row, label, kind, update);
46
+ if (deleting(row)) { rowId(row.sys_id, `${label}.sys_id`); return; }
47
+ required.forEach((field) => nonEmptyString(row[field], `${label}.${field}`));
48
+ if (row.sys_id != null) rowId(row.sys_id, `${label}.sys_id`);
49
+ }
50
+ function validateInput(row, i, update = false) {
51
+ validateRow(row, `inputs[${i}]`, update && row.sys_id ? [] : ['label', 'type'], 'input', update);
52
+ if (deleting(row)) return;
53
+ if (row.type != null && !INPUT_TYPES.has(row.type)) fail(`inputs[${i}].type is not documented`);
54
+ if (row.type === 'Reference' || row.type === 'reference') nonEmptyString(row.reference, `inputs[${i}].reference`);
55
+ if (row.maxsize != null && (row.type !== 'String' && row.type !== 'string' || !Number.isInteger(row.maxsize) || row.maxsize < 0)) fail(`inputs[${i}].maxsize is only valid for String and must be a non-negative integer`);
56
+ if (row.order != null && !Number.isInteger(row.order)) fail(`inputs[${i}].order must be an integer`);
57
+ }
58
+ function validateAnswer(row, i, update = false) {
59
+ if (!update && row?.name != null) fail(`answerElements[${i}].name is unsupported over REST because it cannot be persisted safely`);
60
+ validateRow(row, `answerElements[${i}]`, update && row.sys_id ? [] : ['label', 'type'], 'answerElement', update);
61
+ if (deleting(row)) return;
62
+ if (row.type != null && !ANSWER_TYPES.has(row.type)) fail(`answerElements[${i}].type is not documented`);
63
+ if (row.type === 'Reference' || row.type === 'reference') nonEmptyString(row.reference, `answerElements[${i}].reference`);
64
+ if (row.maxsize != null && (row.type !== 'String' && row.type !== 'string' || !Number.isInteger(row.maxsize) || row.maxsize < 0)) fail(`answerElements[${i}].maxsize is only valid for String and must be a non-negative integer`);
65
+ }
66
+ function validateQuestion(row, i, update = false) {
67
+ validateRow(row, `questions[${i}]`, [], 'question', update);
68
+ if (deleting(row)) return;
69
+ if (row.condition != null && typeof row.condition !== 'string') fail(`questions[${i}].condition must be a string`);
70
+ if (row.answer != null && !Array.isArray(row.answer)) fail(`questions[${i}].answer must be an array`);
71
+ if (row.answer) row.answer.forEach((answer, j) => { object(answer, `questions[${i}].answer[${j}]`); if (answer.name != null && typeof answer.name !== 'string') fail(`questions[${i}].answer[${j}].name must be a string`); if (answer.value != null && typeof answer.value !== 'string') fail(`questions[${i}].answer[${j}].value must be a string`); });
72
+ if (!update && (typeof row.active !== 'boolean' || typeof row.defaultAnswer !== 'boolean' || !Number.isInteger(row.order))) fail(`questions[${i}] requires active, defaultAnswer, and order`);
73
+ }
74
+ function validateChoice(row, label, ref, update = false) {
75
+ const kind = ref === 'input' ? 'inputChoice' : 'answerElementChoice';
76
+ validateRow(row, label, update ? [] : ['label', 'value'], kind, update);
77
+ if (row[`${ref}ID`] != null) fail(`${label}.${ref}ID is unsupported; use ${ref} _key references instead`);
78
+ if (!deleting(row) && (!update || !row.sys_id)) nonEmptyString(row[ref], `${label}.${ref}`);
79
+ }
80
+ function validateCondition(row, i, inputs, update = false) {
81
+ validateRow(row, `conditions[${i}]`, update ? [] : ['decisionInput', 'label'], 'condition', update);
82
+ if (!update && !/^[0-9a-f]{32}$/i.test(row.decisionInput) && !inputs.some((input) => input?._key === row.decisionInput)) fail(`conditions[${i}].decisionInput must be a 32-character hexadecimal sys_id or a declared input _key`);
83
+ }
84
+
85
+ export function parseDecisionTablePayload(input, { operation = 'create' } = {}) {
86
+ let payload = input;
87
+ if (typeof input === 'string') { try { payload = JSON.parse(input); } catch { fail('must contain valid JSON'); } }
88
+ object(payload, 'payload');
89
+ const allowedTopLevel = new Set(['parent', ...CHILD_KINDS, 'removals']);
90
+ for (const key of Object.keys(payload)) if (!allowedTopLevel.has(key)) fail(`${key} is not documented`);
91
+ const parent = object(payload.parent, 'parent');
92
+ if (operation !== 'update') nonEmptyString(parent.name, 'parent.name');
93
+ allowFields(parent, 'parent', 'parent', operation === 'update');
94
+ if (parent.accessibleFrom != null && !ACCESS.has(parent.accessibleFrom)) fail('parent.accessibleFrom is not documented');
95
+ if (parent.scope != null) nonEmptyString(parent.scope, 'parent.scope');
96
+ for (const kind of CHILD_KINDS) if (payload[kind] != null && !Array.isArray(payload[kind])) fail(`${kind} must be an array`);
97
+ const normalized = { ...payload };
98
+ for (const kind of CHILD_KINDS) normalized[kind] = payload[kind] || [];
99
+ normalized.inputs.forEach((row, i) => validateInput(row, i, operation === 'update'));
100
+ normalized.answerElements.forEach((row, i) => validateAnswer(row, i, operation === 'update'));
101
+ normalized.questions.forEach((row, i) => validateQuestion(row, i, operation === 'update'));
102
+ normalized.inputChoices.forEach((row, i) => validateChoice(row, `inputChoices[${i}]`, 'input', operation === 'update'));
103
+ normalized.answerElementChoices.forEach((row, i) => validateChoice(row, `answerElementChoices[${i}]`, 'answerElement', operation === 'update'));
104
+ normalized.conditions.forEach((row, i) => validateCondition(row, i, normalized.inputs, operation === 'update'));
105
+ if (payload.removals != null) {
106
+ object(payload.removals, 'removals');
107
+ for (const kind of CHILD_KINDS) if (payload.removals[kind] != null) { if (!Array.isArray(payload.removals[kind])) fail(`removals.${kind} must be an array`); payload.removals[kind].forEach((id, i) => rowId(id, `removals.${kind}[${i}]`)); }
108
+ }
109
+ normalized.removals = Object.fromEntries(CHILD_KINDS.map((kind) => [kind, payload.removals?.[kind] || []]));
110
+ return structuredClone(normalized);
111
+ }
112
+
113
+ function exactSysId(record, label) {
114
+ const value = record?.sys_id;
115
+ const id = value && typeof value === 'object' ? value.value : value;
116
+ if (typeof id !== 'string' || !/^[0-9a-f]{32}$/i.test(id)) throw new Error(`ServiceNow returned an invalid ${label} sys_id.`);
117
+ return id;
118
+ }
119
+ function persistedValue(value) { return value && typeof value === 'object' ? (value.value ?? value.display_value) : value; }
120
+ function validatePersistedFields(record, body, label, expectedId) {
121
+ const actualId = exactSysId(record, label);
122
+ if (expectedId && actualId.toLowerCase() !== expectedId.toLowerCase()) throw new Error(`ServiceNow returned the wrong ${label} sys_id on readback.`);
123
+ for (const [field, expected] of Object.entries(body)) if (persistedValue(record?.[field]) === undefined || String(persistedValue(record[field])) !== String(expected)) throw new Error(`ServiceNow did not persist ${label} field ${field} as requested.`);
124
+ }
125
+ function restParentBody(parent, scope) { const body = { name: parent.name, access: parent.accessibleFrom || 'package_private' }; const effectiveScope = scope || parent.scope; if (effectiveScope && effectiveScope !== 'global') body.sys_scope = effectiveScope; return body; }
126
+ function restInputBody(input, parentId) { const body = { model: parentId, label: input.label }; if (input.type != null) body.internal_type = INPUT_TYPE_RUNTIME[input.type] || input.type; if (input.maxsize != null) body.max_length = input.maxsize; if (input.order != null) body.order = input.order; if (input.reference != null) body.reference = input.reference; return body; }
127
+ function restAnswerElementBody(input, parentId) { const body = { model: parentId, label: input.label }; if (input.type != null) body.internal_type = ANSWER_TYPE_RUNTIME[input.type] || input.type; if (input.maxsize != null) body.max_length = input.maxsize; if (input.order != null) body.order = input.order; if (input.reference != null) body.reference = input.reference; return body; }
128
+ function normalizedAnswerElementField(label, id) { const normalized = label.toLowerCase().replace(/[^\w]+/g, '_').replace(/^_+|_+$/g, ''); if (!normalized) throw new Error(`ServiceNow returned a blank answer element element for ${id}, and its label is unusable for fallback mapping.`); return `u_${normalized}`; }
129
+ function answerElementField(record, field, id, fallbackLabel) { const value = record?.[field]; const unwrapped = persistedValue(value); if (typeof unwrapped === 'string' && unwrapped.trim()) return unwrapped; if (field === 'element' && fallbackLabel != null) return normalizedAnswerElementField(fallbackLabel, id); throw new Error(`ServiceNow did not return answer element ${id} ${field} on readback.`); }
130
+ function restInputChoiceBody(choice, input) { const body = { name: input.name, element: input.element, label: choice.label, value: choice.value }; if (choice.order != null) body.sequence = choice.order; return body; }
131
+ function restAnswerElementChoiceBody(choice, input) { const body = { name: input.name, element: input.element, label: choice.label, value: choice.value }; if (choice.order != null) body.sequence = choice.order; return body; }
132
+ function restConditionBody(condition, parentId, inputId) { const body = { decision_table: parentId, decision_input: inputId, label: condition.label }; if (condition.defaultOperator != null) body.default_operator = condition.defaultOperator; if (condition.description != null) body.description = condition.description; if (condition.order != null) body.order = condition.order; return body; }
133
+ function unsupportedCreateChildren(payload) { if (payload.questions.length) throw new Error('Decision table REST mutation has unsupported child kinds: questions.'); }
134
+ function isNotFoundError(error) { return error?.status === 404 || error?.code === 404 || error?.code === '404'; }
135
+
136
+ export class DecisionTableCRUD {
137
+ constructor(sdk, { scope = '' } = {}) { this.sdk = sdk; this.scope = scope; }
138
+
139
+ async createViaRest(document) {
140
+ const payload = parseDecisionTablePayload(document, { operation: 'create' });
141
+ unsupportedCreateChildren(payload);
142
+ if (this.scope && payload.parent.scope && payload.parent.scope !== this.scope) throw new Error('Decision table create parent.scope must match the authenticated execution scope.');
143
+ const created = [];
144
+ try {
145
+ const parentBody = restParentBody(payload.parent, this.scope);
146
+ const parentRecord = await this.sdk.create('sys_decision', parentBody);
147
+ const parentId = exactSysId(parentRecord, 'decision table');
148
+ created.push({ table: 'sys_decision', id: parentId });
149
+ const parent = await this.sdk.get('sys_decision', parentId);
150
+ if (!parent) throw new Error('ServiceNow did not return the created decision table on readback.');
151
+ validatePersistedFields(parent, parentBody, 'decision table', parentId);
152
+ const inputs = [];
153
+ const inputMap = new Map();
154
+ for (const input of payload.inputs) {
155
+ const body = restInputBody(input, parentId);
156
+ const record = await this.sdk.create('sys_decision_input', body);
157
+ const id = exactSysId(record, 'decision input');
158
+ created.push({ table: 'sys_decision_input', id });
159
+ const readback = await this.sdk.get('sys_decision_input', id);
160
+ if (!readback) throw new Error(`ServiceNow did not return decision input ${id} on readback.`);
161
+ validatePersistedFields(readback, body, 'decision input', id);
162
+ const mapped = payload.inputChoices.some((choice) => choice.input === input._key) ? { ...readback, name: answerElementField(readback, 'name', id), element: answerElementField(readback, 'element', id, input.label) } : readback;
163
+ inputs.push(mapped);
164
+ if (input._key) inputMap.set(input._key, { ...mapped, sys_id: id });
165
+ }
166
+ const inputChoices = [];
167
+ for (const choice of payload.inputChoices) {
168
+ const input = choice.input ? inputMap.get(choice.input) : null;
169
+ if (!input) throw new Error(`Decision table create inputChoices requires input _key; unknown key: ${choice.input ?? choice.inputID}.`);
170
+ const body = restInputChoiceBody(choice, input);
171
+ const record = await this.sdk.create('sys_choice', body);
172
+ const id = exactSysId(record, 'input choice');
173
+ created.push({ table: 'sys_choice', id });
174
+ const readback = await this.sdk.get('sys_choice', id);
175
+ if (!readback) throw new Error(`ServiceNow did not return input choice ${id} on readback.`);
176
+ validatePersistedFields(readback, body, 'input choice', id);
177
+ inputChoices.push(readback);
178
+ }
179
+ const conditions = [];
180
+ for (const condition of payload.conditions) {
181
+ const input = inputMap.get(condition.decisionInput);
182
+ const inputId = input?.sys_id || condition.decisionInput;
183
+ const body = restConditionBody(condition, parentId, inputId);
184
+ const record = await this.sdk.create('sn_decision_table_decision_condition', body);
185
+ const conditionId = exactSysId(record, 'decision condition');
186
+ created.push({ table: 'sn_decision_table_decision_condition', id: conditionId });
187
+ const readback = await this.sdk.get('sn_decision_table_decision_condition', conditionId);
188
+ if (!readback) throw new Error(`ServiceNow did not return decision condition ${conditionId} on readback.`);
189
+ validatePersistedFields(readback, body, 'decision condition', conditionId);
190
+ conditions.push(readback);
191
+ }
192
+ const answerElements = [];
193
+ const answerElementMap = new Map();
194
+ for (const answerElement of payload.answerElements) {
195
+ const body = restAnswerElementBody(answerElement, parentId);
196
+ const record = await this.sdk.create('sys_decision_multi_result_element', body);
197
+ const id = exactSysId(record, 'answer element');
198
+ created.push({ table: 'sys_decision_multi_result_element', id });
199
+ const readback = await this.sdk.get('sys_decision_multi_result_element', id);
200
+ if (!readback) throw new Error(`ServiceNow did not return answer element ${id} on readback.`);
201
+ validatePersistedFields(readback, body, 'answer element', id);
202
+ const mapped = { ...readback, name: answerElementField(readback, 'name', id), element: answerElementField(readback, 'element', id, answerElement.label) };
203
+ answerElements.push(mapped);
204
+ if (answerElement._key) answerElementMap.set(answerElement._key, { ...mapped, sys_id: id });
205
+ }
206
+ const answerElementChoices = [];
207
+ for (const choice of payload.answerElementChoices) {
208
+ const answerElement = choice.answerElement ? answerElementMap.get(choice.answerElement) : null;
209
+ if (!answerElement) throw new Error(`Decision table create answerElementChoices requires answerElement _key; unknown key: ${choice.answerElement ?? choice.answerElementID}.`);
210
+ const body = restAnswerElementChoiceBody(choice, answerElement);
211
+ const record = await this.sdk.create('sys_choice', body);
212
+ const id = exactSysId(record, 'answer-element choice');
213
+ created.push({ table: 'sys_choice', id });
214
+ const readback = await this.sdk.get('sys_choice', id);
215
+ if (!readback) throw new Error(`ServiceNow did not return answer-element choice ${id} on readback.`);
216
+ validatePersistedFields(readback, body, 'answer-element choice', id);
217
+ answerElementChoices.push(readback);
218
+ }
219
+ return { status: 'Success', parent, inputs, inputChoices, conditions, answerElements, answerElementChoices };
220
+ } catch (error) {
221
+ const rollbackFailures = [];
222
+ for (const record of [...created].reverse()) {
223
+ try {
224
+ await this.sdk.delete(record.table, record.id);
225
+ try {
226
+ if (await this.sdk.get(record.table, record.id)) rollbackFailures.push(true);
227
+ } catch (readbackError) {
228
+ if (!isNotFoundError(readbackError)) rollbackFailures.push(readbackError);
229
+ }
230
+ } catch (rollbackError) {
231
+ rollbackFailures.push(rollbackError);
232
+ }
233
+ }
234
+ if (rollbackFailures.length) throw new Error('Decision table create failed and rollback could not be verified.', { cause: error });
235
+ if (error instanceof TypeError && error.message.startsWith('Invalid decision table payload:')) throw error;
236
+ if (error instanceof Error && error.message.startsWith('Decision table create')) throw error;
237
+ throw new Error('Decision table create failed; created records were rolled back by exact sys_id.', { cause: error });
238
+ }
239
+ }
240
+
241
+ async update(_id, document) {
242
+ const payload = parseDecisionTablePayload(document, { operation: 'update' });
243
+ if (payload.questions.length) throw new Error('Decision table update does not support questions over REST.');
244
+ throw new Error('Decision table update is intentionally unsupported over REST until complete graph support can be verified safely.');
245
+ }
246
+
247
+ async delete(id) {
248
+ rowId(id, 'decision table sys_id');
249
+ if (typeof this.sdk.delete !== 'function' || typeof this.sdk.get !== 'function') throw new Error('Decision table delete requires Table API delete and readback support.');
250
+ try {
251
+ await this.sdk.delete('sys_decision', id);
252
+ try {
253
+ const remaining = await this.sdk.get('sys_decision', id);
254
+ if (remaining) throw new Error(`ServiceNow still returned decision table ${id} after deletion.`);
255
+ } catch (readbackError) {
256
+ if (!isNotFoundError(readbackError)) throw readbackError;
257
+ }
258
+ return { status: 'Success', sys_id: id };
259
+ } catch (error) {
260
+ if (error instanceof Error && error.message.startsWith('ServiceNow still returned')) throw error;
261
+ throw new Error('Decision table delete failed; deletion could not be verified.', { cause: error });
262
+ }
263
+ }
264
+
265
+ create(document) { return this.createViaRest(document); }
266
+ }