@jacebenson/jsn 4.3.13-dev.82.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 +1 -1
- package/src/related-lists.js +147 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|