@jacebenson/jsn 4.3.7-dev.18 → 4.3.7-dev.20

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/README.md CHANGED
@@ -59,6 +59,7 @@ jsn flows executions --since "2026-08-25 00:00:00" --until "2026-08-26 00:00:00"
59
59
  jsn flows executions --summary # Server totals + sampled duration metrics by flow
60
60
  jsn flows executions --limit 100 # Inspect more rows from the matching population
61
61
  jsn flows executions --record <sys_id> # Executions for one source record
62
+ jsn flows create --data-file flow.manifest.json # Create a draft record-triggered flow
62
63
  jsn rules list --query "collection=incident"
63
64
  jsn updatesets set "My Feature"
64
65
 
@@ -176,6 +177,67 @@ These internal endpoints and runtime-table permissions can vary by instance.
176
177
 
177
178
  ### Flow execution fields
178
179
 
180
+ ### Flow creation manifest
181
+
182
+ `jsn flows create` accepts `--data-file` or inline `--data`. The first slice
183
+ supports one `record_create_or_update` trigger and published Action types whose
184
+ input schemas are available from the instance. JSN resolves action metadata,
185
+ defaults, and typed inputs from ServiceNow; callers do not provide GraphQL or
186
+ editor-generated identifiers.
187
+
188
+ ```json
189
+ {
190
+ "name": "JSN-STUDY-ticket-log",
191
+ "description": "Log JSN study tickets",
192
+ "scope": "<SCOPE_SYS_ID>",
193
+ "trigger": {
194
+ "type": "record_create_or_update",
195
+ "table": "ticket",
196
+ "condition": "short_descriptionSTARTSWITHJSN-STUDY-"
197
+ },
198
+ "actions": [
199
+ { "type": "Log", "inputs": { "log_level": "info", "log_message": "Ticket received" } }
200
+ ]
201
+ }
202
+ ```
203
+
204
+ `scope` is required; JSN never defaults it to a tenant-specific or captured
205
+ scope. The record-trigger condition currently accepts one simple encoded-query
206
+ predicate (`fieldSTARTSWITHvalue`, `field=value`, `fieldLIKEvalue`, and the
207
+ equivalent single-predicate operators). Compound `^`/`^OR` expressions are
208
+ rejected until their predicate AST mapping is proven against the instance.
209
+
210
+ Creation leaves the flow in Draft and inactive, then creates and reads back an
211
+ Autosave version. `flows publish` remains a separate lifecycle operation: it
212
+ reads the saved ProcessFlow definition, POSTs `/snapshot`, creates an
213
+ `Activate/Publish` version, reads version history, and verifies `active=true`.
214
+ Do not use it in a dry-run: publishing activates the flow.
215
+
216
+ `jsn flows test <identifier> --record <sys_id>` is also a mutating operation.
217
+ It requires confirmation (or `--force`), reads the saved definition, verifies
218
+ that the supplied record exists in the record-trigger table, and POSTs the full
219
+ definition to the ProcessFlow test endpoint. A response with only a context ID
220
+ is reported as accepted, not completed. Test runs execute actions and can have
221
+ side effects; this slice does not execute test flows during development.
222
+
223
+ Flow Logic, subflows, flow-variable lifecycle, and error handling are deferred
224
+ and rejected by this slice rather than silently ignored.
225
+
226
+ `jsn flows update <identifier> --data-file update.json` updates supported fields
227
+ of existing inactive Draft flows. The manifest may contain only `trigger` (with
228
+ `table` and/or `condition`) and `actions`. Each action selects one saved instance
229
+ by its one-based `order` and reusable action `type` (name or sys_id). Its `inputs`
230
+ object may change only inputs already present in that saved Action. The CLI reads
231
+ the saved component identities itself; manifests never carry UI UUIDs. Omitted
232
+ trigger fields, Action inputs, metadata, and other flow components are preserved.
233
+ Names must resolve to exactly one flow, and the protected `jace-test-flow` is
234
+ rejected. Update sends the captured GraphQL `triggerInstances.update` and/or
235
+ `actions.update` shapes, reads the full ProcessFlow definition back, creates an
236
+ Autosave version, and reports update-set captures. It never publishes or
237
+ activates the flow. Unsupported Flow Logic, subflows, variables, error handling,
238
+ new Action inputs, ambiguous selectors, active flows, and non-Draft flows are
239
+ rejected before mutation.
240
+
179
241
  `jsn flows executions` reads `sys_flow_context` and returns both the raw row and a normalized `execution` object. JSN discovers the runtime columns from `sys_dictionary` first, then uses these mappings:
180
242
 
181
243
  - `started`: `started`, `start_time`, `started_at`, then `sys_created_on`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.18",
3
+ "version": "4.3.7-dev.20",
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,9 +1,12 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList } from '../helpers.js';
1
+ import fs from 'node:fs';
2
+ import { formatRecordForDisplay, getStringField, interactiveList, confirmDelete } from '../helpers.js';
2
3
  import { discoverFlowContextFields, normalizeFlowContext, summarizeFlowContexts, formatFlowContextSummary, aggregateFlowContextMappings, mergeFlowContextStats, buildFlowContextQuery } from '../flow-context.js';
3
4
  import { declareCapabilities } from '../capabilities.js';
4
5
  import { inspectFlow } from '../flow-inspection.js';
5
- import { publishFlows, publishDoctor, flowStatus } from '../flow-publish.js';
6
+ import { publishFlows, publishFlowLifecycle, publishDoctor, flowStatus } from '../flow-publish.js';
6
7
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
8
+ import { createFlowFromManifest, updateFlowFromManifest, readCurrentUpdateSet } from '../flow-create.js';
9
+ import { testFlow } from '../flow-test.js';
7
10
 
8
11
  function flowInspectionAdapter(app) {
9
12
  return {
@@ -12,7 +15,7 @@ function flowInspectionAdapter(app) {
12
15
  };
13
16
  }
14
17
 
15
- declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete', 'publish'] });
18
+ declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete', 'publish', 'test'] });
16
19
 
17
20
  function renderChecks(checks) {
18
21
  return checks.map((c) => {
@@ -179,9 +182,14 @@ export function flowsCmd(wrap) {
179
182
  resource: argv.action ? 'Custom action' : 'Flow',
180
183
  }));
181
184
 
182
- const result = argv.action
183
- ? await publishFlows(app.sdk, [], [sysID])
184
- : await publishFlows(app.sdk, [sysID], []);
185
+ if (!argv.action) {
186
+ const lifecycle = await publishFlowLifecycle(app.sdk, app.getEffectiveInstance(), sysID);
187
+ return app.ok(lifecycle, {
188
+ summary: `Published ${sysID}; active=${lifecycle.active}; version=${lifecycle.version?.sys_id ?? lifecycle.version?.id ?? 'read back'}`,
189
+ breadcrumbs: [{ action: 'status', cmd: `jsn flows status ${sysID}`, description: 'Verify the flow will run' }],
190
+ });
191
+ }
192
+ const result = await publishFlows(app.sdk, [], [sysID]);
185
193
 
186
194
  const { summary, results, via } = result;
187
195
  const lines = results.map(r => ` ${r.status === 'success' ? '✅' : '❌'} ${r.flow_name ?? r.sys_id}: ${r.message ?? r.status}${r.errorMessage ? ` — ${r.errorMessage}` : ''}`);
@@ -208,6 +216,23 @@ export function flowsCmd(wrap) {
208
216
  });
209
217
  }),
210
218
  })
