@jacebenson/jsn 4.3.13-dev.79.1 → 4.3.13-dev.81.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/app.js +14 -0
- package/src/cli.js +4 -0
- package/src/commands/history.js +26 -0
- package/src/mutation-history.js +75 -0
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +48 -1
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// setEffectiveInstance() are thin session-backed shims kept for the many
|
|
8
8
|
// command handlers that already call them.
|
|
9
9
|
|
|
10
|
+
import { appendMutation } from './mutation-history.js';
|
|
10
11
|
import { AuthManager } from './auth.js';
|
|
11
12
|
import { createAuthRecovery } from './auth-recovery.js';
|
|
12
13
|
import { SDKClient } from './sdk.js';
|
|
@@ -59,6 +60,7 @@ export class App {
|
|
|
59
60
|
}), {
|
|
60
61
|
domain: session?.domain || '',
|
|
61
62
|
recovery: createAuthRecovery({ app: this, session }),
|
|
63
|
+
onMutation: event => this.recordMutation(instance, session?.profileName, event),
|
|
62
64
|
});
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -67,9 +69,21 @@ export class App {
|
|
|
67
69
|
const profile = options.profile || {};
|
|
68
70
|
return new SDKClient(instance, this.auth.createProfileProvider(instance, options), {
|
|
69
71
|
domain: options.domain ?? profile.domain ?? '',
|
|
72
|
+
onMutation: event => this.recordMutation(instance, options.profileName || null, event),
|
|
70
73
|
});
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
recordMutation(instance, profile, event) {
|
|
77
|
+
if (!this._authArgv) return;
|
|
78
|
+
try { appendMutation({ ...event, instance, profile, reason: this._authArgv.reason }); }
|
|
79
|
+
catch {
|
|
80
|
+
if (!this._historyWarningShown) {
|
|
81
|
+
process.stderr.write('Warning: local mutation history could not be saved. The remote result is unchanged.\n');
|
|
82
|
+
this._historyWarningShown = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
loadContext() {
|
|
74
88
|
this.context = contextFromSession(this.session, this.config);
|
|
75
89
|
}
|
package/src/cli.js
CHANGED
|
@@ -71,6 +71,7 @@ import { scrapiCmd } from './commands/scrapi.js';
|
|
|
71
71
|
import { b4rulesCmd } from './commands/b4rules.js';
|
|
72
72
|
import { atfCmd } from './commands/atf.js';
|
|
73
73
|
import { approvalsCmd } from './commands/approvals.js';
|
|
74
|
+
import { historyCmd } from './commands/history.js';
|
|
74
75
|
import { perfCmd } from './commands/perf.js';
|
|
75
76
|
import { customizationsCmd } from './commands/customizations.js';
|
|
76
77
|
import { midserversCmd } from './commands/midservers.js';
|
|
@@ -146,6 +147,8 @@ export function buildCLI() {
|
|
|
146
147
|
},
|
|
147
148
|
])
|
|
148
149
|
.options({
|
|
150
|
+
reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
|
|
151
|
+
|
|
149
152
|
force: {
|
|
150
153
|
type: 'boolean',
|
|
151
154
|
description: 'Approve Global + Default mutation context only after user approval; delete commands also use this for their separate confirmation',
|
|
@@ -416,6 +419,7 @@ export function buildCLI() {
|
|
|
416
419
|
.command(approvalsCmd(wrap))
|
|
417
420
|
.command(skippedUpdatesCmd(wrap))
|
|
418
421
|
.command(perfCmd(wrap))
|
|
422
|
+
.command(historyCmd(wrap))
|
|
419
423
|
.command(customizationsCmd(wrap))
|
|
420
424
|
.command(midserversCmd(wrap))
|
|
421
425
|
.command(diffCmd(wrap))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
2
|
+
import { listHistory, pruneHistory } from '../mutation-history.js';
|
|
3
|
+
|
|
4
|
+
export function historyCmd(wrap) {
|
|
5
|
+
declareCapabilities('history', { noInstance: true, skipDailyChecks: true });
|
|
6
|
+
const list = wrap(async (argv, app) => {
|
|
7
|
+
const entries = listHistory({ ...argv, instance: argv['instance-url'], profile: argv['profile-name'] });
|
|
8
|
+
app.ok({ entries, retentionDays: 7, _formatted: entries.length
|
|
9
|
+
? entries.map(e => `${e.timestamp} ${e.operation} ${e.table}/${e.sys_id || '(unknown ID)'} ${e.outcome} ${e.instance}${e.reason ? ` — ${e.reason}` : ''}`).join('\n')
|
|
10
|
+
: 'No local mutations in the last seven days.' });
|
|
11
|
+
});
|
|
12
|
+
const filters = b => b
|
|
13
|
+
.option('since', { type: 'string', describe: 'Earliest ISO timestamp' })
|
|
14
|
+
.option('until', { type: 'string', describe: 'Latest ISO timestamp' })
|
|
15
|
+
.option('instance-url', { type: 'string', describe: 'Filter by instance origin' })
|
|
16
|
+
.option('profile-name', { type: 'string', describe: 'Filter by recorded profile name (including removed profiles)' })
|
|
17
|
+
.option('table', { type: 'string', describe: 'Filter by table' })
|
|
18
|
+
.option('operation', { choices: ['create', 'update', 'delete'], type: 'string' })
|
|
19
|
+
.option('record', { type: 'string', describe: 'Filter by full resolved sys_id' })
|
|
20
|
+
.option('limit', { type: 'number', default: 50, describe: 'Maximum entries (1–10000)' });
|
|
21
|
+
return { command: 'history [subcommand]', describe: 'Local seven-day mutation history (not a durable ServiceNow audit)',
|
|
22
|
+
builder: y => filters(y).command({ command: 'list', describe: 'List local mutations', builder: filters, handler: list })
|
|
23
|
+
.command({ command: 'prune', describe: 'Remove expired entries only', handler: wrap(async (_argv, app) => {
|
|
24
|
+
app.ok({ ...pruneHistory(), _formatted: 'Expired local history removed; seven days retained.' });
|
|
25
|
+
}) }), handler: list };
|
|
26
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Local convenience history, independent of documentation indexes and sync.
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { errUsage } from './errors.js';
|
|
7
|
+
|
|
8
|
+
export const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const operations = ['create', 'update', 'delete'];
|
|
10
|
+
const outcomes = ['succeeded', 'failed', 'unknown'];
|
|
11
|
+
export function historyPath() {
|
|
12
|
+
return path.join(process.env.JSN_DATA_HOME || path.join(os.homedir(), '.jsn'), 'history.db');
|
|
13
|
+
}
|
|
14
|
+
function openHistory(filename, now) {
|
|
15
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
16
|
+
const db = new Database(filename);
|
|
17
|
+
try {
|
|
18
|
+
if (process.platform !== 'win32') fs.chmodSync(filename, 0o600);
|
|
19
|
+
db.pragma('busy_timeout = 3000');
|
|
20
|
+
db.exec(`CREATE TABLE IF NOT EXISTS mutation_history (
|
|
21
|
+
id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, instance TEXT NOT NULL,
|
|
22
|
+
profile TEXT, table_name TEXT NOT NULL, sys_id TEXT, operation TEXT NOT NULL,
|
|
23
|
+
outcome TEXT NOT NULL, reason TEXT, http_status INTEGER
|
|
24
|
+
); CREATE INDEX IF NOT EXISTS mutation_history_time ON mutation_history(timestamp)`);
|
|
25
|
+
db.prepare('DELETE FROM mutation_history WHERE timestamp < ?').run(now - RETENTION_MS);
|
|
26
|
+
return db;
|
|
27
|
+
} catch (error) { db.close(); throw error; }
|
|
28
|
+
}
|
|
29
|
+
export function sanitizeReason(value) {
|
|
30
|
+
if (!value) return null;
|
|
31
|
+
// eslint-disable-next-line no-control-regex -- strip controls from user-supplied prose
|
|
32
|
+
return String(value).replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
33
|
+
.replace(/\b(?:Bearer|Basic)\s+\S+/gi, '[redacted]')
|
|
34
|
+
.replace(/\b(password|token|cookie|secret|authorization|access_token|sysparm_ck)\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, '$1=[redacted]')
|
|
35
|
+
.slice(0, 1000);
|
|
36
|
+
}
|
|
37
|
+
export function appendMutation(event, { filename = historyPath(), now = Date.now() } = {}) {
|
|
38
|
+
if (!operations.includes(event.operation) || !outcomes.includes(event.outcome)) throw errUsage('Invalid history event');
|
|
39
|
+
const instance = new URL(event.instance).origin;
|
|
40
|
+
// Deliberate whitelist: never serialize SDK options, response bodies, or payloads.
|
|
41
|
+
const row = { timestamp: now, instance, profile: event.profile || null,
|
|
42
|
+
table_name: event.table, sys_id: event.sys_id || null, operation: event.operation,
|
|
43
|
+
outcome: event.outcome, reason: sanitizeReason(event.reason), http_status: event.http_status || null };
|
|
44
|
+
const db = openHistory(filename, now);
|
|
45
|
+
try {
|
|
46
|
+
db.prepare(`INSERT INTO mutation_history
|
|
47
|
+
(timestamp,instance,profile,table_name,sys_id,operation,outcome,reason,http_status)
|
|
48
|
+
VALUES (@timestamp,@instance,@profile,@table_name,@sys_id,@operation,@outcome,@reason,@http_status)`).run(row);
|
|
49
|
+
} finally { db.close(); }
|
|
50
|
+
}
|
|
51
|
+
export function pruneHistory({ filename = historyPath(), now = Date.now() } = {}) {
|
|
52
|
+
const db = openHistory(filename, now);
|
|
53
|
+
try { return { retentionDays: 7 }; } finally { db.close(); }
|
|
54
|
+
}
|
|
55
|
+
export function listHistory(filters = {}, { filename = historyPath(), now = Date.now() } = {}) {
|
|
56
|
+
const where = [], values = [];
|
|
57
|
+
for (const [key, column] of Object.entries({ instance: 'instance', profile: 'profile', table: 'table_name', operation: 'operation', record: 'sys_id' })) {
|
|
58
|
+
if (filters[key]) { where.push(`${column} = ?`); values.push(key === 'instance' ? new URL(filters[key]).origin : filters[key]); }
|
|
59
|
+
}
|
|
60
|
+
for (const [key, operator] of [['since', '>='], ['until', '<=']]) {
|
|
61
|
+
if (filters[key]) {
|
|
62
|
+
const time = Date.parse(filters[key]);
|
|
63
|
+
if (!Number.isFinite(time)) throw errUsage(`Invalid --${key} timestamp; use an ISO date`);
|
|
64
|
+
where.push(`timestamp ${operator} ?`); values.push(time);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const limit = filters.limit ?? 50;
|
|
68
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10000) throw errUsage('--limit must be an integer from 1 to 10000');
|
|
69
|
+
const db = openHistory(filename, now);
|
|
70
|
+
try {
|
|
71
|
+
return db.prepare(`SELECT id,timestamp,instance,profile,table_name AS "table",sys_id,operation,outcome,reason,http_status
|
|
72
|
+
FROM mutation_history ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY timestamp DESC,id DESC LIMIT ?`)
|
|
73
|
+
.all(...values, limit).map(row => ({ ...row, timestamp: new Date(row.timestamp).toISOString() }));
|
|
74
|
+
} finally { db.close(); }
|
|
75
|
+
}
|
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';
|
|
@@ -48,6 +49,7 @@ export class SDKClient {
|
|
|
48
49
|
this.baseURL = baseURL.replace(/\/$/, '');
|
|
49
50
|
this.authProvider = authProvider;
|
|
50
51
|
this.recovery = opts.recovery;
|
|
52
|
+
this.onMutation = opts.onMutation;
|
|
51
53
|
this.timeout = opts.timeout || DEFAULT_TIMEOUT;
|
|
52
54
|
this.domain = opts.domain || '';
|
|
53
55
|
this.oauthCookies = null;
|
|
@@ -104,7 +106,47 @@ export class SDKClient {
|
|
|
104
106
|
|
|
105
107
|
// Keep the timeout alive through body consumption for text/JSON requests.
|
|
106
108
|
// Raw-response callers retain their existing timeout boundary at headers.
|
|
107
|
-
async _withAuthenticatedRequest(endpoint, opts, {
|
|
109
|
+
async _withAuthenticatedRequest(endpoint, opts, settings, consume = (response) => response) {
|
|
110
|
+
const url = new URL(endpoint);
|
|
111
|
+
const match = url.pathname.match(/^\/api\/now\/table\/([^/]+)(?:\/([^/]+))?\/?$/);
|
|
112
|
+
const operation = { POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete' }[(opts.method || 'GET').toUpperCase()];
|
|
113
|
+
const event = match && operation && this.onMutation ? {
|
|
114
|
+
table: decodeURIComponent(match[1]), sys_id: match[2] ? decodeURIComponent(match[2]) : null, operation,
|
|
115
|
+
} : null;
|
|
116
|
+
let result;
|
|
117
|
+
try {
|
|
118
|
+
result = await this._performAuthenticatedRequest(endpoint, opts, settings, async response => {
|
|
119
|
+
if (event) event.http_status = response.status;
|
|
120
|
+
const value = await consume(response);
|
|
121
|
+
if (event && value instanceof Response && response.ok && operation === 'create') {
|
|
122
|
+
try { event.sys_id = (await response.clone().json())?.result?.sys_id || null; } catch { /* ID remains unknown. */ }
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
});
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (event) await this._reportMutation({ ...event, outcome: error.status >= 400 && error.status < 500 && error.status !== 408
|
|
128
|
+
? 'failed' : 'unknown', http_status: error.status || null });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
if (event) {
|
|
132
|
+
let body = result;
|
|
133
|
+
if (typeof body === 'string') { try { body = JSON.parse(body); } catch { body = null; } }
|
|
134
|
+
const id = body?.result?.sys_id;
|
|
135
|
+
if (typeof id === 'string') event.sys_id = id;
|
|
136
|
+
else if (typeof id?.value === 'string') event.sys_id = id.value;
|
|
137
|
+
await this._reportMutation({ ...event, outcome: event.http_status >= 400
|
|
138
|
+
? (event.http_status < 500 && event.http_status !== 408 ? 'failed' : 'unknown')
|
|
139
|
+
: (event.sys_id ? 'succeeded' : 'unknown') });
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async _reportMutation(event) {
|
|
145
|
+
// History failures must never change the outcome of a completed remote write.
|
|
146
|
+
try { await this.onMutation(event); } catch { /* App callback reports local storage failures. */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async _performAuthenticatedRequest(endpoint, opts, {
|
|
108
150
|
timeout = this.timeout, prepare, skipOAuthSession = false, normalizeErrors = false,
|
|
109
151
|
}, consume = (response) => response) {
|
|
110
152
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -200,6 +242,11 @@ export class SDKClient {
|
|
|
200
242
|
});
|
|
201
243
|
}
|
|
202
244
|
|
|
245
|
+
// Explicit opt-in; ordinary SDK methods remain unbatched.
|
|
246
|
+
async batch(requests, options = {}) {
|
|
247
|
+
return batchRead(this, requests, options);
|
|
248
|
+
}
|
|
249
|
+
|
|
203
250
|
async getSkippedUpdateBaseline(sysID) {
|
|
204
251
|
return readSkippedBaseline(this, sysID);
|
|
205
252
|
}
|