@jacebenson/jsn 4.3.13-dev.80.1 → 4.3.13-dev.82.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 +1 -1
- package/src/commands/forms.js +22 -97
- package/src/metadata/records.js +245 -0
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +6 -0
package/package.json
CHANGED
package/src/commands/forms.js
CHANGED
|
@@ -1,105 +1,29 @@
|
|
|
1
1
|
import { getStringField, interactiveList } from '../helpers.js';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
if (!record || typeof record !== 'object') return '';
|
|
5
|
-
const val = record[key];
|
|
6
|
-
if (val == null) return '';
|
|
7
|
-
if (typeof val === 'string') return val;
|
|
8
|
-
if (typeof val === 'object') {
|
|
9
|
-
if (val.display_value != null && val.display_value !== '') return String(val.display_value);
|
|
10
|
-
if (val.value != null) return String(val.value);
|
|
11
|
-
}
|
|
12
|
-
return String(val);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function getIntValue(record, key) {
|
|
16
|
-
if (!record || typeof record !== 'object') return 0;
|
|
17
|
-
const val = record[key];
|
|
18
|
-
if (val == null) return 0;
|
|
19
|
-
if (typeof val === 'number') return Math.floor(val);
|
|
20
|
-
if (typeof val === 'string') { const n = parseInt(val, 10); return isNaN(n) ? 0 : n; }
|
|
21
|
-
if (typeof val === 'object' && val.value != null) { const n = parseInt(val.value, 10); return isNaN(n) ? 0 : n; }
|
|
22
|
-
return 0;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function showForm(app, table, viewName) {
|
|
26
|
-
// 1) Look up the view
|
|
27
|
-
const viewParams = new URLSearchParams();
|
|
28
|
-
viewParams.set('sysparm_limit', '1');
|
|
29
|
-
viewParams.set('sysparm_fields', 'sys_id');
|
|
30
|
-
viewParams.set('sysparm_display_value', 'all');
|
|
31
|
-
viewParams.set('sysparm_query', `name=${viewName}`);
|
|
32
|
-
let viewSysID = '';
|
|
33
|
-
try { const vr = await app.sdk.list('sys_ui_view', viewParams); if (vr.length > 0) viewSysID = getStringField(vr[0], 'sys_id'); } catch { /* ignore */ }
|
|
34
|
-
if (!viewSysID) {
|
|
35
|
-
viewParams.set('sysparm_query', `title=${viewName}`);
|
|
36
|
-
try { const vr = await app.sdk.list('sys_ui_view', viewParams); if (vr.length > 0) viewSysID = getStringField(vr[0], 'sys_id'); } catch { /* ignore */ }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// 2) Get sections for this table + view
|
|
40
|
-
const secParams = new URLSearchParams();
|
|
41
|
-
secParams.set('sysparm_limit', '200');
|
|
42
|
-
secParams.set('sysparm_fields', 'sys_id,caption');
|
|
43
|
-
secParams.set('sysparm_display_value', 'all');
|
|
44
|
-
secParams.set('sysparm_query', `name=${table}^view=${viewName}^ORDERBYposition`);
|
|
45
|
-
if (viewSysID) {
|
|
46
|
-
secParams.set('sysparm_query', `name=${table}^view=${viewSysID}^ORDERBYposition`);
|
|
47
|
-
}
|
|
48
|
-
const sections = await app.sdk.list('sys_ui_section', secParams);
|
|
49
|
-
|
|
50
|
-
// 3) Get elements for each section
|
|
51
|
-
const sectionsOut = [];
|
|
52
|
-
for (const sec of sections) {
|
|
53
|
-
const secSysID = getStringField(sec, 'sys_id');
|
|
54
|
-
const sectionCaption = getDisplayValue(sec, 'caption') || '(unnamed)';
|
|
3
|
+
import { createRecordMetadataReader } from '../metadata/records.js';
|
|
55
4
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
sectionsOut.push({
|
|
67
|
-
caption: sectionCaption,
|
|
68
|
-
position: getIntValue(sec, 'position'),
|
|
69
|
-
elements: elements
|
|
70
|
-
.map(e => ({
|
|
71
|
-
type: getDisplayValue(e, 'type'),
|
|
72
|
-
label: getDisplayValue(e, 'label'),
|
|
73
|
-
element: getDisplayValue(e, 'element'),
|
|
74
|
-
position: getIntValue(e, 'position'),
|
|
75
|
-
}))
|
|
76
|
-
.sort((a, b) => a.position - b.position),
|
|
77
|
-
});
|
|
5
|
+
export function formatFormMetadata(metadata) {
|
|
6
|
+
const lines = [`Form: ${metadata.table} → ${metadata.requested_view}`];
|
|
7
|
+
if (metadata.form?.inherited) lines.push(` Inherited layout: ${metadata.form.table}`);
|
|
8
|
+
if (!metadata.form) lines.push(' No unambiguous shared form layout is visible.');
|
|
9
|
+
for (const section of metadata.form?.sections || []) {
|
|
10
|
+
lines.push('', `── ${section.caption || '(unnamed)'} ──`);
|
|
11
|
+
for (const element of section.elements) lines.push(` ${element.label || element.element || '(unnamed)'}${element.type ? ` (${element.type})` : ''}`);
|
|
12
|
+
if (!section.elements.length) lines.push(' (no visible elements)');
|
|
78
13
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const lines = [];
|
|
84
|
-
lines.push(`Form: ${table} → ${viewName}`);
|
|
85
|
-
if (viewSysID) lines.push(` View: ${app.getEffectiveInstance()}/nav_to.do?uri=sys_ui_view.do?sys_id=${viewSysID}`);
|
|
86
|
-
lines.push('');
|
|
87
|
-
|
|
88
|
-
for (const sec of sectionsOut) {
|
|
89
|
-
const secLabel = sec.caption || '(unnamed)';
|
|
90
|
-
lines.push(`── ${secLabel} ──`);
|
|
91
|
-
if (sec.elements.length === 0) {
|
|
92
|
-
lines.push(' (no elements)');
|
|
93
|
-
}
|
|
94
|
-
for (const el of sec.elements) {
|
|
95
|
-
const elLabel = el.label || el.element || '(unnamed)';
|
|
96
|
-
const typeStr = el.type ? ` (${el.type})` : '';
|
|
97
|
-
lines.push(` ${elLabel}${typeStr}`);
|
|
98
|
-
}
|
|
99
|
-
lines.push('');
|
|
14
|
+
const configured = metadata.related_lists.filter(item => item.configured);
|
|
15
|
+
if (configured.length) {
|
|
16
|
+
lines.push('', 'Configured related lists:');
|
|
17
|
+
for (const list of configured) lines.push(` ${list.label || list.id} (${list.query_status})`);
|
|
100
18
|
}
|
|
19
|
+
lines.push('', metadata.coverage.scope);
|
|
20
|
+
for (const warning of metadata.coverage.warnings) lines.push(` ${warning.source}: ${warning.reason}`);
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
101
23
|
|
|
102
|
-
|
|
24
|
+
async function showForm(app, table, view) {
|
|
25
|
+
const metadata = await createRecordMetadataReader(app.sdk).read({ table, view });
|
|
26
|
+
app.ok({ ...metadata, _formatted: formatFormMetadata(metadata) }, { summary: `Form metadata for ${table}` });
|
|
103
27
|
}
|
|
104
28
|
|
|
105
29
|
export function formsCmd(wrap) {
|
|
@@ -164,7 +88,8 @@ export function formsCmd(wrap) {
|
|
|
164
88
|
aliases: ['get'],
|
|
165
89
|
describe: 'Show form layout',
|
|
166
90
|
builder: (y) => y
|
|
167
|
-
.option('view', { type: 'string', default: 'Default view', describe: '
|
|
91
|
+
.option('view', { type: 'string', default: 'Default view', describe: 'Shared view name, title or sys_id' })
|
|
92
|
+
.epilogue('Discovers shared layouts, inherited forms and related-list metadata. JSON includes coverage limits; custom relationship scripts are never executed. Generic records get presentation is unchanged.'),
|
|
168
93
|
handler: wrap(async (argv, app) => {
|
|
169
94
|
app.requireInstance();
|
|
170
95
|
await showForm(app, argv.table, argv.view);
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { assertSafeExactMatch } from '../helpers.js';
|
|
2
|
+
import { errUsage, errAuth } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const namePattern = /^[a-zA-Z0-9_]+$/;
|
|
5
|
+
const raw = value => String(value && typeof value === 'object' ? value.value ?? '' : value ?? '');
|
|
6
|
+
const field = (record, name) => raw(record?.[name]);
|
|
7
|
+
const position = row => Number(field(row, 'position')) || 0;
|
|
8
|
+
const ordered = rows => [...rows].sort((a, b) => position(a) - position(b));
|
|
9
|
+
const reference = value => {
|
|
10
|
+
const text = raw(value);
|
|
11
|
+
assertSafeExactMatch(text);
|
|
12
|
+
return text;
|
|
13
|
+
};
|
|
14
|
+
const isName = value => typeof value === 'string' && namePattern.test(value);
|
|
15
|
+
const chunks = values => Array.from({ length: Math.ceil(values.length / 100) }, (_, index) => values.slice(index * 100, (index + 1) * 100));
|
|
16
|
+
|
|
17
|
+
function request(table, query, fields, limit = 1000) {
|
|
18
|
+
return { table, params: new URLSearchParams({
|
|
19
|
+
sysparm_query: query, sysparm_fields: fields, sysparm_limit: String(limit),
|
|
20
|
+
sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true',
|
|
21
|
+
}) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Invocation-scoped, SDK-bound metadata reader. No persistence across users. */
|
|
25
|
+
export function createRecordMetadataReader(sdk, { limit = 1000, maxDepth = 32 } = {}) {
|
|
26
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10000) throw errUsage('Metadata limit must be an integer from 1 to 10000');
|
|
27
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 100) throw errUsage('Metadata inheritance depth must be an integer from 1 to 100');
|
|
28
|
+
const cache = new Map();
|
|
29
|
+
const reports = new Map();
|
|
30
|
+
|
|
31
|
+
async function reads(specs, warnings) {
|
|
32
|
+
const missing = specs.filter(spec => !cache.has(`${spec.table}?${spec.params}`));
|
|
33
|
+
// Reserve promises before transport so concurrent metadata requests share
|
|
34
|
+
// both successful reads and observed failures within this reader only.
|
|
35
|
+
const unique = [...new Map(missing.map(spec => [`${spec.table}?${spec.params}`, spec])).values()];
|
|
36
|
+
if (unique.length) {
|
|
37
|
+
const pending = (async () => {
|
|
38
|
+
if (unique.length > 1 && typeof sdk.batch === 'function') {
|
|
39
|
+
try {
|
|
40
|
+
const batch = await sdk.batch(unique.map((spec, index) => ({
|
|
41
|
+
id: `metadata-${index}`, method: 'GET', url: `/api/now/table/${spec.table}?${spec.params}`,
|
|
42
|
+
})), { fallback: true });
|
|
43
|
+
return unique.map((spec, index) => {
|
|
44
|
+
const item = batch.results?.find(result => result.id === `metadata-${index}`);
|
|
45
|
+
if (item?.status === 401) throw errAuth('Authentication failed while reading metadata');
|
|
46
|
+
if (!item?.ok) return { rows: [], error: item?.error?.message || `Metadata read failed (${item?.status || 'unknown status'})` };
|
|
47
|
+
return normalize(item.data?.result, spec);
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code === 'auth_error' || error.status === 401) throw error;
|
|
51
|
+
// The batch provider owns unsupported-endpoint fallback. Never
|
|
52
|
+
// replay authentication/ACL/server failures through another path.
|
|
53
|
+
return unique.map(() => ({ rows: [], error: error.message || 'Metadata batch failed' }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Promise.all(unique.map(async spec => {
|
|
57
|
+
try { return normalize(await sdk.list(spec.table, spec.params), spec); }
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === 'auth_error' || error.status === 401) throw error;
|
|
60
|
+
return { rows: [], error: error.message || 'Metadata read failed' };
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
})();
|
|
64
|
+
unique.forEach((spec, index) => cache.set(`${spec.table}?${spec.params}`, pending.then(results => results[index])));
|
|
65
|
+
}
|
|
66
|
+
return Promise.all(specs.map(async spec => {
|
|
67
|
+
const result = await cache.get(`${spec.table}?${spec.params}`);
|
|
68
|
+
if (result.error) warnings.push({ source: spec.table, reason: result.error });
|
|
69
|
+
if (result.truncated) warnings.push({ source: spec.table, reason: `Metadata limit ${spec.params.get('sysparm_limit')} reached; results may be incomplete` });
|
|
70
|
+
return result.rows;
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalize(rows, spec) {
|
|
75
|
+
if (!Array.isArray(rows)) return { rows: [], error: 'Metadata response was not an array' };
|
|
76
|
+
return { rows, truncated: rows.length >= Number(spec.params.get('sysparm_limit')) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function read({ table, view = 'Default view' }) {
|
|
80
|
+
if (!isName(table)) throw errUsage('Metadata table must be a table name containing letters, numbers or underscores');
|
|
81
|
+
if (typeof view !== 'string' || !view) throw errUsage('Metadata view must be a nonempty name, title or sys_id');
|
|
82
|
+
assertSafeExactMatch(view);
|
|
83
|
+
const key = `${table}\0${view}`;
|
|
84
|
+
if (!reports.has(key)) reports.set(key, load(table, view));
|
|
85
|
+
// Protect cached objects from a consumer attaching record-specific hints.
|
|
86
|
+
return structuredClone(await reports.get(key));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function load(table, view) {
|
|
90
|
+
const warnings = [];
|
|
91
|
+
const hierarchy = [];
|
|
92
|
+
const visited = new Set();
|
|
93
|
+
let next = request('sys_db_object', `name=${table}`, 'sys_id,name,super_class', 2);
|
|
94
|
+
for (let depth = 0; next && depth < maxDepth; depth++) {
|
|
95
|
+
const [rows] = await reads([next], warnings);
|
|
96
|
+
if (rows.length !== 1) {
|
|
97
|
+
warnings.push({ source: 'sys_db_object', reason: `Expected one visible table definition, found ${rows.length}; inheritance coverage is incomplete` });
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const row = rows[0];
|
|
101
|
+
const name = field(row, 'name');
|
|
102
|
+
const id = field(row, 'sys_id');
|
|
103
|
+
if (!isName(name) || visited.has(name)) {
|
|
104
|
+
warnings.push({ source: 'sys_db_object', reason: 'Invalid or cyclic table inheritance metadata' });
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
visited.add(name);
|
|
108
|
+
hierarchy.push({ name, sys_id: id });
|
|
109
|
+
if (!Object.hasOwn(row, 'super_class')) warnings.push({ source: 'sys_db_object', reason: 'Parent metadata is not visible; inheritance coverage is incomplete' });
|
|
110
|
+
const parent = reference(row.super_class);
|
|
111
|
+
next = parent ? request('sys_db_object', `sys_id=${parent}`, 'sys_id,name,super_class', 2) : null;
|
|
112
|
+
}
|
|
113
|
+
if (next && hierarchy.length === maxDepth) warnings.push({ source: 'sys_db_object', reason: `Inheritance depth limit ${maxDepth} reached` });
|
|
114
|
+
if (!hierarchy.length) hierarchy.push({ name: table, sys_id: '' });
|
|
115
|
+
const names = hierarchy.map(item => item.name);
|
|
116
|
+
const defaultView = view === 'Default view' || view === 'default';
|
|
117
|
+
const viewQuery = defaultView ? 'name=^ORname=default^ORtitle=Default view' : `name=${view}^ORtitle=${view}^ORsys_id=${view}`;
|
|
118
|
+
const [views] = await reads([request('sys_ui_view', `${viewQuery}^userISEMPTY`, 'sys_id,name,title', limit)], warnings);
|
|
119
|
+
const byId = views.filter(row => field(row, 'sys_id') === view);
|
|
120
|
+
const byName = views.filter(row => field(row, 'name') === (defaultView ? '' : view));
|
|
121
|
+
const byTitle = views.filter(row => field(row, 'title') === view);
|
|
122
|
+
const byAlias = defaultView ? views.filter(row => field(row, 'name') === 'default') : [];
|
|
123
|
+
const candidates = byId.length ? byId : byName.length ? byName : byTitle.length ? byTitle : byAlias;
|
|
124
|
+
let viewId = '';
|
|
125
|
+
const viewCapped = views.length >= limit;
|
|
126
|
+
if (candidates.length === 1 && !viewCapped) {
|
|
127
|
+
viewId = reference(candidates[0].sys_id);
|
|
128
|
+
if (!viewId) warnings.push({ source: 'sys_ui_view', reason: 'View id is absent or not visible' });
|
|
129
|
+
} else if (candidates.length > 1 || viewCapped) warnings.push({ source: 'sys_ui_view', reason: 'View lookup is ambiguous or capped; no layout was selected' });
|
|
130
|
+
else if (!defaultView) warnings.push({ source: 'sys_ui_view', reason: 'Requested view is absent or not visible; no layout was selected' });
|
|
131
|
+
const canReadLayout = !viewCapped && candidates.length <= 1 && (defaultView || !!viewId);
|
|
132
|
+
const viewFilter = viewId ? `view=${viewId}${defaultView ? '^ORviewISEMPTY' : ''}` : 'viewISEMPTY';
|
|
133
|
+
const specs = [
|
|
134
|
+
request('sys_dictionary', `referenceIN${names.join(',')}^elementISNOTEMPTY`, 'sys_id,name,element,reference,column_label', limit),
|
|
135
|
+
request('sys_relationship', `basic_apply_toIN${names.join(',')}`, 'sys_id,name,basic_apply_to,basic_query_from,reference_field,simple_reference,advanced,query_with', limit),
|
|
136
|
+
...(canReadLayout ? [
|
|
137
|
+
request('sys_ui_form', `nameIN${names.join(',')}^${viewFilter}^sys_userISEMPTY`, 'sys_id,name,view', limit),
|
|
138
|
+
request('sys_ui_related_list', `nameIN${names.join(',')}^${viewFilter}^sys_userISEMPTY`, 'sys_id,name,view', limit),
|
|
139
|
+
] : []),
|
|
140
|
+
];
|
|
141
|
+
const [dictionary, relationships, forms = [], containers = []] = await reads(specs, warnings);
|
|
142
|
+
const nearest = rows => {
|
|
143
|
+
for (const name of names) {
|
|
144
|
+
const candidates = rows.filter(row => field(row, 'name') === name);
|
|
145
|
+
const exact = viewId ? candidates.filter(row => field(row, 'view') === viewId) : [];
|
|
146
|
+
const preferred = exact.length ? exact : candidates;
|
|
147
|
+
if (preferred.length > 1) warnings.push({ source: 'layout', reason: `Ambiguous layout for ${name}; no layout was selected` });
|
|
148
|
+
if (preferred.length) {
|
|
149
|
+
if (!field(preferred[0], 'sys_id')) {
|
|
150
|
+
warnings.push({ source: 'layout', reason: `Layout id is absent or not visible for ${name}` });
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return preferred.length === 1 ? preferred[0] : null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
};
|
|
158
|
+
const form = nearest(forms);
|
|
159
|
+
const container = nearest(containers);
|
|
160
|
+
const detailSpecs = [
|
|
161
|
+
...(form ? [request('sys_ui_form_section', `sys_ui_form=${reference(form.sys_id)}^ORDERBYposition`, 'sys_id,sys_ui_form,sys_ui_section,position', limit)] : []),
|
|
162
|
+
...(container ? [request('sys_ui_related_list_entry', `list_id=${reference(container.sys_id)}^ORDERBYposition`, 'sys_id,related_list,position,filter,order_by', limit)] : []),
|
|
163
|
+
];
|
|
164
|
+
const details = await reads(detailSpecs, warnings);
|
|
165
|
+
const links = form ? details.shift() : [];
|
|
166
|
+
const entries = container ? details.shift() : [];
|
|
167
|
+
const sectionIds = [...new Set(links.map(link => reference(link.sys_ui_section)).filter(Boolean))];
|
|
168
|
+
const relationshipIds = entries.map(entry => field(entry, 'related_list')).filter(value => /^REL:[a-f0-9]{32}$/i.test(value)).map(value => value.slice(4));
|
|
169
|
+
const missingRelationships = relationshipIds.filter(id => !relationships.some(row => field(row, 'sys_id') === id));
|
|
170
|
+
const tailSpecs = [
|
|
171
|
+
...chunks(sectionIds).flatMap(ids => [
|
|
172
|
+
{ ...request('sys_ui_section', `sys_idIN${ids.join(',')}^sys_userISEMPTY`, 'sys_id,name,caption,header,title,view', limit), group: 'sections' },
|
|
173
|
+
{ ...request('sys_ui_element', `sys_ui_sectionIN${ids.join(',')}^sys_userISEMPTY^ORDERBYposition`, 'sys_id,sys_ui_section,element,type,position', limit), group: 'elements' },
|
|
174
|
+
]),
|
|
175
|
+
...chunks([...new Set(missingRelationships)]).map(ids => ({
|
|
176
|
+
...request('sys_relationship', `sys_idIN${ids.join(',')}`, 'sys_id,name,basic_apply_to,basic_query_from,reference_field,simple_reference,advanced,query_with', limit), group: 'relationships',
|
|
177
|
+
})),
|
|
178
|
+
];
|
|
179
|
+
const tail = await reads(tailSpecs, warnings);
|
|
180
|
+
const group = name => tail.flatMap((rows, index) => tailSpecs[index].group === name ? rows : []);
|
|
181
|
+
const sections = group('sections');
|
|
182
|
+
const elements = group('elements');
|
|
183
|
+
relationships.push(...group('relationships'));
|
|
184
|
+
const sectionsOut = ordered(links).map(link => {
|
|
185
|
+
const id = field(link, 'sys_ui_section');
|
|
186
|
+
const section = sections.find(row => field(row, 'sys_id') === id);
|
|
187
|
+
if (!section) warnings.push({ source: 'sys_ui_section', reason: `A linked section is absent or not visible` });
|
|
188
|
+
return { sys_id: id, caption: field(section, 'caption'), position: position(link),
|
|
189
|
+
elements: ordered(elements.filter(row => field(row, 'sys_ui_section') === id)).map(row => ({
|
|
190
|
+
element: field(row, 'element'), type: field(row, 'type'), label: field(row, 'label'), position: position(row),
|
|
191
|
+
})) };
|
|
192
|
+
});
|
|
193
|
+
const relatedLists = buildRelationships({ entries, dictionary, relationships, sourceTable: container ? field(container, 'name') : '', names, warnings });
|
|
194
|
+
return {
|
|
195
|
+
table, requested_view: view, view_sys_id: viewId, hierarchy,
|
|
196
|
+
form: form ? { sys_id: field(form, 'sys_id'), table: field(form, 'name'), inherited: field(form, 'name') !== table, sections: sectionsOut } : null,
|
|
197
|
+
related_lists: relatedLists,
|
|
198
|
+
coverage: { state: warnings.length ? 'partial' : 'observed', scope: 'Visible shared layout metadata only; ACLs, personalization and advanced relationships may hide or change runtime results', warnings },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { read, clear() { cache.clear(); reports.clear(); } };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildRelationships({ entries, dictionary, relationships, sourceTable, names, warnings }) {
|
|
206
|
+
const result = new Map();
|
|
207
|
+
for (const row of dictionary) {
|
|
208
|
+
const table = field(row, 'name');
|
|
209
|
+
const referenceField = field(row, 'element');
|
|
210
|
+
if (!isName(table) || !isName(referenceField)) {
|
|
211
|
+
warnings.push({ source: 'sys_dictionary', reason: 'Ignored an invalid reference definition' });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!names.includes(field(row, 'reference'))) warnings.push({ source: 'sys_dictionary', reason: 'A reference target is absent, unrelated, or not visible' });
|
|
215
|
+
const id = `${table}.${referenceField}`;
|
|
216
|
+
result.set(id, { id, kind: 'reference', table, reference_field: referenceField,
|
|
217
|
+
references: field(row, 'reference'), label: field(row, 'column_label'), configured: false,
|
|
218
|
+
query_status: names.includes(field(row, 'reference')) ? 'reference' : 'unavailable' });
|
|
219
|
+
}
|
|
220
|
+
for (const row of relationships) {
|
|
221
|
+
const id = `REL:${field(row, 'sys_id')}`;
|
|
222
|
+
if (!/^REL:[a-f0-9]{32}$/i.test(id)) continue;
|
|
223
|
+
const table = field(row, 'basic_query_from');
|
|
224
|
+
const referenceField = field(row, 'reference_field');
|
|
225
|
+
const queryable = field(row, 'advanced') !== 'true' && field(row, 'advanced') !== '1'
|
|
226
|
+
&& names.includes(field(row, 'basic_apply_to'))
|
|
227
|
+
&& ['true', '1'].includes(field(row, 'simple_reference')) && !field(row, 'query_with').trim()
|
|
228
|
+
&& isName(table) && isName(referenceField);
|
|
229
|
+
result.set(id, { id, kind: 'relationship', relationship_sys_id: field(row, 'sys_id'),
|
|
230
|
+
table: isName(table) ? table : '', reference_field: queryable ? referenceField : '',
|
|
231
|
+
label: field(row, 'name'), configured: false, query_status: queryable ? 'reference' : 'platform_required',
|
|
232
|
+
...(queryable ? {} : { reason: 'Custom/advanced relationship requires platform query logic; scripts are never executed by metadata discovery' }),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
for (const entry of ordered(entries)) {
|
|
236
|
+
const id = field(entry, 'related_list');
|
|
237
|
+
const existing = result.get(id);
|
|
238
|
+
const descriptor = existing || { id, kind: 'unresolved', table: '', reference_field: '', query_status: 'unavailable',
|
|
239
|
+
reason: 'Configured related list metadata is absent, unsupported, or not visible' };
|
|
240
|
+
result.set(id, { ...descriptor, configured: true, source_table: sourceTable, position: position(entry),
|
|
241
|
+
filter: field(entry, 'filter'), order_by: field(entry, 'order_by') });
|
|
242
|
+
if (!existing) warnings.push({ source: 'sys_ui_related_list_entry', reason: descriptor.reason });
|
|
243
|
+
}
|
|
244
|
+
return [...result.values()].sort((a, b) => Number(b.configured) - Number(a.configured) || (a.position || 0) - (b.position || 0));
|
|
245
|
+
}
|
package/src/sdk-batch.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Optional, read-only ServiceNow Batch REST transport. No ordinary SDK call is
|
|
2
|
+
// routed here implicitly; writes are deliberately outside this initial contract.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { AppError, errAPI, errUsage } from './errors.js';
|
|
5
|
+
|
|
6
|
+
const UNSUPPORTED = new Set([404, 405, 501]);
|
|
7
|
+
function malformed(message) { return new AppError('malformed_response', `Invalid Batch API response: ${message}`); }
|
|
8
|
+
function errorData(error) {
|
|
9
|
+
return { code: error.code || 'network_error', message: error.message, ...(error.status ? { status: error.status } : {}) };
|
|
10
|
+
}
|
|
11
|
+
function normalizeRequests(sdk, requests) {
|
|
12
|
+
if (!Array.isArray(requests)) throw errUsage('Batch requests must be an array.');
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
return requests.map((item, index) => {
|
|
15
|
+
if (!item || typeof item !== 'object') throw errUsage('Each batch request must be an object.');
|
|
16
|
+
const id = item.id == null ? String(index + 1) : String(item.id);
|
|
17
|
+
if (!id || ids.has(id)) throw errUsage('Batch request IDs must be nonempty and unique.');
|
|
18
|
+
ids.add(id);
|
|
19
|
+
if (String(item.method || 'GET').toUpperCase() !== 'GET' || item.body !== undefined) {
|
|
20
|
+
throw errUsage('SDK batch supports GET reads only; send writes through ordinary SDK methods with their existing safety and history contracts.');
|
|
21
|
+
}
|
|
22
|
+
if (typeof item.url !== 'string' || !item.url.trim()) throw errUsage('Each batch request needs a same-instance API URL.');
|
|
23
|
+
let url;
|
|
24
|
+
try { url = new URL(item.url, `${sdk.baseURL}/`); } catch { throw errUsage('Invalid batch API URL.'); }
|
|
25
|
+
if (url.origin !== new URL(sdk.baseURL).origin || url.username || url.password || url.hash
|
|
26
|
+
|| !url.pathname.startsWith('/api/') || /\/batch\/?$/.test(url.pathname)) {
|
|
27
|
+
throw errUsage('Batch URLs must target a same-instance API endpoint; nested batches, credentials, and fragments are unsupported.');
|
|
28
|
+
}
|
|
29
|
+
const headers = new Headers(item.headers || {});
|
|
30
|
+
for (const name of headers.keys()) {
|
|
31
|
+
if (/^(authorization|cookie|x-usertoken|host|proxy-authorization)$/i.test(name)) throw errUsage('Batch subrequests cannot override authentication headers.');
|
|
32
|
+
}
|
|
33
|
+
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
|
|
34
|
+
if (sdk.domain) headers.set('X-Now-Domain', sdk.domain);
|
|
35
|
+
return { id, method: 'GET', url: url.pathname + url.search, headers: [...headers].map(([name, value]) => ({ name, value })), exclude_response_headers: true };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function decodeBody(body) {
|
|
39
|
+
if (body == null || body === '') return null;
|
|
40
|
+
if (typeof body !== 'string') throw malformed('item body must be base64 text');
|
|
41
|
+
const encoded = body.replace(/\s/g, '');
|
|
42
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) throw malformed('item body is not valid base64');
|
|
43
|
+
const text = Buffer.from(encoded, 'base64').toString('utf8');
|
|
44
|
+
if (!text) return null;
|
|
45
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
46
|
+
}
|
|
47
|
+
function parseBatch(response, requests, batchId) {
|
|
48
|
+
// ServiceNow's endpoint returns these arrays directly, not in a Table API
|
|
49
|
+
// result wrapper. Missing/duplicated correlations are never inferred by order.
|
|
50
|
+
if (!response || response.batch_request_id !== batchId || !Array.isArray(response.serviced_requests) || !Array.isArray(response.unserviced_requests)) {
|
|
51
|
+
throw malformed('expected matching batch_request_id and serviced/unserviced arrays');
|
|
52
|
+
}
|
|
53
|
+
const expected = new Set(requests.map(item => item.id));
|
|
54
|
+
const mapped = new Map();
|
|
55
|
+
for (const id of response.unserviced_requests) {
|
|
56
|
+
if (typeof id !== 'string' || !expected.has(id) || mapped.has(id)) throw malformed('unknown or duplicate unserviced ID');
|
|
57
|
+
mapped.set(id, { id, ok: false, serviced: false, status: null, data: null,
|
|
58
|
+
error: { code: 'batch_unserviced', message: 'The batch processing limit left this request unserviced; it was not retried.' } });
|
|
59
|
+
}
|
|
60
|
+
for (const item of response.serviced_requests) {
|
|
61
|
+
if (!item || !expected.has(item.id) || mapped.has(item.id)) throw malformed('unknown or duplicate serviced ID');
|
|
62
|
+
const status = item.status_code;
|
|
63
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) throw malformed('invalid item HTTP status');
|
|
64
|
+
const ok = status >= 200 && status < 300;
|
|
65
|
+
let data;
|
|
66
|
+
try { data = decodeBody(item.body); } catch (error) {
|
|
67
|
+
mapped.set(item.id, { id: item.id, ok: false, serviced: true, status, data: null, error: errorData(error) });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const error = ok ? null : errorData(errAPI(status, typeof data === 'string' ? data : JSON.stringify(data || { error: item.error_message || item.status_text || 'Request failed' })));
|
|
71
|
+
mapped.set(item.id, { id: item.id, ok, serviced: true, status, data, error });
|
|
72
|
+
}
|
|
73
|
+
return requests.map(({ id }) => mapped.get(id) || { id, ok: false, serviced: null, status: null, data: null,
|
|
74
|
+
error: { code: 'batch_missing_response', message: 'No correlated response was returned; execution is unknown and was not retried.' } });
|
|
75
|
+
}
|
|
76
|
+
async function individual(sdk, request, timeout) {
|
|
77
|
+
try {
|
|
78
|
+
return await sdk._withAuthenticatedRequest(`${sdk.baseURL}${request.url}`, {
|
|
79
|
+
method: 'GET', headers: Object.fromEntries(request.headers.map(({ name, value }) => [name, value])),
|
|
80
|
+
}, { timeout, normalizeErrors: true }, async response => {
|
|
81
|
+
const text = await response.text();
|
|
82
|
+
let data = text || null;
|
|
83
|
+
if (text) { try { data = JSON.parse(text); } catch { /* Non-JSON REST reads remain text. */ } }
|
|
84
|
+
return { id: request.id, ok: response.ok, serviced: true, status: response.status, data,
|
|
85
|
+
error: response.ok ? null : errorData(errAPI(response.status, text)) };
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { id: request.id, ok: false, serviced: null, status: error.status || null, data: null, error: errorData(error) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {SDKClient} sdk authenticated SDK instance
|
|
94
|
+
* @param {Array<{id?: string, method?: 'GET', url: string, headers?: object}>} requests
|
|
95
|
+
* @param {{fallback?: boolean, chunkSize?: number, timeout?: number}} options
|
|
96
|
+
*/
|
|
97
|
+
export async function batchRead(sdk, requests, { fallback = true, chunkSize = 50, timeout = sdk.timeout } = {}) {
|
|
98
|
+
if (typeof fallback !== 'boolean') throw errUsage('Batch fallback must be a boolean.');
|
|
99
|
+
const normalized = normalizeRequests(sdk, requests);
|
|
100
|
+
if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > 1000) throw errUsage('Batch chunkSize must be an integer from 1 to 1000.');
|
|
101
|
+
const results = [], transports = new Set();
|
|
102
|
+
for (let offset = 0; offset < normalized.length; offset += chunkSize) {
|
|
103
|
+
const chunk = normalized.slice(offset, offset + chunkSize);
|
|
104
|
+
let useIndividual = fallback && sdk._batchUnsupported === true;
|
|
105
|
+
if (!useIndividual) {
|
|
106
|
+
const batchId = randomUUID();
|
|
107
|
+
try {
|
|
108
|
+
const response = await sdk.request(`${sdk.baseURL}/api/now/v1/batch`, {
|
|
109
|
+
method: 'POST', timeout, touchLastSeen: false,
|
|
110
|
+
body: JSON.stringify({ batch_request_id: batchId, rest_requests: chunk }),
|
|
111
|
+
});
|
|
112
|
+
results.push(...parseBatch(response, chunk, batchId));
|
|
113
|
+
transports.add('batch');
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Only explicit endpoint absence permits fallback. ACL/auth, rate limits,
|
|
116
|
+
// malformed responses and uncertain transport failures must not replay.
|
|
117
|
+
if (!fallback || !UNSUPPORTED.has(error.status)) throw error;
|
|
118
|
+
sdk._batchUnsupported = true;
|
|
119
|
+
useIndividual = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (useIndividual) {
|
|
123
|
+
transports.add('individual');
|
|
124
|
+
for (const request of chunk) results.push(await individual(sdk, request, timeout));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const successful = results.filter(item => item.ok).length;
|
|
128
|
+
return { transport: transports.size > 1 ? 'mixed' : [...transports][0] || 'none',
|
|
129
|
+
results, counts: { total: results.length, successful, failed: results.length - successful } };
|
|
130
|
+
}
|
package/src/sdk.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// ServiceNow REST API client
|
|
2
2
|
|
|
3
|
+
import { batchRead } from './sdk-batch.js';
|
|
3
4
|
import { gunzipSync } from 'node:zlib';
|
|
4
5
|
import { CodeAuth, errAuth, errAPI, errNetwork } from './errors.js';
|
|
5
6
|
import { getStringField } from './helpers.js';
|
|
@@ -241,6 +242,11 @@ export class SDKClient {
|
|
|
241
242
|
});
|
|
242
243
|
}
|
|
243
244
|
|
|
245
|
+
// Explicit opt-in; ordinary SDK methods remain unbatched.
|
|
246
|
+
async batch(requests, options = {}) {
|
|
247
|
+
return batchRead(this, requests, options);
|
|
248
|
+
}
|
|
249
|
+
|
|
244
250
|
async getSkippedUpdateBaseline(sysID) {
|
|
245
251
|
return readSkippedBaseline(this, sysID);
|
|
246
252
|
}
|