219
+ .command({
220
+ command: 'test <identifier>',
221
+ describe: 'Execute a record-triggered flow against one record (may cause side effects)',
222
+ builder: (y) => y
223
+ .option('record', { type: 'string', demandOption: true, describe: 'Trigger record sys_id to test against' })
224
+ .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
225
+ handler: wrap(async (argv, app) => {
226
+ app.requireInstance();
227
+ await confirmDelete(app, argv, `Execute flow test for ${argv.identifier} against record ${argv.record}; actions may cause side effects`);
228
+ const result = await testFlow(app.sdk, app.getEffectiveInstance(), argv.identifier, argv.record);
229
+ return app.ok(result, {
230
+ summary: result.status === 'completed'
231
+ ? `Flow test completed (${result.success ? 'success' : 'failed'})`
232
+ : 'Flow test accepted for execution',
233
+ });
234
+ }),
235
+ })
211
236
  .command({
212
237
  command: 'doctor',
213
238
  describe: 'Check whether this instance can publish flows at all',
@@ -224,22 +249,56 @@ export function flowsCmd(wrap) {
224
249
  })
225
250
  .command({
226
251
  command: 'create',
227
- describe: 'Create a new flow (not yet implemented)',
252
+ describe: 'Create a draft flow from a schema-driven JSON manifest',
228
253
  builder: (y) => y
229
- .option('data', { type: 'string', describe: 'JSON data for the flow' }),
230
- handler: wrap(async (_argv, _app) => {
231
- throw new Error('Flow creation requires the Flow Designer GraphQL API - not yet implemented.\n'
232
- + 'Use the ServiceNow web UI to create flows, then use "jsn flows list" to view them.');
254
+ .option('data', { type: 'string', describe: 'Flow manifest as inline JSON' })
255
+ .option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' }),
256
+ handler: wrap(async (argv, app) => {
257
+ app.requireInstance();
258
+ const dataFile = argv.dataFile ?? argv['data-file'];
259
+ if (!dataFile && argv.data === undefined) throw new Error('Provide --data-file or --data for the flow manifest');
260
+ let manifest;
261
+ try {
262
+ const raw = dataFile ? fs.readFileSync(dataFile, 'utf8') : argv.data;
263
+ manifest = JSON.parse(raw.charCodeAt?.(0) === 0xFEFF ? raw.slice(1) : raw);
264
+ } catch (error) {
265
+ throw new Error(`Invalid flow manifest JSON: ${error.message}`, { cause: error });
266
+ }
267
+ const result = await createFlowFromManifest(app.sdk, app.getEffectiveInstance(), manifest, {
268
+ readUpdateSet: () => readCurrentUpdateSet(app.sdk),
269
+ });
270
+ app.ok(result, {
271
+ summary: `Created draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
272
+ breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the draft flow' }],
273
+ });
233
274
  }),
234
275
  })
235
276
  .command({
236
277
  command: 'update <identifier>',
237
- describe: 'Update a flow (not yet implemented)',
278
+ describe: 'Safely update supported fields of an existing inactive Draft flow',
238
279
  builder: (y) => y
239
- .option('data', { type: 'string', describe: 'JSON data to update' }),
240
- handler: wrap(async (_argv, _app) => {
241
- throw new Error('Flow updates require the Flow Designer GraphQL API - not yet implemented.\n'
242
- + 'Use the ServiceNow web UI to update flows, then use "jsn flows list" to view them.');
280
+ .option('data', { type: 'string', describe: 'Update manifest as inline JSON' })
281
+ .option('data-file', { type: 'string', describe: 'Read the update manifest from a JSON file' })
282
+ .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
283
+ handler: wrap(async (argv, app) => {
284
+ app.requireInstance();
285
+ const dataFile = argv.dataFile ?? argv['data-file'];
286
+ if (!dataFile && argv.data === undefined) throw new Error('Provide --data-file or --data for the flow update manifest');
287
+ let manifest;
288
+ try {
289
+ const raw = dataFile ? fs.readFileSync(dataFile, 'utf8') : argv.data;
290
+ manifest = JSON.parse(raw.charCodeAt?.(0) === 0xFEFF ? raw.slice(1) : raw);
291
+ } catch (error) {
292
+ throw new Error(`Invalid flow update manifest JSON: ${error.message}`, { cause: error });
293
+ }
294
+ await confirmDelete(app, argv, `Update draft flow ${argv.identifier}; unspecified components and inputs are preserved`);
295
+ const result = await updateFlowFromManifest(app.sdk, app.getEffectiveInstance(), argv.identifier, manifest, {
296
+ readUpdateSet: () => readCurrentUpdateSet(app.sdk),
297
+ });
298
+ return app.ok(result, {
299
+ summary: `Updated draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
300
+ breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the updated draft flow' }],
301
+ });
243
302
  }),
244
303
  })
245
304
  .command({
@@ -260,9 +319,10 @@ export function flowsCmd(wrap) {
260
319
  console.log(' show <identifier> Show flow details by name or sys_id');
261
320
  console.log(' status <identifier> Check whether a flow is actually able to run');
262
321
  console.log(' publish <identifier> Publish (activate) a flow');
322
+ console.log(' test <identifier> Execute a record-triggered flow against one record');
263
323
  console.log(' doctor Check whether this instance can publish flows');
264
- console.log(' create Create a new flow (not yet implemented)');
265
- console.log(' update <identifier> Update a flow (not yet implemented)');
324
+ console.log(' create Create a draft flow from a JSON manifest');
325
+ console.log(' update <identifier> Safely update supported fields of an existing Draft flow');
266
326
  console.log(' delete <identifier> Delete a flow (not yet implemented)');
267
327
  console.log('');
268
328
  console.log('Run "jsn flows <command> --help" for details.');
@@ -0,0 +1,774 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { assertSafeExactMatch } from './helpers.js';
3
+ import { createVerifiedFlowVersion } from './flow-versioning.js';
4
+
5
+ const FLOW_ENDPOINT = '/api/now/processflow/flow';
6
+ const GRAPHQL_ENDPOINT = '/api/now/graphql';
7
+ const TABLE_DESCRIPTOR_ENDPOINT = '/api/now/processflow/trigger/record/table';
8
+ const PROTECTED_FLOW_ID = 'c215135293bf03d087b0f14fdd03d652';
9
+ const PROTECTED_FLOW_NAME = 'jace-test-flow';
10
+
11
+ function isSysId(value) {
12
+ return typeof value === 'string' && /^[0-9a-f]{32}$/i.test(value);
13
+ }
14
+
15
+ function stringValue(value) {
16
+ if (value && typeof value === 'object') return String(value.value ?? value.sys_id ?? value.name ?? value.display_value ?? '');
17
+ return value == null ? '' : String(value);
18
+ }
19
+
20
+ function idValue(value) {
21
+ return stringValue(value);
22
+ }
23
+
24
+ function gqlString(value) {
25
+ return JSON.stringify(String(value));
26
+ }
27
+
28
+ function gqlValue(value) {
29
+ if (value === null || value === undefined) return 'null';
30
+ if (typeof value === 'string') return gqlString(value);
31
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
32
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null';
33
+ if (Array.isArray(value)) return `[${value.map(gqlValue).join(', ')}]`;
34
+ if (typeof value === 'object') return `{${Object.entries(value).map(([key, item]) => `${key}: ${gqlValue(item)}`).join(', ')}}`;
35
+ return gqlString(value);
36
+ }
37
+
38
+ function gqlInputObject(value) {
39
+ return `{${Object.entries(value).map(([key, item]) => `${key}: ${gqlValue(item)}`).join(', ')}}`;
40
+ }
41
+
42
+ function parseMaybeJson(value, fallback = {}) {
43
+ if (!value) return fallback;
44
+ if (typeof value === 'object') return value;
45
+ try { return JSON.parse(value); } catch { return fallback; }
46
+ }
47
+
48
+ function walk(value, visitor, seen = new Set()) {
49
+ if (!value || typeof value !== 'object' || seen.has(value)) return;
50
+ seen.add(value);
51
+ visitor(value);
52
+ if (Array.isArray(value)) value.forEach(item => walk(item, visitor, seen));
53
+ else Object.values(value).forEach(item => walk(item, visitor, seen));
54
+ }
55
+
56
+ function findArray(value, key) {
57
+ let found;
58
+ walk(value, object => {
59
+ if (!found && !Array.isArray(object) && Array.isArray(object[key])) found = object[key];
60
+ });
61
+ return found || [];
62
+ }
63
+
64
+ function findFlowData(response) {
65
+ return response?.result?.data ?? response?.data?.result?.data ?? response?.data ?? response?.result ?? response;
66
+ }
67
+
68
+ function findId(value, keys = ['sysId', 'sys_id', 'flowId', 'id']) {
69
+ let found = '';
70
+ walk(value, object => {
71
+ if (found || Array.isArray(object)) return;
72
+ for (const key of keys) {
73
+ const candidate = idValue(object[key]);
74
+ if (candidate) {
75
+ found = candidate;
76
+ break;
77
+ }
78
+ }
79
+ });
80
+ return found;
81
+ }
82
+
83
+ function graphQLErrors(response) {
84
+ const errors = response?.errors || response?.data?.errors || response?.result?.errors;
85
+ return Array.isArray(errors) ? errors : [];
86
+ }
87
+
88
+ function assertGraphQLSuccess(response, operation) {
89
+ const errors = graphQLErrors(response);
90
+ if (errors.length) throw new Error(`${operation} failed: ${errors.map(error => String(error.message || error.code || 'GraphQL error')).join('; ')}`);
91
+ if (response?.result?.errorMessage) throw new Error(`${operation} failed: ${response.result.errorMessage}`);
92
+ }
93
+
94
+ function schemaEntries(definition) {
95
+ const entries = [];
96
+ const seen = new Set();
97
+ walk(definition, object => {
98
+ for (const key of ['inputs', 'inputSchema', 'parameters', 'fields']) {
99
+ if (!Array.isArray(object[key])) continue;
100
+ for (const field of object[key]) {
101
+ if (!field || typeof field !== 'object') continue;
102
+ const name = stringValue(field.name || field.key || field.element || field.parameter);
103
+ if (!name || seen.has(name)) continue;
104
+ seen.add(name);
105
+ entries.push({
106
+ ...field,
107
+ name,
108
+ type: stringValue(field.type || field.inputType || field.valueType || 'string') || 'string',
109
+ default: field.default ?? field.defaultValue,
110
+ });
111
+ }
112
+ }
113
+ });
114
+ return entries;
115
+ }
116
+
117
+ function actionTypeId(record) {
118
+ return idValue(record?.sys_id || record?.sysId || record?.id || record?.actionTypeId);
119
+ }
120
+
121
+ function exactActionName(record) {
122
+ return stringValue(record?.name || record?.f_name || record?.fName || record?.display_name || record?.displayName);
123
+ }
124
+
125
+ export function validateFlowManifest(manifest) {
126
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('Flow manifest must be a JSON object');
127
+ if (!String(manifest.name || '').trim()) throw new Error('Flow manifest requires name');
128
+ if (!String(manifest.scope || '').trim()) throw new Error('Flow manifest requires scope');
129
+ if (manifest.logic !== undefined) throw new Error('Flow Logic is unsupported in this creation slice');
130
+ if (manifest.subflows !== undefined || manifest.subFlows !== undefined) throw new Error('Subflows are unsupported in this creation slice');
131
+ if (manifest.variables !== undefined || manifest.flowVariables !== undefined) throw new Error('Flow-variable lifecycle is unsupported in this creation slice');
132
+ if (manifest.errorHandling !== undefined) throw new Error('Error handling is unsupported in this creation slice');
133
+ const trigger = manifest.trigger;
134
+ if (!trigger || trigger.type !== 'record_create_or_update') throw new Error('Only trigger.type=record_create_or_update is supported');
135
+ if (!String(trigger.table || '').trim()) throw new Error('Record trigger requires table');
136
+ if (trigger.condition !== undefined && typeof trigger.condition !== 'string') throw new Error('Record trigger condition must be a string');
137
+ if (trigger.condition && !/^[A-Za-z_][A-Za-z0-9_.]*(?:STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)[^,^]*$/.test(trigger.condition)) {
138
+ throw new Error('Record trigger condition uses an unsupported encoded-query condition subset');
139
+ }
140
+ if (!Array.isArray(manifest.actions)) throw new Error('Flow manifest requires an actions array');
141
+ for (const [index, action] of manifest.actions.entries()) {
142
+ if (!action || typeof action !== 'object' || !String(action.type || '').trim()) throw new Error(`Action ${index + 1} requires type`);
143
+ if (action.inputs !== undefined && (!action.inputs || typeof action.inputs !== 'object' || Array.isArray(action.inputs))) throw new Error(`Action ${index + 1} inputs must be an object`);
144
+ }
145
+ return manifest;
146
+ }
147
+
148
+ export function buildFlowProperties(manifest) {
149
+ return {
150
+ name: manifest.name,
151
+ description: manifest.description || '',
152
+ type: 'flow',
153
+ status: 'draft',
154
+ active: false,
155
+ scope: manifest.scope,
156
+ runAs: 'user',
157
+ flowPriority: 'MEDIUM',
158
+ access: 'public',
159
+ protection: '',
160
+ runWithRoles: { value: '', displayValue: '' },
161
+ deleted: false,
162
+ security: { can_read: true, can_write: true },
163
+ };
164
+ }
165
+
166
+ export function buildFlowPatchMutation({ flowId, trigger, actions }) {
167
+ const triggerUpdate = {
168
+ id: trigger.id,
169
+ metadata: trigger.metadata,
170
+ inputs: trigger.inputs,
171
+ };
172
+ const actionInsert = actions.map(action => ({
173
+ actionTypeSysId: action.actionTypeSysId,
174
+ flowSysId: action.flowSysId,
175
+ generationSource: action.generationSource,
176
+ type: action.type,
177
+ order: action.order,
178
+ parent: action.parent,
179
+ uiUniqueIdentifier: action.uiUniqueIdentifier,
180
+ parentUiId: action.parentUiId,
181
+ metadata: action.metadata,
182
+ inputs: action.inputs,
183
+ }));
184
+ const patch = `flowPatch: {flowId: ${gqlString(flowId)}, triggerInstances: {update: [${gqlInputObject(triggerUpdate)}]}, actions: {insert: [${actionInsert.map(gqlInputObject).join(', ')}]}}`;
185
+ return `mutation { global { snFlowDesigner { flow(${patch}) { triggerInstances { inserts { sysId uiUniqueIdentifier } updates { sysId uiUniqueIdentifier } } actions { inserts { sysId uiUniqueIdentifier } updates { sysId uiUniqueIdentifier } } } } } }`;
186
+ }
187
+
188
+ export function buildFlowUpdateMutation({ flowId, triggerUpdates = [], actionUpdates = [] }) {
189
+ const updates = [];
190
+ if (triggerUpdates.length) updates.push(`triggerInstances: {update: [${triggerUpdates.map(gqlInputObject).join(', ')}]}`);
191
+ if (actionUpdates.length) updates.push(`actions: {update: [${actionUpdates.map(gqlInputObject).join(', ')}]}`);
192
+ const patch = `flowPatch: {flowId: ${gqlString(flowId)}, ${updates.join(', ')}}`;
193
+ return `mutation { global { snFlowDesigner { flow(${patch}) { triggerInstances { updates } actions { updates } } } } }`;
194
+ }
195
+
196
+ async function readProcessFlow(sdk, instance, flowId, scope) {
197
+ if (!scope) throw new Error(`Flow ${flowId} has no transaction scope for ProcessFlow read`);
198
+ const endpoint = `${instance}${FLOW_ENDPOINT}/${encodeURIComponent(flowId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`;
199
+ const response = await sdk.request(endpoint, { method: 'GET' });
200
+ const data = findFlowData(response);
201
+ if (!data || typeof data !== 'object') throw new Error(`ProcessFlow read-back failed for flow ${flowId}`);
202
+ const returnedScope = stringValue(data.scope);
203
+ if (returnedScope && returnedScope !== scope) throw new Error(`ProcessFlow read-back scope did not match flow ${flowId}`);
204
+ return data;
205
+ }
206
+
207
+ async function resolveActionType(sdk, actionType) {
208
+ if (isSysId(actionType)) return { id: actionType, record: { sys_id: actionType, name: actionType } };
209
+ const params = new URLSearchParams({
210
+ sysparm_query: `name=${actionType}^ORf_name=${actionType}^active=true`,
211
+ sysparm_limit: '20',
212
+ sysparm_display_value: 'all',
213
+ sysparm_fields: 'sys_id,name,f_name,fName,active,published',
214
+ });
215
+ const records = await sdk.list('sys_hub_action_type_definition', params);
216
+ const exact = records.filter(record => exactActionName(record).toLowerCase() === actionType.toLowerCase());
217
+ if (exact.length !== 1) throw new Error(exact.length === 0 ? `Published action type not found: ${actionType}` : `Action type is ambiguous: ${actionType}`);
218
+ const record = exact[0];
219
+ if (record.active === false || record.active === 'false' || record.published === false || record.published === 'false') throw new Error(`Action type is not published: ${actionType}`);
220
+ return { id: actionTypeId(record), record };
221
+ }
222
+
223
+ async function readActionDefinition(sdk, instance, actionTypeId, scope) {
224
+ const endpoint = `${instance}/api/now/processflow/action/action_types/${encodeURIComponent(actionTypeId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`;
225
+ const response = await sdk.request(endpoint, { method: 'GET' });
226
+ const definition = response?.result ?? response?.data?.result ?? response?.data ?? response;
227
+ const schema = schemaEntries(definition);
228
+ if (schema.length === 0) throw new Error(`Action type ${actionTypeId} returned no input schema`);
229
+ return { definition, schema };
230
+ }
231
+
232
+ function buildTypedInputs(action, schema) {
233
+ const supplied = action.inputs || {};
234
+ const allowed = new Set(schema.map(field => field.name));
235
+ for (const key of Object.keys(supplied)) if (!allowed.has(key)) throw new Error(`Unsupported input "${key}" for action ${action.type}`);
236
+ return schema.map(field => {
237
+ if (!field || typeof field !== 'object' || !field.id || !field.parameter || typeof field.parameter !== 'object') {
238
+ throw new Error(`Action ${action.type} returned an incomplete input schema; refusing to invent input metadata for "${field?.name || 'unknown'}"`);
239
+ }
240
+ const suppliedValue = Object.prototype.hasOwnProperty.call(supplied, field.name) ? supplied[field.name] : field.default;
241
+ if ((suppliedValue === undefined || suppliedValue === null) && (field.mandatory === true || field.required === true)) throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
242
+ const value = suppliedValue ?? null;
243
+ const displayValue = field.displayValue ?? field.display_value ?? value;
244
+ return {
245
+ ...field,
246
+ name: field.name,
247
+ value: { schemaless: false, schemalessValue: '', value },
248
+ displayValue: { schemaless: false, schemalessValue: '', value: displayValue },
249
+ };
250
+ });
251
+ }
252
+
253
+ function parseSimpleCondition(condition) {
254
+ if (!condition) return null;
255
+ const match = condition.match(/^([A-Za-z_][A-Za-z0-9_.]*)(STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)([^,^]*)$/);
256
+ if (!match) throw new Error('Record trigger condition cannot be encoded as a single supported predicate');
257
+ return { field: match[1], operator: match[2], term: match[3] };
258
+ }
259
+
260
+ async function readTableDescriptor(sdk, instance, scope, table, override) {
261
+ if (override) return override(table);
262
+ const endpoint = `${instance}${TABLE_DESCRIPTOR_ENDPOINT}?table=${encodeURIComponent(table)}&sysparm_transaction_scope=${encodeURIComponent(scope)}`;
263
+ const response = await sdk.request(endpoint, { method: 'GET' });
264
+ const descriptor = response?.result ?? response?.data?.result ?? response?.data ?? response;
265
+ if (!descriptor || typeof descriptor !== 'object') throw new Error(`ProcessFlow table descriptor was unavailable for ${table}`);
266
+ const label = stringValue(descriptor.label || descriptor.displayValue || descriptor.display_value || descriptor.name);
267
+ const displayField = stringValue(descriptor.displayField || descriptor.display_field || descriptor.displayColumn || descriptor.display_column);
268
+ if (!label || !displayField) throw new Error(`ProcessFlow table descriptor for ${table} lacks label/display field`);
269
+ return { ...descriptor, label, displayField };
270
+ }
271
+
272
+ function buildPredicateMetadata(condition, descriptor) {
273
+ const predicate = parseSimpleCondition(condition);
274
+ if (!predicate) return { predicates: [], order_by: [], group_by: [], has_rlq_conditions: false };
275
+ const field = descriptor.fields?.find(item => stringValue(item.name || item.element || item.field) === predicate.field)
276
+ || descriptor.schema?.find(item => stringValue(item.name || item.element || item.field) === predicate.field);
277
+ if (!field) throw new Error(`Record trigger condition field "${predicate.field}" is absent from the table descriptor`);
278
+ const fieldLabel = stringValue(field.label || field.column_label || predicate.field);
279
+ const operatorLabel = stringValue(field?.operators?.[predicate.operator] || predicate.operator);
280
+ const comparison = {
281
+ field_type: stringValue(field?.field_type || field?.type || 'string'),
282
+ operator: predicate.operator,
283
+ term_label: predicate.term,
284
+ or_query: false,
285
+ field_label: fieldLabel,
286
+ column_type: stringValue(field?.column_type || 'element'),
287
+ term: predicate.term,
288
+ display_value: '',
289
+ new_query: false,
290
+ goto_query: false,
291
+ is_pre_evaluated: false,
292
+ rlqc_query: false,
293
+ operator_label: operatorLabel,
294
+ value: predicate.term,
295
+ field: predicate.field,
296
+ type: 'comparison',
297
+ };
298
+ return {
299
+ predicates: [{ compound_type: 'or', subpredicates: [{ compound_type: 'and', subpredicates: [{ compound_type: 'and', subpredicates: [comparison], type: 'compound' }], type: 'compound' }], type: 'compound' }],
300
+ order_by: [],
301
+ group_by: [],
302
+ has_rlq_conditions: false,
303
+ };
304
+ }
305
+
306
+ function predicateMetadataMatches(metadata, condition) {
307
+ const expected = parseSimpleCondition(condition);
308
+ if (!expected) return true;
309
+ const parsed = parseMaybeJson(metadata, null);
310
+ const comparisons = [];
311
+ walk(parsed, object => {
312
+ if (stringValue(object.type).toLowerCase() !== 'comparison') return;
313
+ comparisons.push({
314
+ field: stringValue(object.field),
315
+ operator: stringValue(object.operator),
316
+ term: stringValue(object.term ?? object.value),
317
+ });
318
+ });
319
+ return comparisons.some(comparison => comparison.field === expected.field
320
+ && comparison.operator === expected.operator
321
+ && comparison.term === expected.term);
322
+ }
323
+
324
+ function assertPredicateMetadataMatches(metadata, condition, label) {
325
+ if (!predicateMetadataMatches(metadata, condition)) throw new Error(`${label} predicate metadata did not match the requested field, operator, and term`);
326
+ }
327
+
328
+ function actionTypeIdOf(action) {
329
+ return idValue(action?.actionTypeSysId || action?.action_type_sys_id || action?.actionType?.sys_id || action?.actionType?.sysId);
330
+ }
331
+
332
+ function verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, actionInstances, inserted }) {
333
+ const returnedFlowId = stringValue(processflow.flowId || processflow.flow_id || processflow.sysId || processflow.sys_id);
334
+ if (returnedFlowId && returnedFlowId !== flowId) throw new Error(`ProcessFlow read-back returned a different flow ID (${returnedFlowId})`);
335
+
336
+ const triggers = findArray(processflow, 'triggerInstances');
337
+ if (triggers.length !== 1) throw new Error(`ProcessFlow read-back returned ${triggers.length} trigger instances; expected one`);
338
+ const savedTrigger = triggers[0];
339
+ if (findId(savedTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== trigger.id) throw new Error('Created trigger identity was not preserved in ProcessFlow read-back');
340
+ const savedTriggerInputs = inputList(savedTrigger);
341
+ for (const expected of trigger.inputs) {
342
+ const actual = savedTriggerInputs.find(input => input.name === expected.name);
343
+ if (!actual || inputRawValue(actual) !== inputRawValue(expected)) throw new Error(`Created trigger ${expected.name} did not match the requested value in ProcessFlow read-back`);
344
+ }
345
+ assertPredicateMetadataMatches(savedTrigger.metadata, manifest.trigger.condition || '', 'Created trigger');
346
+
347
+ const savedActions = findExistingActions(processflow);
348
+ if (savedActions.length !== actionInstances.length) throw new Error(`ProcessFlow read-back returned ${savedActions.length} Actions; expected ${actionInstances.length}`);
349
+ const insertedByUiId = new Map(inserted.map(action => [stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier), action]));
350
+ for (const expected of actionInstances) {
351
+ const insertedAction = insertedByUiId.get(expected.uiUniqueIdentifier);
352
+ if (!insertedAction) throw new Error(`Created Action ${expected.uiUniqueIdentifier} was not returned by the GraphQL mutation`);
353
+ const savedMatches = savedActions.filter(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === expected.uiUniqueIdentifier);
354
+ if (savedMatches.length !== 1) throw new Error(`Created Action ${expected.uiUniqueIdentifier} was not uniquely present in ProcessFlow read-back`);
355
+ const savedAction = savedMatches[0];
356
+ const insertedId = findId(insertedAction, ['sysId', 'sys_id', 'id']);
357
+ const savedId = findId(savedAction, ['sysId', 'sys_id', 'id']);
358
+ if (!insertedId || savedId !== insertedId) throw new Error(`Created Action ${expected.uiUniqueIdentifier} identity did not match the GraphQL mutation read-back`);
359
+ if (actionTypeIdOf(savedAction) !== expected.actionTypeSysId) throw new Error(`Created Action ${expected.uiUniqueIdentifier} type did not match the requested Action`);
360
+ if (String(savedAction.order) !== String(expected.order)) throw new Error(`Created Action ${expected.uiUniqueIdentifier} order did not match the requested order`);
361
+ const savedInputs = inputList(savedAction);
362
+ if (savedInputs.length !== expected.inputs.length) throw new Error(`Created Action ${expected.uiUniqueIdentifier} input count did not match the installed schema`);
363
+ for (const expectedInput of expected.inputs) {
364
+ const actualInput = savedInputs.find(input => input.name === expectedInput.name);
365
+ if (!actualInput || inputRawValue(actualInput) !== inputRawValue(expectedInput)) throw new Error(`Created Action ${expected.uiUniqueIdentifier} input ${expectedInput.name} did not match the requested value`);
366
+ }
367
+ }
368
+ }
369
+
370
+ function makeTriggerPatch(flow, manifest, descriptor) {
371
+ const trigger = findArray(flow, 'triggerInstances')[0];
372
+ if (!trigger) throw new Error('Created flow did not return its default record trigger instance');
373
+ const id = findId(trigger, ['sysId', 'sys_id', 'id', 'triggerId']);
374
+ if (!id) throw new Error('Created flow trigger has no usable identity');
375
+ const metadata = buildPredicateMetadata(manifest.trigger.condition || '', descriptor);
376
+ const wrapped = value => ({ schemaless: false, schemalessValue: '', value });
377
+ return {
378
+ id,
379
+ metadata: JSON.stringify(metadata),
380
+ inputs: [
381
+ { name: 'table', displayField: descriptor.displayField, displayValue: wrapped(descriptor.label), value: wrapped(manifest.trigger.table) },
382
+ { name: 'condition', displayField: manifest.trigger.condition || '', displayValue: wrapped(manifest.trigger.condition || ''), value: wrapped(manifest.trigger.condition || '') },
383
+ ],
384
+ };
385
+ }
386
+
387
+ function clone(value) {
388
+ return value == null ? value : JSON.parse(JSON.stringify(value));
389
+ }
390
+
391
+ function inputList(component) {
392
+ return component?.inputs || component?.inputValues || [];
393
+ }
394
+
395
+ function inputRawValue(input) {
396
+ return input?.value?.value ?? input?.value ?? input?.displayValue?.value ?? input?.displayValue ?? null;
397
+ }
398
+
399
+ function wrappedValue(value) {
400
+ return { schemaless: false, schemalessValue: '', value };
401
+ }
402
+
403
+ function buildActionUpdate(existing, requested, index) {
404
+ const existingInputs = new Set(inputList(existing).map(input => stringValue(input?.name)));
405
+ for (const name of Object.keys(requested.inputs)) {
406
+ if (!existingInputs.has(name)) throw new Error(`Update action ${index + 1} input "${name}" is not present in the saved flow; refusing to add an input`);
407
+ }
408
+ const uiUniqueIdentifier = stringValue(existing.uiUniqueIdentifier || existing.ui_unique_identifier);
409
+ if (!uiUniqueIdentifier) throw new Error(`Update action ${index + 1} has no saved uiUniqueIdentifier`);
410
+ return {
411
+ uiUniqueIdentifier,
412
+ type: 'action',
413
+ inputs: Object.entries(requested.inputs).map(([name, value]) => ({ name, value: wrappedValue(value) })),
414
+ };
415
+ }
416
+
417
+ function findExistingActions(definition) {
418
+ const actionInstances = findArray(definition, 'actionInstances');
419
+ return actionInstances.length ? actionInstances : findArray(definition, 'actions');
420
+ }
421
+
422
+ async function resolveExistingAction(sdk, actions, requested, index) {
423
+ const actionType = await resolveActionType(sdk, requested.type);
424
+ const matches = actions.filter(action => String(action.order) === String(requested.order)
425
+ && idValue(action.actionTypeSysId || action.action_type_sys_id || action.actionType?.sys_id || action.actionType?.sysId) === actionType.id);
426
+ if (matches.length !== 1) throw new Error(matches.length === 0
427
+ ? `Update action ${index + 1} did not match an existing Action instance at order ${requested.order} with type ${requested.type}`
428
+ : `Update action ${index + 1} matched multiple Action instances at order ${requested.order} with type ${requested.type}`);
429
+ return matches[0];
430
+ }
431
+
432
+ export function validateFlowUpdateManifest(manifest) {
433
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('Flow update manifest must be a JSON object');
434
+ const allowed = new Set(['scope', 'trigger', 'actions']);
435
+ for (const key of Object.keys(manifest)) if (!allowed.has(key)) throw new Error(`Flow update field "${key}" is unsupported; only trigger and existing action inputs may be updated`);
436
+ if (manifest.scope !== undefined && !String(manifest.scope).trim()) throw new Error('Flow update scope must not be empty');
437
+ if (manifest.trigger !== undefined) {
438
+ if (!manifest.trigger || typeof manifest.trigger !== 'object' || Array.isArray(manifest.trigger)) throw new Error('Flow update trigger must be an object');
439
+ for (const key of Object.keys(manifest.trigger)) if (!['type', 'table', 'condition'].includes(key)) throw new Error(`Flow update trigger field "${key}" is unsupported`);
440
+ if (manifest.trigger.type !== undefined && manifest.trigger.type !== 'record_create_or_update') throw new Error('Only record_create_or_update trigger updates are supported');
441
+ if (manifest.trigger.table !== undefined && !String(manifest.trigger.table).trim()) throw new Error('Flow update trigger table must not be empty');
442
+ if (manifest.trigger.condition !== undefined && typeof manifest.trigger.condition !== 'string') throw new Error('Flow update trigger condition must be a string');
443
+ if (manifest.trigger.table === undefined && manifest.trigger.condition === undefined) throw new Error('Flow update trigger must specify table and/or condition');
444
+ if (manifest.trigger.condition && !/^[A-Za-z_][A-Za-z0-9_.]*(?:STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)[^,^]*$/.test(manifest.trigger.condition)) throw new Error('Flow update trigger condition uses an unsupported encoded-query condition subset');
445
+ }
446
+ if (manifest.actions !== undefined) {
447
+ if (!Array.isArray(manifest.actions)) throw new Error('Flow update actions must be an array');
448
+ for (const [index, action] of manifest.actions.entries()) {
449
+ if (!action || typeof action !== 'object' || Array.isArray(action)) throw new Error(`Update action ${index + 1} must be an object`);
450
+ for (const key of Object.keys(action)) if (!['order', 'type', 'inputs'].includes(key)) throw new Error(`Update action ${index + 1} field "${key}" is unsupported; caller-supplied identities are not accepted`);
451
+ if (!Number.isInteger(action.order) || action.order < 1 || !String(action.type || '').trim()) throw new Error(`Update action ${index + 1} requires a positive integer order and type`);
452
+ if (!action.inputs || typeof action.inputs !== 'object' || Array.isArray(action.inputs)) throw new Error(`Update action ${index + 1} inputs must be an object`);
453
+ if (Object.keys(action.inputs).length === 0) throw new Error(`Update action ${index + 1} inputs must contain at least one changed value`);
454
+ }
455
+ }
456
+ if (manifest.trigger === undefined && manifest.actions === undefined) throw new Error('Flow update manifest must specify trigger and/or actions');
457
+ return manifest;
458
+ }
459
+
460
+ function readFlowRecord(sdk, identifier) {
461
+ const exactIdentifier = String(identifier ?? '');
462
+ if (!exactIdentifier) throw new Error('Flow identifier must be an exact sys_id or name');
463
+ assertSafeExactMatch(exactIdentifier);
464
+ const query = isSysId(exactIdentifier) ? `sys_id=${exactIdentifier}` : `name=${exactIdentifier}`;
465
+ return sdk.list('sys_hub_flow', new URLSearchParams({ sysparm_query: query, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,scope,active,status,type' }));
466
+ }
467
+
468
+ async function resolveDraftFlow(sdk, identifier) {
469
+ const rows = await readFlowRecord(sdk, identifier);
470
+ if (rows.length !== 1) throw new Error(rows.length === 0 ? `Flow not found: ${identifier}` : `Flow identifier is ambiguous: ${identifier}; exact target ID or unique name is required`);
471
+ const flow = rows[0];
472
+ const flowId = idValue(flow.sys_id);
473
+ const flowName = stringValue(flow.name);
474
+ if (flowId === PROTECTED_FLOW_ID || flowName === PROTECTED_FLOW_NAME) throw new Error(`Protected flow ${PROTECTED_FLOW_NAME} cannot be updated`);
475
+ if (String(flow.active?.value ?? flow.active ?? '').toLowerCase() === 'true' || String(flow.status?.value ?? flow.status ?? '').toLowerCase() !== 'draft') throw new Error(`Flow ${flowId} is not an inactive Draft; refusing update`);
476
+ return { flow, flowId, flowName };
477
+ }
478
+
479
+ function updatedTrigger(flow, manifest, descriptor) {
480
+ const existing = clone(findArray(flow, 'triggerInstances')[0]);
481
+ if (!existing) throw new Error('Saved flow has no trigger instance');
482
+ const existingMetadata = parseMaybeJson(existing.metadata, null);
483
+ if (existing.metadata && (!existingMetadata || typeof existingMetadata !== 'object' || Array.isArray(existingMetadata))) throw new Error('Saved record trigger metadata is unreadable; refusing to replace it');
484
+ const existingType = stringValue(existing.triggerType || existing.trigger_type || existingMetadata?.triggerType || existingMetadata?.trigger_type);
485
+ if (existingType && existingType !== 'record_create_or_update') throw new Error(`Only record_create_or_update trigger updates are supported; found ${existingType}`);
486
+ const inputs = clone(inputList(existing));
487
+ const tableInput = inputs.find(input => input.name === 'table');
488
+ const conditionInput = inputs.find(input => input.name === 'condition');
489
+ if (!tableInput || !conditionInput) throw new Error('Saved record trigger must have table and condition inputs');
490
+ const currentTable = inputRawValue(tableInput);
491
+ const currentCondition = inputRawValue(conditionInput);
492
+ const table = manifest.trigger.table ?? currentTable;
493
+ const condition = manifest.trigger.condition ?? currentCondition;
494
+ if (manifest.trigger.table !== undefined) {
495
+ tableInput.displayField = descriptor.displayField;
496
+ tableInput.displayValue = wrappedValue(descriptor.label);
497
+ tableInput.value = wrappedValue(table);
498
+ }
499
+ if (manifest.trigger.condition !== undefined) {
500
+ conditionInput.displayValue = wrappedValue(condition);
501
+ conditionInput.value = wrappedValue(condition);
502
+ }
503
+ const id = findId(existing, ['sysId', 'sys_id', 'id', 'triggerId']);
504
+ if (!id) throw new Error('Saved record trigger has no usable identity');
505
+ const update = { id, inputs };
506
+ if (manifest.trigger.condition !== undefined) {
507
+ const generated = buildPredicateMetadata(condition, descriptor);
508
+ const metadata = { ...generated, ...(existingMetadata || {}), predicates: generated.predicates };
509
+ update.metadata = JSON.stringify(metadata);
510
+ } else if (Object.prototype.hasOwnProperty.call(existing, 'metadata')) {
511
+ update.metadata = existing.metadata;
512
+ }
513
+ return { update, currentTable: table };
514
+ }
515
+
516
+ function insertedActions(response) {
517
+ let found = [];
518
+ walk(response, object => {
519
+ if (!found.length && object.actions?.inserts) found = object.actions.inserts;
520
+ });
521
+ return found;
522
+ }
523
+
524
+ async function reconcileAmbiguousCreate(sdk, manifest) {
525
+ const params = new URLSearchParams({ sysparm_query: `name=${manifest.name}`, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,active,status,scope' });
526
+ const records = await sdk.list('sys_hub_flow', params);
527
+ throw new Error(`Flow parent creation was ambiguous; reconciliation found ${records.length} flow(s) named "${manifest.name}". No retry was attempted.`);
528
+ }
529
+
530
+ async function mutationContext(label, readUpdateSet, snapshots, operation, capture) {
531
+ const before = await readUpdateSet();
532
+ const captureBefore = capture ? await capture(before) : undefined;
533
+ try {
534
+ const result = await operation();
535
+ const after = await readUpdateSet();
536
+ const captureAfter = capture ? await capture(after) : undefined;
537
+ snapshots.push({ label, before, after, ...(capture ? { capture_before: captureBefore, capture_after: captureAfter } : {}) });
538
+ return result;
539
+ } catch (error) {
540
+ let after;
541
+ try { after = await readUpdateSet(); } catch (readError) { after = { unavailable: true, reason: readError.message }; }
542
+ let captureAfter;
543
+ if (capture) {
544
+ try { captureAfter = await capture(after); } catch (readError) { captureAfter = unavailableCapture(`capture read failed: ${readError.message}`); }
545
+ }
546
+ snapshots.push({ label, before, after, ...(capture ? { capture_before: captureBefore, capture_after: captureAfter } : {}), error: error.message });
547
+ throw error;
548
+ }
549
+ }
550
+
551
+ function unavailableCapture(reason) {
552
+ return { available: false, status: 'unavailable', reason };
553
+ }
554
+
555
+ async function addPostMutationCapture(snapshot, sdk, readUpdateSet, flowId) {
556
+ const updateSet = await readUpdateSet();
557
+ snapshot.after = updateSet;
558
+ snapshot.capture_after = await readUpdateSetCapture(sdk, updateSet, flowId);
559
+ }
560
+
561
+ export async function readUpdateSetCapture(sdk, updateSet, flowId) {
562
+ const updateSetId = idValue(updateSet?.sys_id || updateSet?.sysId);
563
+ if (!updateSetId) return unavailableCapture('current update set has no selected sys_id (likely Default)');
564
+ const rows = await sdk.list('sys_update_xml', new URLSearchParams({
565
+ sysparm_query: `update_set=${updateSetId}`,
566
+ sysparm_limit: '200',
567
+ sysparm_display_value: 'all',
568
+ sysparm_fields: 'sys_id,name,type,action,target_name,update_set,payload,xml',
569
+ }));
570
+ const matches = rows.filter(row => JSON.stringify(row).includes(flowId));
571
+ return {
572
+ available: true,
573
+ status: matches.length ? 'captured' : 'missing',
574
+ update_set: updateSet,
575
+ count: matches.length,
576
+ records: matches,
577
+ };
578
+ }
579
+
580
+ export async function createFlowFromManifest(sdk, instance, inputManifest, options = {}) {
581
+ const manifest = validateFlowManifest(inputManifest);
582
+ const scope = manifest.scope;
583
+ const idFactory = options.idFactory || (() => randomUUID());
584
+ const readUpdateSet = options.readUpdateSet || (async () => null);
585
+ const snapshots = [];
586
+
587
+ let parentResponse;
588
+ try {
589
+ parentResponse = await mutationContext('parent_create', readUpdateSet, snapshots, () => sdk.request(`${instance}${FLOW_ENDPOINT}?param_only_properties=true&sysparm_transaction_scope=${encodeURIComponent(scope)}`, {
590
+ method: 'POST',
591
+ body: JSON.stringify(buildFlowProperties(manifest)),
592
+ }));
593
+ } catch (error) {
594
+ await reconcileAmbiguousCreate(sdk, manifest);
595
+ throw error;
596
+ }
597
+ const flowId = findId(parentResponse, ['sys_id', 'sysId', 'flowId', 'id']);
598
+ if (!flowId) throw new Error('Flow creation response did not include a Flow ID');
599
+ try {
600
+ const parentSnapshot = snapshots.find(snapshot => snapshot.label === 'parent_create');
601
+ if (parentSnapshot) {
602
+ parentSnapshot.capture_before = unavailableCapture('flow ID was not known before parent creation');
603
+ await addPostMutationCapture(parentSnapshot, sdk, readUpdateSet, flowId);
604
+ }
605
+ const initialDefinition = await readProcessFlow(sdk, instance, flowId, scope);
606
+ const tableDescriptor = await readTableDescriptor(sdk, instance, scope, manifest.trigger.table, options.readTableDescriptor);
607
+ const trigger = makeTriggerPatch(initialDefinition, manifest, tableDescriptor);
608
+
609
+ const actionInstances = [];
610
+ for (const [index, action] of manifest.actions.entries()) {
611
+ const resolved = await resolveActionType(sdk, action.type);
612
+ const actionDefinition = await readActionDefinition(sdk, instance, resolved.id, scope);
613
+ actionInstances.push({
614
+ actionTypeSysId: resolved.id,
615
+ flowSysId: flowId,
616
+ generationSource: '',
617
+ type: 'action',
618
+ order: String(index + 1),
619
+ parent: '',
620
+ uiUniqueIdentifier: idFactory(),
621
+ parentUiId: '',
622
+ metadata: JSON.stringify(parseMaybeJson(actionDefinition.definition.metadata, {})),
623
+ inputs: buildTypedInputs(action, actionDefinition.schema),
624
+ });
625
+ }
626
+
627
+ const patchResponse = await mutationContext('trigger_and_actions_patch', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
628
+ method: 'POST',
629
+ body: JSON.stringify({ query: buildFlowPatchMutation({ flowId, trigger, actions: actionInstances }), variables: {} }),
630
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
631
+ assertGraphQLSuccess(patchResponse, 'Flow trigger/action patch');
632
+ const inserted = insertedActions(patchResponse);
633
+ if (inserted.length !== actionInstances.length) throw new Error(`Flow action patch returned ${inserted.length} inserted action identities; expected ${actionInstances.length}`);
634
+
635
+ const processflow = await readProcessFlow(sdk, instance, flowId, scope);
636
+ verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, actionInstances, inserted });
637
+ const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
638
+ instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow create',
639
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
640
+ const selectedUpdateSet = await readUpdateSet();
641
+ const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
642
+
643
+ const triggerReadback = findArray(processflow, 'triggerInstances')[0] || {};
644
+ return {
645
+ flow: { id: flowId, name: manifest.name, status: 'draft', active: false },
646
+ trigger: { ...triggerReadback, id: findId(triggerReadback, ['sysId', 'sys_id', 'id']) || trigger.id },
647
+ actions: inserted,
648
+ processflow,
649
+ definition_readback: { available: true, status: 'read_back', flow_id: flowId },
650
+ version,
651
+ update_set_capture: updateSetCapture,
652
+ update_set_mutations: snapshots,
653
+ lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
654
+ };
655
+ } catch (error) {
656
+ throw new Error(`Flow ${flowId} creation incomplete: ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
657
+ }
658
+ }
659
+
660
+ export async function updateFlowFromManifest(sdk, instance, identifier, inputManifest, options = {}) {
661
+ const manifest = validateFlowUpdateManifest(inputManifest);
662
+ const target = await resolveDraftFlow(sdk, identifier);
663
+ const flowId = target.flowId;
664
+ const scope = stringValue(manifest.scope ?? target.flow.scope);
665
+ const readUpdateSet = options.readUpdateSet || (async () => null);
666
+ if (!scope) throw new Error(`Flow ${flowId} has no transaction scope`);
667
+ if (manifest.scope !== undefined && stringValue(target.flow.scope) && manifest.scope !== stringValue(target.flow.scope)) throw new Error(`Flow ${flowId} scope does not match the update manifest`);
668
+ const beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
669
+ const snapshots = [];
670
+ const triggerUpdates = [];
671
+ const actionUpdates = [];
672
+ const changedActionInputs = new Map();
673
+ const actionTargets = new Map();
674
+ let triggerUpdate;
675
+ if (manifest.trigger !== undefined) {
676
+ const currentTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
677
+ const currentTable = inputRawValue(inputList(currentTrigger).find(input => input.name === 'table'));
678
+ const descriptor = await readTableDescriptor(sdk, instance, scope, manifest.trigger.table ?? currentTable, options.readTableDescriptor);
679
+ triggerUpdate = updatedTrigger(beforeDefinition, manifest, descriptor).update;
680
+ triggerUpdates.push(triggerUpdate);
681
+ }
682
+ const existingActions = findExistingActions(beforeDefinition);
683
+ for (const [index, requested] of (manifest.actions || []).entries()) {
684
+ const existing = await resolveExistingAction(sdk, existingActions, requested, index);
685
+ const update = buildActionUpdate(existing, requested, index);
686
+ actionUpdates.push(update);
687
+ const uiId = stringValue(existing.uiUniqueIdentifier || existing.ui_unique_identifier);
688
+ actionTargets.set(uiId, existing);
689
+ changedActionInputs.set(uiId, new Set(Object.keys(requested.inputs)));
690
+ }
691
+ if (triggerUpdates.length === 0 && actionUpdates.length === 0) throw new Error(`Flow ${flowId} update has no supported changes`);
692
+
693
+ try {
694
+ const patchResponse = await mutationContext('trigger_and_actions_update', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
695
+ method: 'POST',
696
+ body: JSON.stringify({ query: buildFlowUpdateMutation({ flowId, triggerUpdates, actionUpdates }), variables: {} }),
697
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
698
+ assertGraphQLSuccess(patchResponse, 'Flow trigger/action update');
699
+ const processflow = await readProcessFlow(sdk, instance, flowId, scope);
700
+ const readbackTrigger = findArray(processflow, 'triggerInstances')[0];
701
+ if (triggerUpdate) {
702
+ if (!readbackTrigger || findId(readbackTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== triggerUpdate.id) throw new Error(`Flow ${flowId} trigger update was not readable after mutation`);
703
+ const beforeTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
704
+ const preserveBefore = { ...beforeTrigger, inputs: undefined, metadata: undefined };
705
+ const preserveAfter = { ...readbackTrigger, inputs: undefined, metadata: undefined };
706
+ if (JSON.stringify(preserveBefore) !== JSON.stringify(preserveAfter)) throw new Error(`Flow ${flowId} trigger readback changed fields omitted by the manifest`);
707
+ const readbackInputs = inputList(readbackTrigger);
708
+ const beforeInputs = inputList(beforeTrigger);
709
+ if (readbackInputs.length !== beforeInputs.length) throw new Error(`Flow ${flowId} trigger input count changed during update`);
710
+ const requestedInputs = new Set();
711
+ if (manifest.trigger.table !== undefined) requestedInputs.add('table');
712
+ if (manifest.trigger.condition !== undefined) requestedInputs.add('condition');
713
+ for (const beforeInput of beforeInputs) {
714
+ const afterInput = readbackInputs.find(input => input.name === beforeInput.name);
715
+ if (!afterInput) throw new Error(`Flow ${flowId} trigger lost input ${beforeInput.name} during update`);
716
+ if (!requestedInputs.has(beforeInput.name) && JSON.stringify(beforeInput) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} trigger changed omitted input ${beforeInput.name}`);
717
+ }
718
+ if (manifest.trigger.table !== undefined && inputRawValue(readbackInputs.find(input => input.name === 'table')) !== manifest.trigger.table) throw new Error(`Flow ${flowId} trigger table readback did not match the requested value`);
719
+ if (manifest.trigger.condition !== undefined) {
720
+ if (inputRawValue(readbackInputs.find(input => input.name === 'condition')) !== manifest.trigger.condition) throw new Error(`Flow ${flowId} trigger condition readback did not match the requested value`);
721
+ assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
722
+ const beforeMetadata = parseMaybeJson(beforeTrigger.metadata, {});
723
+ const afterMetadata = parseMaybeJson(readbackTrigger.metadata, {});
724
+ delete beforeMetadata.predicates;
725
+ delete afterMetadata.predicates;
726
+ if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
727
+ } else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
728
+ throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
729
+ }
730
+ }
731
+ const readbackActions = findExistingActions(processflow);
732
+ for (const update of actionUpdates) {
733
+ const uiId = stringValue(update.uiUniqueIdentifier);
734
+ const readback = readbackActions.find(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === uiId);
735
+ if (!readback) throw new Error(`Flow ${flowId} Action ${uiId} was not readable after mutation`);
736
+ const before = actionTargets.get(uiId);
737
+ const changed = changedActionInputs.get(uiId) || new Set();
738
+ for (const input of inputList(before)) {
739
+ const afterInput = inputList(readback).find(candidate => candidate.name === input.name);
740
+ if (!afterInput) throw new Error(`Flow ${flowId} Action ${uiId} lost input ${input.name} during update`);
741
+ if (!changed.has(input.name) && JSON.stringify(input) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} Action ${uiId} changed omitted input ${input.name}`);
742
+ if (changed.has(input.name) && inputRawValue(afterInput) !== inputRawValue(update.inputs.find(candidate => candidate.name === input.name))) throw new Error(`Flow ${flowId} Action ${uiId} input ${input.name} did not match the requested value`);
743
+ }
744
+ }
745
+ const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
746
+ instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow update',
747
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
748
+ const selectedUpdateSet = await readUpdateSet();
749
+ const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
750
+ return {
751
+ flow: { id: flowId, name: target.flowName, status: 'draft', active: false },
752
+ processflow,
753
+ definition_readback: { available: true, status: 'read_back', flow_id: flowId },
754
+ version,
755
+ update_set_capture: updateSetCapture,
756
+ update_set_mutations: snapshots,
757
+ lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
758
+ };
759
+ } catch (error) {
760
+ throw new Error(`Flow ${flowId} update incomplete: ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
761
+ }
762
+ }
763
+
764
+ export async function readCurrentUpdateSet(sdk) {
765
+ const params = new URLSearchParams({ sysparm_query: 'user_name=javascript:gs.getUserName()', sysparm_limit: '1', sysparm_fields: 'sys_id' });
766
+ const users = await sdk.list('sys_user', params);
767
+ const userId = idValue(users[0]?.sys_id);
768
+ if (!userId) return { name: 'Default', sys_id: '' };
769
+ const preference = await sdk.list('sys_user_preference', new URLSearchParams({ sysparm_query: `user=${userId}^name=sys_update_set`, sysparm_limit: '1', sysparm_fields: 'value' }));
770
+ const updateSetId = idValue(preference[0]?.value);
771
+ if (!updateSetId || updateSetId === '-') return { name: 'Default', sys_id: '' };
772
+ const records = await sdk.list('sys_update_set', new URLSearchParams({ sysparm_query: `sys_id=${updateSetId}`, sysparm_limit: '1', sysparm_fields: 'sys_id,name,state' }));
773
+ return records[0] || { name: updateSetId, sys_id: updateSetId };
774
+ }
@@ -14,8 +14,10 @@
14
14
  */
15
15
 
16
16
  import { decodeGzipJson } from './sdk.js';
17
+ import { createVerifiedFlowVersion } from './flow-versioning.js';
17
18
 
18
19
  const ACTIVATE_PATH = '/api/now/wfa_fluent/activate_flows';
20
+ const PROCESSFLOW_PATH = '/api/now/processflow/flow';
19
21
 
20
22
  // The endpoint returns 200 (all published), 207 (partial) or 422 (all failed).
21
23
  // 422 is NOT an error case -- it carries a per-flow result body that is the
@@ -75,6 +77,36 @@ export async function publishFlows(sdk, flowIDs = [], actionIDs = []) {
75
77
  throw new Error(`Publish failed (HTTP ${resp.status}): ${message}`);
76
78
  }
77
79
 
80
+ /** Publish one flow through the ProcessFlow snapshot/version lifecycle. */
81
+ export async function publishFlowLifecycle(sdk, instance, flowId) {
82
+ const flowRecord = sdk.get ? await sdk.get('sys_hub_flow', flowId) : null;
83
+ const scope = str(flowRecord, 'scope');
84
+ if (!scope) throw new Error(`Flow ${flowId} has no transaction scope; refusing to publish`);
85
+ const definitionResponse = await sdk.request(`${instance}${PROCESSFLOW_PATH}/${encodeURIComponent(flowId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`, { method: 'GET' });
86
+ const definition = definitionResponse?.result?.data ?? definitionResponse?.data?.result?.data ?? definitionResponse?.data ?? definitionResponse?.result ?? definitionResponse;
87
+ if (!definition || typeof definition !== 'object') throw new Error(`Flow definition could not be read for ${flowId}`);
88
+ const definitionScope = str(definition, 'scope');
89
+ if (definitionScope && definitionScope !== scope) throw new Error(`Flow definition scope did not match saved flow ${flowId}`);
90
+ const snapshot = await sdk.request(`${instance}${PROCESSFLOW_PATH}/${encodeURIComponent(flowId)}/snapshot`, {
91
+ method: 'POST',
92
+ body: JSON.stringify(definition),
93
+ });
94
+ if (snapshot?.errors?.length || snapshot?.result?.errorMessage) throw new Error(`Flow snapshot failed for ${flowId}`);
95
+ const { version } = await createVerifiedFlowVersion(sdk, {
96
+ instance, flowId, scope, type: 'Activate/Publish', annotation: 'JSN flow publish',
97
+ });
98
+ const activeFlow = sdk.get ? await sdk.get('sys_hub_flow', flowId) : null;
99
+ const active = String(activeFlow?.active?.value ?? activeFlow?.active ?? '').toLowerCase() === 'true';
100
+ if (!active) throw new Error(`Flow ${flowId} publish did not persist active=true`);
101
+ return {
102
+ flow_id: flowId,
103
+ active,
104
+ snapshot: snapshot?.result ?? snapshot,
105
+ version,
106
+ lifecycle: 'Activate/Publish',
107
+ };
108
+ }
109
+
78
110
  /**
79
111
  * Fallback: call FlowDesignerUtils.publishAll() through a background script.
80
112
  * Same code path the REST endpoint uses internally.
@@ -0,0 +1,122 @@
1
+ import { assertSafeExactMatch } from './helpers.js';
2
+
3
+ const FLOW_ENDPOINT = '/api/now/processflow/flow';
4
+
5
+ function valueOf(value) {
6
+ if (value && typeof value === 'object') return String(value.value ?? value.sys_id ?? value.display_value ?? '');
7
+ return value == null ? '' : String(value);
8
+ }
9
+
10
+ function findArray(value, key) {
11
+ if (!value || typeof value !== 'object') return [];
12
+ if (Array.isArray(value[key])) return value[key];
13
+ for (const item of Array.isArray(value) ? value : Object.values(value)) {
14
+ const found = findArray(item, key);
15
+ if (found.length) return found;
16
+ }
17
+ return [];
18
+ }
19
+
20
+ function flowData(response) {
21
+ return response?.result?.data ?? response?.data?.result?.data ?? response?.data ?? response?.result ?? response;
22
+ }
23
+
24
+ function findTrigger(definition) {
25
+ return findArray(definition, 'triggerInstances')[0] || findArray(definition, 'trigger_instances')[0] || null;
26
+ }
27
+
28
+ function inputValue(trigger, name) {
29
+ const input = (trigger?.inputs || trigger?.triggerInputs || []).find(item => item?.name === name);
30
+ return valueOf(input?.value ?? input?.displayValue);
31
+ }
32
+
33
+ function triggerType(trigger) {
34
+ return valueOf(trigger?.triggerType || trigger?.trigger_type || trigger?.type);
35
+ }
36
+
37
+ export function deriveRecordTrigger(definition) {
38
+ const trigger = findTrigger(definition);
39
+ if (!trigger) throw new Error('Flow definition has no trigger instance');
40
+ const type = triggerType(trigger);
41
+ if (type !== 'record_create_or_update') throw new Error(`Flow test supports only record_create_or_update triggers; found ${type || 'unknown'}`);
42
+ const table = inputValue(trigger, 'table');
43
+ if (!table) throw new Error('Flow definition record trigger has no table');
44
+ return { trigger, type, table };
45
+ }
46
+
47
+ function booleanFlag(value) {
48
+ if (value === true || value === 'true') return true;
49
+ if (value === false || value === 'false') return false;
50
+ return undefined;
51
+ }
52
+
53
+ function classifyResponse(response) {
54
+ const data = flowData(response);
55
+ const contextId = data?.contextId || data?.context_id || data?.sys_id || data?.execution_id || '';
56
+ const state = valueOf(data?.state ?? data?.status).toLowerCase();
57
+ const succeeded = ['success', 'succeeded'].includes(state);
58
+ const failed = ['failed', 'failure', 'error', 'cancelled', 'canceled'].includes(state);
59
+ const completed = booleanFlag(data?.completed) === true
60
+ || booleanFlag(data?.finished) === true
61
+ || succeeded
62
+ || failed
63
+ || ['complete', 'completed'].includes(state);
64
+ const explicitSuccess = booleanFlag(data?.success ?? data?.successful);
65
+ const success = failed || explicitSuccess === false
66
+ ? false
67
+ : completed && (succeeded || explicitSuccess === true) ? true : undefined;
68
+ return {
69
+ accepted: true,
70
+ status: completed ? 'completed' : 'accepted',
71
+ success,
72
+ context_id: contextId || undefined,
73
+ response: data,
74
+ };
75
+ }
76
+
77
+ export async function testFlow(sdk, instance, identifier, recordId) {
78
+ if (!recordId || !String(recordId).trim()) throw new Error('Flow test requires --record <sys_id>');
79
+ if (!/^[0-9a-f]{32}$/i.test(String(recordId))) throw new Error(`--record must be a 32-character sys_id; flow was not dispatched`);
80
+ const flowIdentifier = String(identifier);
81
+ assertSafeExactMatch(flowIdentifier);
82
+ const isId = /^[0-9a-f]{32}$/i.test(flowIdentifier);
83
+ const flowRows = await sdk.list('sys_hub_flow', new URLSearchParams({
84
+ sysparm_query: `${isId ? 'sys_id' : 'name'}=${flowIdentifier}`,
85
+ sysparm_limit: '2',
86
+ sysparm_display_value: 'all',
87
+ sysparm_fields: 'sys_id,name,scope',
88
+ }));
89
+ if (!flowRows.length) throw new Error(`Flow not found: ${identifier}`);
90
+ if (flowRows.length > 1) throw new Error(`Flow identifier is ambiguous: ${identifier}`);
91
+ const flow = flowRows[0];
92
+ const flowId = valueOf(flow.sys_id);
93
+ const scope = valueOf(flow.scope);
94
+ if (!scope) throw new Error(`Flow ${flowId} has no transaction scope; flow was not dispatched`);
95
+ const response = await sdk.request(`${instance}${FLOW_ENDPOINT}/${encodeURIComponent(flowId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`, { method: 'GET' });
96
+ const definition = flowData(response);
97
+ const trigger = deriveRecordTrigger(definition);
98
+ const records = await sdk.list(trigger.table, new URLSearchParams({
99
+ sysparm_query: `sys_id=${recordId}`,
100
+ sysparm_limit: '1',
101
+ sysparm_fields: 'sys_id',
102
+ }));
103
+ if (!records.length) throw new Error(`Record ${recordId} was not found in trigger table ${trigger.table}; flow was not dispatched`);
104
+ const definitionScope = valueOf(definition.scope);
105
+ if (definitionScope && definitionScope !== scope) throw new Error(`Flow ${flowId} definition scope did not match the saved record; flow was not dispatched`);
106
+ const body = {
107
+ ...definition,
108
+ outputMap: { current: recordId, table_name: trigger.table },
109
+ runOnThread: true,
110
+ tracingEnabled: false,
111
+ };
112
+ let dispatch;
113
+ try {
114
+ dispatch = await sdk.request(`${instance}${FLOW_ENDPOINT}/${encodeURIComponent(flowId)}/test?sysparm_transaction_scope=${encodeURIComponent(scope)}`, {
115
+ method: 'POST',
116
+ body: JSON.stringify(body),
117
+ });
118
+ } catch (error) {
119
+ throw new Error(`Flow test dispatch failed for ${flowId}: ${error.message}`, { cause: error });
120
+ }
121
+ return { flow: { id: flowId, name: valueOf(flow.name) }, trigger: { type: trigger.type, table: trigger.table }, record: recordId, ...classifyResponse(dispatch) };
122
+ }
@@ -0,0 +1,53 @@
1
+ const VERSION_ENDPOINT = '/api/now/processflow/versioning/create_version';
2
+ const VERSION_TABLE = 'sys_hub_flow_version';
3
+
4
+ function stringValue(value) {
5
+ if (value && typeof value === 'object') return String(value.value ?? value.sys_id ?? value.display_value ?? '');
6
+ return value == null ? '' : String(value);
7
+ }
8
+
9
+ function versionId(record) {
10
+ return stringValue(record?.sys_id ?? record?.sysId ?? record?.id);
11
+ }
12
+
13
+ function responseVersionId(response) {
14
+ const result = response?.result?.result ?? response?.result ?? response?.data?.result ?? response?.data ?? response;
15
+ return versionId(result);
16
+ }
17
+
18
+ function versionType(record) {
19
+ const value = record?.type;
20
+ if (value && typeof value === 'object') return String(value.display_value ?? value.value ?? '');
21
+ return stringValue(value);
22
+ }
23
+
24
+ function versionQuery(flowId) {
25
+ return new URLSearchParams({
26
+ sysparm_query: `flow=${flowId}^ORDERBYDESCsys_updated_on`,
27
+ sysparm_limit: '20',
28
+ sysparm_display_value: 'all',
29
+ sysparm_fields: 'sys_id,flow,type,sys_created_on,sys_updated_on',
30
+ });
31
+ }
32
+
33
+ export async function createVerifiedFlowVersion(sdk, { instance, flowId, scope, type, annotation }) {
34
+ if (!instance || !flowId || !scope || !type) throw new Error('Flow version creation requires instance, flow ID, scope, and type');
35
+ const before = await sdk.list(VERSION_TABLE, versionQuery(flowId));
36
+ const beforeIds = new Set(before.map(versionId).filter(Boolean));
37
+ const response = await sdk.request(`${instance}${VERSION_ENDPOINT}?sysparm_transaction_scope=${encodeURIComponent(scope)}`, {
38
+ method: 'POST',
39
+ body: JSON.stringify({ item_sys_id: flowId, type, annotation, favorite: false }),
40
+ });
41
+ const responseId = responseVersionId(response);
42
+ const after = await sdk.list(VERSION_TABLE, versionQuery(flowId));
43
+ const matching = after.filter(record => stringValue(record.flow) === flowId && versionType(record) === type);
44
+ const newVersions = matching.filter(record => versionId(record) && !beforeIds.has(versionId(record)));
45
+ const verified = responseId
46
+ ? newVersions.find(record => versionId(record) === responseId)
47
+ : newVersions.length === 1 ? newVersions[0] : null;
48
+ if (!verified) {
49
+ if (newVersions.length > 1 && !responseId) throw new Error(`Flow ${flowId} has ambiguous new ${type} versions; refusing to guess`);
50
+ throw new Error(`New ${type} version for flow ${flowId} was not read back with an exact ID`);
51
+ }
52
+ return { version: verified, response };
53
+ }