@jacebenson/jsn 4.3.7-dev.38 → 4.3.7-dev.40
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/commands/flows.js +18 -4
- package/src/flow-create.js +173 -86
- package/src/flow-designer-interactive.js +100 -10
- package/src/flow-triggers.js +395 -0
package/package.json
CHANGED
package/src/commands/flows.js
CHANGED
|
@@ -6,6 +6,7 @@ import { inspectFlow } from '../flow-inspection.js';
|
|
|
6
6
|
import { publishFlows, publishFlowLifecycle, publishDoctor, flowStatus } from '../flow-publish.js';
|
|
7
7
|
import { resolveRecord, unwrapSysId } from '../resolve-record.js';
|
|
8
8
|
import { createFlowFromManifest, updateFlowFromManifest, readCurrentUpdateSet } from '../flow-create.js';
|
|
9
|
+
import { EXCLUDED_TRIGGER_TYPES, SUPPORTED_TRIGGER_TYPES } from '../flow-triggers.js';
|
|
9
10
|
import { testFlow } from '../flow-test.js';
|
|
10
11
|
import { errUsage } from '../errors.js';
|
|
11
12
|
import { cancelled, pickDesignerRecord, guidedFlowCreate, guidedFlowEdit, guidedFlowTestRecord, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
|
|
@@ -32,6 +33,18 @@ function flowInspectionAdapter(app) {
|
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// The picker is driven by the instance's own trigger definitions, but this CLI
|
|
37
|
+
// can only create the families it has verified. State both plainly so a created
|
|
38
|
+
// flow never surprises the operator with a trigger that was not really set.
|
|
39
|
+
function triggerGuidance(trigger) {
|
|
40
|
+
return {
|
|
41
|
+
supported_families: [...SUPPORTED_TRIGGER_TYPES].sort(),
|
|
42
|
+
excluded_families: [...EXCLUDED_TRIGGER_TYPES.entries()].map(([type, reason]) => ({ type, reason })).sort((a, b) => a.type.localeCompare(b.type)),
|
|
43
|
+
created_trigger: trigger ? { id: trigger.id, type: trigger.type, name: trigger.name, triggerDefinitionId: trigger.triggerDefinitionId } : null,
|
|
44
|
+
note: 'The trigger picker lists only families this CLI can create and verify. Families the picker omits are reported in excluded_families.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'edit', 'delete', 'publish', 'test'] });
|
|
36
49
|
|
|
37
50
|
function renderChecks(checks) {
|
|
@@ -281,10 +294,11 @@ export function flowsCmd(wrap) {
|
|
|
281
294
|
})
|
|
282
295
|
.command({
|
|
283
296
|
command: 'create',
|
|
284
|
-
describe: '
|
|
297
|
+
describe: 'Create a draft flow: pick a trigger category then trigger in a terminal, or use a JSON manifest in scripts',
|
|
285
298
|
builder: (y) => y
|
|
286
299
|
.option('data', { type: 'string', describe: 'Flow manifest as inline JSON' })
|
|
287
|
-
.option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' })
|
|
300
|
+
.option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' })
|
|
301
|
+
.option('scope', { type: 'string', describe: 'Transaction scope sys_id or global for the guided picker' }),
|
|
288
302
|
handler: wrap(async (argv, app) => {
|
|
289
303
|
app.requireInstance();
|
|
290
304
|
const dataFile = argv.dataFile ?? argv['data-file'];
|
|
@@ -299,8 +313,8 @@ export function flowsCmd(wrap) {
|
|
|
299
313
|
const result = await createFlowFromManifest(app.sdk, app.getEffectiveInstance(), manifest, {
|
|
300
314
|
readUpdateSet: () => readCurrentUpdateSet(app.sdk),
|
|
301
315
|
});
|
|
302
|
-
app.ok(result, {
|
|
303
|
-
summary: `Created draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
|
|
316
|
+
app.ok({ ...result, trigger_guidance: triggerGuidance(result.trigger) }, {
|
|
317
|
+
summary: `Created draft flow "${result.flow.name}" (${result.flow.id}) with trigger ${result.trigger?.type ?? 'unknown'} (${result.trigger?.id ?? 'no trigger id'}) without activation`,
|
|
304
318
|
breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the draft flow' }],
|
|
305
319
|
});
|
|
306
320
|
}),
|
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 = [] }) {
|
|
@@ -230,21 +249,35 @@ export async function readActionDefinition(sdk, instance, actionTypeId, scope, {
|
|
|
230
249
|
return { definition, schema };
|
|
231
250
|
}
|
|
232
251
|
|
|
233
|
-
function buildTypedInputs(action, schema) {
|
|
252
|
+
export function buildTypedInputs(action, schema) {
|
|
234
253
|
const supplied = action.inputs || {};
|
|
235
|
-
const allowed = new Set(schema.map(field => field
|
|
254
|
+
const allowed = new Set(schema.map(field => field?.name).filter(Boolean));
|
|
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,
|
|
258
|
+
// everything else is echoed back untouched. Demanding a key the provider
|
|
259
|
+
// omits breaks real actions.
|
|
260
|
+
if (!field || typeof field !== 'object' || !field.id || !field.name) {
|
|
239
261
|
throw new Error(`Action ${action.type} returned an incomplete input schema; refusing to invent input metadata for "${field?.name || 'unknown'}"`);
|
|
240
262
|
}
|
|
263
|
+
if (field.parameter !== undefined && (typeof field.parameter !== 'object' || field.parameter === null || Array.isArray(field.parameter))) {
|
|
264
|
+
throw new Error(`Action ${action.type} input "${field.name}" has a malformed parameter block; refusing to change it`);
|
|
265
|
+
}
|
|
241
266
|
const suppliedValue = Object.prototype.hasOwnProperty.call(supplied, field.name) ? supplied[field.name] : field.default;
|
|
242
267
|
if ((suppliedValue === undefined || suppliedValue === null) && (field.mandatory === true || field.required === true)) throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
|
|
243
268
|
const value = suppliedValue ?? null;
|
|
244
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`.
|
|
245
274
|
return {
|
|
246
|
-
|
|
275
|
+
id: field.id,
|
|
247
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),
|
|
248
281
|
value: { schemaless: false, schemalessValue: '', value },
|
|
249
282
|
displayValue: { schemaless: false, schemalessValue: '', value: displayValue },
|
|
250
283
|
};
|
|
@@ -258,51 +291,15 @@ function parseSimpleCondition(condition) {
|
|
|
258
291
|
return { field: match[1], operator: match[2], term: match[3] };
|
|
259
292
|
}
|
|
260
293
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return { ...descriptor, label, displayField };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function buildPredicateMetadata(condition, descriptor) {
|
|
274
|
-
const predicate = parseSimpleCondition(condition);
|
|
275
|
-
if (!predicate) return { predicates: [], order_by: [], group_by: [], has_rlq_conditions: false };
|
|
276
|
-
const field = descriptor.fields?.find(item => stringValue(item.name || item.element || item.field) === predicate.field)
|
|
277
|
-
|| descriptor.schema?.find(item => stringValue(item.name || item.element || item.field) === predicate.field);
|
|
278
|
-
if (!field) throw new Error(`Record trigger condition field "${predicate.field}" is absent from the table descriptor`);
|
|
279
|
-
const fieldLabel = stringValue(field.label || field.column_label || predicate.field);
|
|
280
|
-
const operatorLabel = stringValue(field?.operators?.[predicate.operator] || predicate.operator);
|
|
281
|
-
const comparison = {
|
|
282
|
-
field_type: stringValue(field?.field_type || field?.type || 'string'),
|
|
283
|
-
operator: predicate.operator,
|
|
284
|
-
term_label: predicate.term,
|
|
285
|
-
or_query: false,
|
|
286
|
-
field_label: fieldLabel,
|
|
287
|
-
column_type: stringValue(field?.column_type || 'element'),
|
|
288
|
-
term: predicate.term,
|
|
289
|
-
display_value: '',
|
|
290
|
-
new_query: false,
|
|
291
|
-
goto_query: false,
|
|
292
|
-
is_pre_evaluated: false,
|
|
293
|
-
rlqc_query: false,
|
|
294
|
-
operator_label: operatorLabel,
|
|
295
|
-
value: predicate.term,
|
|
296
|
-
field: predicate.field,
|
|
297
|
-
type: 'comparison',
|
|
298
|
-
};
|
|
299
|
-
return {
|
|
300
|
-
predicates: [{ compound_type: 'or', subpredicates: [{ compound_type: 'and', subpredicates: [{ compound_type: 'and', subpredicates: [comparison], type: 'compound' }], type: 'compound' }], type: 'compound' }],
|
|
301
|
-
order_by: [],
|
|
302
|
-
group_by: [],
|
|
303
|
-
has_rlq_conditions: false,
|
|
304
|
-
};
|
|
305
|
-
}
|
|
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 = '{}';
|
|
306
303
|
|
|
307
304
|
function predicateMetadataMatches(metadata, condition) {
|
|
308
305
|
const expected = parseSimpleCondition(condition);
|
|
@@ -311,10 +308,15 @@ function predicateMetadataMatches(metadata, condition) {
|
|
|
311
308
|
const comparisons = [];
|
|
312
309
|
walk(parsed, object => {
|
|
313
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").
|
|
314
316
|
comparisons.push({
|
|
315
317
|
field: stringValue(object.field),
|
|
316
318
|
operator: stringValue(object.operator),
|
|
317
|
-
term: stringValue(object.
|
|
319
|
+
term: stringValue(object.value ?? object.term),
|
|
318
320
|
});
|
|
319
321
|
});
|
|
320
322
|
return comparisons.some(comparison => comparison.field === expected.field
|
|
@@ -326,18 +328,39 @@ function assertPredicateMetadataMatches(metadata, condition, label) {
|
|
|
326
328
|
if (!predicateMetadataMatches(metadata, condition)) throw new Error(`${label} predicate metadata did not match the requested field, operator, and term`);
|
|
327
329
|
}
|
|
328
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
|
+
|
|
329
345
|
function actionTypeIdOf(action) {
|
|
330
346
|
return idValue(action?.actionTypeSysId || action?.action_type_sys_id || action?.actionType?.sys_id || action?.actionType?.sysId);
|
|
331
347
|
}
|
|
332
348
|
|
|
333
|
-
function verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, actionInstances, inserted }) {
|
|
349
|
+
function verifyCreatedFlowReadback({ flowId, processflow, trigger, insertedTrigger, manifest, actionInstances, inserted }) {
|
|
334
350
|
const returnedFlowId = stringValue(processflow.flowId || processflow.flow_id || processflow.sysId || processflow.sys_id);
|
|
335
351
|
if (returnedFlowId && returnedFlowId !== flowId) throw new Error(`ProcessFlow read-back returned a different flow ID (${returnedFlowId})`);
|
|
336
352
|
|
|
337
353
|
const triggers = findArray(processflow, 'triggerInstances');
|
|
338
354
|
if (triggers.length !== 1) throw new Error(`ProcessFlow read-back returned ${triggers.length} trigger instances; expected one`);
|
|
339
355
|
const savedTrigger = triggers[0];
|
|
340
|
-
|
|
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}`);
|
|
341
364
|
const savedTriggerInputs = inputList(savedTrigger);
|
|
342
365
|
for (const expected of trigger.inputs) {
|
|
343
366
|
const actual = savedTriggerInputs.find(input => input.name === expected.name);
|
|
@@ -368,23 +391,70 @@ function verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, act
|
|
|
368
391
|
}
|
|
369
392
|
}
|
|
370
393
|
|
|
371
|
-
function
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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];
|
|
378
406
|
return {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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),
|
|
385
414
|
};
|
|
386
415
|
}
|
|
387
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
|
+
|
|
388
458
|
function clone(value) {
|
|
389
459
|
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
390
460
|
}
|
|
@@ -505,9 +575,10 @@ function updatedTrigger(flow, manifest, descriptor) {
|
|
|
505
575
|
if (!id) throw new Error('Saved record trigger has no usable identity');
|
|
506
576
|
const update = { id, inputs };
|
|
507
577
|
if (manifest.trigger.condition !== undefined) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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;
|
|
511
582
|
} else if (Object.prototype.hasOwnProperty.call(existing, 'metadata')) {
|
|
512
583
|
update.metadata = existing.metadata;
|
|
513
584
|
}
|
|
@@ -522,6 +593,14 @@ function insertedActions(response) {
|
|
|
522
593
|
return found;
|
|
523
594
|
}
|
|
524
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
|
+
|
|
525
604
|
async function reconcileAmbiguousCreate(sdk, manifest) {
|
|
526
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' });
|
|
527
606
|
const records = await sdk.list('sys_hub_flow', params);
|
|
@@ -603,9 +682,12 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
603
682
|
parentSnapshot.capture_before = unavailableCapture('flow ID was not known before parent creation');
|
|
604
683
|
await addPostMutationCapture(parentSnapshot, sdk, readUpdateSet, flowId);
|
|
605
684
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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);
|
|
609
691
|
|
|
610
692
|
const actionInstances = [];
|
|
611
693
|
for (const [index, action] of manifest.actions.entries()) {
|
|
@@ -630,11 +712,13 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
630
712
|
body: JSON.stringify({ query: buildFlowPatchMutation({ flowId, trigger, actions: actionInstances }), variables: {} }),
|
|
631
713
|
}), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
|
|
632
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');
|
|
633
717
|
const inserted = insertedActions(patchResponse);
|
|
634
718
|
if (inserted.length !== actionInstances.length) throw new Error(`Flow action patch returned ${inserted.length} inserted action identities; expected ${actionInstances.length}`);
|
|
635
719
|
|
|
636
720
|
const processflow = await readProcessFlow(sdk, instance, flowId, scope);
|
|
637
|
-
verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, actionInstances, inserted });
|
|
721
|
+
verifyCreatedFlowReadback({ flowId, processflow, trigger, insertedTrigger, manifest, actionInstances, inserted });
|
|
638
722
|
const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
|
|
639
723
|
instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow create',
|
|
640
724
|
}), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
|
|
@@ -644,7 +728,7 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
644
728
|
const triggerReadback = findArray(processflow, 'triggerInstances')[0] || {};
|
|
645
729
|
return {
|
|
646
730
|
flow: { id: flowId, name: manifest.name, status: 'draft', active: false },
|
|
647
|
-
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']) },
|
|
648
732
|
actions: inserted,
|
|
649
733
|
processflow,
|
|
650
734
|
definition_readback: { available: true, status: 'read_back', flow_id: flowId },
|
|
@@ -654,7 +738,10 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
654
738
|
lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
|
|
655
739
|
};
|
|
656
740
|
} catch (error) {
|
|
657
|
-
|
|
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 });
|
|
658
745
|
}
|
|
659
746
|
}
|
|
660
747
|
|
|
@@ -676,7 +763,9 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
|
|
|
676
763
|
if (manifest.trigger !== undefined) {
|
|
677
764
|
const currentTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
|
|
678
765
|
const currentTable = inputRawValue(inputList(currentTrigger).find(input => input.name === 'table'));
|
|
679
|
-
const descriptor =
|
|
766
|
+
const descriptor = options.readTableDescriptor
|
|
767
|
+
? await options.readTableDescriptor(manifest.trigger.table ?? currentTable)
|
|
768
|
+
: await readTableDescriptor(sdk, manifest.trigger.table ?? currentTable);
|
|
680
769
|
triggerUpdate = updatedTrigger(beforeDefinition, manifest, descriptor).update;
|
|
681
770
|
triggerUpdates.push(triggerUpdate);
|
|
682
771
|
}
|
|
@@ -720,10 +809,8 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
|
|
|
720
809
|
if (manifest.trigger.condition !== undefined) {
|
|
721
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`);
|
|
722
811
|
assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
|
|
723
|
-
const beforeMetadata = parseMaybeJson(beforeTrigger.metadata, {});
|
|
724
|
-
const afterMetadata = parseMaybeJson(readbackTrigger.metadata, {});
|
|
725
|
-
delete beforeMetadata.predicates;
|
|
726
|
-
delete afterMetadata.predicates;
|
|
812
|
+
const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
|
|
813
|
+
const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
|
|
727
814
|
if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
|
|
728
815
|
} else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
|
|
729
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,27 +264,106 @@ 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;
|
|
265
355
|
const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), id, manifest.scope, { requireTypes: true });
|
|
266
356
|
const inputs = {};
|
|
267
357
|
for (const field of schema) {
|
|
268
|
-
if (!field.id || !field.
|
|
269
|
-
throw errUsage(`Published action input ${field.name} lacks provider
|
|
358
|
+
if (!field.id || !field.name) {
|
|
359
|
+
throw errUsage(`Published action input ${field.name || 'unknown'} lacks the provider identity needed to bind a value; no flow was allocated`);
|
|
270
360
|
}
|
|
271
361
|
inputs[field.name] = await promptSchemaValue(app, field, field.default, 'Action input: ');
|
|
272
362
|
}
|
|
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
|
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Trigger contract for Flow Designer creation.
|
|
2
|
+
//
|
|
3
|
+
// Flow Designer does NOT give a newly POSTed ProcessFlow a default trigger, so
|
|
4
|
+
// the trigger instance must be INSERTED through the same snFlowDesigner
|
|
5
|
+
// GraphQL `flowPatch` that inserts actions. Everything the insert needs is read
|
|
6
|
+
// from the instance's own schema tables — no trigger input is hardcoded:
|
|
7
|
+
//
|
|
8
|
+
// sys_hub_trigger_definition one row per trigger ("Created", "Daily", …)
|
|
9
|
+
// sys_hub_trigger_category the picker's first level
|
|
10
|
+
// sys_hub_trigger_input the inputs a definition declares. The owning
|
|
11
|
+
// definition is carried by `name`, which is
|
|
12
|
+
// `var__m_sys_hub_trigger_input_<definitionId>`;
|
|
13
|
+
// the `model` reference is EMPTY on this
|
|
14
|
+
// instance for most definitions, and a
|
|
15
|
+
// `trigger_definition=` query is silently
|
|
16
|
+
// ignored, so `name` is the only reliable
|
|
17
|
+
// relation.
|
|
18
|
+
// sys_db_object / sys_dictionary the record trigger's Table label and display
|
|
19
|
+
// column (the ProcessFlow
|
|
20
|
+
// /trigger/record/table descriptor endpoint
|
|
21
|
+
// does not exist: HTTP 400 "Requested URI does
|
|
22
|
+
// not represent any resource").
|
|
23
|
+
//
|
|
24
|
+
// `internal_type` on sys_hub_trigger_input holds the parameter's real type
|
|
25
|
+
// ("glide_time", "table_name", "conditions", …) as its raw value; its display
|
|
26
|
+
// value is the human label ("Time", "Table Name", …).
|
|
27
|
+
|
|
28
|
+
export const TRIGGER_DEFINITION_TABLE = 'sys_hub_trigger_definition';
|
|
29
|
+
export const TRIGGER_CATEGORY_TABLE = 'sys_hub_trigger_category';
|
|
30
|
+
export const TRIGGER_INPUT_TABLE = 'sys_hub_trigger_input';
|
|
31
|
+
|
|
32
|
+
export const TRIGGER_INPUT_NAME_PREFIX = 'var__m_sys_hub_trigger_input_';
|
|
33
|
+
|
|
34
|
+
// Families this CLI can actually create, each verified against a live instance
|
|
35
|
+
// with a disposable draft flow (see the PR body for the probe sys_ids). The
|
|
36
|
+
// picker intersects this set with the instance's own active definitions, so an
|
|
37
|
+
// unimplemented family can never be offered.
|
|
38
|
+
export const SUPPORTED_TRIGGER_TYPES = new Set([
|
|
39
|
+
'record_create',
|
|
40
|
+
'record_update',
|
|
41
|
+
'record_create_or_update',
|
|
42
|
+
'daily',
|
|
43
|
+
'weekly',
|
|
44
|
+
'monthly',
|
|
45
|
+
'repeat',
|
|
46
|
+
'run_once',
|
|
47
|
+
'email',
|
|
48
|
+
'knowledge_management',
|
|
49
|
+
'service_catalog',
|
|
50
|
+
'sla_task',
|
|
51
|
+
'remote_table_query',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// Definitions that exist on the instance but are deliberately not offered, with
|
|
55
|
+
// the reason. Kept explicit so the picker can explain the omission instead of
|
|
56
|
+
// silently hiding a family.
|
|
57
|
+
export const EXCLUDED_TRIGGER_TYPES = new Map([
|
|
58
|
+
['analytics', 'the Proactive Analytics trigger definition is inactive on this instance'],
|
|
59
|
+
['rest', 'the Trigger Rest definition has no resolvable trigger category'],
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// Families whose declared inputs name a table. The manifest's `table` shortcut
|
|
63
|
+
// maps onto whichever of these the chosen definition declares.
|
|
64
|
+
const TABLE_INPUT_NAMES = ['table', 'u_table', 'target_table'];
|
|
65
|
+
// Same idea for the encoded-query condition.
|
|
66
|
+
const CONDITION_INPUT_NAMES = ['condition', 'email_conditions', 'business_calendar_condition'];
|
|
67
|
+
|
|
68
|
+
// Trigger input types this CLI can prompt for. Anything else must be supplied
|
|
69
|
+
// through --data-file so its complete schema and value are preserved.
|
|
70
|
+
export const PROMPTABLE_TRIGGER_TYPES = new Set([
|
|
71
|
+
'string', 'table_name', 'field_name', 'conditions', 'choice', 'reference',
|
|
72
|
+
'glide_time', 'glide_date_time', 'glide_duration', 'glide_list',
|
|
73
|
+
'integer', 'decimal', 'float', 'boolean', 'day_of_week', 'month_of_year',
|
|
74
|
+
'script', 'html', 'url', 'email', 'document_id', 'date', 'datetime',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
export const NUMERIC_TRIGGER_TYPES = new Set(['integer', 'decimal', 'float']);
|
|
78
|
+
|
|
79
|
+
function rawValue(value) {
|
|
80
|
+
if (value && typeof value === 'object') return value.value ?? value.sys_id ?? value.name ?? '';
|
|
81
|
+
return value == null ? '' : value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function displayValue(value) {
|
|
85
|
+
if (value && typeof value === 'object') return value.display_value ?? value.value ?? '';
|
|
86
|
+
return value == null ? '' : value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function text(value) {
|
|
90
|
+
const result = rawValue(value);
|
|
91
|
+
return result == null ? '' : String(result);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isSysId(value) {
|
|
95
|
+
return typeof value === 'string' && /^[0-9a-f]{32}$/i.test(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function boolValue(value) {
|
|
99
|
+
return String(rawValue(value)).toLowerCase() === 'true';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function numeric(value, fallback = 0) {
|
|
103
|
+
const parsed = Number(rawValue(value));
|
|
104
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The category tree the picker shows, built from the instance's own
|
|
109
|
+
* sys_hub_trigger_category rows and the category reference each active
|
|
110
|
+
* definition carries.
|
|
111
|
+
*
|
|
112
|
+
* Definitions whose category does not resolve to a readable category record
|
|
113
|
+
* are returned separately in `ungrouped` — they are never selectable, but the
|
|
114
|
+
* caller can report them.
|
|
115
|
+
*/
|
|
116
|
+
export async function readTriggerCatalog(sdk) {
|
|
117
|
+
const [definitions, categories] = await Promise.all([
|
|
118
|
+
sdk.list(TRIGGER_DEFINITION_TABLE, new URLSearchParams({
|
|
119
|
+
sysparm_query: 'active=true^ORDERBYname',
|
|
120
|
+
sysparm_limit: '200',
|
|
121
|
+
sysparm_display_value: 'all',
|
|
122
|
+
sysparm_fields: 'sys_id,name,type,active,category,description',
|
|
123
|
+
})),
|
|
124
|
+
sdk.list(TRIGGER_CATEGORY_TABLE, new URLSearchParams({
|
|
125
|
+
sysparm_limit: '200',
|
|
126
|
+
sysparm_display_value: 'all',
|
|
127
|
+
sysparm_fields: 'sys_id,name',
|
|
128
|
+
})),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const categoryNames = new Map(categories.map(row => [text(row.sys_id), displayValue(row.name) || text(row.name)]));
|
|
132
|
+
const grouped = new Map();
|
|
133
|
+
const ungrouped = [];
|
|
134
|
+
|
|
135
|
+
for (const row of definitions) {
|
|
136
|
+
const categoryId = text(row.category);
|
|
137
|
+
const categoryName = categoryNames.get(categoryId) || '';
|
|
138
|
+
const definition = {
|
|
139
|
+
sysId: text(row.sys_id),
|
|
140
|
+
type: text(row.type),
|
|
141
|
+
name: displayValue(row.name) || text(row.name),
|
|
142
|
+
description: displayValue(row.description) || '',
|
|
143
|
+
supported: SUPPORTED_TRIGGER_TYPES.has(text(row.type)),
|
|
144
|
+
excludedReason: EXCLUDED_TRIGGER_TYPES.get(text(row.type)) || (SUPPORTED_TRIGGER_TYPES.has(text(row.type)) ? '' : 'this CLI has no verified creation contract for this trigger'),
|
|
145
|
+
};
|
|
146
|
+
if (!categoryId || !categoryName) {
|
|
147
|
+
ungrouped.push(definition);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!grouped.has(categoryId)) grouped.set(categoryId, { id: categoryId, name: categoryName, triggers: [] });
|
|
151
|
+
grouped.get(categoryId).triggers.push(definition);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const categoriesOut = [...grouped.values()]
|
|
155
|
+
.map(category => ({ ...category, triggers: category.triggers.sort((a, b) => a.name.localeCompare(b.name)) }))
|
|
156
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
157
|
+
|
|
158
|
+
return { categories: categoriesOut, ungrouped };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The pickable definitions for one category, in the shape the picker needs. */
|
|
162
|
+
export function selectableTriggers(category) {
|
|
163
|
+
return (category?.triggers || []).filter(trigger => trigger.supported);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Definition descriptions are authored HTML; strip it and keep the first
|
|
167
|
+
// sentence so a picker row stays readable.
|
|
168
|
+
export function triggerChoiceLabel(trigger) {
|
|
169
|
+
const text = String(trigger?.description || '')
|
|
170
|
+
.replace(/<[^>]*>/g, ' ')
|
|
171
|
+
.replace(/&[a-z]+;/gi, ' ')
|
|
172
|
+
.replace(/\s+/g, ' ')
|
|
173
|
+
.trim();
|
|
174
|
+
if (!text) return trigger?.name || '';
|
|
175
|
+
const sentence = text.split(/\.\s/)[0];
|
|
176
|
+
return `${trigger.name} — ${sentence.length > 90 ? `${sentence.slice(0, 87)}…` : sentence}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The inputs a trigger definition declares, straight from
|
|
181
|
+
* sys_hub_trigger_input. Answers "what must I supply for this family?" from the
|
|
182
|
+
* instance rather than from a hand-written table.
|
|
183
|
+
*/
|
|
184
|
+
export async function readTriggerInputDefinitions(sdk, definitionId) {
|
|
185
|
+
if (!isSysId(definitionId)) throw new Error(`Trigger definition id must be an exact sys_id, got "${definitionId}"`);
|
|
186
|
+
const rows = await sdk.list(TRIGGER_INPUT_TABLE, new URLSearchParams({
|
|
187
|
+
sysparm_query: `name=${TRIGGER_INPUT_NAME_PREFIX}${definitionId}^ORDERBYorder`,
|
|
188
|
+
sysparm_limit: '200',
|
|
189
|
+
sysparm_display_value: 'all',
|
|
190
|
+
sysparm_fields: 'sys_id,element,label,column_label,internal_type,mandatory,order,max_length,default_value,attributes,choice,reference,use_dependent_field,dependent_on_field,read_only,hint',
|
|
191
|
+
}));
|
|
192
|
+
return rows
|
|
193
|
+
.map(normalizeTriggerInput)
|
|
194
|
+
.filter(input => input.name);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeTriggerInput(row) {
|
|
198
|
+
const parameter = {
|
|
199
|
+
id: text(row.sys_id),
|
|
200
|
+
label: displayValue(row.label) || displayValue(row.column_label) || text(row.element),
|
|
201
|
+
name: text(row.element),
|
|
202
|
+
type: text(row.internal_type),
|
|
203
|
+
hint: displayValue(row.hint) || '',
|
|
204
|
+
order: numeric(row.order),
|
|
205
|
+
extended: false,
|
|
206
|
+
mandatory: boolValue(row.mandatory),
|
|
207
|
+
readonly: boolValue(row.read_only),
|
|
208
|
+
maxsize: numeric(row.max_length),
|
|
209
|
+
children: [],
|
|
210
|
+
reference: text(row.reference) ? displayValue(row.reference) : '',
|
|
211
|
+
reference_display: '',
|
|
212
|
+
ref_qual: '',
|
|
213
|
+
choiceOption: text(row.choice),
|
|
214
|
+
table: '',
|
|
215
|
+
columnName: '',
|
|
216
|
+
defaultValue: rawValue(row.default_value) ?? '',
|
|
217
|
+
use_dependent: boolValue(row.use_dependent_field),
|
|
218
|
+
dependent_on: text(row.dependent_on_field),
|
|
219
|
+
show_ref_finder: false,
|
|
220
|
+
local: false,
|
|
221
|
+
// On INPUT the GraphQL type is String; the read path renders it as an object.
|
|
222
|
+
attributes: text(row.attributes),
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
id: parameter.id,
|
|
226
|
+
name: parameter.name,
|
|
227
|
+
label: parameter.label,
|
|
228
|
+
type: parameter.type,
|
|
229
|
+
mandatory: parameter.mandatory,
|
|
230
|
+
order: parameter.order,
|
|
231
|
+
defaultValue: parameter.defaultValue === '' ? '' : String(parameter.defaultValue),
|
|
232
|
+
reference: parameter.reference,
|
|
233
|
+
parameter,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The record trigger's table metadata. The documented ProcessFlow descriptor
|
|
239
|
+
* route (/api/now/processflow/trigger/record/table) does not exist on this
|
|
240
|
+
* instance, so the label comes from sys_db_object and the display column from
|
|
241
|
+
* the nearest sys_dictionary `display=true` element walking up the
|
|
242
|
+
* super_class chain (a child table such as `ticket` inherits `number` from
|
|
243
|
+
* `task`).
|
|
244
|
+
*/
|
|
245
|
+
export async function readTableDescriptor(sdk, table) {
|
|
246
|
+
const name = String(table || '').trim();
|
|
247
|
+
if (!name) throw new Error('Record trigger requires a table name');
|
|
248
|
+
const rows = await sdk.list('sys_db_object', new URLSearchParams({
|
|
249
|
+
sysparm_query: `name=${name}`,
|
|
250
|
+
sysparm_limit: '2',
|
|
251
|
+
sysparm_display_value: 'all',
|
|
252
|
+
sysparm_fields: 'name,label,super_class',
|
|
253
|
+
}));
|
|
254
|
+
if (rows.length !== 1) throw new Error(rows.length === 0 ? `Table not found: ${name}` : `Table name is ambiguous: ${name}`);
|
|
255
|
+
const label = displayValue(rows[0].label) || text(rows[0].label);
|
|
256
|
+
if (!label) throw new Error(`Table ${name} has no label`);
|
|
257
|
+
return { table: name, label, displayField: await readDisplayField(sdk, name, text(rows[0].super_class)) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function readDisplayField(sdk, table, superId) {
|
|
261
|
+
let current = table;
|
|
262
|
+
let parentId = superId;
|
|
263
|
+
for (let depth = 0; depth < 16 && current; depth += 1) {
|
|
264
|
+
const field = await sdk.list('sys_dictionary', new URLSearchParams({
|
|
265
|
+
sysparm_query: `name=${current}^display=true`,
|
|
266
|
+
sysparm_limit: '5',
|
|
267
|
+
sysparm_display_value: 'all',
|
|
268
|
+
sysparm_fields: 'element',
|
|
269
|
+
}));
|
|
270
|
+
const element = field.map(row => text(row.element)).find(Boolean);
|
|
271
|
+
if (element) return element;
|
|
272
|
+
if (!parentId) return '';
|
|
273
|
+
const parent = await sdk.list('sys_db_object', new URLSearchParams({
|
|
274
|
+
sysparm_query: `sys_id=${parentId}`,
|
|
275
|
+
sysparm_limit: '2',
|
|
276
|
+
sysparm_display_value: 'all',
|
|
277
|
+
sysparm_fields: 'name,super_class',
|
|
278
|
+
}));
|
|
279
|
+
if (parent.length !== 1) return '';
|
|
280
|
+
current = text(parent[0].name);
|
|
281
|
+
parentId = text(parent[0].super_class);
|
|
282
|
+
}
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The declared input a manifest shortcut (`table`, `condition`) should drive. */
|
|
287
|
+
export function tableInputFor(declared) {
|
|
288
|
+
return declared.find(input => TABLE_INPUT_NAMES.includes(input.name)) || null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function conditionInputFor(declared) {
|
|
292
|
+
return declared.find(input => CONDITION_INPUT_NAMES.includes(input.name)) || null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function wrapped(value) {
|
|
296
|
+
const scalar = value === '' || value === null || value === undefined ? '' : String(value);
|
|
297
|
+
return { schemaless: false, schemalessValue: '', value: scalar };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function declaredDefault(input) {
|
|
301
|
+
return input.defaultValue === '' || input.defaultValue === undefined ? '' : input.defaultValue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Turn a definition's declared inputs plus the caller's values into the exact
|
|
306
|
+
* `inputs` array the snFlowDesigner insert expects.
|
|
307
|
+
*
|
|
308
|
+
* Every declared input is written, using the instance's own default when the
|
|
309
|
+
* caller supplies nothing, because Flow Designer stores the full input set
|
|
310
|
+
* (a real record trigger carries run_on_extended / run_flow_in /
|
|
311
|
+
* run_when_setting / … alongside table and condition).
|
|
312
|
+
*/
|
|
313
|
+
export function buildTriggerInputs(declared, supplied = {}, tableDescriptor = null) {
|
|
314
|
+
const byName = new Map(declared.map(input => [input.name, input]));
|
|
315
|
+
for (const name of Object.keys(supplied)) {
|
|
316
|
+
if (!byName.has(name)) throw new Error(`Trigger input "${name}" is not declared by this trigger definition`);
|
|
317
|
+
}
|
|
318
|
+
for (const input of declared) {
|
|
319
|
+
const value = Object.prototype.hasOwnProperty.call(supplied, input.name) ? supplied[input.name] : declaredDefault(input);
|
|
320
|
+
const resolved = value === '' || value === null || value === undefined ? '' : String(value);
|
|
321
|
+
if (resolved === '' && input.mandatory) throw new Error(`Trigger input "${input.name}" (${input.label || input.type}) is required`);
|
|
322
|
+
const tableValue = tableDescriptor && TABLE_INPUT_NAMES.includes(input.name);
|
|
323
|
+
const renderedValue = tableValue ? tableDescriptor.table : resolved;
|
|
324
|
+
const renderedDisplay = tableValue ? tableDescriptor.label : resolved;
|
|
325
|
+
if (!input.parameter?.id) throw new Error(`Trigger input "${input.name}" is missing its provider identity; refusing to invent one`);
|
|
326
|
+
byName.get(input.name).value = renderedValue;
|
|
327
|
+
byName.get(input.name).displayValue = renderedDisplay;
|
|
328
|
+
byName.get(input.name).displayField = tableValue ? tableDescriptor.displayField || '' : '';
|
|
329
|
+
}
|
|
330
|
+
return declared.map(input => ({
|
|
331
|
+
name: input.name,
|
|
332
|
+
value: wrapped(input.value),
|
|
333
|
+
displayValue: wrapped(input.displayValue),
|
|
334
|
+
displayField: input.displayField || '',
|
|
335
|
+
scriptActive: false,
|
|
336
|
+
label: input.parameter.label,
|
|
337
|
+
internalType: input.parameter.type,
|
|
338
|
+
fromTemplate: false,
|
|
339
|
+
order: input.parameter.order,
|
|
340
|
+
valueSysId: '',
|
|
341
|
+
mandatory: input.parameter.mandatory === true,
|
|
342
|
+
parameter: input.parameter,
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The triggerInstances.insert payload for the snFlowDesigner flow patch. */
|
|
347
|
+
export function buildTriggerInstance({ flowId, definition, triggerType = '', inputs, metadata = '{}' }) {
|
|
348
|
+
if (!flowId) throw new Error('Trigger insert requires the created flow id');
|
|
349
|
+
if (!definition?.sysId || !definition?.type) throw new Error('Trigger insert requires a resolved trigger definition');
|
|
350
|
+
return {
|
|
351
|
+
flowSysId: flowId,
|
|
352
|
+
name: definition.name,
|
|
353
|
+
type: definition.type,
|
|
354
|
+
triggerType: triggerType || '',
|
|
355
|
+
triggerDefinitionId: definition.sysId,
|
|
356
|
+
metadata,
|
|
357
|
+
inputs,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The snFlowDesigner GraphQL input types take `attributes` as a String
|
|
362
|
+
// ("key=value,key=value") even though the ProcessFlow read path renders it as an
|
|
363
|
+
// object. Sending the object fails validation: "not a valid 'String'".
|
|
364
|
+
export function attributesToString(value) {
|
|
365
|
+
if (value === null || value === undefined) return '';
|
|
366
|
+
if (typeof value === 'string') return value;
|
|
367
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('Provider attributes must be a string or a flat object');
|
|
368
|
+
return Object.entries(value).map(([key, item]) => `${key}=${item}`).join(',');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Accepted `parameter` keys on global_snFlowDesigner_FlowDesignParameter.
|
|
372
|
+
// GraphQL introspection is disabled on this instance, so this list was
|
|
373
|
+
// established empirically by probing each candidate field against the live
|
|
374
|
+
// schema (unknown keys answer "contains a field not in
|
|
375
|
+
// 'global_snFlowDesigner_FlowDesignParameter'"). Anything the ProcessFlow
|
|
376
|
+
// action definition returns that is NOT in this list — notably the synthetic
|
|
377
|
+
// `default` this CLI adds, plus `scriptActive`/`value`/`displayValue` — must be
|
|
378
|
+
// carried elsewhere or dropped, or the mutation fails validation.
|
|
379
|
+
const FLOW_DESIGN_PARAMETER_FIELDS = [
|
|
380
|
+
'id', 'label', 'name', 'type', 'type_label', 'hint', 'order', 'extended',
|
|
381
|
+
'mandatory', 'readonly', 'maxsize', 'children', 'data_structure', 'reference',
|
|
382
|
+
'reference_display', 'ref_qual', 'choiceOption', 'table', 'columnName',
|
|
383
|
+
'defaultValue', 'defaultDisplayValue', 'use_dependent', 'dependent_on',
|
|
384
|
+
'show_ref_finder', 'local', 'attributes', 'sys_class_name', 'choices',
|
|
385
|
+
'defaultChoices', 'uiDisplayType', 'uiDisplayTypeLabel', 'placeholder',
|
|
386
|
+
'comments', 'internal_link', 'dynamic', 'searchField',
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
/** A provider schema entry narrowed to the GraphQL parameter input type. */
|
|
390
|
+
export function flowDesignParameter(entry) {
|
|
391
|
+
const out = {};
|
|
392
|
+
for (const key of FLOW_DESIGN_PARAMETER_FIELDS) if (entry?.[key] !== undefined) out[key] = entry[key];
|
|
393
|
+
out.attributes = attributesToString(entry?.attributes);
|
|
394
|
+
return out;
|
|
395
|
+
}
|