@jacebenson/jsn 4.3.7-dev.39 → 4.3.7-dev.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli-adapter.js +96 -4
- package/src/cli.js +101 -5
- package/src/commands/cmdb.js +18 -0
- package/src/commands/docs/docs.js +18 -16
- package/src/commands/flows.js +18 -4
- package/src/commands/platform.js +17 -0
- package/src/commands/records.js +19 -15
- package/src/errors.js +52 -5
- package/src/flow-create.js +166 -85
- package/src/flow-designer-interactive.js +98 -8
- package/src/flow-triggers.js +395 -0
- package/src/help.js +93 -28
- package/src/output.js +7 -0
package/src/flow-create.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { assertSafeExactMatch } from './helpers.js';
|
|
3
3
|
import { createVerifiedFlowVersion } from './flow-versioning.js';
|
|
4
|
+
import {
|
|
5
|
+
SUPPORTED_TRIGGER_TYPES,
|
|
6
|
+
TRIGGER_DEFINITION_TABLE,
|
|
7
|
+
buildTriggerInputs,
|
|
8
|
+
buildTriggerInstance,
|
|
9
|
+
conditionInputFor,
|
|
10
|
+
readTableDescriptor,
|
|
11
|
+
readTriggerInputDefinitions,
|
|
12
|
+
tableInputFor,
|
|
13
|
+
flowDesignParameter,
|
|
14
|
+
} from './flow-triggers.js';
|
|
4
15
|
|
|
5
16
|
const FLOW_ENDPOINT = '/api/now/processflow/flow';
|
|
6
17
|
const GRAPHQL_ENDPOINT = '/api/now/graphql';
|
|
7
|
-
const TABLE_DESCRIPTOR_ENDPOINT = '/api/now/processflow/trigger/record/table';
|
|
8
18
|
const PROTECTED_FLOW_ID = 'c215135293bf03d087b0f14fdd03d652';
|
|
9
19
|
const PROTECTED_FLOW_NAME = 'jace-test-flow';
|
|
10
20
|
|
|
@@ -132,12 +142,15 @@ export function validateFlowManifest(manifest) {
|
|
|
132
142
|
if (manifest.variables !== undefined || manifest.flowVariables !== undefined) throw new Error('Flow-variable lifecycle is unsupported in this creation slice');
|
|
133
143
|
if (manifest.errorHandling !== undefined) throw new Error('Error handling is unsupported in this creation slice');
|
|
134
144
|
const trigger = manifest.trigger;
|
|
135
|
-
if (!trigger || trigger
|
|
136
|
-
if (!
|
|
145
|
+
if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) throw new Error('Flow manifest requires a trigger object');
|
|
146
|
+
for (const key of Object.keys(trigger)) if (!['type', 'table', 'condition', 'inputs'].includes(key)) throw new Error(`Flow manifest trigger field "${key}" is unsupported`);
|
|
147
|
+
if (!SUPPORTED_TRIGGER_TYPES.has(String(trigger.type))) throw new Error(`Unsupported trigger.type "${trigger.type}"; supported families are ${[...SUPPORTED_TRIGGER_TYPES].join(', ')}`);
|
|
148
|
+
if (trigger.table !== undefined && !String(trigger.table).trim()) throw new Error('Trigger table must not be empty');
|
|
137
149
|
if (trigger.condition !== undefined && typeof trigger.condition !== 'string') throw new Error('Record trigger condition must be a string');
|
|
138
150
|
if (trigger.condition && !/^[A-Za-z_][A-Za-z0-9_.]*(?:STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)[^,^]*$/.test(trigger.condition)) {
|
|
139
151
|
throw new Error('Record trigger condition uses an unsupported encoded-query condition subset');
|
|
140
152
|
}
|
|
153
|
+
if (trigger.inputs !== undefined && (!trigger.inputs || typeof trigger.inputs !== 'object' || Array.isArray(trigger.inputs))) throw new Error('Trigger inputs must be an object');
|
|
141
154
|
if (!Array.isArray(manifest.actions)) throw new Error('Flow manifest requires an actions array');
|
|
142
155
|
for (const [index, action] of manifest.actions.entries()) {
|
|
143
156
|
if (!action || typeof action !== 'object' || !String(action.type || '').trim()) throw new Error(`Action ${index + 1} requires type`);
|
|
@@ -165,8 +178,12 @@ export function buildFlowProperties(manifest) {
|
|
|
165
178
|
}
|
|
166
179
|
|
|
167
180
|
export function buildFlowPatchMutation({ flowId, trigger, actions }) {
|
|
168
|
-
const
|
|
169
|
-
|
|
181
|
+
const triggerInsert = {
|
|
182
|
+
flowSysId: trigger.flowSysId,
|
|
183
|
+
name: trigger.name,
|
|
184
|
+
type: trigger.type,
|
|
185
|
+
triggerType: trigger.triggerType,
|
|
186
|
+
triggerDefinitionId: trigger.triggerDefinitionId,
|
|
170
187
|
metadata: trigger.metadata,
|
|
171
188
|
inputs: trigger.inputs,
|
|
172
189
|
};
|
|
@@ -182,8 +199,10 @@ export function buildFlowPatchMutation({ flowId, trigger, actions }) {
|
|
|
182
199
|
metadata: action.metadata,
|
|
183
200
|
inputs: action.inputs,
|
|
184
201
|
}));
|
|
185
|
-
const patch = `flowPatch: {flowId: ${gqlString(flowId)}, triggerInstances: {
|
|
186
|
-
|
|
202
|
+
const patch = `flowPatch: {flowId: ${gqlString(flowId)}, triggerInstances: {insert: [${gqlInputObject(triggerInsert)}]}, actions: {insert: [${actionInsert.map(gqlInputObject).join(', ')}]}}`;
|
|
203
|
+
// `triggerInstances.updates` is a leaf [String] on this schema; only `inserts`
|
|
204
|
+
// carries identities, and only for triggers does `inserts` lack a uiUniqueIdentifier.
|
|
205
|
+
return `mutation { global { snFlowDesigner { flow(${patch}) { triggerInstances { inserts { sysId } updates } actions { inserts { sysId uiUniqueIdentifier } } } } } }`;
|
|
187
206
|
}
|
|
188
207
|
|
|
189
208
|
export function buildFlowUpdateMutation({ flowId, triggerUpdates = [], actionUpdates = [] }) {
|
|
@@ -236,8 +255,8 @@ export function buildTypedInputs(action, schema) {
|
|
|
236
255
|
for (const key of Object.keys(supplied)) if (!allowed.has(key)) throw new Error(`Unsupported input "${key}" for action ${action.type}`);
|
|
237
256
|
return schema.map(field => {
|
|
238
257
|
// The instance's own definition is the contract: identity is required,
|
|
239
|
-
// everything else
|
|
240
|
-
//
|
|
258
|
+
// everything else is echoed back untouched. Demanding a key the provider
|
|
259
|
+
// omits breaks real actions.
|
|
241
260
|
if (!field || typeof field !== 'object' || !field.id || !field.name) {
|
|
242
261
|
throw new Error(`Action ${action.type} returned an incomplete input schema; refusing to invent input metadata for "${field?.name || 'unknown'}"`);
|
|
243
262
|
}
|
|
@@ -248,9 +267,17 @@ export function buildTypedInputs(action, schema) {
|
|
|
248
267
|
if ((suppliedValue === undefined || suppliedValue === null) && (field.mandatory === true || field.required === true)) throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
|
|
249
268
|
const value = suppliedValue ?? null;
|
|
250
269
|
const displayValue = field.displayValue ?? field.display_value ?? value;
|
|
270
|
+
// global_snFlowDesigner_ActionInstanceValue accepts ONLY these keys. The
|
|
271
|
+
// ProcessFlow action definition hands back the whole UI schema on the input
|
|
272
|
+
// object (uiDisplayTypeLabel, choices, type_label, …) and spreading it makes
|
|
273
|
+
// the mutation fail validation, so the schema is carried inside `parameter`.
|
|
251
274
|
return {
|
|
252
|
-
|
|
275
|
+
id: field.id,
|
|
253
276
|
name: field.name,
|
|
277
|
+
children: Array.isArray(field.children) ? field.children : [],
|
|
278
|
+
displayField: stringValue(field.displayField || field.display_field || ''),
|
|
279
|
+
scriptActive: false,
|
|
280
|
+
parameter: flowDesignParameter(field),
|
|
254
281
|
value: { schemaless: false, schemalessValue: '', value },
|
|
255
282
|
displayValue: { schemaless: false, schemalessValue: '', value: displayValue },
|
|
256
283
|
};
|
|
@@ -264,51 +291,15 @@ function parseSimpleCondition(condition) {
|
|
|
264
291
|
return { field: match[1], operator: match[2], term: match[3] };
|
|
265
292
|
}
|
|
266
293
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return { ...descriptor, label, displayField };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function buildPredicateMetadata(condition, descriptor) {
|
|
280
|
-
const predicate = parseSimpleCondition(condition);
|
|
281
|
-
if (!predicate) return { predicates: [], order_by: [], group_by: [], has_rlq_conditions: false };
|
|
282
|
-
const field = descriptor.fields?.find(item => stringValue(item.name || item.element || item.field) === predicate.field)
|
|
283
|
-
|| descriptor.schema?.find(item => stringValue(item.name || item.element || item.field) === predicate.field);
|
|
284
|
-
if (!field) throw new Error(`Record trigger condition field "${predicate.field}" is absent from the table descriptor`);
|
|
285
|
-
const fieldLabel = stringValue(field.label || field.column_label || predicate.field);
|
|
286
|
-
const operatorLabel = stringValue(field?.operators?.[predicate.operator] || predicate.operator);
|
|
287
|
-
const comparison = {
|
|
288
|
-
field_type: stringValue(field?.field_type || field?.type || 'string'),
|
|
289
|
-
operator: predicate.operator,
|
|
290
|
-
term_label: predicate.term,
|
|
291
|
-
or_query: false,
|
|
292
|
-
field_label: fieldLabel,
|
|
293
|
-
column_type: stringValue(field?.column_type || 'element'),
|
|
294
|
-
term: predicate.term,
|
|
295
|
-
display_value: '',
|
|
296
|
-
new_query: false,
|
|
297
|
-
goto_query: false,
|
|
298
|
-
is_pre_evaluated: false,
|
|
299
|
-
rlqc_query: false,
|
|
300
|
-
operator_label: operatorLabel,
|
|
301
|
-
value: predicate.term,
|
|
302
|
-
field: predicate.field,
|
|
303
|
-
type: 'comparison',
|
|
304
|
-
};
|
|
305
|
-
return {
|
|
306
|
-
predicates: [{ compound_type: 'or', subpredicates: [{ compound_type: 'and', subpredicates: [{ compound_type: 'and', subpredicates: [comparison], type: 'compound' }], type: 'compound' }], type: 'compound' }],
|
|
307
|
-
order_by: [],
|
|
308
|
-
group_by: [],
|
|
309
|
-
has_rlq_conditions: false,
|
|
310
|
-
};
|
|
311
|
-
}
|
|
294
|
+
// Trigger predicate metadata is DERIVED BY THE INSTANCE, not by this CLI: the
|
|
295
|
+
// snFlowDesigner patch parses the `condition` input into
|
|
296
|
+
// `metadata.query_parse.predicates` itself (verified live on insert and on
|
|
297
|
+
// update — the returned field_label/operator_label/field_type match Flow
|
|
298
|
+
// Designer's own output). Sending `{}` and verifying the derived predicate by
|
|
299
|
+
// read-back is therefore both simpler and more faithful than rebuilding the
|
|
300
|
+
// predicate tree from a table descriptor, and it removes the dependency on the
|
|
301
|
+
// ProcessFlow table-descriptor route, which returns 400 on this instance.
|
|
302
|
+
const DEFAULT_TRIGGER_METADATA = '{}';
|
|
312
303
|
|
|
313
304
|
function predicateMetadataMatches(metadata, condition) {
|
|
314
305
|
const expected = parseSimpleCondition(condition);
|
|
@@ -317,10 +308,15 @@ function predicateMetadataMatches(metadata, condition) {
|
|
|
317
308
|
const comparisons = [];
|
|
318
309
|
walk(parsed, object => {
|
|
319
310
|
if (stringValue(object.type).toLowerCase() !== 'comparison') return;
|
|
311
|
+
// The instance stores the parsed predicate with `term` holding the WHOLE
|
|
312
|
+
// encoded condition and `value` holding the extracted term, so the term
|
|
313
|
+
// comparison must prefer `value` (e.g. for
|
|
314
|
+
// "short_descriptionSTARTSWITHjacetest": term = the full string,
|
|
315
|
+
// value = "jacetest").
|
|
320
316
|
comparisons.push({
|
|
321
317
|
field: stringValue(object.field),
|
|
322
318
|
operator: stringValue(object.operator),
|
|
323
|
-
term: stringValue(object.
|
|
319
|
+
term: stringValue(object.value ?? object.term),
|
|
324
320
|
});
|
|
325
321
|
});
|
|
326
322
|
return comparisons.some(comparison => comparison.field === expected.field
|
|
@@ -332,18 +328,39 @@ function assertPredicateMetadataMatches(metadata, condition, label) {
|
|
|
332
328
|
if (!predicateMetadataMatches(metadata, condition)) throw new Error(`${label} predicate metadata did not match the requested field, operator, and term`);
|
|
333
329
|
}
|
|
334
330
|
|
|
331
|
+
// The instance stores predicate metadata as {query_parse: {predicates: [...]}},
|
|
332
|
+
// so dropping only a top-level `predicates` key is not enough to compare the
|
|
333
|
+
// parts of the metadata a manifest did not ask to change.
|
|
334
|
+
function stripPredicates(value) {
|
|
335
|
+
if (Array.isArray(value)) return value.map(stripPredicates);
|
|
336
|
+
if (!value || typeof value !== 'object') return value;
|
|
337
|
+
const out = {};
|
|
338
|
+
for (const [key, item] of Object.entries(value)) {
|
|
339
|
+
if (key === 'predicates') continue;
|
|
340
|
+
out[key] = stripPredicates(item);
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
335
345
|
function actionTypeIdOf(action) {
|
|
336
346
|
return idValue(action?.actionTypeSysId || action?.action_type_sys_id || action?.actionType?.sys_id || action?.actionType?.sysId);
|
|
337
347
|
}
|
|
338
348
|
|
|
339
|
-
function verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, actionInstances, inserted }) {
|
|
349
|
+
function verifyCreatedFlowReadback({ flowId, processflow, trigger, insertedTrigger, manifest, actionInstances, inserted }) {
|
|
340
350
|
const returnedFlowId = stringValue(processflow.flowId || processflow.flow_id || processflow.sysId || processflow.sys_id);
|
|
341
351
|
if (returnedFlowId && returnedFlowId !== flowId) throw new Error(`ProcessFlow read-back returned a different flow ID (${returnedFlowId})`);
|
|
342
352
|
|
|
343
353
|
const triggers = findArray(processflow, 'triggerInstances');
|
|
344
354
|
if (triggers.length !== 1) throw new Error(`ProcessFlow read-back returned ${triggers.length} trigger instances; expected one`);
|
|
345
355
|
const savedTrigger = triggers[0];
|
|
346
|
-
|
|
356
|
+
const savedTriggerId = findId(savedTrigger, ['sysId', 'sys_id', 'id', 'triggerId']);
|
|
357
|
+
const insertedTriggerId = findId(insertedTrigger || {}, ['sysId', 'sys_id', 'id']);
|
|
358
|
+
if (!insertedTriggerId) throw new Error('Flow trigger insert did not return a trigger identity');
|
|
359
|
+
if (savedTriggerId !== insertedTriggerId) throw new Error('Created trigger identity was not preserved in ProcessFlow read-back');
|
|
360
|
+
if (stringValue(savedTrigger.triggerDefinitionId || savedTrigger.trigger_definition_id) !== trigger.triggerDefinitionId) {
|
|
361
|
+
throw new Error(`Created trigger definition did not match the requested ${trigger.triggerDefinitionId}`);
|
|
362
|
+
}
|
|
363
|
+
if (stringValue(savedTrigger.type) !== trigger.type) throw new Error(`Created trigger type did not match the requested ${trigger.type}`);
|
|
347
364
|
const savedTriggerInputs = inputList(savedTrigger);
|
|
348
365
|
for (const expected of trigger.inputs) {
|
|
349
366
|
const actual = savedTriggerInputs.find(input => input.name === expected.name);
|
|
@@ -374,23 +391,70 @@ function verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, act
|
|
|
374
391
|
}
|
|
375
392
|
}
|
|
376
393
|
|
|
377
|
-
function
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
394
|
+
export async function resolveTriggerDefinition(sdk, type) {
|
|
395
|
+
if (!SUPPORTED_TRIGGER_TYPES.has(String(type))) throw new Error(`Unsupported trigger.type "${type}"`);
|
|
396
|
+
const rows = await sdk.list(TRIGGER_DEFINITION_TABLE, new URLSearchParams({
|
|
397
|
+
sysparm_query: `type=${type}^active=true`,
|
|
398
|
+
sysparm_limit: '5',
|
|
399
|
+
sysparm_display_value: 'all',
|
|
400
|
+
sysparm_fields: 'sys_id,name,type,active,category',
|
|
401
|
+
}));
|
|
402
|
+
if (rows.length !== 1) throw new Error(rows.length === 0
|
|
403
|
+
? `Trigger definition not found or inactive: ${type}`
|
|
404
|
+
: `Trigger definition is ambiguous: ${type}`);
|
|
405
|
+
const row = rows[0];
|
|
384
406
|
return {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
407
|
+
sysId: idValue(row.sys_id || row.sysId),
|
|
408
|
+
name: stringValue(row.name),
|
|
409
|
+
type: stringValue(row.type),
|
|
410
|
+
// The definition's category display name is what the trigger instance stores
|
|
411
|
+
// as triggerType ("Record", "Scheduled", "Application"). The raw value is the
|
|
412
|
+
// category sys_id, so take the display value when the API returned one.
|
|
413
|
+
triggerType: categoryDisplay(row.category),
|
|
391
414
|
};
|
|
392
415
|
}
|
|
393
416
|
|
|
417
|
+
function categoryDisplay(value) {
|
|
418
|
+
if (value && typeof value === 'object') return stringValue(value.display_value ?? value.value);
|
|
419
|
+
return stringValue(value);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Build the triggerInstances.insert payload from the definition's own declared
|
|
424
|
+
* inputs. `manifest.trigger.table` / `.condition` are shortcuts onto whichever
|
|
425
|
+
* declared input carries them; everything else goes through `trigger.inputs`.
|
|
426
|
+
*/
|
|
427
|
+
export async function buildCreateTrigger(sdk, flowId, manifest, definition, options = {}) {
|
|
428
|
+
const declared = await readTriggerInputDefinitions(sdk, definition.sysId);
|
|
429
|
+
if (!declared.length && options.requireInputs === true) throw new Error(`Trigger definition ${definition.type} declares no inputs`);
|
|
430
|
+
const tableInput = tableInputFor(declared);
|
|
431
|
+
const conditionInput = conditionInputFor(declared);
|
|
432
|
+
const supplied = { ...(manifest.trigger.inputs || {}) };
|
|
433
|
+
|
|
434
|
+
if (manifest.trigger.table !== undefined) {
|
|
435
|
+
if (!tableInput) throw new Error(`Trigger ${definition.type} does not declare a table input`);
|
|
436
|
+
supplied[tableInput.name] = manifest.trigger.table;
|
|
437
|
+
}
|
|
438
|
+
if (manifest.trigger.condition !== undefined) {
|
|
439
|
+
if (!conditionInput) throw new Error(`Trigger ${definition.type} does not declare a condition input`);
|
|
440
|
+
supplied[conditionInput.name] = manifest.trigger.condition;
|
|
441
|
+
}
|
|
442
|
+
let descriptor = null;
|
|
443
|
+
if (tableInput && supplied[tableInput.name]) {
|
|
444
|
+
descriptor = options.readTableDescriptor
|
|
445
|
+
? await options.readTableDescriptor(supplied[tableInput.name])
|
|
446
|
+
: await readTableDescriptor(sdk, supplied[tableInput.name]);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return buildTriggerInstance({
|
|
450
|
+
flowId,
|
|
451
|
+
definition,
|
|
452
|
+
triggerType: definition.triggerType,
|
|
453
|
+
metadata: DEFAULT_TRIGGER_METADATA,
|
|
454
|
+
inputs: buildTriggerInputs(declared, supplied, descriptor),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
394
458
|
function clone(value) {
|
|
395
459
|
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
396
460
|
}
|
|
@@ -511,9 +575,10 @@ function updatedTrigger(flow, manifest, descriptor) {
|
|
|
511
575
|
if (!id) throw new Error('Saved record trigger has no usable identity');
|
|
512
576
|
const update = { id, inputs };
|
|
513
577
|
if (manifest.trigger.condition !== undefined) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
578
|
+
// The instance re-derives predicate metadata from the condition input; see
|
|
579
|
+
// DEFAULT_TRIGGER_METADATA. The read-back check below still proves the
|
|
580
|
+
// derived predicate matches what was asked for.
|
|
581
|
+
update.metadata = DEFAULT_TRIGGER_METADATA;
|
|
517
582
|
} else if (Object.prototype.hasOwnProperty.call(existing, 'metadata')) {
|
|
518
583
|
update.metadata = existing.metadata;
|
|
519
584
|
}
|
|
@@ -528,6 +593,14 @@ function insertedActions(response) {
|
|
|
528
593
|
return found;
|
|
529
594
|
}
|
|
530
595
|
|
|
596
|
+
function insertedTriggers(response) {
|
|
597
|
+
let found = [];
|
|
598
|
+
walk(response, object => {
|
|
599
|
+
if (!found.length && object.triggerInstances?.inserts) found = object.triggerInstances.inserts;
|
|
600
|
+
});
|
|
601
|
+
return found;
|
|
602
|
+
}
|
|
603
|
+
|
|
531
604
|
async function reconcileAmbiguousCreate(sdk, manifest) {
|
|
532
605
|
const params = new URLSearchParams({ sysparm_query: `name=${manifest.name}`, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,active,status,scope' });
|
|
533
606
|
const records = await sdk.list('sys_hub_flow', params);
|
|
@@ -609,9 +682,12 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
609
682
|
parentSnapshot.capture_before = unavailableCapture('flow ID was not known before parent creation');
|
|
610
683
|
await addPostMutationCapture(parentSnapshot, sdk, readUpdateSet, flowId);
|
|
611
684
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
685
|
+
// A newly POSTed ProcessFlow has NO trigger (verified live: triggerInstances
|
|
686
|
+
// comes back empty), so the trigger is inserted by the same snFlowDesigner
|
|
687
|
+
// patch as the actions. There is nothing to read back from the parent apart
|
|
688
|
+
// from the flow identity.
|
|
689
|
+
const definition = await resolveTriggerDefinition(sdk, manifest.trigger.type);
|
|
690
|
+
const trigger = await buildCreateTrigger(sdk, flowId, manifest, definition, options);
|
|
615
691
|
|
|
616
692
|
const actionInstances = [];
|
|
617
693
|
for (const [index, action] of manifest.actions.entries()) {
|
|
@@ -636,11 +712,13 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
636
712
|
body: JSON.stringify({ query: buildFlowPatchMutation({ flowId, trigger, actions: actionInstances }), variables: {} }),
|
|
637
713
|
}), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
|
|
638
714
|
assertGraphQLSuccess(patchResponse, 'Flow trigger/action patch');
|
|
715
|
+
const insertedTrigger = insertedTriggers(patchResponse)[0];
|
|
716
|
+
if (!insertedTrigger) throw new Error('Flow trigger insert returned no trigger identity');
|
|
639
717
|
const inserted = insertedActions(patchResponse);
|
|
640
718
|
if (inserted.length !== actionInstances.length) throw new Error(`Flow action patch returned ${inserted.length} inserted action identities; expected ${actionInstances.length}`);
|
|
641
719
|
|
|
642
720
|
const processflow = await readProcessFlow(sdk, instance, flowId, scope);
|
|
643
|
-
verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, actionInstances, inserted });
|
|
721
|
+
verifyCreatedFlowReadback({ flowId, processflow, trigger, insertedTrigger, manifest, actionInstances, inserted });
|
|
644
722
|
const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
|
|
645
723
|
instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow create',
|
|
646
724
|
}), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
|
|
@@ -650,7 +728,7 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
650
728
|
const triggerReadback = findArray(processflow, 'triggerInstances')[0] || {};
|
|
651
729
|
return {
|
|
652
730
|
flow: { id: flowId, name: manifest.name, status: 'draft', active: false },
|
|
653
|
-
trigger: { ...triggerReadback, id: findId(triggerReadback, ['sysId', 'sys_id', 'id']) ||
|
|
731
|
+
trigger: { ...triggerReadback, id: findId(triggerReadback, ['sysId', 'sys_id', 'id']) || findId(insertedTrigger, ['sysId', 'sys_id', 'id']) },
|
|
654
732
|
actions: inserted,
|
|
655
733
|
processflow,
|
|
656
734
|
definition_readback: { available: true, status: 'read_back', flow_id: flowId },
|
|
@@ -660,7 +738,10 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
660
738
|
lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
|
|
661
739
|
};
|
|
662
740
|
} catch (error) {
|
|
663
|
-
|
|
741
|
+
// Creation is two-stage: the parent POST leaves a real draft behind when the
|
|
742
|
+
// trigger/action patch fails. Name that orphan explicitly instead of leaving
|
|
743
|
+
// the caller to hunt for it.
|
|
744
|
+
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 });
|
|
664
745
|
}
|
|
665
746
|
}
|
|
666
747
|
|
|
@@ -682,7 +763,9 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
|
|
|
682
763
|
if (manifest.trigger !== undefined) {
|
|
683
764
|
const currentTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
|
|
684
765
|
const currentTable = inputRawValue(inputList(currentTrigger).find(input => input.name === 'table'));
|
|
685
|
-
const descriptor =
|
|
766
|
+
const descriptor = options.readTableDescriptor
|
|
767
|
+
? await options.readTableDescriptor(manifest.trigger.table ?? currentTable)
|
|
768
|
+
: await readTableDescriptor(sdk, manifest.trigger.table ?? currentTable);
|
|
686
769
|
triggerUpdate = updatedTrigger(beforeDefinition, manifest, descriptor).update;
|
|
687
770
|
triggerUpdates.push(triggerUpdate);
|
|
688
771
|
}
|
|
@@ -726,10 +809,8 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
|
|
|
726
809
|
if (manifest.trigger.condition !== undefined) {
|
|
727
810
|
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`);
|
|
728
811
|
assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
|
|
729
|
-
const beforeMetadata = parseMaybeJson(beforeTrigger.metadata, {});
|
|
730
|
-
const afterMetadata = parseMaybeJson(readbackTrigger.metadata, {});
|
|
731
|
-
delete beforeMetadata.predicates;
|
|
732
|
-
delete afterMetadata.predicates;
|
|
812
|
+
const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
|
|
813
|
+
const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
|
|
733
814
|
if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
|
|
734
815
|
} else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
|
|
735
816
|
throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
|
|
@@ -7,7 +7,18 @@ import { FormatAuto } from './output.js';
|
|
|
7
7
|
import { errUsage, errConfirmationRequired } from './errors.js';
|
|
8
8
|
import { getCurrentUser, getCurrentApplication } from './context.js';
|
|
9
9
|
import { validateDefinition, validateScope, ACTION_TABLE } from './processflow-actions.js';
|
|
10
|
-
import { validateFlowManifest, validateFlowUpdateManifest, readProcessFlow, readActionDefinition
|
|
10
|
+
import { validateFlowManifest, validateFlowUpdateManifest, readProcessFlow, readActionDefinition } from './flow-create.js';
|
|
11
|
+
import {
|
|
12
|
+
NUMERIC_TRIGGER_TYPES,
|
|
13
|
+
PROMPTABLE_TRIGGER_TYPES,
|
|
14
|
+
conditionInputFor,
|
|
15
|
+
readTableDescriptor,
|
|
16
|
+
readTriggerCatalog,
|
|
17
|
+
readTriggerInputDefinitions,
|
|
18
|
+
selectableTriggers,
|
|
19
|
+
tableInputFor,
|
|
20
|
+
triggerChoiceLabel,
|
|
21
|
+
} from './flow-triggers.js';
|
|
11
22
|
import { deriveRecordTrigger } from './flow-test.js';
|
|
12
23
|
import { getActiveProfile } from './config.js';
|
|
13
24
|
|
|
@@ -253,12 +264,91 @@ async function tableName(argv, app) {
|
|
|
253
264
|
if (!name) throw errUsage('Selected trigger table has no raw name');
|
|
254
265
|
return name;
|
|
255
266
|
}
|
|
267
|
+
|
|
268
|
+
// Prompt for one declared trigger input. Choice lists are not exposed by
|
|
269
|
+
// sys_hub_trigger_input on this instance, and every supported family declares
|
|
270
|
+
// its choice/boolean inputs as optional with a default, so those keep the
|
|
271
|
+
// instance default instead of inventing options.
|
|
272
|
+
async function promptTriggerInput(app, input) {
|
|
273
|
+
if (!input?.name || !input.type) throw errUsage('Provider returned an incomplete named, typed trigger input');
|
|
274
|
+
if (input.type === 'choice') {
|
|
275
|
+
if (input.mandatory) throw errUsage(`Trigger input ${input.name} is a mandatory choice whose options this instance does not expose; supply it through --data-file`);
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
if (input.type === 'boolean') return null;
|
|
279
|
+
if (!PROMPTABLE_TRIGGER_TYPES.has(input.type)) {
|
|
280
|
+
throw errUsage(`Trigger input ${input.name} has unsupported type ${input.type}; use --data-file to preserve its complete schema and value`);
|
|
281
|
+
}
|
|
282
|
+
const p = prompts(app);
|
|
283
|
+
const message = `Trigger input: ${input.label || input.name} [${input.type}]`;
|
|
284
|
+
const fallback = input.defaultValue === '' || input.defaultValue === undefined ? '' : String(input.defaultValue);
|
|
285
|
+
if (NUMERIC_TRIGGER_TYPES.has(input.type)) {
|
|
286
|
+
const answer = await p.input({ message, default: scalarDefault(fallback), validate: value => {
|
|
287
|
+
if (value === '' && input.mandatory) return 'A value is required';
|
|
288
|
+
if (value === '') return true;
|
|
289
|
+
return Number.isFinite(Number(value)) ? true : 'Enter a number';
|
|
290
|
+
} });
|
|
291
|
+
return answer === '' ? '' : Number(answer);
|
|
292
|
+
}
|
|
293
|
+
return p.input({ message, default: scalarDefault(fallback), validate: value => (input.mandatory && value === '' ? 'A value is required' : true) });
|
|
294
|
+
}
|
|
295
|
+
|
|
256
296
|
export async function guidedFlowCreate(argv, app) {
|
|
257
297
|
if (!interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the flow manifest in noninteractive mode');
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
298
|
+
const name = await prompts(app).input({ message: 'Flow name', validate: required });
|
|
299
|
+
const description = await prompts(app).input({ message: 'Description' });
|
|
300
|
+
const scope = await designerScope(argv, app);
|
|
301
|
+
const manifest = { name, description, scope, trigger: { type: '' }, actions: [] };
|
|
302
|
+
|
|
303
|
+
// Category first, then the triggers that category actually contains, both
|
|
304
|
+
// read from the instance (sys_hub_trigger_category + the definition's own
|
|
305
|
+
// `category` reference). Definitions this CLI cannot create are never offered.
|
|
306
|
+
const catalog = await readTriggerCatalog(app.sdk);
|
|
307
|
+
const pickable = catalog.categories.filter(category => selectableTriggers(category).length);
|
|
308
|
+
if (!pickable.length) {
|
|
309
|
+
throw errUsage('No supported trigger categories are available on this instance; use --data-file to supply the manifest directly');
|
|
310
|
+
}
|
|
311
|
+
const chosenCategory = await prompts(app).select({
|
|
312
|
+
message: 'Select a trigger category',
|
|
313
|
+
choices: pickable.map(category => ({ name: `${category.name} (${selectableTriggers(category).length})`, value: category })),
|
|
314
|
+
});
|
|
315
|
+
const triggers = selectableTriggers(chosenCategory);
|
|
316
|
+
const definition = await prompts(app).select({
|
|
317
|
+
message: `Select a ${chosenCategory.name} trigger`,
|
|
318
|
+
choices: triggers.map(trigger => ({ name: triggerChoiceLabel(trigger), value: trigger })),
|
|
319
|
+
});
|
|
320
|
+
manifest.trigger.type = definition.type;
|
|
321
|
+
|
|
322
|
+
const declared = await readTriggerInputDefinitions(app.sdk, definition.sysId);
|
|
323
|
+
const tableInput = tableInputFor(declared);
|
|
324
|
+
if (tableInput) {
|
|
325
|
+
manifest.trigger.table = await tableName(argv, app);
|
|
326
|
+
}
|
|
327
|
+
const conditionInput = conditionInputFor(declared);
|
|
328
|
+
if (conditionInput) {
|
|
329
|
+
manifest.trigger.condition = await prompts(app).input({ message: 'Trigger condition (single supported encoded predicate, or empty)' });
|
|
330
|
+
}
|
|
331
|
+
const optional = declared.filter(input => !tableInput || input.name !== tableInput.name)
|
|
332
|
+
.filter(input => !conditionInput || input.name !== conditionInput.name);
|
|
333
|
+
if (optional.some(input => input.mandatory)) {
|
|
334
|
+
manifest.trigger.inputs = {};
|
|
335
|
+
for (const input of optional.filter(candidate => candidate.mandatory)) {
|
|
336
|
+
const value = await promptTriggerInput(app, input);
|
|
337
|
+
if (value !== null) manifest.trigger.inputs[input.name] = value;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (optional.some(input => !input.mandatory && input.type !== 'choice' && input.type !== 'boolean')
|
|
341
|
+
&& await prompts(app).confirm({ message: 'Change optional trigger inputs (otherwise the instance defaults are used)?', default: false })) {
|
|
342
|
+
manifest.trigger.inputs = manifest.trigger.inputs || {};
|
|
343
|
+
for (const input of optional.filter(candidate => !candidate.mandatory)) {
|
|
344
|
+
if (input.type === 'choice' || input.type === 'boolean') continue;
|
|
345
|
+
if (!await prompts(app).confirm({ message: `Change ${input.label || input.name} [${input.type}]?`, default: false })) continue;
|
|
346
|
+
const value = await promptTriggerInput(app, input);
|
|
347
|
+
if (value !== null) manifest.trigger.inputs[input.name] = value;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (manifest.trigger.inputs && !Object.keys(manifest.trigger.inputs).length) delete manifest.trigger.inputs;
|
|
351
|
+
|
|
262
352
|
while (await prompts(app).confirm({ message: 'Add a published action?', default: true })) {
|
|
263
353
|
const id = await pickDesignerRecord(argv, app, ACTION_TABLE, 'published action', 'active=true^published=true');
|
|
264
354
|
if (id === undefined) throw CANCEL;
|
|
@@ -273,7 +363,7 @@ export async function guidedFlowCreate(argv, app) {
|
|
|
273
363
|
manifest.actions.push({ type: id, inputs });
|
|
274
364
|
}
|
|
275
365
|
validateFlowManifest(manifest);
|
|
276
|
-
await confirmDesignerSave(app, `Create inactive draft flow '${manifest.name}' with ${manifest.actions.length} action(s)? No publish or execution.`);
|
|
366
|
+
await confirmDesignerSave(app, `Create inactive draft flow '${manifest.name}' with trigger ${definition.name} and ${manifest.actions.length} action(s)? No publish or execution.`);
|
|
277
367
|
return manifest;
|
|
278
368
|
}
|
|
279
369
|
export async function savedFlowDefinition(app, identifier) {
|
|
@@ -319,9 +409,9 @@ export async function guidedFlowEdit(argv, app) {
|
|
|
319
409
|
export async function guidedFlowTestRecord(argv, app) {
|
|
320
410
|
if (argv.record !== undefined) return argv.record;
|
|
321
411
|
if (!interactiveDesigner(argv, app)) throw errUsage('Provide --record <sys_id> in noninteractive mode');
|
|
322
|
-
const {
|
|
412
|
+
const { definition } = await savedFlowDefinition(app, argv.identifier);
|
|
323
413
|
const { table } = deriveRecordTrigger(definition);
|
|
324
|
-
const descriptor = await readTableDescriptor(app.sdk,
|
|
414
|
+
const descriptor = await readTableDescriptor(app.sdk, table);
|
|
325
415
|
const { identifier: _identifier, ...pickerArgs } = argv;
|
|
326
416
|
return pickDesignerRecord(pickerArgs, app, table, `test record in ${table}`, '', descriptor.displayField);
|
|
327
417
|
}
|