@jacebenson/jsn 4.3.13-dev.81.1 → 4.3.13-dev.83.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.81.1",
3
+ "version": "4.3.13-dev.83.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": {
@@ -1,105 +1,29 @@
1
1
  import { getStringField, interactiveList } from '../helpers.js';
2
2
 
3
- function getDisplayValue(record, key) {
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
- let elements = [];
57
- if (secSysID) {
58
- const elemParams = new URLSearchParams();
59
- elemParams.set('sysparm_limit', '500');
60
- elemParams.set('sysparm_fields', 'element,type,label,position');
61
- elemParams.set('sysparm_display_value', 'all');
62
- elemParams.set('sysparm_query', `sys_ui_section=${secSysID}^ORDERBYposition`);
63
- try { elements = await app.sdk.list('sys_ui_element', elemParams); } catch { /* ignore */ }
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
- sectionsOut.sort((a, b) => a.position - b.position);
81
-
82
- // Build formatted output
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
- process.stdout.write(lines.join('\n') + '\n');
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: 'View name' }),
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
+ }
@@ -0,0 +1,147 @@
1
+ // Opt-in related-list counts. Counts never fetch supplementary records; hints
2
+ // leave opening the list to an explicit subsequent command.
3
+ import { assertSafeExactMatch, quoteShellArg } from './helpers.js';
4
+ import { errUsage } from './errors.js';
5
+ const namePattern = /^[A-Za-z0-9_]+$/;
6
+ const isName = value => typeof value === 'string' && namePattern.test(value);
7
+ const raw = value => String(value && typeof value === 'object' ? value.value ?? '' : value ?? '');
8
+
9
+ /** Only a conservative, AND-only literal encoded-query subset can be combined
10
+ * with a record reference. Unknown platform/dynamic logic stays unavailable. */
11
+ export function safeRelatedFilter(value = '') {
12
+ if (typeof value !== 'string' || /javascript\s*:|\r|\n/i.test(value)) return null;
13
+ if (!value) return '';
14
+ const clauses = value.split('^');
15
+ if (clauses.at(-1) === 'EQ') clauses.pop();
16
+ if (!clauses.length) return '';
17
+ for (const clause of clauses) {
18
+ if (!clause || /^(?:OR|NQ|ORDERBY)/i.test(clause)) return null;
19
+ if (!/^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*(?:(?:!=|>=|<=|=|>|<|NOT LIKE|STARTSWITH|ENDSWITH|LIKE|NOT IN|IN)[^^]*|ISEMPTY|ISNOTEMPTY)$/.test(clause)) return null;
20
+ }
21
+ return clauses.join('^');
22
+ }
23
+ function parseCount(body) {
24
+ const result = body?.result;
25
+ const entry = Array.isArray(result) && result.length === 1 && !result[0]?.groupby_fields?.length
26
+ ? result[0] : result;
27
+ let stats = entry?.stats;
28
+ if (typeof stats === 'string') { try { stats = JSON.parse(stats); } catch { return null; } }
29
+ const count = stats?.count;
30
+ if (typeof count !== 'number' && (typeof count !== 'string' || !/^\d+$/.test(count))) return null;
31
+ const number = Number(count);
32
+ return Number.isSafeInteger(number) && number >= 0 ? number : null;
33
+ }
34
+ function hint(table, query, { profile, instance }) {
35
+ const tokens = ['jsn', 'records', 'list', '--table', table, '--query', query];
36
+ if (profile) tokens.push('--profile', profile);
37
+ if (instance) tokens.push('--instance', instance);
38
+ return tokens.map(quoteShellArg).join(' ');
39
+ }
40
+
41
+ /** Invocation-bound reader. Metadata/aggregate failures are coverage gaps, not
42
+ * zero counts. Optional batch owns endpoint fallback; items never replay. */
43
+ export function createRelatedListReader(sdk, { metadataReader, maxLists = 50 } = {}) {
44
+ if (!Number.isInteger(maxLists) || maxLists < 1 || maxLists > 1000) throw errUsage('Related-list limit must be an integer from 1 to 1000.');
45
+ const cache = new Map();
46
+ let reader = metadataReader;
47
+ async function read({ table, recordId, view = 'Default view', profile = '', instance = '' }) {
48
+ if (!isName(table)) throw errUsage('Related-list table must be a valid table name.');
49
+ const id = raw(recordId);
50
+ if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) throw errUsage('Related-list counts require a safe, resolved record sys_id.');
51
+ assertSafeExactMatch(id);
52
+ if (!reader) {
53
+ const { createRecordMetadataReader } = await import('./metadata/records.js');
54
+ reader = createRecordMetadataReader(sdk);
55
+ }
56
+ const metadata = await reader.read({ table, view });
57
+ const warnings = [...(metadata.coverage?.warnings || [])];
58
+ const descriptors = metadata.related_lists || [];
59
+ const prepared = descriptors.map((list, index) => {
60
+ const result = { id: list.id, label: list.label || list.id, table: list.table || null,
61
+ reference_field: list.reference_field || null, configured: !!list.configured,
62
+ status: 'unavailable', count: null };
63
+ let reason;
64
+ const filter = safeRelatedFilter(list.filter || '');
65
+ if (list.query_status !== 'reference' || !isName(list.table) || !isName(list.reference_field)) reason = list.reason || 'Related list requires platform relationship logic or unavailable metadata.';
66
+ else if (list.configured && list.filter_available === false) reason = 'Configured related-list filter is not visible; count was not requested.';
67
+ else if (filter === null) reason = 'Related-list filter uses unsupported dynamic or disjunctive query logic; count was not requested.';
68
+ if (reason) {
69
+ result.reason = reason; warnings.push({ source: list.id, reason }); return { result };
70
+ }
71
+ const query = `${list.reference_field}=${id}${filter ? `^${filter}` : ''}`;
72
+ if (index >= maxLists) {
73
+ const reason = `Related-list limit ${maxLists} reached; count was not requested. Open this specific list explicitly or raise --related-limit.`;
74
+ warnings.push({ source: list.id, reason });
75
+ return { result: { ...result, reason, query, command: hint(list.table, query, { profile, instance }) } };
76
+ }
77
+ const params = new URLSearchParams({ sysparm_count: 'true', sysparm_query: query });
78
+ const url = `/api/now/stats/${list.table}?${params}`;
79
+ return { result, query, url };
80
+ });
81
+ const unique = [...new Set(prepared.filter(item => item.url && !cache.has(item.url)).map(item => item.url))];
82
+ if (unique.length) {
83
+ const pending = (async () => {
84
+ if (typeof sdk.batch === 'function' && unique.length > 1) {
85
+ try {
86
+ const batch = await sdk.batch(unique.map((url, index) => ({ id: `related-${index}`, method: 'GET', url })), { fallback: true });
87
+ return unique.map((_, index) => {
88
+ const item = batch.results?.find(value => value.id === `related-${index}`);
89
+ if (!item?.ok) return { count: null, status: item?.status || null,
90
+ reason: item?.error?.message || 'Aggregate subrequest was unavailable or unserviced.' };
91
+ const count = parseCount(item.data);
92
+ return { count, status: item.status, ...(count === null ? { reason: 'Aggregate response did not contain a valid count.' } : {}) };
93
+ });
94
+ } catch (error) {
95
+ return unique.map(() => ({ count: null, status: error.status || null, reason: error.message || 'Aggregate batch failed.' }));
96
+ }
97
+ }
98
+ const individual = async url => {
99
+ try {
100
+ const body = await sdk.request(`${sdk.baseURL}${url}`, { method: 'GET', readOnly: true });
101
+ const count = parseCount(body);
102
+ return { count, ...(count === null ? { reason: 'Aggregate response did not contain a valid count.' } : {}) };
103
+ } catch (error) { return { count: null, status: error.status || null, reason: error.message || 'Aggregate request failed.' }; }
104
+ };
105
+ const results = new Array(unique.length);
106
+ let next = 0;
107
+ await Promise.all(Array.from({ length: Math.min(4, unique.length) }, async () => {
108
+ while (next < unique.length) {
109
+ const index = next++;
110
+ results[index] = await individual(unique[index]);
111
+ }
112
+ }));
113
+ return results;
114
+ })();
115
+ unique.forEach((url, index) => cache.set(url, pending.then(items => items[index])));
116
+ }
117
+ const lists = await Promise.all(prepared.map(async item => {
118
+ if (!item.url) return item.result;
119
+ const observed = await cache.get(item.url);
120
+ if (observed.count === null) {
121
+ warnings.push({ source: item.result.id, reason: observed.reason, ...(observed.status ? { status: observed.status } : {}) });
122
+ return { ...item.result, reason: observed.reason, ...(observed.status ? { http_status: observed.status } : {}) };
123
+ }
124
+ return { ...item.result, status: 'available', count: observed.count, query: item.query,
125
+ ...(observed.count > 0 ? { command: hint(item.result.table, item.query, { profile, instance }) } : {}) };
126
+ }));
127
+ return { table, record_id: id, view, lists, nonempty: lists.filter(list => list.count > 0),
128
+ coverage: { state: metadata.coverage?.state === 'partial' || warnings.length ? 'partial' : 'observed',
129
+ scope: 'aggregate_api_access', warnings } };
130
+ }
131
+ return { read, clear() { cache.clear(); reader?.clear?.(); } };
132
+ }
133
+
134
+ /** Specialized show hook; ordinary shows and generic records get do no work. */
135
+ export async function enrichRelatedLists(record, { table, argv = {}, app }) {
136
+ if (!argv.related) return record;
137
+ const limit = argv['related-limit'] ?? 50;
138
+ if (!app.relatedListReader || app._relatedListReaderSDK !== app.sdk || (app._relatedListReaderLimit !== undefined && app._relatedListReaderLimit !== limit)) {
139
+ app.relatedListReader = createRelatedListReader(app.sdk, { maxLists: limit });
140
+ app._relatedListReaderSDK = app.sdk;
141
+ app._relatedListReaderLimit = limit;
142
+ }
143
+ record._related = await app.relatedListReader.read({ table, recordId: record.sys_id,
144
+ view: argv.view || 'Default view', profile: app.session?.profileName || argv.profile || '',
145
+ instance: app.getEffectiveInstance?.() || '' });
146
+ return record;
147
+ }