@xeplr/workflow-engine 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -2
  3. package/db/xcfgSetup.js +8 -0
  4. package/index.js +290 -0
  5. package/lib/actionCatalog.js +197 -0
  6. package/lib/actions/jobChainSteps.js +79 -0
  7. package/lib/actions/jobRun.js +171 -0
  8. package/lib/actions/screenShow.js +71 -0
  9. package/lib/auth.js +19 -0
  10. package/lib/db.js +82 -0
  11. package/lib/envExposed.js +87 -0
  12. package/lib/flows.js +916 -0
  13. package/lib/jobChains.js +426 -0
  14. package/lib/workflowRunner.js +652 -0
  15. package/migrations/0003_workflows.sql +35 -0
  16. package/migrations/0004_workflow_steps.sql +79 -0
  17. package/migrations/0005_workflow_runs.sql +49 -0
  18. package/migrations/0006_workflow_step_runs.sql +42 -0
  19. package/migrations/0007_workflow_resume_keys.sql +42 -0
  20. package/migrations/0008_workflow_run_edges.sql +43 -0
  21. package/migrations/0009_workflow_steps_layout.sql +11 -0
  22. package/migrations/0010_workflow_steps_sample_output.sql +17 -0
  23. package/migrations/0011_workflow_steps_params.sql +27 -0
  24. package/migrations/0012_workflows_kind.sql +27 -0
  25. package/migrations/0013_workflows_key.sql +48 -0
  26. package/migrations/0014_workflows_trigger.sql +30 -0
  27. package/migrations-tenants/0001_companies.sql +23 -0
  28. package/migrations-tenants/0002_workspaces.sql +22 -0
  29. package/models/Company.js +82 -0
  30. package/models/Workflow.js +67 -0
  31. package/models/WorkflowResumeKey.js +32 -0
  32. package/models/WorkflowRun.js +64 -0
  33. package/models/WorkflowRunEdge.js +49 -0
  34. package/models/WorkflowStep.js +66 -0
  35. package/models/WorkflowStepRun.js +49 -0
  36. package/models/Workspace.js +75 -0
  37. package/models/index.js +25 -0
  38. package/package.json +47 -4
