@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.
- package/README.md +12 -0
- package/package.json +2 -2
- package/src/app.js +9 -0
- package/src/auth.js +386 -100
- package/src/cli.js +18 -11
- package/src/commands/auth.js +208 -70
- package/src/commands/decisiontables.js +178 -0
- package/src/commands/dev/_simple.js +1 -2
- package/src/commands/dev/flows.js +92 -838
- package/src/commands/docs/db.js +60 -2
- package/src/commands/perf.js +143 -0
- package/src/decision-table-crud.js +266 -0
- package/src/decision-table-inspection.js +260 -0
- package/src/errors.js +1 -1
- package/src/flow-inspection.js +844 -0
- package/src/flow-publish.js +357 -0
- package/src/helpers.js +7 -1
- package/src/perf-capture.js +112 -0
- package/src/perf.js +335 -0
- package/src/sdk.js +65 -18
- package/src/session.js +14 -0
- package/src/commands/instance.js +0 -362
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { getStringField, assertSafeExactMatch } from './helpers.js';
|
|
2
|
+
import { unwrapSysId } from './resolve-record.js';
|
|
3
|
+
import { getTableURL, hyperlink } from './output.js';
|
|
4
|
+
|
|
5
|
+
const FIELD_OPERATORS = /^(.*?)(NOT LIKE|NOT IN|ISNOTEMPTY|INSTANCEOF|STARTSWITH|ENDSWITH|BETWEEN|DATEPART|DYNAMIC|SAMEAS|NSAMEAS|GT_FIELD|LT_FIELD|GT_OR_EQUALS_FIELD|LT_OR_EQUALS_FIELD|VALCHANGES|CHANGES|CHANGEDFROM|CHANGEDTO|LIKE|ISEMPTY|IN|!=|>=|<=|=|>|<)(.*)$/;
|
|
6
|
+
|
|
7
|
+
function valueParts(value) {
|
|
8
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
9
|
+
const technical = value.value ?? value.display_value;
|
|
10
|
+
if (technical != null) return { technical: String(technical), display: String(value.display_value ?? technical) };
|
|
11
|
+
const raw = readableRaw(value);
|
|
12
|
+
return { technical: raw, display: raw };
|
|
13
|
+
}
|
|
14
|
+
const technical = value == null ? '' : String(value);
|
|
15
|
+
return { technical, display: technical };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readableRaw(value) {
|
|
19
|
+
if (value == null) return '';
|
|
20
|
+
if (typeof value === 'object') {
|
|
21
|
+
try { return JSON.stringify(value); } catch { return '[unreadable object]'; }
|
|
22
|
+
}
|
|
23
|
+
return String(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function answerElements(answer) {
|
|
27
|
+
if (Array.isArray(answer)) return answer;
|
|
28
|
+
return answer && typeof answer === 'object' && Array.isArray(answer.answerElementValues) ? answer.answerElementValues : [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatAnswer(answer) {
|
|
32
|
+
const elements = answerElements(answer);
|
|
33
|
+
if (elements.length === 0) {
|
|
34
|
+
if (answer && typeof answer === 'object' && !Array.isArray(answer) && answer.label != null) {
|
|
35
|
+
const value = answer.value ?? answer.display_value ?? '';
|
|
36
|
+
return `${answer.label}: ${formatDecisionValue(value, answer.display_value)}`;
|
|
37
|
+
}
|
|
38
|
+
return formatDecisionValue(answer);
|
|
39
|
+
}
|
|
40
|
+
return elements.map((element) => {
|
|
41
|
+
const label = element && typeof element === 'object' ? (element.label ?? element.answerElementName ?? element.name ?? '') : '';
|
|
42
|
+
const value = element && typeof element === 'object' ? element.value ?? element.display_value ?? element : element;
|
|
43
|
+
const rendered = formatDecisionValue(value, element?.display_value);
|
|
44
|
+
return label ? `${label}: ${rendered}` : rendered;
|
|
45
|
+
}).join(', ');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function technicalAnswer(answer) {
|
|
49
|
+
const elements = answerElements(answer);
|
|
50
|
+
if (elements.length === 0) return valueParts(answer).technical;
|
|
51
|
+
return elements.map((element) => valueParts(element).technical);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function displayAnswer(answer) {
|
|
55
|
+
const elements = answerElements(answer);
|
|
56
|
+
if (elements.length === 0) {
|
|
57
|
+
if (answer && typeof answer === 'object' && !Array.isArray(answer)) return String(answer.display_value ?? answer.label ?? valueParts(answer).display);
|
|
58
|
+
return valueParts(answer).display;
|
|
59
|
+
}
|
|
60
|
+
return elements.map((element) => valueParts(element).display);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isNotFound(error) {
|
|
64
|
+
return error?.code === 'not_found' || error?.status === 404 || error?.response?.status === 404;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseConditionBranches(rawCondition) {
|
|
68
|
+
const raw = rawCondition == null ? '' : String(rawCondition);
|
|
69
|
+
return raw.split('^NQ').map((branchRaw) => ({
|
|
70
|
+
raw: branchRaw,
|
|
71
|
+
tokens: branchRaw.split('^').filter(Boolean).map((fragment) => {
|
|
72
|
+
const match = fragment.match(FIELD_OPERATORS);
|
|
73
|
+
if (!match || !match[1]) return { raw: fragment, unparseable: true };
|
|
74
|
+
return { raw: fragment, field: match[1], operator: match[2], value: match[3] };
|
|
75
|
+
}),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatDecisionValue(value, displayValue) {
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
return value.map((item, index) => formatDecisionValue(item, Array.isArray(displayValue) ? displayValue[index] : undefined)).join(', ');
|
|
82
|
+
}
|
|
83
|
+
const parts = valueParts(value);
|
|
84
|
+
const display = displayValue == null || displayValue === '' ? parts.display : String(displayValue);
|
|
85
|
+
if (!display || display === parts.technical) return parts.technical;
|
|
86
|
+
return `${display} (${parts.technical})`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function inputName(input) {
|
|
90
|
+
return getStringField(input, 'element') || getStringField(input, 'columnName') || getStringField(input, 'name') || getStringField(input, 'field') || getStringField(input, 'question') || getStringField(input, 'label');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function inputLabel(input) {
|
|
94
|
+
return getStringField(input, 'label') || getStringField(input, 'columnName') || getStringField(input, 'name') || inputName(input);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function conditionOf(row) {
|
|
98
|
+
return getStringField(row, 'condition') || getStringField(row, 'conditions') || getStringField(row, 'input_conditions');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function answerOf(row) {
|
|
102
|
+
if (!row || typeof row !== 'object') return '';
|
|
103
|
+
return row.answer ?? row.result ?? row.output ?? '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function defaultOf(row) {
|
|
107
|
+
if (!row || typeof row !== 'object') return '';
|
|
108
|
+
return row.defaultAnswer ?? row.default ?? row.is_default ?? '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function addFormatted(data, text) {
|
|
112
|
+
Object.defineProperty(data, '_formatted', { value: text, enumerable: false, configurable: true });
|
|
113
|
+
return data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function renderDecisionMatrix(detail, instanceURL = '') {
|
|
117
|
+
const labels = detail.inputs.map(inputLabel);
|
|
118
|
+
const hasUnparseable = detail.matrix.some((row) => row.unparseable);
|
|
119
|
+
const headers = [...labels, ...(hasUnparseable ? ['Unparsed'] : []), 'Answer', 'Default', 'Active', 'Order'];
|
|
120
|
+
const lines = [`Decision table: ${getStringField(detail.table, 'name') || getStringField(detail.table, 'sys_id') || ''}`, headers.join(' | '), headers.map(() => '---').join(' | ')];
|
|
121
|
+
for (const row of detail.matrix) {
|
|
122
|
+
lines.push(row.values.concat([...(hasUnparseable ? [row.unparseable] : []), row.answer, row.default, row.active, row.order]).map((v) => String(v ?? '')).join(' | '));
|
|
123
|
+
}
|
|
124
|
+
if (detail.matrix.length === 0) lines.push('(no decision rows)');
|
|
125
|
+
if (detail.questions.some((q) => q.parsed_branches.length > 1)) lines.push('OR branches are shown as separate rows (NQ).');
|
|
126
|
+
const tableId = unwrapSysId(detail.table);
|
|
127
|
+
if (instanceURL && tableId) {
|
|
128
|
+
const url = getTableURL(instanceURL, 'sys_decision', tableId);
|
|
129
|
+
lines.push(`Open in Workflow Studio: ${hyperlink(url, url)}`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join('\n') + '\n';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function conditionFieldForInput(field, name) {
|
|
135
|
+
return field === name || field.startsWith(`${name}.`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function conditionCell(input, token, choices, references) {
|
|
139
|
+
const name = inputName(input);
|
|
140
|
+
const label = inputLabel(input);
|
|
141
|
+
const values = token.operator === 'IN' || token.operator === 'NOT IN' ? token.value.split(',').map((value) => value.trim()).filter(Boolean) : [token.value];
|
|
142
|
+
const rendered = values.map((value) => formatDecisionValue(value, choices.get(`${name}\u0000${token.field}\u0000${value}`) || references.get(`${name}\u0000${value}`))).join(', ');
|
|
143
|
+
return `${label} ${token.operator} ${rendered}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function choiceElement(field) {
|
|
147
|
+
return field.slice(field.lastIndexOf('.') + 1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function assertSafeReferenceId(value) {
|
|
151
|
+
assertSafeExactMatch(value);
|
|
152
|
+
const hasControlCharacter = value && [...value].some((character) => character.charCodeAt(0) < 32);
|
|
153
|
+
const hasTraversalSegment = value === '.' || value === '..' || value.includes('../') || value.includes('..\\');
|
|
154
|
+
if (value && (/[/?#\\]/.test(value) || hasControlCharacter || hasTraversalSegment || value.includes('%'))) {
|
|
155
|
+
throw new Error('Unsafe identifier for exact-match lookup: contains URL path characters or traversal syntax. Refusing to fetch a reference record.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function assertSafeReferenceTable(value) {
|
|
160
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/.test(value)) {
|
|
161
|
+
throw new Error('Unsafe reference table for lookup: contains invalid ServiceNow identifier characters. Refusing to fetch a reference record.');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function inspectDecisionTable(app, table) {
|
|
166
|
+
const sysId = unwrapSysId(table);
|
|
167
|
+
const [inputs, questions] = await Promise.all([
|
|
168
|
+
app.sdk.list('sys_decision_input', new URLSearchParams({ sysparm_query: `model=${sysId}^ORDERBYorder`, sysparm_display_value: 'all' })),
|
|
169
|
+
app.sdk.list('sys_decision_question', new URLSearchParams({ sysparm_query: `decision_table=${sysId}^ORDERBYorder`, sysparm_display_value: 'all' })),
|
|
170
|
+
]);
|
|
171
|
+
const choices = new Map();
|
|
172
|
+
const references = new Map();
|
|
173
|
+
for (const input of inputs) {
|
|
174
|
+
const name = inputName(input);
|
|
175
|
+
const referenceTable = getStringField(input, 'reference') || getStringField(input, 'reference_table');
|
|
176
|
+
if (!name) continue;
|
|
177
|
+
if (referenceTable) {
|
|
178
|
+
assertSafeReferenceTable(referenceTable);
|
|
179
|
+
references.set(name, referenceTable);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const normalizedQuestions = questions.map((row) => {
|
|
183
|
+
const objectRow = row && typeof row === 'object' ? row : {};
|
|
184
|
+
const rawCondition = conditionOf(objectRow);
|
|
185
|
+
const answer = answerOf(objectRow);
|
|
186
|
+
return {
|
|
187
|
+
...objectRow,
|
|
188
|
+
raw_question: row,
|
|
189
|
+
malformed: !row || typeof row !== 'object' || !rawCondition,
|
|
190
|
+
raw_condition: rawCondition,
|
|
191
|
+
parsed_branches: parseConditionBranches(rawCondition),
|
|
192
|
+
technical_answer: technicalAnswer(answer),
|
|
193
|
+
display_answer: displayAnswer(answer),
|
|
194
|
+
defaultAnswer: defaultOf(objectRow),
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
for (const [name, referenceTable] of references) {
|
|
198
|
+
const values = new Set();
|
|
199
|
+
for (const row of normalizedQuestions) for (const branch of row.parsed_branches) for (const token of branch.tokens) {
|
|
200
|
+
if (token.field === name && token.value) {
|
|
201
|
+
for (const value of token.operator === 'IN' || token.operator === 'NOT IN' ? token.value.split(',') : [token.value]) {
|
|
202
|
+
if (value.trim()) values.add(value.trim());
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const value of values) {
|
|
207
|
+
try {
|
|
208
|
+
assertSafeReferenceId(value);
|
|
209
|
+
const record = await app.sdk.get(referenceTable, value);
|
|
210
|
+
const display = getStringField(record, 'name') || getStringField(record, 'label') || getStringField(record, 'number') || getStringField(record, 'sys_id');
|
|
211
|
+
if (display) references.set(`${name}\u0000${value}`, display);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!isNotFound(error)) throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const input of inputs) {
|
|
218
|
+
const name = inputName(input);
|
|
219
|
+
const sourceTable = getStringField(input, 'table') || getStringField(input, 'source_table') || getStringField(input, 'reference_table') || getStringField(input, 'reference');
|
|
220
|
+
if (!name || !sourceTable) continue;
|
|
221
|
+
assertSafeReferenceTable(sourceTable);
|
|
222
|
+
const fields = new Set();
|
|
223
|
+
for (const row of normalizedQuestions) for (const branch of row.parsed_branches) for (const token of branch.tokens) {
|
|
224
|
+
if (token.field && conditionFieldForInput(token.field, name)) fields.add(token.field);
|
|
225
|
+
}
|
|
226
|
+
for (const field of fields) {
|
|
227
|
+
const records = await app.sdk.list('sys_choice', new URLSearchParams({
|
|
228
|
+
sysparm_query: `name=${sourceTable}^element=${choiceElement(field)}^language=en`,
|
|
229
|
+
sysparm_display_value: 'all',
|
|
230
|
+
}));
|
|
231
|
+
for (const choice of records) choices.set(`${name}\u0000${field}\u0000${getStringField(choice, 'value')}`, getStringField(choice, 'label'));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const matrix = [];
|
|
235
|
+
for (const question of normalizedQuestions) {
|
|
236
|
+
for (let branchIndex = 0; branchIndex < question.parsed_branches.length; branchIndex += 1) {
|
|
237
|
+
const branch = question.parsed_branches[branchIndex];
|
|
238
|
+
const tokenMap = new Map(branch.tokens.filter((t) => t.field).map((t) => [t.field, t]));
|
|
239
|
+
matrix.push({
|
|
240
|
+
question_sys_id: getStringField(question, 'sys_id'),
|
|
241
|
+
branch: branchIndex + 1,
|
|
242
|
+
condition: branch.raw,
|
|
243
|
+
unparseable: branch.tokens.filter((token) => token.unparseable).map((token) => token.raw).join(' ^ '),
|
|
244
|
+
values: inputs.map((input) => {
|
|
245
|
+
const name = inputName(input);
|
|
246
|
+
const token = [...tokenMap.values()].find((candidate) => conditionFieldForInput(candidate.field, name));
|
|
247
|
+
if (!token) return question.malformed ? '[malformed question]' : '';
|
|
248
|
+
if (token.unparseable) return `[unparseable: ${readableRaw(token.raw)}]`;
|
|
249
|
+
return conditionCell(input, token, choices, references);
|
|
250
|
+
}),
|
|
251
|
+
answer: formatAnswer(answerOf(question)),
|
|
252
|
+
default: formatDecisionValue(question.defaultAnswer),
|
|
253
|
+
active: getStringField(question, 'active'),
|
|
254
|
+
order: getStringField(question, 'order'),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const detail = { table, inputs, questions: normalizedQuestions, matrix };
|
|
259
|
+
return addFormatted(detail, renderDecisionMatrix(detail, app.getEffectiveInstance?.() || ''));
|
|
260
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -256,5 +256,5 @@ export function guardExit(argv, opts) {
|
|
|
256
256
|
const { stream, text } = guardError(argv, opts);
|
|
257
257
|
if (stream === 'stdout') process.stdout.write(text);
|
|
258
258
|
else process.stderr.write(text);
|
|
259
|
-
process.exit(1);
|
|
259
|
+
process.exit(opts.code === CodeUsage || opts.code === 'usage' ? 2 : 1);
|
|
260
260
|
}
|