@jacebenson/jsn 4.3.13-dev.82.1 → 4.3.13-dev.84.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.
@@ -9,7 +9,7 @@
9
9
  // resolve → mutate → report.
10
10
 
11
11
  import { assertSafeExactMatch, isHexString } from './helpers.js';
12
- import { errNotFound } from './errors.js';
12
+ import { errNotFound, errUsage, AppError, CodeAmbiguous } from './errors.js';
13
13
 
14
14
  /**
15
15
  * True when the identifier looks like a ServiceNow sys_id (32 hex chars).
@@ -25,40 +25,60 @@ export function isSysId(identifier) {
25
25
  */
26
26
  export function unwrapSysId(record) {
27
27
  const sysId = record?.sys_id;
28
- if (sysId && typeof sysId === 'object') return sysId.value || sysId.display_value || '';
28
+ if (sysId && typeof sysId === 'object') return sysId.value || '';
29
29
  return sysId || '';
30
30
  }
31
31
 
32
32
  /**
33
- * Resolve a single record by human identifier.
34
- *
35
- * @param {object} sdk — SDK handle (uses sdk.list)
36
- * @param {object} opts
37
- * @param {string} opts.table — ServiceNow table
38
- * @param {string} opts.identifier — sys_id (32 hex) or value of matchField
39
- * @param {string} opts.matchField — field to match non-sys_id identifiers on
40
- * (e.g. 'number', 'name', 'user_name')
41
- * @param {string} [opts.resource] — label for the not-found error
42
- * (e.g. 'User' → "User not found: <id>"); defaults to matchField
43
- * @param {string[]} [opts.fields] — restrict sysparm_fields when set
44
- * @returns {Promise<object>} the record (display values requested)
45
- * @throws {AppError} code not_found when no record matches
33
+ * Exact lookups precede partial sys_id lookup. Configured human identifiers keep
34
+ * their legacy precedence; identifierField explicitly disambiguates collisions.
35
+ * Each query requests at most two rows so no operation silently picks a match.
46
36
  */
47
- export async function resolveRecord(sdk, { table, identifier, matchField, resource, fields }) {
37
+ export async function resolveRecord(sdk, { table, identifier, matchField = 'sys_id', identifierField,
38
+ resource, fields, displayValue = 'all', partial = true }) {
48
39
  assertSafeExactMatch(identifier);
49
- const queryField = isSysId(identifier) ? 'sys_id' : matchField;
50
- const params = new URLSearchParams();
51
- params.set('sysparm_query', `${queryField}=${identifier}`);
52
- params.set('sysparm_limit', '1');
53
- params.set('sysparm_display_value', 'all');
54
- if (fields && fields.length > 0) {
55
- params.set('sysparm_fields', fields.join(','));
40
+ if (typeof identifier !== 'string' || !identifier.trim()) throw errUsage('An identifier is required.');
41
+ for (const value of [table, identifierField || matchField]) {
42
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value || '')) throw errUsage('Invalid table or identifier field name.');
56
43
  }
57
- const records = await sdk.list(table, params);
58
- if (records.length === 0) {
59
- throw errNotFound(resource || matchField, identifier);
44
+ const explicit = identifierField !== undefined;
45
+ const firstField = identifierField || (isSysId(identifier) ? 'sys_id' : matchField);
46
+ const attempts = [];
47
+ async function lookup(field, operator, mode) {
48
+ const params = new URLSearchParams({
49
+ sysparm_query: `${field}${operator}${identifier}`,
50
+ sysparm_limit: '2', sysparm_display_value: displayValue,
51
+ });
52
+ if (fields?.length) params.set('sysparm_fields', [...new Set(['sys_id', ...fields])].join(','));
53
+ const records = await sdk.list(table, params);
54
+ const resolution = { table, field, mode, matches: Math.min(records.length, 2),
55
+ ...(records.length >= 2 ? { at_least: true } : {}) };
56
+ attempts.push(resolution);
57
+ if (records.length > 1) {
58
+ const error = new AppError(CodeAmbiguous, `Multiple ${resource || 'records'} found matching ${mode} ${field}: ${identifier}`,
59
+ 'Use a longer/full sys_id or select the exact human field with --identifier-field <field>.');
60
+ error.resolution = { ...resolution, attempts };
61
+ throw error;
62
+ }
63
+ return records[0];
60
64
  }
61
- return records[0];
65
+ let record = await lookup(firstField, '=', 'exact');
66
+ if (record) return record;
67
+ if (!explicit && firstField !== 'sys_id') {
68
+ record = await lookup('sys_id', '=', 'exact');
69
+ if (record) return record;
70
+ }
71
+ // An explicitly selected human field must never fall back to another field.
72
+ if (partial && (!explicit || firstField === 'sys_id')) {
73
+ record = await lookup('sys_id', 'STARTSWITH', 'prefix');
74
+ if (record) return record;
75
+ record = await lookup('sys_id', 'ENDSWITH', 'suffix');
76
+ if (record) return record;
77
+ }
78
+ const error = errNotFound(resource || matchField, identifier);
79
+ error.hint = 'No accessible record matched. Check the table/ACLs, supply a full or longer sys_id, or use --identifier-field <field> for an exact human identifier.';
80
+ error.resolution = { ...attempts.at(-1), attempts };
81
+ throw error;
62
82
  }
63
83
 
64
84
  /**
@@ -67,5 +87,7 @@ export async function resolveRecord(sdk, { table, identifier, matchField, resour
67
87
  */
68
88
  export async function resolveSysId(sdk, opts) {
69
89
  const record = await resolveRecord(sdk, { fields: ['sys_id'], ...opts });
70
- return unwrapSysId(record);
90
+ const id = unwrapSysId(record);
91
+ if (!id) throw errUsage('The matched record has no readable sys_id; check field ACLs before retrying.');
92
+ return id;
71
93
  }