@jacebenson/jsn 4.3.8 → 4.3.9-dev.62

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.
@@ -0,0 +1,177 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { errUsage, errNotFound, AppError } from './errors.js';
3
+ import { analyzeImpact, baselineFields, isImpactField } from './impact-analysis.js';
4
+
5
+ export const SKIP_TABLE = 'sys_upgrade_history_log';
6
+ const SKIPPED = ['4', '104', '9', '10', '11'];
7
+ export const raw = value => String(value?.value ?? value ?? '');
8
+ export const unresolved = record => Boolean(record && Object.hasOwn(record, 'disposition')
9
+ && Object.hasOwn(record, 'resolution_status') && SKIPPED.includes(raw(record.disposition))
10
+ && ['', 'not_reviewed'].includes(raw(record.resolution_status)));
11
+ const FIELDS = 'sys_id,file_name,target_name,disposition,resolution_status,upgrade_history,reason,changed,sys_mod_count,sys_updated_on';
12
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
13
+ const validId = id => /^[0-9a-fA-F]{32}$/.test(id || '');
14
+
15
+ function validateSelection(ids, action) {
16
+ if (!Array.isArray(ids) || !ids.length || ids.length > 100 || ids.some(id => !validId(id))) {
17
+ throw errUsage('Select between 1 and 100 literal finding sys_ids.');
18
+ }
19
+ if (new Set(ids.map(id => id.toLowerCase())).size !== ids.length) throw errUsage('Duplicate finding sys_ids are not allowed.');
20
+ if (!['retain', 'revert'].includes(action)) throw errUsage('Select an explicit action: retain or revert. Recommendations are never actions.');
21
+ }
22
+
23
+ export async function reviewSkippedUpdate(sdk, id) {
24
+ if (!validId(id)) throw errUsage('A literal 32-hex finding sys_id is required.');
25
+ const finding = await sdk.get(SKIP_TABLE, id);
26
+ if (!finding) throw errNotFound('Skipped finding', id);
27
+ if (raw(finding.sys_id).toLowerCase() !== id.toLowerCase()) throw errUsage('Provider finding identity does not match selection.');
28
+ const fileName = raw(finding.file_name);
29
+ const match = fileName.match(/^([a-z][a-z0-9_]*)_([0-9a-fA-F]{32})$/);
30
+ const artifact = { update_name: fileName, name: raw(finding.target_name),
31
+ table: match?.[1] || null, sys_id: match?.[2] || null };
32
+ let current = null;
33
+ let baseline = null;
34
+ const unknown = [];
35
+ if (match) {
36
+ try { current = await sdk.get(artifact.table, artifact.sys_id); }
37
+ catch { unknown.push('Current artifact could not be read; access or availability is unknown.'); }
38
+ } else unknown.push('Artifact identity cannot be decoded from the provider update name.');
39
+ try { baseline = await sdk.getSkippedUpdateBaseline(id); }
40
+ catch { unknown.push('Base inspection is unavailable. Background-script access and version API availability are required.'); }
41
+ const dependencies = [];
42
+ if (match) {
43
+ try {
44
+ const refs = await sdk.list('sys_dictionary', new URLSearchParams({
45
+ sysparm_query: `reference=${artifact.table}^elementISNOTEMPTY^ORDERBYname^ORDERBYelement`,
46
+ sysparm_fields: 'name,element', sysparm_limit: '21', sysparm_display_value: 'false',
47
+ }));
48
+ if (refs.length > 20) unknown.push('Incoming reference discovery is truncated to 20 dictionary fields.');
49
+ for (const ref of refs.slice(0, 20)) {
50
+ const table = raw(ref.name), field = raw(ref.element);
51
+ if (!/^[a-z][a-z0-9_]*$/.test(table) || !/^[a-z][a-z0-9_]*$/.test(field)) continue;
52
+ try {
53
+ const rows = await sdk.list(table, new URLSearchParams({ sysparm_query: `${field}=${artifact.sys_id}^ORDERBYsys_id`,
54
+ sysparm_fields: 'sys_id', sysparm_limit: '11', sysparm_display_value: 'false' }));
55
+ for (const row of rows.slice(0, 10)) dependencies.push({ table, field, sys_id: raw(row.sys_id), relation: 'incoming_reference' });
56
+ if (rows.length > 10) unknown.push(`Incoming references from ${table}.${field} are truncated to 10 records.`);
57
+ } catch { unknown.push(`Incoming references from ${table}.${field} could not be read.`); }
58
+ }
59
+ } catch { unknown.push('Dictionary-backed incoming dependency discovery is unavailable.'); }
60
+ }
61
+ const impact = analyzeImpact({ artifact, current, baseline, dependencies, unknown });
62
+ const fields = baselineFields(baseline?.payload, artifact);
63
+ const fingerprint = digest({ finding, current, baseline });
64
+ return { sys_id: raw(finding.sys_id), artifact, finding: Object.fromEntries(FIELDS.split(',').map(key => [key, raw(finding[key])])),
65
+ impact, fingerprint, eligible: unresolved(finding),
66
+ // The provider's revert writes a NEW base version record whose payload is a
67
+ // copy of the version that was read here, so revert readback compares base
68
+ // CONTENT (this digest) instead of the previewed version sys_id.
69
+ baseline_payload_digest: typeof baseline?.payload === 'string' && baseline.payload
70
+ ? digest(baseline.payload) : null,
71
+ revert_supported: raw(finding.disposition) === '4' && validId(baseline?.sys_id)
72
+ && Boolean(current && raw(current.sys_id) === artifact.sys_id && fields
73
+ && Object.keys(fields).every(field => !isImpactField(field) || Object.hasOwn(current, field))) };
74
+ }
75
+
76
+ export async function prepareSkippedBatch(sdk, { ids, action, instance = '' }) {
77
+ validateSelection(ids, action);
78
+ const selected = [];
79
+ for (const id of ids) {
80
+ const review = await reviewSkippedUpdate(sdk, id);
81
+ if (!review.eligible) throw errUsage(`Finding ${id} is no longer unresolved and skipped.`);
82
+ if (action === 'revert' && !review.revert_supported) throw errUsage(`Revert is unsupported for ${id}: requires disposition 4, readable artifact and exact base version.`);
83
+ selected.push({ ...review, action, operation: action === 'retain'
84
+ ? 'Set resolution_status=reviewed_retained only; leave artifact unchanged.'
85
+ : 'Call RevertUpdateVersionAjax.revertToBaseSystem; replace customization with provider-selected base version.' });
86
+ }
87
+ if (action === 'revert') {
88
+ const includes = await sdk.list('sys_script_include', new URLSearchParams({
89
+ sysparm_query: 'name=RevertUpdateVersionAjax', sysparm_fields: 'script', sysparm_limit: '2', sysparm_display_value: 'false' }));
90
+ if (includes.length !== 1 || !/revertToBaseSystem\s*:\s*function\s*\(\s*sys_id\s*\)/.test(raw(includes[0].script))
91
+ || !raw(includes[0].script).includes('GlideappUpdateVersion.getVersion')) {
92
+ throw errUsage('Provider revert lifecycle contract is unavailable; no writes performed.');
93
+ }
94
+ }
95
+ const preview = { instance, selected, count: selected.length };
96
+ return { ...preview, confirmation_token: digest(preview), writes_performed: false };
97
+ }
98
+
99
+
100
+ export async function processSkippedBatch({ sdk, ids, action, instance = '', readOnly = false,
101
+ confirmationToken, confirm, onPreview }) {
102
+ if (readOnly) throw new AppError('read_only', 'Read-only profiles cannot process findings.');
103
+ const preview = await prepareSkippedBatch(sdk, { ids, action, instance });
104
+ // Preview precedes the sole confirmation. Never honor force or a profile's
105
+ // skip_confirmations here; this batch always requires explicit consent.
106
+ if (onPreview) await onPreview(preview);
107
+ if (confirmationToken) {
108
+ if (confirmationToken !== preview.confirmation_token) throw errUsage('Preview changed or confirmation token does not match. Review a new preview before confirming.');
109
+ } else if (confirm) {
110
+ if (!await confirm(preview)) return { preview, cancelled: true, results: [], success_count: 0, failure_count: 0, partial: false, writes_performed: false };
111
+ } else {
112
+ throw new AppError('confirmation_required', 'Review the exact batch preview before processing.',
113
+ `Run jsn skipped-updates preview --ids ${ids.join(',')} --action ${action}; review its exact selection, then process that selection with --confirm <preview-token>.`);
114
+ }
115
+ const results = [];
116
+ for (const selected of preview.selected) {
117
+ let attempted = false;
118
+ try {
119
+ const latest = await reviewSkippedUpdate(sdk, selected.sys_id);
120
+ if (!latest.eligible || latest.fingerprint !== selected.fingerprint) throw errUsage('Finding, artifact or base version changed after preview; no write attempted.');
121
+ attempted = true;
122
+ if (action === 'retain') await sdk.update(SKIP_TABLE, selected.sys_id, { resolution_status: 'reviewed_retained' });
123
+ else await sdk.revertSkippedUpdate(selected.sys_id, selected.finding.sys_mod_count);
124
+ const persisted = await sdk.get(SKIP_TABLE, selected.sys_id);
125
+ const expected = action === 'retain' ? 'reviewed_retained' : 'reviewed_reverted';
126
+ if (!persisted || raw(persisted.sys_id) !== selected.sys_id || raw(persisted.resolution_status) !== expected
127
+ || raw(persisted.disposition) !== (action === 'revert' ? '5' : selected.finding.disposition)) {
128
+ throw errUsage('Provider state readback did not verify the selected action.');
129
+ }
130
+ if (action === 'revert') {
131
+ const artifact = await sdk.get(selected.artifact.table, selected.artifact.sys_id);
132
+ const baseline = await sdk.getSkippedUpdateBaseline(selected.sys_id);
133
+ const fields = baselineFields(baseline?.payload, selected.artifact);
134
+ // RevertToBaseSystem reverts to the previewed base version and leaves a new
135
+ // base version record behind, so the sys_id legitimately changes. What must
136
+ // still match is the base CONTENT we previewed and the artifact now at base.
137
+ if (!artifact || raw(artifact.sys_id) !== selected.artifact.sys_id || !fields
138
+ || !validId(baseline?.sys_id) || typeof baseline?.payload !== 'string'
139
+ || digest(baseline.payload) !== selected.baseline_payload_digest
140
+ || Object.entries(fields).some(([field, value]) => isImpactField(field)
141
+ && (!Object.hasOwn(artifact, field) || raw(artifact[field]) !== value))) {
142
+ throw errUsage('Artifact readback did not verify the previewed base content on the reverted artifact.');
143
+ }
144
+ results.push({ sys_id: selected.sys_id, action, success: true, write_attempted: true,
145
+ resolution_status: raw(persisted.resolution_status), disposition: raw(persisted.disposition),
146
+ base_version: raw(baseline.sys_id), verified: true });
147
+ continue;
148
+ }
149
+ results.push({ sys_id: selected.sys_id, action, success: true, write_attempted: true,
150
+ resolution_status: raw(persisted.resolution_status), disposition: raw(persisted.disposition), verified: true });
151
+ } catch (err) {
152
+ results.push({ sys_id: selected.sys_id, action, success: false, write_attempted: attempted,
153
+ effect: attempted ? 'unknown' : 'unchanged', code: err.code || 'provider_error',
154
+ message: attempted ? 'Selected action or its readback failed; inspect this finding before retrying.' : err.message });
155
+ }
156
+ }
157
+ const successCount = results.filter(result => result.success).length;
158
+ return { preview, results, cancelled: false, success_count: successCount,
159
+ failure_count: results.length - successCount, partial: successCount > 0 && successCount < results.length,
160
+ writes_performed: results.some(result => result.write_attempted) };
161
+ }
162
+
163
+ export async function discoverSkippedUpdates(sdk, { limit = 100, offset = 0 } = {}) {
164
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500 || !Number.isInteger(offset) || offset < 0) {
165
+ throw errUsage('Use a limit between 1 and 500 and a nonnegative integer offset.');
166
+ }
167
+ const params = new URLSearchParams({
168
+ sysparm_query: 'dispositionIN4,104,9,10,11^resolution_statusIN,not_reviewed^ORDERBYsys_id',
169
+ sysparm_fields: FIELDS, sysparm_display_value: 'all',
170
+ sysparm_limit: String(limit + 1), sysparm_offset: String(offset),
171
+ });
172
+ const rows = await sdk.list(SKIP_TABLE, params);
173
+ // Recheck locally: an instance can ignore a query field the caller cannot read.
174
+ if (rows.some(row => !unresolved(row))) throw errUsage('Provider returned a finding outside the unresolved skipped state.');
175
+ return { findings: rows.slice(0, limit), count: Math.min(rows.length, limit),
176
+ limit, offset, has_more: rows.length > limit, total: null };
177
+ }