@jacebenson/jsn 4.3.7 → 4.3.8-dev.47

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.
Files changed (41) hide show
  1. package/README.md +266 -1
  2. package/package.json +1 -1
  3. package/skills/servicenow/SKILL.md +16 -0
  4. package/src/app.js +8 -1
  5. package/src/auth.js +14 -12
  6. package/src/cli-adapter.js +162 -11
  7. package/src/cli.js +121 -13
  8. package/src/commands/_generic.js +3 -2
  9. package/src/commands/_simple.js +1 -1
  10. package/src/commands/actions.js +156 -0
  11. package/src/commands/auth.js +21 -11
  12. package/src/commands/cmdb.js +18 -0
  13. package/src/commands/diff.js +205 -0
  14. package/src/commands/docs/docs.js +18 -16
  15. package/src/commands/flows.js +149 -47
  16. package/src/commands/graphql.js +95 -0
  17. package/src/commands/groupmembers.js +19 -4
  18. package/src/commands/grouproles.js +15 -4
  19. package/src/commands/midservers.js +708 -0
  20. package/src/commands/perf.js +36 -9
  21. package/src/commands/platform.js +202 -0
  22. package/src/commands/records.js +19 -15
  23. package/src/commands/skill.js +5 -2
  24. package/src/commands/transactions.js +78 -0
  25. package/src/commands/updatesets.js +77 -11
  26. package/src/errors.js +71 -6
  27. package/src/flow-create.js +884 -0
  28. package/src/flow-designer-interactive.js +417 -0
  29. package/src/flow-inspection.js +15 -5
  30. package/src/flow-publish.js +32 -0
  31. package/src/flow-test.js +122 -0
  32. package/src/flow-triggers.js +448 -0
  33. package/src/flow-versioning.js +53 -0
  34. package/src/help.js +93 -28
  35. package/src/helpers.js +124 -9
  36. package/src/midserver-direct.js +312 -0
  37. package/src/midserver-install.js +170 -0
  38. package/src/midserver-zip.js +107 -0
  39. package/src/output.js +7 -0
  40. package/src/processflow-actions.js +366 -0
  41. package/src/sdk.js +8 -0