package/lib/flows.js ADDED
@@ -0,0 +1,916 @@
1
+ // FLOWS — a screen is a step, and an app can design and run one without
2
+ // knowing anything about workflows.
3
+ //
4
+ // A "flow" is a workflow of kind 'screens' (migrations/0013). Every step is a
5
+ // `wait` step on the `screen-show` action carrying one screen key, so the
6
+ // engine that already exists runs it unchanged: the wait machinery parks the
7
+ // run and mints the resume key, resumeByKey merges the submitted values onto
8
+ // the step's output, and the ordinary transitions route on `output.<field>`.
9
+ //
10
+ // NOTHING HERE IS A SECOND ENGINE. This module translates one vocabulary into
11
+ // another and back:
12
+ //
13
+ // a flow is a workflow row with kind 'screens' and a `key`
14
+ // a screen is a step with actionName 'screen-show', kind 'wait',
15
+ // values.screen = the screen key
16
+ // a branch is a transition whose condition is an expression-handler node
17
+ // a submit is resumeByKey(theRun'sLiveKey, values)
18
+ //
19
+ // ── WHY THE DESIGNER NEVER SENDS AN EXPRESSION ──────────────────────────
20
+ //
21
+ // Transitions arrive and leave as `{ when: { field, op, value } | null,
22
+ // target }`. `field` is a field NAME on that step's screen; this module is
23
+ // what turns it into the path the engine reads (`output.<field>`) and what
24
+ // turns the designer's `=` into the engine's `eq`.
25
+ //
26
+ // That translation is deliberately one-way-in-one-place. The alternative — the
27
+ // browser compiling a formula, as the workflow builder does (@xeplr-workflow/ui's src/
28
+ // conditions.js) — means the client has to know that a resumed wait step's
29
+ // values land under `output`, which is an engine fact that has already changed
30
+ // once. A designer that only ever says "the field `type` equals contractor"
31
+ // keeps working the day it changes again.
32
+ //
33
+ // ── WHY A FLOW CANNOT BE EDITED THROUGH /workflows/save ─────────────────
34
+ //
35
+ // The steps of a flow are generated from a design held somewhere else. A hand
36
+ // edit through the workflow builder would be overwritten by the next PUT here
37
+ // with no warning and no record, so the workflow document routes refuse a
38
+ // 'screens' workflow outright (see touchesScreens; the API's router refuses the save)
39
+ // and the builder shows it read-only. Runs and history stay visible, because
40
+ // none of that is editing.
41
+
42
+ var { generateId } = require('@xeplr/utils/lib/helpers');
43
+ var db = require('./db');
44
+ var workflowRunner = require('./workflowRunner');
45
+
46
+ // The `workflows.kind` value that makes a workflow a flow. Not read by the
47
+ // engine — see migrations/0012's note; it decides who is allowed to edit the
48
+ // row and which facade lists it.
49
+ var FLOW_KIND = 'screens';
50
+
51
+ // Every step of a flow names this one action. A step on any other action is
52
+ // not something this facade can describe, which is why PUT builds the rows
53
+ // rather than accepting them.
54
+ var SCREEN_ACTION = 'screen-show';
55
+
56
+ // What the workflow document's own routes say when they refuse a flow. One
57
+ // sentence, naming where the thing IS editable — a refusal that does not say
58
+ // where to go instead is a dead end.
59
+ var SCREENS_EDIT_MESSAGE = 'This flow is designed in Configure UI';
60
+
61
+ // A flow's key is in its URL (GET /flows/:key), and /flows/runs/:runId shares
62
+ // that space. A flow keyed "runs" would make the second unreachable.
63
+ var RESERVED_KEYS = ['runs'];
64
+
65
+ var KEY_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
66
+ var STEP_KEY_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$/;
67
+ // A field name on a screen. Dots are allowed so a nested value
68
+ // (`address.city`) can be branched on — the engine's getPath walks them.
69
+ var FIELD_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
70
+
71
+ // The two sentinels the engine accepts as a transition target besides a step
72
+ // key. Restated here because they are part of this facade's contract too.
73
+ var END_TARGETS = ['end_success', 'end_failed'];
74
+
75
+ // Which run statuses mean "still going". `queued` is only ever a fanned-out
76
+ // child's starting state, and a screens flow does not fan out — it is in the
77
+ // list because the column has it, not because a flow reaches it.
78
+ var LIVE_RUN_STATUSES = ['queued', 'running', 'waiting'];
79
+
80
+ // ── the comparisons a designer may offer ────────────────────────────────
81
+ //
82
+ // KEY = what the designer sends and what GET hands back; VALUE = the operator
83
+ // @xeplr/expression-handler actually evaluates (lib/operators.js). Every one
84
+ // of these is an operator that package already has — this table adds no
85
+ // semantics, it only spells them the way a person picking from a dropdown
86
+ // would.
87
+ //
88
+ // The spellings are the ones the workflow builder's formula syntax already
89
+ // uses for the comparisons that have a symbol — the builder now writes a
90
+ // stored condition back with the engine's own `xf.toText` — so the two halves
91
+ // of the product do not disagree about what "=" means.
92
+ var OPS = {
93
+ '=': 'eq',
94
+ '!=': 'neq',
95
+ '>': 'gt',
96
+ '>=': 'gte',
97
+ '<': 'lt',
98
+ '<=': 'lte',
99
+ 'contains': 'contains',
100
+ 'notContains': 'notContains',
101
+ 'startsWith': 'startsWith',
102
+ 'endsWith': 'endsWith',
103
+ 'in': 'in',
104
+ 'notIn': 'notIn',
105
+ 'between': 'between',
106
+ 'isEmpty': 'isNull',
107
+ 'isNotEmpty': 'isNotNull'
108
+ };
109
+
110
+ // Other spellings of the same comparisons, accepted on the way IN only. A
111
+ // designer written against the engine's own vocabulary ('eq') keeps working;
112
+ // GET always answers in the canonical spelling above, so a round-trip is
113
+ // stable rather than merely lossless.
114
+ var OP_ALIASES = {
115
+ eq: '=', equals: '=', '==': '=',
116
+ neq: '!=', notEquals: '!=', '<>': '!=',
117
+ gt: '>', gte: '>=', lt: '<', lte: '<=',
118
+ isNull: 'isEmpty', isNotNull: 'isNotEmpty',
119
+ doesNotContain: 'notContains'
120
+ };
121
+
122
+ // Engine operator → canonical designer spelling, for reading a stored
123
+ // condition back out.
124
+ var OP_FROM_ENGINE = {};
125
+ Object.keys(OPS).forEach(function(op) { OP_FROM_ENGINE[OPS[op]] = op; });
126
+
127
+ // Take no right-hand value at all (arity 1 in the expression package).
128
+ var UNARY_OPS = ['isEmpty', 'isNotEmpty'];
129
+ // Take a list rather than a single value.
130
+ var LIST_OPS = ['in', 'notIn', 'between'];
131
+
132
+ /** The comparison names a designer may send, for an error message and for docs. */
133
+ function operators() {
134
+ return Object.keys(OPS);
135
+ }
136
+
137
+ // ── errors ──────────────────────────────────────────────────────────────
138
+ //
139
+ // Same body every other route in this package answers with —
140
+ // { message, code, details } — plus an HTTP status carried on the error so the
141
+ // router does not have to classify anything. `code` is what a client branches
142
+ // on; `message` is what a person reads.
143
+ function fail(status, code, message, details) {
144
+ var err = new Error(message);
145
+ err.status = status;
146
+ err.code = code;
147
+ err.details = details || [];
148
+ return err;
149
+ }
150
+
151
+ function uid() { return generateId(); }
152
+
153
+ // ── a designer's `when` ⇄ the engine's condition node ───────────────────
154
+
155
+ function canonicalOp(op) {
156
+ var raw = String(op == null ? '' : op).trim();
157
+ if (OPS[raw]) return raw;
158
+ if (OP_ALIASES[raw]) return OP_ALIASES[raw];
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * `{ field, op, value }` → the expression node the engine evaluates, or null
164
+ * for the catch-all.
165
+ *
166
+ * `field` is a bare field name of the step's own screen and is prefixed here
167
+ * with `output.` — which is where a resumed wait step's submitted values live
168
+ * (resumeByKey merges them onto the step run's output, and routeAfterStep
169
+ * evaluates against `{ output, item, params }`). The designer is never told
170
+ * that; if it ever changes, it changes in this one function.
171
+ *
172
+ * @param {object|null} when
173
+ * @param {string} where what to call this in an error ("step 'details',
174
+ * transition 2")
175
+ */
176
+ function whenToCondition(when, where) {
177
+ if (when === null || when === undefined) return null;
178
+ if (typeof when !== 'object' || Array.isArray(when)) {
179
+ throw fail(400, 'FLOW_INVALID', where + ': `when` must be an object, or null for the catch-all.');
180
+ }
181
+
182
+ var field = String(when.field == null ? '' : when.field).trim();
183
+ if (!field) {
184
+ throw fail(400, 'FLOW_INVALID', where + ': `when.field` is required — the name of a field on this step\'s screen.');
185
+ }
186
+ // A designer that sends `output.type` has learned an engine fact it should
187
+ // not have to know, and storing it would double the prefix. Named rather
188
+ // than silently stripped, so the mistake is fixed where it was made.
189
+ if (/^(output|params|item|steps|previous_step)\./.test(field)) {
190
+ throw fail(400, 'FLOW_INVALID', where + ': `when.field` is a plain field name of this screen, not a path — send "' +
191
+ field.replace(/^[a-z_]+\./, '') + '" rather than "' + field + '".');
192
+ }
193
+ if (!FIELD_PATTERN.test(field)) {
194
+ throw fail(400, 'FLOW_INVALID', where + ': "' + field + '" is not a usable field name (letters, digits, _ - and . only).');
195
+ }
196
+
197
+ return comparisonNode(when, where, 'output.' + field, 'FLOW_INVALID');
198
+ }
199
+
200
+ /**
201
+ * `{ op, value }` against the engine path `path` → the expression node the
202
+ * engine evaluates. The comparison half of a `when`, shared by every facade
203
+ * that writes conditions (screens flows here, job chains in jobChains.js), so
204
+ * "=" means one thing however a condition arrives.
205
+ *
206
+ * @param {object} when { op, value }
207
+ * @param {string} where what to call this in an error
208
+ * @param {string} path the engine path the left side reads
209
+ * @param {string} code the error code a refusal carries
210
+ */
211
+ function comparisonNode(when, where, path, code) {
212
+ var op = canonicalOp(when.op);
213
+ if (!op) {
214
+ throw fail(400, code, where + ': "' + when.op + '" is not a comparison this engine has. One of: ' + operators().join(', ') + '.');
215
+ }
216
+
217
+ var node = { left: { field: path }, op: OPS[op] };
218
+
219
+ if (UNARY_OPS.indexOf(op) > -1) {
220
+ if (when.value !== undefined && when.value !== null) {
221
+ throw fail(400, code, where + ': "' + op + '" takes no value.');
222
+ }
223
+ return node;
224
+ }
225
+
226
+ var value = when.value;
227
+
228
+ if (LIST_OPS.indexOf(op) > -1) {
229
+ if (!Array.isArray(value) || !value.length) {
230
+ throw fail(400, code, where + ': "' + op + '" needs `when.value` to be a non-empty list.');
231
+ }
232
+ if (op === 'between' && value.length !== 2) {
233
+ throw fail(400, code, where + ': "between" needs exactly two values, low then high.');
234
+ }
235
+ node.right = { value: value.slice() };
236
+ return node;
237
+ }
238
+
239
+ if (value === undefined || value === null) {
240
+ throw fail(400, code, where + ': "' + op + '" needs a `when.value`.');
241
+ }
242
+ var type = typeof value;
243
+ if (type !== 'string' && type !== 'number' && type !== 'boolean') {
244
+ throw fail(400, code, where + ': `when.value` must be a string, number or boolean.');
245
+ }
246
+ node.right = { value: value };
247
+ return node;
248
+ }
249
+
250
+ /**
251
+ * The stored condition node → the `when` the designer sent.
252
+ *
253
+ * The inverse of whenToCondition for everything this facade writes. A node it
254
+ * did not write (a hand-edited row, an older workflow) has no `when` that
255
+ * describes it, so it comes back as null — which reads in the designer as a
256
+ * catch-all rather than as a branch it would silently mistranslate. Publish
257
+ * validation is what stops such a row being created through this facade in the
258
+ * first place.
259
+ */
260
+ function conditionToWhen(node) {
261
+ if (!node || typeof node !== 'object') return null;
262
+ var op = OP_FROM_ENGINE[node.op];
263
+ var field = node.left && typeof node.left.field === 'string' ? node.left.field : '';
264
+ if (!op || field.indexOf('output.') !== 0) return null;
265
+
266
+ var when = { field: field.slice('output.'.length), op: op };
267
+ if (UNARY_OPS.indexOf(op) === -1) {
268
+ when.value = node.right && Object.prototype.hasOwnProperty.call(node.right, 'value') ? node.right.value : null;
269
+ }
270
+ return when;
271
+ }
272
+
273
+ // ── the flow document ───────────────────────────────────────────────────
274
+
275
+ function normaliseKey(key) {
276
+ var value = String(key == null ? '' : key).trim();
277
+ if (!value) throw fail(400, 'FLOW_KEY_INVALID', 'A flow needs a `key`.');
278
+ if (!KEY_PATTERN.test(value)) {
279
+ throw fail(400, 'FLOW_KEY_INVALID', '"' + value + '" is not a usable flow key — lowercase letters, digits, _ and -, starting with a letter or digit.');
280
+ }
281
+ if (RESERVED_KEYS.indexOf(value) > -1) {
282
+ throw fail(400, 'FLOW_KEY_INVALID', '"' + value + '" is reserved — /flows/runs/:runId already uses it.');
283
+ }
284
+ return value;
285
+ }
286
+
287
+ /**
288
+ * One step as the designer sees it. `transitions` always comes back as an
289
+ * array (never null) so a client never has to branch on the absence of one.
290
+ */
291
+ function stepView(step) {
292
+ var values = step.values || {};
293
+ return {
294
+ stepKey: step.stepKey,
295
+ // What the designer calls the step. Kept in the step's own `name`, so the
296
+ // workflow app shows the same words for it as Configure UI does.
297
+ label: step.name || null,
298
+ screen: values.screen || null,
299
+ layout: step.layout || null,
300
+ transitions: (step.transitions || []).map(function(t) {
301
+ return { when: conditionToWhen(t.condition), target: t.target };
302
+ })
303
+ };
304
+ }
305
+
306
+ function flowView(workflow, steps) {
307
+ return {
308
+ id: workflow.id,
309
+ key: workflow.key,
310
+ name: workflow.name,
311
+ status: workflow.status,
312
+ steps: (steps || []).map(stepView)
313
+ };
314
+ }
315
+
316
+ /** The list row. `steps` is a COUNT here, and the whole array on GET /flows/:key. */
317
+ function flowSummary(workflow, stepCount) {
318
+ return {
319
+ id: workflow.id,
320
+ key: workflow.key,
321
+ name: workflow.name,
322
+ status: workflow.status,
323
+ steps: stepCount
324
+ };
325
+ }
326
+
327
+ /**
328
+ * The flow row for a key, or a 404.
329
+ *
330
+ * Tenant-scoped by query() — a key that belongs to another company is not
331
+ * found rather than refused, because "no such flow" and "not yours" must read
332
+ * the same from outside. See BaseModel's tenant modifier.
333
+ */
334
+ async function loadFlow(key) {
335
+ var flow = await db.model('Workflow').query().where({ key: key, kind: FLOW_KIND }).first();
336
+ if (!flow) throw fail(404, 'FLOW_NOT_FOUND', 'No flow "' + key + '".');
337
+ return flow;
338
+ }
339
+
340
+ /**
341
+ * ANY workflow with that key — the one to RUN, whichever kind it is.
342
+ *
343
+ * A flow used to mean "a workflow of kind 'screens', designed elsewhere". It
344
+ * now also means an ordinary workflow drawn in the designer, addressed by the
345
+ * key its app gave it, whose steps happen to include screens (@xeplr/ui-
346
+ * workflow's Screen type). Both are run the same way and by the same page, so
347
+ * the routes that START and LIST runs ask only for the key.
348
+ *
349
+ * DESIGN is the part that is still kind-gated, and deliberately: putFlow and
350
+ * publishFlow generate the step rows from a screens-only description, and
351
+ * pointing those at a workflow drawn here would delete a design they cannot
352
+ * express. So loadFlow above still guards them, and this is for running.
353
+ */
354
+ async function loadRunnable(key) {
355
+ var flow = await db.model('Workflow').query().where({ key: key }).first();
356
+ if (!flow) throw fail(404, 'FLOW_NOT_FOUND', 'No flow "' + key + '".');
357
+ return flow;
358
+ }
359
+
360
+ async function loadSteps(workflowId) {
361
+ return db.model('WorkflowStep').query().where({ workflowId: workflowId }).orderBy('position');
362
+ }
363
+
364
+ // ── the steps a PUT writes ──────────────────────────────────────────────
365
+
366
+ /**
367
+ * The designer's steps → workflow_steps rows.
368
+ *
369
+ * EVERYTHING IS VALIDATED BEFORE ANYTHING IS WRITTEN. The rows are built
370
+ * completely first and only then does putSteps touch the table, so a PUT that
371
+ * is refused leaves the draft exactly as it was rather than half-replaced.
372
+ *
373
+ * `screen` may be missing here and is required at PUBLISH: a draft is allowed
374
+ * to be unfinished, which is the difference between the two verbs.
375
+ */
376
+ function toStepRows(workflowId, steps) {
377
+ if (!Array.isArray(steps)) {
378
+ throw fail(400, 'FLOW_INVALID', '`steps` must be an array.');
379
+ }
380
+
381
+ var seen = {};
382
+ return steps.map(function(step, index) {
383
+ var where = 'step ' + (index + 1);
384
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
385
+ throw fail(400, 'FLOW_INVALID', where + ': each step must be an object.');
386
+ }
387
+
388
+ var stepKey = String(step.stepKey == null ? '' : step.stepKey).trim();
389
+ if (!stepKey) throw fail(400, 'FLOW_INVALID', where + ': `stepKey` is required.');
390
+ if (!STEP_KEY_PATTERN.test(stepKey)) {
391
+ throw fail(400, 'FLOW_INVALID', where + ': "' + stepKey + '" is not a usable stepKey (letters, digits, _ and -).');
392
+ }
393
+ // Two steps with one key would make `target: "x"` ambiguous and would
394
+ // collide on workflow_steps' own unique index — caught here so the answer
395
+ // names the step rather than the constraint.
396
+ if (seen[stepKey]) throw fail(400, 'FLOW_INVALID', 'Two steps share the stepKey "' + stepKey + '".');
397
+ seen[stepKey] = true;
398
+
399
+ var screen = step.screen == null ? '' : String(step.screen).trim();
400
+
401
+ var transitions = step.transitions === undefined || step.transitions === null ? [] : step.transitions;
402
+ if (!Array.isArray(transitions)) {
403
+ throw fail(400, 'FLOW_INVALID', 'step "' + stepKey + '": `transitions` must be an array.');
404
+ }
405
+
406
+ var compiled = transitions.map(function(t, ti) {
407
+ var label = 'step "' + stepKey + '", transition ' + (ti + 1);
408
+ if (!t || typeof t !== 'object' || Array.isArray(t)) {
409
+ throw fail(400, 'FLOW_INVALID', label + ': must be an object.');
410
+ }
411
+ var target = String(t.target == null ? '' : t.target).trim();
412
+ if (!target) throw fail(400, 'FLOW_INVALID', label + ': `target` is required.');
413
+ return {
414
+ condition: whenToCondition(t.when, label),
415
+ // Single, always. `each` fans a transition out into one child run per
416
+ // element, which has no meaning for a person filling in a screen —
417
+ // there is one of them and one run.
418
+ mode: 'single',
419
+ target: target
420
+ };
421
+ });
422
+
423
+ if (step.layout !== undefined && step.layout !== null &&
424
+ (typeof step.layout !== 'object' || Array.isArray(step.layout))) {
425
+ throw fail(400, 'FLOW_INVALID', 'step "' + stepKey + '": `layout` must be an object or null.');
426
+ }
427
+
428
+ return {
429
+ id: uid(),
430
+ workflowId: workflowId,
431
+ stepKey: stepKey,
432
+ name: typeof step.label === 'string' && step.label.trim() ? step.label.trim().slice(0, 255) : null,
433
+ actionName: SCREEN_ACTION,
434
+ // THE TWO FACTS THAT MAKE A SCREEN A STEP. `wait` is what parks the run
435
+ // and mints the resume key before the action runs; `stop` is the engine
436
+ // default, restated because a flow that walked on past a screen nobody
437
+ // filled in would be worse than one that stopped.
438
+ kind: 'wait',
439
+ onError: 'stop',
440
+ values: { screen: screen },
441
+ transitions: compiled.length ? compiled : null,
442
+ layout: step.layout || null,
443
+ position: index
444
+ };
445
+ });
446
+ }
447
+
448
+ // ── what publishing checks ──────────────────────────────────────────────
449
+
450
+ /**
451
+ * Everything that must be true before a flow can be run, as a list of
452
+ * { stepKey, message } — all of them, not the first one. A designer fixing a
453
+ * flow one refusal at a time is a designer publishing five times.
454
+ */
455
+ function validateFlow(steps) {
456
+ var problems = [];
457
+ function problem(stepKey, message) { problems.push({ stepKey: stepKey, message: message }); }
458
+
459
+ // A flow with no steps has no first step to start a run at.
460
+ if (!steps.length) {
461
+ problem(null, 'This flow has no screens yet.');
462
+ return problems;
463
+ }
464
+
465
+ var byKey = {};
466
+ steps.forEach(function(s) { byKey[s.stepKey] = s; });
467
+
468
+ steps.forEach(function(step, index) {
469
+ var values = step.values || {};
470
+ if (!values.screen) problem(step.stepKey, 'No screen is chosen for this step.');
471
+
472
+ var transitions = step.transitions || [];
473
+
474
+ // No transitions means the engine advances by position — which for the
475
+ // LAST step is "end the run", and for any other step is a fall-through
476
+ // nobody drew. A screen with no way out in the middle of a flow is a
477
+ // missing arrow, not a design.
478
+ if (!transitions.length) {
479
+ if (index !== steps.length - 1) {
480
+ problem(step.stepKey, 'This step has no next screen. Give it a transition, or make it the last step.');
481
+ }
482
+ return;
483
+ }
484
+
485
+ var catchAlls = 0;
486
+ transitions.forEach(function(t, ti) {
487
+ if (!t.condition) catchAlls++;
488
+ // First match wins (routeAfterStep), so anything after a catch-all can
489
+ // never be reached.
490
+ if (!t.condition && ti !== transitions.length - 1) {
491
+ problem(step.stepKey, 'The catch-all is not the last transition, so nothing after it can ever run.');
492
+ }
493
+ if (t.target && END_TARGETS.indexOf(t.target) === -1 && !byKey[t.target]) {
494
+ problem(step.stepKey, 'Transition ' + (ti + 1) + ' goes to "' + t.target + '", which is not a step of this flow.');
495
+ }
496
+ });
497
+ if (catchAlls > 1) {
498
+ problem(step.stepKey, 'This step has ' + catchAlls + ' catch-alls; only the first could ever match.');
499
+ }
500
+ });
501
+
502
+ // Every run starts at the first step, so a step nothing points at is a
503
+ // screen no one will ever see. Reported rather than pruned — an unreachable
504
+ // step is usually an arrow somebody forgot to draw, not a step they meant to
505
+ // delete.
506
+ var reachable = {};
507
+ var queue = [steps[0].stepKey];
508
+ while (queue.length) {
509
+ var key = queue.shift();
510
+ if (reachable[key] || !byKey[key]) continue;
511
+ reachable[key] = true;
512
+ (byKey[key].transitions || []).forEach(function(t) {
513
+ if (t.target && END_TARGETS.indexOf(t.target) === -1) queue.push(t.target);
514
+ });
515
+ }
516
+ steps.forEach(function(step) {
517
+ if (!reachable[step.stepKey]) {
518
+ problem(step.stepKey, 'Nothing leads to this step, so it can never be shown.');
519
+ }
520
+ });
521
+
522
+ return problems;
523
+ }
524
+
525
+ // ── a run, as the app sees it ───────────────────────────────────────────
526
+
527
+ // The engine's five statuses collapse to the four an app showing screens can
528
+ // act on. 'queued' and 'running' are both "the engine still has it"; a flow
529
+ // only ever pauses on a screen, so a caller sees 'waiting' or a terminal.
530
+ var STATUS = { queued: 'running', running: 'running', waiting: 'waiting', success: 'done', failed: 'failed' };
531
+
532
+ /**
533
+ * What the run is parked on, and what that step already holds.
534
+ *
535
+ * `values` merges every step run this run has recorded for that step key, in
536
+ * order. Normally that is the one waiting step run and its output is empty —
537
+ * but a flow that loops back to a screen already filled in reopens it with the
538
+ * answers from last time, which is the only behaviour anyone expects.
539
+ */
540
+ function runView(run, steps, stepRuns) {
541
+ var status = STATUS[run.status] || run.status;
542
+ var waiting = null;
543
+ for (var i = stepRuns.length - 1; i >= 0; i--) {
544
+ if (stepRuns[i].status === 'waiting') { waiting = stepRuns[i]; break; }
545
+ }
546
+
547
+ var view = {
548
+ runId: run.id,
549
+ status: status,
550
+ stepKey: waiting ? waiting.stepKey : null,
551
+ screen: null,
552
+ values: {},
553
+ // WHAT THE FLOW ALREADY KNOWS, for the screen about to be shown: the
554
+ // step's `values` input, resolved. A containment's complaint comes from
555
+ // {steps.d1.output.recordId}, and the person is never asked for it.
556
+ //
557
+ // Read off the STEP RUN rather than the step row, because that is where
558
+ // the resolved input is kept (workflowRunner patches `input` as it parks)
559
+ // — the step row holds the tokens, and a browser has nothing to resolve
560
+ // them with.
561
+ given: {}
562
+ };
563
+ if (!waiting) {
564
+ if (run.status === 'failed' && run.error) view.error = run.error;
565
+ return view;
566
+ }
567
+
568
+ var step = steps.filter(function(s) { return s.stepKey === waiting.stepKey; })[0];
569
+ view.screen = step && step.values ? (step.values.screen || null) : null;
570
+ if (waiting.input && typeof waiting.input.values === 'object' && waiting.input.values !== null) {
571
+ view.given = waiting.input.values;
572
+ }
573
+
574
+ stepRuns.forEach(function(sr) {
575
+ if (sr.stepKey !== waiting.stepKey) return;
576
+ Object.assign(view.values, sr.output || {});
577
+ });
578
+ return view;
579
+ }
580
+
581
+ async function stepRunsOf(runId) {
582
+ return db.model('WorkflowStepRun').query().where({ runId: runId }).orderBy('recordCreatedDate', 'asc');
583
+ }
584
+
585
+ /**
586
+ * A run of a flow, or a 404.
587
+ *
588
+ * Tenant-scoped by query(), and that IS the isolation: a runId from another
589
+ * company simply is not there, and every route below goes through here before
590
+ * it touches anything.
591
+ */
592
+ async function loadRun(runId) {
593
+ var run = await db.model('WorkflowRun').query().findById(runId);
594
+ if (!run) throw fail(404, 'RUN_NOT_FOUND', 'No run "' + runId + '".');
595
+ var flow = await db.model('Workflow').query().findById(run.workflowId);
596
+ if (!flow) throw fail(404, 'RUN_NOT_FOUND', 'No run "' + runId + '".');
597
+ return { run: run, flow: flow };
598
+ }
599
+
600
+ /**
601
+ * ANY workflow's run, not just a flow's.
602
+ *
603
+ * This used to demand kind 'screens', which made a screen inside an ORDINARY
604
+ * workflow a trap: the engine parks the run correctly — it is a wait step
605
+ * like any other — but nothing could ever submit it, because the two routes
606
+ * that show and resume a screen answered 404 for it. A step that can be drawn
607
+ * and can never be finished is worse than one that cannot be drawn.
608
+ *
609
+ * A flow of screens is still designed, validated and published through the
610
+ * facade's own routes (loadFlow, and those keep the kind gate). This is only
611
+ * about a RUN that is sitting on a screen, whichever kind of workflow put it
612
+ * there.
613
+ *
614
+ * The isolation is unchanged and is what it always was: query() is tenant
615
+ * scoped, so a runId from another company is simply not there.
616
+ */
617
+
618
+ // ── the services the router is a thin wrapper over ──────────────────────
619
+
620
+ /** GET /flows */
621
+ async function listFlows() {
622
+ var flows = await db.model('Workflow').query().where({ kind: FLOW_KIND }).orderBy('name');
623
+ if (!flows.length) return [];
624
+ var steps = await db.model('WorkflowStep').query()
625
+ .whereIn('workflowId', flows.map(function(f) { return f.id; }));
626
+ var counts = {};
627
+ steps.forEach(function(s) { counts[s.workflowId] = (counts[s.workflowId] || 0) + 1; });
628
+ return flows.map(function(f) { return flowSummary(f, counts[f.id] || 0); });
629
+ }
630
+
631
+ /** POST /flows */
632
+ async function createFlow(body) {
633
+ body = body || {};
634
+ var key = normaliseKey(body.key);
635
+ var name = String(body.name == null ? '' : body.name).trim() || key;
636
+
637
+ var existing = await db.model('Workflow').query().where({ key: key }).first();
638
+ if (existing) throw fail(409, 'FLOW_KEY_TAKEN', 'A flow keyed "' + key + '" already exists.');
639
+
640
+ var flow = await db.model('Workflow').query().insert({
641
+ id: uid(),
642
+ key: key,
643
+ name: name,
644
+ kind: FLOW_KIND,
645
+ status: 'draft'
646
+ });
647
+ return flowSummary(flow, 0);
648
+ }
649
+
650
+ /** GET /flows/:key */
651
+ async function getFlow(key) {
652
+ var flow = await loadFlow(key);
653
+ return flowView(flow, await loadSteps(flow.id));
654
+ }
655
+
656
+ /**
657
+ * PUT /flows/:key — replace the steps of a DRAFT.
658
+ *
659
+ * A published flow is refused rather than edited. A run in flight is parked on
660
+ * a step row by id; rewriting those rows underneath it would move a person
661
+ * from the screen they are looking at to whatever now sits at that position,
662
+ * with no record that it happened. Publishing a new version is the edit.
663
+ */
664
+ async function putFlow(key, body) {
665
+ body = body || {};
666
+ var flow = await loadFlow(key);
667
+ if (flow.status !== 'draft') {
668
+ throw fail(409, 'FLOW_PUBLISHED', 'This flow is published; publish a new version instead of editing it.');
669
+ }
670
+
671
+ var rows = toStepRows(flow.id, body.steps);
672
+
673
+ var name = body.name === undefined ? null : String(body.name == null ? '' : body.name).trim();
674
+ if (name !== null) {
675
+ if (!name) throw fail(400, 'FLOW_INVALID', '`name` cannot be blank.');
676
+ await db.model('Workflow').query().findById(flow.id).patch({ name: name });
677
+ flow.name = name;
678
+ }
679
+
680
+ // HARD delete, not the soft one the document routes use: workflow_steps'
681
+ // unique index is (workflowId, stepKey) and it does not care about
682
+ // isActive, so a soft-deleted step would block a design that still uses its
683
+ // key — which is most of them.
684
+ await db.model('WorkflowStep').query().delete().where({ workflowId: flow.id });
685
+ for (var i = 0; i < rows.length; i++) {
686
+ await db.model('WorkflowStep').query().insert(rows[i]);
687
+ }
688
+
689
+ return flowView(flow, await loadSteps(flow.id));
690
+ }
691
+
692
+ /**
693
+ * POST /flows/:key/publish
694
+ *
695
+ * Idempotent: publishing an already-published flow answers with it rather than
696
+ * refusing, because a double click is not a mistake worth an error.
697
+ */
698
+ async function publishFlow(key) {
699
+ var flow = await loadFlow(key);
700
+ var steps = await loadSteps(flow.id);
701
+ if (flow.status === 'published') return flowSummary(flow, steps.length);
702
+
703
+ var problems = validateFlow(steps);
704
+ if (problems.length) {
705
+ throw fail(400, 'FLOW_INVALID', 'This flow cannot be published yet: ' + problems[0].message, problems);
706
+ }
707
+
708
+ await db.model('Workflow').query().findById(flow.id).patch({ status: 'published' });
709
+ flow.status = 'published';
710
+ return flowSummary(flow, steps.length);
711
+ }
712
+
713
+ /**
714
+ * POST /flows/:key/runs
715
+ *
716
+ * A DRAFT IS REFUSED. Everything publish checks — a screen on every step, a
717
+ * target that exists, a way out of every screen — is what stops a run failing
718
+ * halfway through in front of whoever was filling it in. Publishing is cheap;
719
+ * a half-designed run in someone's hands is not.
720
+ */
721
+ async function startFlowRun(key, body, user) {
722
+ var flow = await loadRunnable(key);
723
+ // PUBLISHING IS A SCREENS-FLOW IDEA. There, publish is what checks that
724
+ // every step names a screen and every branch leads somewhere — the thing
725
+ // that stops a run failing halfway through in front of whoever is filling
726
+ // it in, and the facade's PUT is the only way its steps get written.
727
+ //
728
+ // A workflow drawn in the designer has no such gate anywhere else: POST
729
+ // /workflows/:id/run starts one as it is. Demanding a status it is never
730
+ // given would make every flow this app draws unrunnable, which is a trap
731
+ // rather than a safeguard.
732
+ if (flow.kind === FLOW_KIND && flow.status !== 'published') {
733
+ throw fail(409, 'FLOW_NOT_PUBLISHED', 'This flow is still a draft — publish it before starting a run.');
734
+ }
735
+ var run;
736
+ try {
737
+ run = await workflowRunner.startRun(flow.id, (body && body.params) || {}, { user: user, trigger: 'flow' });
738
+ } catch (err) {
739
+ throw fail(400, err.code || 'RUN_NOT_STARTED', err.message, err.details);
740
+ }
741
+ var steps = await loadSteps(flow.id);
742
+ return runView(run, steps, await stepRunsOf(run.id));
743
+ }
744
+
745
+ /** GET /flows/runs/:runId */
746
+ async function getRun(runId) {
747
+ var loaded = await loadRun(runId);
748
+ return runView(loaded.run, await loadSteps(loaded.flow.id), await stepRunsOf(runId));
749
+ }
750
+
751
+ /**
752
+ * POST /flows/runs/:runId/submit
753
+ *
754
+ * THE RESUME KEY NEVER LEAVES THE SERVER. The engine's own resume route is
755
+ * public by design — a key arrives from an email client with no token — but a
756
+ * screen is submitted by somebody who is signed in and looking at the run, so
757
+ * this route is gated like every other and looks the key up from the run.
758
+ * A browser holding a resume key could resume a run it was never shown.
759
+ *
760
+ * `recordId` is kept on the step's output beside the submitted values: the app
761
+ * has already written the record to its own table, and a later step (or a
762
+ * transition) that needs to point at it can read `output.recordId` like any
763
+ * other field.
764
+ */
765
+ async function submitRun(runId, body) {
766
+ body = body || {};
767
+ var values = body.values === undefined || body.values === null ? {} : body.values;
768
+ if (typeof values !== 'object' || Array.isArray(values)) {
769
+ throw fail(400, 'SUBMIT_INVALID', '`values` must be an object.');
770
+ }
771
+
772
+ var loaded = await loadRun(runId);
773
+ if (loaded.run.status !== 'waiting') {
774
+ throw fail(409, 'RUN_NOT_WAITING', 'This run is not waiting on a screen (it is ' + (STATUS[loaded.run.status] || loaded.run.status) + ').');
775
+ }
776
+
777
+ var pending = await db.model('WorkflowResumeKey').query()
778
+ .where({ runId: runId, consumedDate: null }).first();
779
+ if (!pending) {
780
+ throw fail(409, 'RUN_NOT_WAITING', 'This run has no screen waiting to be submitted.');
781
+ }
782
+
783
+ // ONLY A SCREEN IS SUBMITTED HERE. Now that a run of any workflow can reach
784
+ // this route, "waiting" is no longer the same thing as "waiting for a
785
+ // person": a step can be parked on an email that has not arrived or a
786
+ // callback nobody has made, and those are resumed by whatever they are
787
+ // waiting FOR, through the engine's own resume route and its key. Letting a
788
+ // signed-in browser post values to one would let somebody standing in front
789
+ // of a run hand it the answer it was waiting for the world to give.
790
+ var steps = await loadSteps(loaded.flow.id);
791
+ // By id, which is what the resume key carries (0007) — a step key is only
792
+ // unique within a workflow, and the key row points at the row.
793
+ var waitingOn = steps.filter(function(st) { return st.id === pending.stepId; })[0];
794
+ if (waitingOn && waitingOn.actionName !== SCREEN_ACTION) {
795
+ throw fail(409, 'NOT_A_SCREEN',
796
+ 'This run is waiting on "' + (waitingOn.name || waitingOn.stepKey) + '", which is not a screen — ' +
797
+ 'it resumes when what it is waiting for happens.');
798
+ }
799
+
800
+ var output = Object.assign({}, values);
801
+ if (body.recordId !== undefined && body.recordId !== null) {
802
+ output.recordId = body.recordId;
803
+ }
804
+
805
+ try {
806
+ await workflowRunner.resumeByKey(pending.key, output);
807
+ } catch (err) {
808
+ // RESUME_KEY_NOT_READY is the one worth retrying — the step's action has
809
+ // not finished parking yet. Passed through with its own code rather than
810
+ // flattened, so a client can tell "try again in a moment" from "this was
811
+ // already submitted".
812
+ throw fail(err.code === 'RESUME_KEY_NOT_READY' ? 409 : 400, err.code || 'SUBMIT_FAILED', err.message);
813
+ }
814
+
815
+ var run = await db.model('WorkflowRun').query().findById(runId);
816
+ return runView(run, await loadSteps(loaded.flow.id), await stepRunsOf(runId));
817
+ }
818
+
819
+ /**
820
+ * GET /flows/:key/runs?mine=1 — what is still in progress, newest first.
821
+ *
822
+ * `mine=1` is "started by me", which is what an app showing somebody their own
823
+ * unfinished work means by it. Without a signed-in user it is an empty list
824
+ * rather than everybody's runs — the safer reading of an unanswerable question.
825
+ */
826
+ async function listRuns(key, opts) {
827
+ opts = opts || {};
828
+ var flow = await loadRunnable(key);
829
+
830
+ var query = db.model('WorkflowRun').query()
831
+ .where({ workflowId: flow.id })
832
+ .whereIn('status', LIVE_RUN_STATUSES)
833
+ .orderBy('recordCreatedDate', 'desc');
834
+ if (opts.mine) {
835
+ if (!opts.userId) return [];
836
+ query = query.where({ recordCreatedBy: opts.userId });
837
+ }
838
+ var runs = await query;
839
+ if (!runs.length) return [];
840
+
841
+ var steps = await loadSteps(flow.id);
842
+ var stepRuns = await db.model('WorkflowStepRun').query()
843
+ .whereIn('runId', runs.map(function(r) { return r.id; }))
844
+ .orderBy('recordCreatedDate', 'asc');
845
+ var byRun = {};
846
+ stepRuns.forEach(function(sr) { (byRun[sr.runId] = byRun[sr.runId] || []).push(sr); });
847
+
848
+ return runs.map(function(run) {
849
+ var view = runView(run, steps, byRun[run.id] || []);
850
+ return {
851
+ runId: view.runId,
852
+ status: view.status,
853
+ stepKey: view.stepKey,
854
+ screen: view.screen,
855
+ startedAt: run.startedAt || run.recordCreatedDate || null
856
+ };
857
+ });
858
+ }
859
+
860
+ // ── keeping the workflow document's routes off a flow ───────────────────
861
+
862
+ /**
863
+ * Does this POST /workflows/save body touch a 'screens' workflow, or try to
864
+ * create one? True means refuse it (SCREENS_EDIT_MESSAGE says why).
865
+ *
866
+ * Not a permission check — whoever is here is allowed to edit workflows. It is
867
+ * that these particular rows are GENERATED from a design held elsewhere, so a
868
+ * hand edit survives only until the next PUT /flows/:key and then vanishes
869
+ * with no record. Refusing is the only outcome that does not lose work.
870
+ * The API's route turns `true` into a 400.
871
+ */
872
+ async function touchesScreens(body) {
873
+ var entries = Array.isArray(body) ? body : (body ? [body] : []);
874
+ // Creating one here is refused for the same reason editing one is: a flow
875
+ // whose steps this facade did not generate is a flow the designer cannot
876
+ // open.
877
+ if (entries.some(function(e) { return e && e.kind === FLOW_KIND; })) return true;
878
+
879
+ var ids = entries.map(function(e) { return e && e.id; }).filter(Boolean);
880
+ if (!ids.length) return false;
881
+
882
+ var rows = await db.model('Workflow').query().whereIn('id', ids);
883
+ return rows.some(function(r) { return r.kind === FLOW_KIND; });
884
+ }
885
+
886
+ module.exports = {
887
+ FLOW_KIND: FLOW_KIND,
888
+ SCREEN_ACTION: SCREEN_ACTION,
889
+ SCREENS_EDIT_MESSAGE: SCREENS_EDIT_MESSAGE,
890
+ OPS: OPS,
891
+ operators: operators,
892
+
893
+ // Pure, and exported for their own sake — the translation between what a
894
+ // designer says and what the engine evaluates is the part of this module
895
+ // that can be wrong without anything failing loudly.
896
+ whenToCondition: whenToCondition,
897
+ comparisonNode: comparisonNode,
898
+ fail: fail,
899
+ END_TARGETS: END_TARGETS,
900
+ conditionToWhen: conditionToWhen,
901
+ toStepRows: toStepRows,
902
+ validateFlow: validateFlow,
903
+ runView: runView,
904
+
905
+ listFlows: listFlows,
906
+ createFlow: createFlow,
907
+ getFlow: getFlow,
908
+ putFlow: putFlow,
909
+ publishFlow: publishFlow,
910
+ startFlowRun: startFlowRun,
911
+ getRun: getRun,
912
+ submitRun: submitRun,
913
+ listRuns: listRuns,
914
+
915
+ touchesScreens: touchesScreens
916
+ };