@@ -0,0 +1,884 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { assertSafeExactMatch } from './helpers.js';
3
+ import { createVerifiedFlowVersion } from './flow-versioning.js';
4
+ import {
5
+ RECORD_TRIGGER_FAMILY,
6
+ SUPPORTED_TRIGGER_TYPES,
7
+ TRIGGER_DEFINITION_TABLE,
8
+ buildTriggerInputs,
9
+ buildTriggerInstance,
10
+ conditionInputFor,
11
+ readTableDescriptor,
12
+ readTriggerInputDefinitions,
13
+ tableInputFor,
14
+ flowDesignParameter,
15
+ triggerFamily,
16
+ } from './flow-triggers.js';
17
+
18
+ const FLOW_ENDPOINT = '/api/now/processflow/flow';
19
+ const GRAPHQL_ENDPOINT = '/api/now/graphql';
20
+ const PROTECTED_FLOW_ID = 'c215135293bf03d087b0f14fdd03d652';
21
+ const PROTECTED_FLOW_NAME = 'jace-test-flow';
22
+
23
+ function isSysId(value) {
24
+ return typeof value === 'string' && /^[0-9a-f]{32}$/i.test(value);
25
+ }
26
+
27
+ function stringValue(value) {
28
+ if (value && typeof value === 'object') return String(value.value ?? value.sys_id ?? value.name ?? value.display_value ?? '');
29
+ return value == null ? '' : String(value);
30
+ }
31
+
32
+ function idValue(value) {
33
+ return stringValue(value);
34
+ }
35
+
36
+ function gqlString(value) {
37
+ return JSON.stringify(String(value));
38
+ }
39
+
40
+ function gqlValue(value) {
41
+ if (value === null || value === undefined) return 'null';
42
+ if (typeof value === 'string') return gqlString(value);
43
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
44
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null';
45
+ if (Array.isArray(value)) return `[${value.map(gqlValue).join(', ')}]`;
46
+ if (typeof value === 'object') return `{${Object.entries(value).map(([key, item]) => `${key}: ${gqlValue(item)}`).join(', ')}}`;
47
+ return gqlString(value);
48
+ }
49
+
50
+ function gqlInputObject(value) {
51
+ return `{${Object.entries(value).map(([key, item]) => `${key}: ${gqlValue(item)}`).join(', ')}}`;
52
+ }
53
+
54
+ function parseMaybeJson(value, fallback = {}) {
55
+ if (!value) return fallback;
56
+ if (typeof value === 'object') return value;
57
+ try { return JSON.parse(value); } catch { return fallback; }
58
+ }
59
+
60
+ function walk(value, visitor, seen = new Set()) {
61
+ if (!value || typeof value !== 'object' || seen.has(value)) return;
62
+ seen.add(value);
63
+ visitor(value);
64
+ if (Array.isArray(value)) value.forEach(item => walk(item, visitor, seen));
65
+ else Object.values(value).forEach(item => walk(item, visitor, seen));
66
+ }
67
+
68
+ function findArray(value, key) {
69
+ let found;
70
+ walk(value, object => {
71
+ if (!found && !Array.isArray(object) && Array.isArray(object[key])) found = object[key];
72
+ });
73
+ return found || [];
74
+ }
75
+
76
+ function findFlowData(response) {
77
+ return response?.result?.data ?? response?.data?.result?.data ?? response?.data ?? response?.result ?? response;
78
+ }
79
+
80
+ function findId(value, keys = ['sysId', 'sys_id', 'flowId', 'id']) {
81
+ let found = '';
82
+ walk(value, object => {
83
+ if (found || Array.isArray(object)) return;
84
+ for (const key of keys) {
85
+ const candidate = idValue(object[key]);
86
+ if (candidate) {
87
+ found = candidate;
88
+ break;
89
+ }
90
+ }
91
+ });
92
+ return found;
93
+ }
94
+
95
+ function graphQLErrors(response) {
96
+ const errors = response?.errors || response?.data?.errors || response?.result?.errors;
97
+ return Array.isArray(errors) ? errors : [];
98
+ }
99
+
100
+ function assertGraphQLSuccess(response, operation) {
101
+ const errors = graphQLErrors(response);
102
+ if (errors.length) throw new Error(`${operation} failed: ${errors.map(error => String(error.message || error.code || 'GraphQL error')).join('; ')}`);
103
+ if (response?.result?.errorMessage) throw new Error(`${operation} failed: ${response.result.errorMessage}`);
104
+ }
105
+
106
+ function schemaEntries(definition, requireTypes = false) {
107
+ const entries = [];
108
+ const seen = new Set();
109
+ walk(definition, object => {
110
+ for (const key of ['inputs', 'inputSchema', 'parameters', 'fields']) {
111
+ if (!Array.isArray(object[key])) continue;
112
+ for (const field of object[key]) {
113
+ if (!field || typeof field !== 'object') continue;
114
+ const name = stringValue(field.name || field.key || field.element || field.parameter);
115
+ if (!name || seen.has(name)) continue;
116
+ seen.add(name);
117
+ if (requireTypes && !stringValue(field.type || field.inputType || field.valueType)) throw new Error(`Input ${name} has no provider type; refusing to invent a guided schema`);
118
+ entries.push({
119
+ ...field,
120
+ name,
121
+ type: stringValue(field.type || field.inputType || field.valueType || 'string') || 'string',
122
+ default: field.default ?? field.defaultValue,
123
+ });
124
+ }
125
+ }
126
+ });
127
+ return entries;
128
+ }
129
+
130
+ function actionTypeId(record) {
131
+ return idValue(record?.sys_id || record?.sysId || record?.id || record?.actionTypeId);
132
+ }
133
+
134
+ function exactActionName(record) {
135
+ return stringValue(record?.name || record?.f_name || record?.fName || record?.display_name || record?.displayName);
136
+ }
137
+
138
+ export function validateFlowManifest(manifest) {
139
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('Flow manifest must be a JSON object');
140
+ if (!String(manifest.name || '').trim()) throw new Error('Flow manifest requires name');
141
+ if (!String(manifest.scope || '').trim()) throw new Error('Flow manifest requires scope');
142
+ if (manifest.logic !== undefined) throw new Error('Flow Logic is unsupported in this creation slice');
143
+ if (manifest.subflows !== undefined || manifest.subFlows !== undefined) throw new Error('Subflows are unsupported in this creation slice');
144
+ if (manifest.variables !== undefined || manifest.flowVariables !== undefined) throw new Error('Flow-variable lifecycle is unsupported in this creation slice');
145
+ if (manifest.errorHandling !== undefined) throw new Error('Error handling is unsupported in this creation slice');
146
+ const trigger = manifest.trigger;
147
+ if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) throw new Error('Flow manifest requires a trigger object');
148
+ for (const key of Object.keys(trigger)) if (!['type', 'table', 'condition', 'inputs'].includes(key)) throw new Error(`Flow manifest trigger field "${key}" is unsupported`);
149
+ if (!SUPPORTED_TRIGGER_TYPES.has(String(trigger.type))) throw new Error(`Unsupported trigger.type "${trigger.type}"; supported families are ${[...SUPPORTED_TRIGGER_TYPES].join(', ')}`);
150
+ if (trigger.table !== undefined && !String(trigger.table).trim()) throw new Error('Trigger table must not be empty');
151
+ if (trigger.condition !== undefined && typeof trigger.condition !== 'string') throw new Error('Record trigger condition must be a string');
152
+ if (trigger.condition && !/^[A-Za-z_][A-Za-z0-9_.]*(?:STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)[^,^]*$/.test(trigger.condition)) {
153
+ throw new Error('Record trigger condition uses an unsupported encoded-query condition subset');
154
+ }
155
+ if (trigger.inputs !== undefined && (!trigger.inputs || typeof trigger.inputs !== 'object' || Array.isArray(trigger.inputs))) throw new Error('Trigger inputs must be an object');
156
+ if (!Array.isArray(manifest.actions)) throw new Error('Flow manifest requires an actions array');
157
+ for (const [index, action] of manifest.actions.entries()) {
158
+ if (!action || typeof action !== 'object' || !String(action.type || '').trim()) throw new Error(`Action ${index + 1} requires type`);
159
+ 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`);
160
+ }
161
+ return manifest;
162
+ }
163
+
164
+ export function buildFlowProperties(manifest) {
165
+ return {
166
+ name: manifest.name,
167
+ description: manifest.description || '',
168
+ type: 'flow',
169
+ status: 'draft',
170
+ active: false,
171
+ scope: manifest.scope,
172
+ runAs: 'user',
173
+ flowPriority: 'MEDIUM',
174
+ access: 'public',
175
+ protection: '',
176
+ runWithRoles: { value: '', displayValue: '' },
177
+ deleted: false,
178
+ security: { can_read: true, can_write: true },
179
+ };
180
+ }
181
+
182
+ export function buildFlowPatchMutation({ flowId, trigger, actions }) {
183
+ const triggerInsert = {
184
+ flowSysId: trigger.flowSysId,
185
+ name: trigger.name,
186
+ type: trigger.type,
187
+ triggerType: trigger.triggerType,
188
+ triggerDefinitionId: trigger.triggerDefinitionId,
189
+ metadata: trigger.metadata,
190
+ inputs: trigger.inputs,
191
+ };
192
+ const actionInsert = actions.map(action => ({
193
+ actionTypeSysId: action.actionTypeSysId,
194
+ flowSysId: action.flowSysId,
195
+ generationSource: action.generationSource,
196
+ type: action.type,
197
+ order: action.order,
198
+ parent: action.parent,
199
+ uiUniqueIdentifier: action.uiUniqueIdentifier,
200
+ parentUiId: action.parentUiId,
201
+ metadata: action.metadata,
202
+ inputs: action.inputs,
203
+ }));
204
+ const patch = `flowPatch: {flowId: ${gqlString(flowId)}, triggerInstances: {insert: [${gqlInputObject(triggerInsert)}]}, actions: {insert: [${actionInsert.map(gqlInputObject).join(', ')}]}}`;
205
+ // `triggerInstances.updates` is a leaf [String] on this schema; only `inserts`
206
+ // carries identities, and only for triggers does `inserts` lack a uiUniqueIdentifier.
207
+ return `mutation { global { snFlowDesigner { flow(${patch}) { triggerInstances { inserts { sysId } updates } actions { inserts { sysId uiUniqueIdentifier } } } } } }`;
208
+ }
209
+
210
+ export function buildFlowUpdateMutation({ flowId, triggerUpdates = [], actionUpdates = [] }) {
211
+ const updates = [];
212
+ if (triggerUpdates.length) updates.push(`triggerInstances: {update: [${triggerUpdates.map(gqlInputObject).join(', ')}]}`);
213
+ if (actionUpdates.length) updates.push(`actions: {update: [${actionUpdates.map(gqlInputObject).join(', ')}]}`);
214
+ const patch = `flowPatch: {flowId: ${gqlString(flowId)}, ${updates.join(', ')}}`;
215
+ return `mutation { global { snFlowDesigner { flow(${patch}) { triggerInstances { updates } actions { updates } } } } }`;
216
+ }
217
+
218
+ export async function readProcessFlow(sdk, instance, flowId, scope) {
219
+ if (!scope) throw new Error(`Flow ${flowId} has no transaction scope for ProcessFlow read`);
220
+ const endpoint = `${instance}${FLOW_ENDPOINT}/${encodeURIComponent(flowId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`;
221
+ const response = await sdk.request(endpoint, { method: 'GET' });
222
+ const data = findFlowData(response);
223
+ if (!data || typeof data !== 'object') throw new Error(`ProcessFlow read-back failed for flow ${flowId}`);
224
+ const returnedScope = stringValue(data.scope);
225
+ if (returnedScope && returnedScope !== scope) throw new Error(`ProcessFlow read-back scope did not match flow ${flowId}`);
226
+ return data;
227
+ }
228
+
229
+ async function resolveActionType(sdk, actionType) {
230
+ if (isSysId(actionType)) return { id: actionType, record: { sys_id: actionType, name: actionType } };
231
+ const params = new URLSearchParams({
232
+ sysparm_query: `name=${actionType}^ORf_name=${actionType}^active=true`,
233
+ sysparm_limit: '20',
234
+ sysparm_display_value: 'all',
235
+ sysparm_fields: 'sys_id,name,f_name,fName,active,published',
236
+ });
237
+ const records = await sdk.list('sys_hub_action_type_definition', params);
238
+ const exact = records.filter(record => exactActionName(record).toLowerCase() === actionType.toLowerCase());
239
+ if (exact.length !== 1) throw new Error(exact.length === 0 ? `Published action type not found: ${actionType}` : `Action type is ambiguous: ${actionType}`);
240
+ const record = exact[0];
241
+ if (record.active === false || record.active === 'false' || record.published === false || record.published === 'false') throw new Error(`Action type is not published: ${actionType}`);
242
+ return { id: actionTypeId(record), record };
243
+ }
244
+
245
+ export async function readActionDefinition(sdk, instance, actionTypeId, scope, { requireTypes = false } = {}) {
246
+ const endpoint = `${instance}/api/now/processflow/action/action_types/${encodeURIComponent(actionTypeId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`;
247
+ const response = await sdk.request(endpoint, { method: 'GET' });
248
+ const definition = response?.result ?? response?.data?.result ?? response?.data ?? response;
249
+ const schema = schemaEntries(definition, requireTypes);
250
+ if (schema.length === 0) throw new Error(`Action type ${actionTypeId} returned no input schema`);
251
+ return { definition, schema };
252
+ }
253
+
254
+ export function buildTypedInputs(action, schema) {
255
+ const supplied = action.inputs || {};
256
+ const allowed = new Set(schema.map(field => field?.name).filter(Boolean));
257
+ for (const key of Object.keys(supplied)) if (!allowed.has(key)) throw new Error(`Unsupported input "${key}" for action ${action.type}`);
258
+ return schema.map(field => {
259
+ // The instance's own definition is the contract: identity is required,
260
+ // everything else is echoed back untouched. Demanding a key the provider
261
+ // omits breaks real actions.
262
+ if (!field || typeof field !== 'object' || !field.id || !field.name) {
263
+ throw new Error(`Action ${action.type} returned an incomplete input schema; refusing to invent input metadata for "${field?.name || 'unknown'}"`);
264
+ }
265
+ if (field.parameter !== undefined && (typeof field.parameter !== 'object' || field.parameter === null || Array.isArray(field.parameter))) {
266
+ throw new Error(`Action ${action.type} input "${field.name}" has a malformed parameter block; refusing to change it`);
267
+ }
268
+ const suppliedValue = Object.prototype.hasOwnProperty.call(supplied, field.name) ? supplied[field.name] : field.default;
269
+ const parameter = field.parameter || {};
270
+ if ((suppliedValue === undefined || suppliedValue === null)
271
+ && (field.mandatory === true || field.required === true || parameter.mandatory === true || parameter.required === true)) {
272
+ throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
273
+ }
274
+ const value = suppliedValue ?? null;
275
+ const displayValue = field.displayValue ?? field.display_value ?? value;
276
+ // global_snFlowDesigner_ActionInstanceValue accepts ONLY these keys. The
277
+ // ProcessFlow action definition hands back the whole UI schema on the input
278
+ // object (uiDisplayTypeLabel, choices, type_label, …) and spreading it makes
279
+ // the mutation fail validation, so the schema is carried inside `parameter`.
280
+ return {
281
+ id: field.id,
282
+ name: field.name,
283
+ children: Array.isArray(field.children) ? field.children : [],
284
+ displayField: stringValue(field.displayField || field.display_field || ''),
285
+ scriptActive: false,
286
+ parameter: actionInputParameter(field),
287
+ value: { schemaless: false, schemalessValue: '', value },
288
+ displayValue: { schemaless: false, schemalessValue: '', value: displayValue },
289
+ };
290
+ });
291
+ }
292
+
293
+ // Providers hand back the input metadata in one of two shapes: nested inside a
294
+ // `parameter` block (the earlier contract) or FLAT on the input object (the real
295
+ // published Log action's action_types route). The nested block wins when it
296
+ // exists, flat keys fill in only what it leaves empty, and the result is always
297
+ // narrowed to the fields global_snFlowDesigner_FlowDesignParameter accepts.
298
+ export function actionInputParameter(field) {
299
+ if (field?.parameter === undefined) return flowDesignParameter(field);
300
+ const parameter = flowDesignParameter(field.parameter);
301
+ const flat = flowDesignParameter(field);
302
+ for (const key of Object.keys(flat)) {
303
+ if (flat[key] === undefined || flat[key] === '') continue;
304
+ if (parameter[key] === undefined || parameter[key] === '') parameter[key] = flat[key];
305
+ }
306
+ return parameter;
307
+ }
308
+
309
+ function parseSimpleCondition(condition) {
310
+ if (!condition) return null;
311
+ const match = condition.match(/^([A-Za-z_][A-Za-z0-9_.]*)(STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)([^,^]*)$/);
312
+ if (!match) throw new Error('Record trigger condition cannot be encoded as a single supported predicate');
313
+ return { field: match[1], operator: match[2], term: match[3] };
314
+ }
315
+
316
+ // Trigger predicate metadata is DERIVED BY THE INSTANCE, not by this CLI: the
317
+ // snFlowDesigner patch parses the `condition` input into
318
+ // `metadata.query_parse.predicates` itself (verified live on insert and on
319
+ // update — the returned field_label/operator_label/field_type match Flow
320
+ // Designer's own output). Sending `{}` and verifying the derived predicate by
321
+ // read-back is therefore both simpler and more faithful than rebuilding the
322
+ // predicate tree from a table descriptor, and it removes the dependency on the
323
+ // ProcessFlow table-descriptor route, which returns 400 on this instance.
324
+ const DEFAULT_TRIGGER_METADATA = '{}';
325
+
326
+ function predicateMetadataMatches(metadata, condition) {
327
+ const expected = parseSimpleCondition(condition);
328
+ if (!expected) return true;
329
+ const parsed = parseMaybeJson(metadata, null);
330
+ const comparisons = [];
331
+ walk(parsed, object => {
332
+ if (stringValue(object.type).toLowerCase() !== 'comparison') return;
333
+ // The instance stores the parsed predicate with `term` holding the WHOLE
334
+ // encoded condition and `value` holding the extracted term, so the term
335
+ // comparison must prefer `value` (e.g. for
336
+ // "short_descriptionSTARTSWITHjacetest": term = the full string,
337
+ // value = "jacetest").
338
+ comparisons.push({
339
+ field: stringValue(object.field),
340
+ operator: stringValue(object.operator),
341
+ term: stringValue(object.value ?? object.term),
342
+ });
343
+ });
344
+ return comparisons.some(comparison => comparison.field === expected.field
345
+ && comparison.operator === expected.operator
346
+ && comparison.term === expected.term);
347
+ }
348
+
349
+ function assertPredicateMetadataMatches(metadata, condition, label) {
350
+ if (!predicateMetadataMatches(metadata, condition)) throw new Error(`${label} predicate metadata did not match the requested field, operator, and term`);
351
+ }
352
+
353
+ // The instance stores predicate metadata as {query_parse: {predicates: [...]}},
354
+ // so dropping only a top-level `predicates` key is not enough to compare the
355
+ // parts of the metadata a manifest did not ask to change.
356
+ function stripPredicates(value) {
357
+ if (Array.isArray(value)) return value.map(stripPredicates);
358
+ if (!value || typeof value !== 'object') return value;
359
+ const out = {};
360
+ for (const [key, item] of Object.entries(value)) {
361
+ if (key === 'predicates') continue;
362
+ out[key] = stripPredicates(item);
363
+ }
364
+ return out;
365
+ }
366
+
367
+ function actionTypeIdOf(action) {
368
+ return idValue(action?.actionTypeSysId || action?.action_type_sys_id || action?.actionType?.sys_id || action?.actionType?.sysId);
369
+ }
370
+
371
+ function verifyCreatedFlowReadback({ flowId, processflow, trigger, insertedTrigger, manifest, actionInstances, inserted }) {
372
+ const returnedFlowId = stringValue(processflow.flowId || processflow.flow_id || processflow.sysId || processflow.sys_id);
373
+ if (returnedFlowId && returnedFlowId !== flowId) throw new Error(`ProcessFlow read-back returned a different flow ID (${returnedFlowId})`);
374
+
375
+ const triggers = findArray(processflow, 'triggerInstances');
376
+ if (triggers.length !== 1) throw new Error(`ProcessFlow read-back returned ${triggers.length} trigger instances; expected one`);
377
+ const savedTrigger = triggers[0];
378
+ const savedTriggerId = findId(savedTrigger, ['sysId', 'sys_id', 'id', 'triggerId']);
379
+ const insertedTriggerId = findId(insertedTrigger || {}, ['sysId', 'sys_id', 'id']);
380
+ if (!insertedTriggerId) throw new Error('Flow trigger insert did not return a trigger identity');
381
+ if (savedTriggerId !== insertedTriggerId) throw new Error('Created trigger identity was not preserved in ProcessFlow read-back');
382
+ if (stringValue(savedTrigger.triggerDefinitionId || savedTrigger.trigger_definition_id) !== trigger.triggerDefinitionId) {
383
+ throw new Error(`Created trigger definition did not match the requested ${trigger.triggerDefinitionId}`);
384
+ }
385
+ if (stringValue(savedTrigger.type) !== trigger.type) throw new Error(`Created trigger type did not match the requested ${trigger.type}`);
386
+ const savedTriggerInputs = inputList(savedTrigger);
387
+ for (const expected of trigger.inputs) {
388
+ const actual = savedTriggerInputs.find(input => input.name === expected.name);
389
+ if (!actual || inputRawValue(actual) !== inputRawValue(expected)) throw new Error(`Created trigger ${expected.name} did not match the requested value in ProcessFlow read-back`);
390
+ }
391
+ assertPredicateMetadataMatches(savedTrigger.metadata, manifest.trigger.condition || '', 'Created trigger');
392
+
393
+ const savedActions = findExistingActions(processflow);
394
+ if (savedActions.length !== actionInstances.length) throw new Error(`ProcessFlow read-back returned ${savedActions.length} Actions; expected ${actionInstances.length}`);
395
+ const insertedByUiId = new Map(inserted.map(action => [stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier), action]));
396
+ for (const expected of actionInstances) {
397
+ const insertedAction = insertedByUiId.get(expected.uiUniqueIdentifier);
398
+ if (!insertedAction) throw new Error(`Created Action ${expected.uiUniqueIdentifier} was not returned by the GraphQL mutation`);
399
+ const savedMatches = savedActions.filter(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === expected.uiUniqueIdentifier);
400
+ if (savedMatches.length !== 1) throw new Error(`Created Action ${expected.uiUniqueIdentifier} was not uniquely present in ProcessFlow read-back`);
401
+ const savedAction = savedMatches[0];
402
+ const insertedId = findId(insertedAction, ['sysId', 'sys_id', 'id']);
403
+ const savedId = findId(savedAction, ['sysId', 'sys_id', 'id']);
404
+ if (!insertedId || savedId !== insertedId) throw new Error(`Created Action ${expected.uiUniqueIdentifier} identity did not match the GraphQL mutation read-back`);
405
+ if (actionTypeIdOf(savedAction) !== expected.actionTypeSysId) throw new Error(`Created Action ${expected.uiUniqueIdentifier} type did not match the requested Action`);
406
+ if (String(savedAction.order) !== String(expected.order)) throw new Error(`Created Action ${expected.uiUniqueIdentifier} order did not match the requested order`);
407
+ const savedInputs = inputList(savedAction);
408
+ if (savedInputs.length !== expected.inputs.length) throw new Error(`Created Action ${expected.uiUniqueIdentifier} input count did not match the installed schema`);
409
+ for (const expectedInput of expected.inputs) {
410
+ const actualInput = savedInputs.find(input => input.name === expectedInput.name);
411
+ if (!actualInput || inputRawValue(actualInput) !== inputRawValue(expectedInput)) throw new Error(`Created Action ${expected.uiUniqueIdentifier} input ${expectedInput.name} did not match the requested value`);
412
+ }
413
+ }
414
+ }
415
+
416
+ export async function resolveTriggerDefinition(sdk, type) {
417
+ if (!SUPPORTED_TRIGGER_TYPES.has(String(type))) throw new Error(`Unsupported trigger.type "${type}"`);
418
+ const rows = await sdk.list(TRIGGER_DEFINITION_TABLE, new URLSearchParams({
419
+ sysparm_query: `type=${type}^active=true`,
420
+ sysparm_limit: '5',
421
+ sysparm_display_value: 'all',
422
+ sysparm_fields: 'sys_id,name,type,active,category',
423
+ }));
424
+ if (rows.length !== 1) throw new Error(rows.length === 0
425
+ ? `Trigger definition not found or inactive: ${type}`
426
+ : `Trigger definition is ambiguous: ${type}`);
427
+ const row = rows[0];
428
+ return {
429
+ sysId: idValue(row.sys_id || row.sysId),
430
+ name: stringValue(row.name),
431
+ type: stringValue(row.type),
432
+ // The definition's category display name is what the trigger instance stores
433
+ // as triggerType ("Record", "Scheduled", "Application"). The raw value is the
434
+ // category sys_id, so take the display value when the API returned one.
435
+ triggerType: categoryDisplay(row.category),
436
+ };
437
+ }
438
+
439
+ function categoryDisplay(value) {
440
+ if (value && typeof value === 'object') return stringValue(value.display_value ?? value.value);
441
+ return stringValue(value);
442
+ }
443
+
444
+ /**
445
+ * Build the triggerInstances.insert payload from the definition's own declared
446
+ * inputs. `manifest.trigger.table` / `.condition` are shortcuts onto whichever
447
+ * declared input carries them; everything else goes through `trigger.inputs`.
448
+ */
449
+ export async function buildCreateTrigger(sdk, flowId, manifest, definition, options = {}) {
450
+ const declared = await readTriggerInputDefinitions(sdk, definition.sysId);
451
+ if (!declared.length && options.requireInputs === true) throw new Error(`Trigger definition ${definition.type} declares no inputs`);
452
+ const tableInput = tableInputFor(declared);
453
+ const conditionInput = conditionInputFor(declared);
454
+ const supplied = { ...(manifest.trigger.inputs || {}) };
455
+
456
+ if (manifest.trigger.table !== undefined) {
457
+ if (!tableInput) throw new Error(`Trigger ${definition.type} does not declare a table input`);
458
+ supplied[tableInput.name] = manifest.trigger.table;
459
+ }
460
+ if (manifest.trigger.condition !== undefined) {
461
+ if (!conditionInput) throw new Error(`Trigger ${definition.type} does not declare a condition input`);
462
+ supplied[conditionInput.name] = manifest.trigger.condition;
463
+ }
464
+ let descriptor = null;
465
+ if (tableInput && supplied[tableInput.name]) {
466
+ descriptor = options.readTableDescriptor
467
+ ? await options.readTableDescriptor(supplied[tableInput.name])
468
+ : await readTableDescriptor(sdk, supplied[tableInput.name]);
469
+ }
470
+
471
+ return buildTriggerInstance({
472
+ flowId,
473
+ definition,
474
+ triggerType: definition.triggerType,
475
+ metadata: DEFAULT_TRIGGER_METADATA,
476
+ inputs: buildTriggerInputs(declared, supplied, descriptor),
477
+ });
478
+ }
479
+
480
+ function clone(value) {
481
+ return value == null ? value : JSON.parse(JSON.stringify(value));
482
+ }
483
+
484
+ function inputList(component) {
485
+ return component?.inputs || component?.inputValues || [];
486
+ }
487
+
488
+ function inputRawValue(input) {
489
+ return input?.value?.value ?? input?.value ?? input?.displayValue?.value ?? input?.displayValue ?? null;
490
+ }
491
+
492
+ function wrappedValue(value) {
493
+ return { schemaless: false, schemalessValue: '', value };
494
+ }
495
+
496
+ function buildActionUpdate(existing, requested, index) {
497
+ const existingInputs = new Set(inputList(existing).map(input => stringValue(input?.name)));
498
+ for (const name of Object.keys(requested.inputs)) {
499
+ 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`);
500
+ }
501
+ const uiUniqueIdentifier = stringValue(existing.uiUniqueIdentifier || existing.ui_unique_identifier);
502
+ if (!uiUniqueIdentifier) throw new Error(`Update action ${index + 1} has no saved uiUniqueIdentifier`);
503
+ return {
504
+ uiUniqueIdentifier,
505
+ type: 'action',
506
+ inputs: Object.entries(requested.inputs).map(([name, value]) => ({ name, value: wrappedValue(value) })),
507
+ };
508
+ }
509
+
510
+ function findExistingActions(definition) {
511
+ const actionInstances = findArray(definition, 'actionInstances');
512
+ return actionInstances.length ? actionInstances : findArray(definition, 'actions');
513
+ }
514
+
515
+ async function resolveExistingAction(sdk, actions, requested, index) {
516
+ const actionType = await resolveActionType(sdk, requested.type);
517
+ const matches = actions.filter(action => String(action.order) === String(requested.order)
518
+ && idValue(action.actionTypeSysId || action.action_type_sys_id || action.actionType?.sys_id || action.actionType?.sysId) === actionType.id);
519
+ if (matches.length !== 1) throw new Error(matches.length === 0
520
+ ? `Update action ${index + 1} did not match an existing Action instance at order ${requested.order} with type ${requested.type}`
521
+ : `Update action ${index + 1} matched multiple Action instances at order ${requested.order} with type ${requested.type}`);
522
+ return matches[0];
523
+ }
524
+
525
+ export function validateFlowUpdateManifest(manifest) {
526
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('Flow update manifest must be a JSON object');
527
+ const allowed = new Set(['scope', 'trigger', 'actions']);
528
+ 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`);
529
+ if (manifest.scope !== undefined && !String(manifest.scope).trim()) throw new Error('Flow update scope must not be empty');
530
+ if (manifest.trigger !== undefined) {
531
+ if (!manifest.trigger || typeof manifest.trigger !== 'object' || Array.isArray(manifest.trigger)) throw new Error('Flow update trigger must be an object');
532
+ for (const key of Object.keys(manifest.trigger)) if (!['type', 'table', 'condition'].includes(key)) throw new Error(`Flow update trigger field "${key}" is unsupported`);
533
+ if (manifest.trigger.type !== undefined && manifest.trigger.type !== 'record_create_or_update') throw new Error('Only record_create_or_update trigger updates are supported');
534
+ if (manifest.trigger.table !== undefined && !String(manifest.trigger.table).trim()) throw new Error('Flow update trigger table must not be empty');
535
+ if (manifest.trigger.condition !== undefined && typeof manifest.trigger.condition !== 'string') throw new Error('Flow update trigger condition must be a string');
536
+ if (manifest.trigger.table === undefined && manifest.trigger.condition === undefined) throw new Error('Flow update trigger must specify table and/or condition');
537
+ 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');
538
+ }
539
+ if (manifest.actions !== undefined) {
540
+ if (!Array.isArray(manifest.actions)) throw new Error('Flow update actions must be an array');
541
+ for (const [index, action] of manifest.actions.entries()) {
542
+ if (!action || typeof action !== 'object' || Array.isArray(action)) throw new Error(`Update action ${index + 1} must be an object`);
543
+ 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`);
544
+ 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`);
545
+ if (!action.inputs || typeof action.inputs !== 'object' || Array.isArray(action.inputs)) throw new Error(`Update action ${index + 1} inputs must be an object`);
546
+ if (Object.keys(action.inputs).length === 0) throw new Error(`Update action ${index + 1} inputs must contain at least one changed value`);
547
+ }
548
+ }
549
+ if (manifest.trigger === undefined && manifest.actions === undefined) throw new Error('Flow update manifest must specify trigger and/or actions');
550
+ return manifest;
551
+ }
552
+
553
+ function readFlowRecord(sdk, identifier) {
554
+ const exactIdentifier = String(identifier ?? '');
555
+ if (!exactIdentifier) throw new Error('Flow identifier must be an exact sys_id or name');
556
+ assertSafeExactMatch(exactIdentifier);
557
+ const query = isSysId(exactIdentifier) ? `sys_id=${exactIdentifier}` : `name=${exactIdentifier}`;
558
+ 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' }));
559
+ }
560
+
561
+ async function resolveDraftFlow(sdk, identifier) {
562
+ const rows = await readFlowRecord(sdk, identifier);
563
+ 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`);
564
+ const flow = rows[0];
565
+ const flowId = idValue(flow.sys_id);
566
+ const flowName = stringValue(flow.name);
567
+ if (flowId === PROTECTED_FLOW_ID || flowName === PROTECTED_FLOW_NAME) throw new Error(`Protected flow ${PROTECTED_FLOW_NAME} cannot be updated`);
568
+ 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`);
569
+ return { flow, flowId, flowName };
570
+ }
571
+
572
+ function updatedTrigger(flow, manifest, descriptor) {
573
+ const existing = clone(findArray(flow, 'triggerInstances')[0]);
574
+ if (!existing) throw new Error('Saved flow has no trigger instance');
575
+ const existingMetadata = parseMaybeJson(existing.metadata, null);
576
+ if (existing.metadata && (!existingMetadata || typeof existingMetadata !== 'object' || Array.isArray(existingMetadata))) throw new Error('Saved record trigger metadata is unreadable; refusing to replace it');
577
+ const existingFamily = triggerFamily(existing);
578
+ if (existingFamily && existingFamily !== RECORD_TRIGGER_FAMILY) throw new Error(`Only ${RECORD_TRIGGER_FAMILY} trigger updates are supported; found ${existingFamily}`);
579
+ const inputs = clone(inputList(existing));
580
+ const tableInput = inputs.find(input => input.name === 'table');
581
+ const conditionInput = inputs.find(input => input.name === 'condition');
582
+ if (!tableInput || !conditionInput) throw new Error('Saved record trigger must have table and condition inputs');
583
+ const currentTable = inputRawValue(tableInput);
584
+ const currentCondition = inputRawValue(conditionInput);
585
+ const table = manifest.trigger.table ?? currentTable;
586
+ const condition = manifest.trigger.condition ?? currentCondition;
587
+ if (manifest.trigger.table !== undefined) {
588
+ tableInput.displayField = descriptor.displayField;
589
+ tableInput.displayValue = wrappedValue(descriptor.label);
590
+ tableInput.value = wrappedValue(table);
591
+ }
592
+ if (manifest.trigger.condition !== undefined) {
593
+ conditionInput.displayValue = wrappedValue(condition);
594
+ conditionInput.value = wrappedValue(condition);
595
+ }
596
+ const id = findId(existing, ['sysId', 'sys_id', 'id', 'triggerId']);
597
+ if (!id) throw new Error('Saved record trigger has no usable identity');
598
+ const update = { id, inputs };
599
+ if (manifest.trigger.condition !== undefined) {
600
+ // The instance re-derives predicate metadata from the condition input; see
601
+ // DEFAULT_TRIGGER_METADATA. The read-back check below still proves the
602
+ // derived predicate matches what was asked for.
603
+ update.metadata = DEFAULT_TRIGGER_METADATA;
604
+ } else if (Object.prototype.hasOwnProperty.call(existing, 'metadata')) {
605
+ update.metadata = existing.metadata;
606
+ }
607
+ return { update, currentTable: table };
608
+ }
609
+
610
+ function insertedActions(response) {
611
+ let found = [];
612
+ walk(response, object => {
613
+ if (!found.length && object.actions?.inserts) found = object.actions.inserts;
614
+ });
615
+ return found;
616
+ }
617
+
618
+ function insertedTriggers(response) {
619
+ let found = [];
620
+ walk(response, object => {
621
+ if (!found.length && object.triggerInstances?.inserts) found = object.triggerInstances.inserts;
622
+ });
623
+ return found;
624
+ }
625
+
626
+ async function reconcileAmbiguousCreate(sdk, manifest) {
627
+ const params = new URLSearchParams({ sysparm_query: `name=${manifest.name}`, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,active,status,scope' });
628
+ const records = await sdk.list('sys_hub_flow', params);
629
+ throw new Error(`Flow parent creation was ambiguous; reconciliation found ${records.length} flow(s) named "${manifest.name}". No retry was attempted.`);
630
+ }
631
+
632
+ async function mutationContext(label, readUpdateSet, snapshots, operation, capture) {
633
+ const before = await readUpdateSet();
634
+ const captureBefore = capture ? await capture(before) : undefined;
635
+ try {
636
+ const result = await operation();
637
+ const after = await readUpdateSet();
638
+ const captureAfter = capture ? await capture(after) : undefined;
639
+ snapshots.push({ label, before, after, ...(capture ? { capture_before: captureBefore, capture_after: captureAfter } : {}) });
640
+ return result;
641
+ } catch (error) {
642
+ let after;
643
+ try { after = await readUpdateSet(); } catch (readError) { after = { unavailable: true, reason: readError.message }; }
644
+ let captureAfter;
645
+ if (capture) {
646
+ try { captureAfter = await capture(after); } catch (readError) { captureAfter = unavailableCapture(`capture read failed: ${readError.message}`); }
647
+ }
648
+ snapshots.push({ label, before, after, ...(capture ? { capture_before: captureBefore, capture_after: captureAfter } : {}), error: error.message });
649
+ throw error;
650
+ }
651
+ }
652
+
653
+ function unavailableCapture(reason) {
654
+ return { available: false, status: 'unavailable', reason };
655
+ }
656
+
657
+ async function addPostMutationCapture(snapshot, sdk, readUpdateSet, flowId) {
658
+ const updateSet = await readUpdateSet();
659
+ snapshot.after = updateSet;
660
+ snapshot.capture_after = await readUpdateSetCapture(sdk, updateSet, flowId);
661
+ }
662
+
663
+ export async function readUpdateSetCapture(sdk, updateSet, flowId) {
664
+ const updateSetId = idValue(updateSet?.sys_id || updateSet?.sysId);
665
+ if (!updateSetId) return unavailableCapture('current update set has no selected sys_id (likely Default)');
666
+ const rows = await sdk.list('sys_update_xml', new URLSearchParams({
667
+ sysparm_query: `update_set=${updateSetId}`,
668
+ sysparm_limit: '200',
669
+ sysparm_display_value: 'all',
670
+ sysparm_fields: 'sys_id,name,type,action,target_name,update_set,payload,xml',
671
+ }));
672
+ const matches = rows.filter(row => JSON.stringify(row).includes(flowId));
673
+ return {
674
+ available: true,
675
+ status: matches.length ? 'captured' : 'missing',
676
+ update_set: updateSet,
677
+ count: matches.length,
678
+ records: matches,
679
+ };
680
+ }
681
+
682
+ export async function createFlowFromManifest(sdk, instance, inputManifest, options = {}) {
683
+ const manifest = validateFlowManifest(inputManifest);
684
+ const scope = manifest.scope;
685
+ const idFactory = options.idFactory || (() => randomUUID());
686
+ const readUpdateSet = options.readUpdateSet || (async () => null);
687
+ const snapshots = [];
688
+
689
+ let parentResponse;
690
+ try {
691
+ parentResponse = await mutationContext('parent_create', readUpdateSet, snapshots, () => sdk.request(`${instance}${FLOW_ENDPOINT}?param_only_properties=true&sysparm_transaction_scope=${encodeURIComponent(scope)}`, {
692
+ method: 'POST',
693
+ body: JSON.stringify(buildFlowProperties(manifest)),
694
+ }));
695
+ } catch (error) {
696
+ await reconcileAmbiguousCreate(sdk, manifest);
697
+ throw error;
698
+ }
699
+ const flowId = findId(parentResponse, ['sys_id', 'sysId', 'flowId', 'id']);
700
+ if (!flowId) throw new Error('Flow creation response did not include a Flow ID');
701
+ try {
702
+ const parentSnapshot = snapshots.find(snapshot => snapshot.label === 'parent_create');
703
+ if (parentSnapshot) {
704
+ parentSnapshot.capture_before = unavailableCapture('flow ID was not known before parent creation');
705
+ await addPostMutationCapture(parentSnapshot, sdk, readUpdateSet, flowId);
706
+ }
707
+ // A newly POSTed ProcessFlow has NO trigger (verified live: triggerInstances
708
+ // comes back empty), so the trigger is inserted by the same snFlowDesigner
709
+ // patch as the actions. There is nothing to read back from the parent apart
710
+ // from the flow identity.
711
+ const definition = await resolveTriggerDefinition(sdk, manifest.trigger.type);
712
+ const trigger = await buildCreateTrigger(sdk, flowId, manifest, definition, options);
713
+
714
+ const actionInstances = [];
715
+ for (const [index, action] of manifest.actions.entries()) {
716
+ const resolved = await resolveActionType(sdk, action.type);
717
+ const actionDefinition = await readActionDefinition(sdk, instance, resolved.id, scope);
718
+ actionInstances.push({
719
+ actionTypeSysId: resolved.id,
720
+ flowSysId: flowId,
721
+ generationSource: '',
722
+ type: 'action',
723
+ order: String(index + 1),
724
+ parent: '',
725
+ uiUniqueIdentifier: idFactory(),
726
+ parentUiId: '',
727
+ metadata: JSON.stringify(parseMaybeJson(actionDefinition.definition.metadata, {})),
728
+ inputs: buildTypedInputs(action, actionDefinition.schema),
729
+ });
730
+ }
731
+
732
+ const patchResponse = await mutationContext('trigger_and_actions_patch', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
733
+ method: 'POST',
734
+ body: JSON.stringify({ query: buildFlowPatchMutation({ flowId, trigger, actions: actionInstances }), variables: {} }),
735
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
736
+ assertGraphQLSuccess(patchResponse, 'Flow trigger/action patch');
737
+ const insertedTrigger = insertedTriggers(patchResponse)[0];
738
+ if (!insertedTrigger) throw new Error('Flow trigger insert returned no trigger identity');
739
+ const inserted = insertedActions(patchResponse);
740
+ if (inserted.length !== actionInstances.length) throw new Error(`Flow action patch returned ${inserted.length} inserted action identities; expected ${actionInstances.length}`);
741
+
742
+ const processflow = await readProcessFlow(sdk, instance, flowId, scope);
743
+ verifyCreatedFlowReadback({ flowId, processflow, trigger, insertedTrigger, manifest, actionInstances, inserted });
744
+ const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
745
+ instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow create',
746
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
747
+ const selectedUpdateSet = await readUpdateSet();
748
+ const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
749
+
750
+ const triggerReadback = findArray(processflow, 'triggerInstances')[0] || {};
751
+ return {
752
+ flow: { id: flowId, name: manifest.name, status: 'draft', active: false },
753
+ trigger: { ...triggerReadback, id: findId(triggerReadback, ['sysId', 'sys_id', 'id']) || findId(insertedTrigger, ['sysId', 'sys_id', 'id']) },
754
+ actions: inserted,
755
+ processflow,
756
+ definition_readback: { available: true, status: 'read_back', flow_id: flowId },
757
+ version,
758
+ update_set_capture: updateSetCapture,
759
+ update_set_mutations: snapshots,
760
+ lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
761
+ };
762
+ } catch (error) {
763
+ // Creation is two-stage: the parent POST leaves a real draft behind when the
764
+ // trigger/action patch fails. Name that orphan explicitly instead of leaving
765
+ // the caller to hunt for it.
766
+ throw new Error(`Flow ${flowId} creation incomplete and left orphan draft flow ${flowId} (delete it in Flow Designer): ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
767
+ }
768
+ }
769
+
770
+ export async function updateFlowFromManifest(sdk, instance, identifier, inputManifest, options = {}) {
771
+ const manifest = validateFlowUpdateManifest(inputManifest);
772
+ const target = await resolveDraftFlow(sdk, identifier);
773
+ const flowId = target.flowId;
774
+ const scope = stringValue(manifest.scope ?? target.flow.scope);
775
+ const readUpdateSet = options.readUpdateSet || (async () => null);
776
+ if (!scope) throw new Error(`Flow ${flowId} has no transaction scope`);
777
+ 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`);
778
+ const beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
779
+ const snapshots = [];
780
+ const triggerUpdates = [];
781
+ const actionUpdates = [];
782
+ const changedActionInputs = new Map();
783
+ const actionTargets = new Map();
784
+ let triggerUpdate;
785
+ if (manifest.trigger !== undefined) {
786
+ const currentTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
787
+ const currentTable = inputRawValue(inputList(currentTrigger).find(input => input.name === 'table'));
788
+ const descriptor = options.readTableDescriptor
789
+ ? await options.readTableDescriptor(manifest.trigger.table ?? currentTable)
790
+ : await readTableDescriptor(sdk, manifest.trigger.table ?? currentTable);
791
+ triggerUpdate = updatedTrigger(beforeDefinition, manifest, descriptor).update;
792
+ triggerUpdates.push(triggerUpdate);
793
+ }
794
+ const existingActions = findExistingActions(beforeDefinition);
795
+ for (const [index, requested] of (manifest.actions || []).entries()) {
796
+ const existing = await resolveExistingAction(sdk, existingActions, requested, index);
797
+ const update = buildActionUpdate(existing, requested, index);
798
+ actionUpdates.push(update);
799
+ const uiId = stringValue(existing.uiUniqueIdentifier || existing.ui_unique_identifier);
800
+ actionTargets.set(uiId, existing);
801
+ changedActionInputs.set(uiId, new Set(Object.keys(requested.inputs)));
802
+ }
803
+ if (triggerUpdates.length === 0 && actionUpdates.length === 0) throw new Error(`Flow ${flowId} update has no supported changes`);
804
+
805
+ try {
806
+ const patchResponse = await mutationContext('trigger_and_actions_update', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
807
+ method: 'POST',
808
+ body: JSON.stringify({ query: buildFlowUpdateMutation({ flowId, triggerUpdates, actionUpdates }), variables: {} }),
809
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
810
+ assertGraphQLSuccess(patchResponse, 'Flow trigger/action update');
811
+ const processflow = await readProcessFlow(sdk, instance, flowId, scope);
812
+ const readbackTrigger = findArray(processflow, 'triggerInstances')[0];
813
+ if (triggerUpdate) {
814
+ if (!readbackTrigger || findId(readbackTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== triggerUpdate.id) throw new Error(`Flow ${flowId} trigger update was not readable after mutation`);
815
+ const beforeTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
816
+ const preserveBefore = { ...beforeTrigger, inputs: undefined, metadata: undefined };
817
+ const preserveAfter = { ...readbackTrigger, inputs: undefined, metadata: undefined };
818
+ if (JSON.stringify(preserveBefore) !== JSON.stringify(preserveAfter)) throw new Error(`Flow ${flowId} trigger readback changed fields omitted by the manifest`);
819
+ const readbackInputs = inputList(readbackTrigger);
820
+ const beforeInputs = inputList(beforeTrigger);
821
+ if (readbackInputs.length !== beforeInputs.length) throw new Error(`Flow ${flowId} trigger input count changed during update`);
822
+ const requestedInputs = new Set();
823
+ if (manifest.trigger.table !== undefined) requestedInputs.add('table');
824
+ if (manifest.trigger.condition !== undefined) requestedInputs.add('condition');
825
+ for (const beforeInput of beforeInputs) {
826
+ const afterInput = readbackInputs.find(input => input.name === beforeInput.name);
827
+ if (!afterInput) throw new Error(`Flow ${flowId} trigger lost input ${beforeInput.name} during update`);
828
+ if (!requestedInputs.has(beforeInput.name) && JSON.stringify(beforeInput) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} trigger changed omitted input ${beforeInput.name}`);
829
+ }
830
+ 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`);
831
+ if (manifest.trigger.condition !== undefined) {
832
+ 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`);
833
+ assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
834
+ const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
835
+ const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
836
+ if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
837
+ } else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
838
+ throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
839
+ }
840
+ }
841
+ const readbackActions = findExistingActions(processflow);
842
+ for (const update of actionUpdates) {
843
+ const uiId = stringValue(update.uiUniqueIdentifier);
844
+ const readback = readbackActions.find(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === uiId);
845
+ if (!readback) throw new Error(`Flow ${flowId} Action ${uiId} was not readable after mutation`);
846
+ const before = actionTargets.get(uiId);
847
+ const changed = changedActionInputs.get(uiId) || new Set();
848
+ for (const input of inputList(before)) {
849
+ const afterInput = inputList(readback).find(candidate => candidate.name === input.name);
850
+ if (!afterInput) throw new Error(`Flow ${flowId} Action ${uiId} lost input ${input.name} during update`);
851
+ if (!changed.has(input.name) && JSON.stringify(input) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} Action ${uiId} changed omitted input ${input.name}`);
852
+ 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`);
853
+ }
854
+ }
855
+ const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
856
+ instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow update',
857
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
858
+ const selectedUpdateSet = await readUpdateSet();
859
+ const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
860
+ return {
861
+ flow: { id: flowId, name: target.flowName, status: 'draft', active: false },
862
+ processflow,
863
+ definition_readback: { available: true, status: 'read_back', flow_id: flowId },
864
+ version,
865
+ update_set_capture: updateSetCapture,
866
+ update_set_mutations: snapshots,
867
+ lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
868
+ };
869
+ } catch (error) {
870
+ throw new Error(`Flow ${flowId} update incomplete: ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
871
+ }
872
+ }
873
+
874
+ export async function readCurrentUpdateSet(sdk) {
875
+ const params = new URLSearchParams({ sysparm_query: 'user_name=javascript:gs.getUserName()', sysparm_limit: '1', sysparm_fields: 'sys_id' });
876
+ const users = await sdk.list('sys_user', params);
877
+ const userId = idValue(users[0]?.sys_id);
878
+ if (!userId) return { name: 'Default', sys_id: '' };
879
+ const preference = await sdk.list('sys_user_preference', new URLSearchParams({ sysparm_query: `user=${userId}^name=sys_update_set`, sysparm_limit: '1', sysparm_fields: 'value' }));
880
+ const updateSetId = idValue(preference[0]?.value);
881
+ if (!updateSetId || updateSetId === '-') return { name: 'Default', sys_id: '' };
882
+ const records = await sdk.list('sys_update_set', new URLSearchParams({ sysparm_query: `sys_id=${updateSetId}`, sysparm_limit: '1', sysparm_fields: 'sys_id,name,state' }));
883
+ return records[0] || { name: updateSetId, sys_id: updateSetId };
884
+ }