@xeplr/workflow 1.0.1

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +352 -0
  3. package/bin/www +7 -0
  4. package/db/xcfgSetup.js +8 -0
  5. package/env.required.js +41 -0
  6. package/index.js +313 -0
  7. package/lib/actionCatalog.js +193 -0
  8. package/lib/actions/jobRun.js +149 -0
  9. package/lib/actions/screenShow.js +55 -0
  10. package/lib/db.js +82 -0
  11. package/lib/envExposed.js +87 -0
  12. package/lib/flows.js +832 -0
  13. package/lib/flowsRouter.js +113 -0
  14. package/lib/router.js +260 -0
  15. package/lib/workflowRunner.js +649 -0
  16. package/migrations/0001_companies.sql +23 -0
  17. package/migrations/0002_workspaces.sql +22 -0
  18. package/migrations/0003_workflows.sql +35 -0
  19. package/migrations/0004_workflow_steps.sql +79 -0
  20. package/migrations/0005_workflow_runs.sql +49 -0
  21. package/migrations/0006_workflow_step_runs.sql +42 -0
  22. package/migrations/0007_workflow_resume_keys.sql +42 -0
  23. package/migrations/0008_workflow_run_edges.sql +43 -0
  24. package/migrations/0009_workflow_steps_layout.sql +11 -0
  25. package/migrations/0010_workflow_steps_sample_output.sql +17 -0
  26. package/migrations/0011_workflow_steps_params.sql +27 -0
  27. package/migrations/0012_workflows_kind.sql +27 -0
  28. package/migrations/0013_workflows_key.sql +48 -0
  29. package/migrations-auth/0001_workflow_access.sql +129 -0
  30. package/migrations-auth/0003_nav_menus.sql +62 -0
  31. package/migrations-auth/0004_flows_access.sql +76 -0
  32. package/models/Company.js +82 -0
  33. package/models/Workflow.js +63 -0
  34. package/models/WorkflowResumeKey.js +32 -0
  35. package/models/WorkflowRun.js +64 -0
  36. package/models/WorkflowRunEdge.js +49 -0
  37. package/models/WorkflowStep.js +66 -0
  38. package/models/WorkflowStepRun.js +49 -0
  39. package/models/Workspace.js +75 -0
  40. package/models/index.js +25 -0
  41. package/orchestration/standalone.js +123 -0
  42. package/package.json +69 -0
package/lib/flows.js ADDED
@@ -0,0 +1,832 @@
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/ui-workflow'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 refuseScreensEdit, wired in lib/router.js)
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 six comparisons that have a symbol (see @xeplr/ui-workflow's src/conditions.js's
90
+ // OP_TEXT), so the two halves of the product do not disagree about what "="
91
+ // 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
+ var op = canonicalOp(when.op);
198
+ if (!op) {
199
+ throw fail(400, 'FLOW_INVALID', where + ': "' + when.op + '" is not a comparison this engine has. One of: ' + operators().join(', ') + '.');
200
+ }
201
+
202
+ var node = { left: { field: 'output.' + field }, op: OPS[op] };
203
+
204
+ if (UNARY_OPS.indexOf(op) > -1) {
205
+ if (when.value !== undefined && when.value !== null) {
206
+ throw fail(400, 'FLOW_INVALID', where + ': "' + op + '" takes no value.');
207
+ }
208
+ return node;
209
+ }
210
+
211
+ var value = when.value;
212
+
213
+ if (LIST_OPS.indexOf(op) > -1) {
214
+ if (!Array.isArray(value) || !value.length) {
215
+ throw fail(400, 'FLOW_INVALID', where + ': "' + op + '" needs `when.value` to be a non-empty list.');
216
+ }
217
+ if (op === 'between' && value.length !== 2) {
218
+ throw fail(400, 'FLOW_INVALID', where + ': "between" needs exactly two values, low then high.');
219
+ }
220
+ node.right = { value: value.slice() };
221
+ return node;
222
+ }
223
+
224
+ if (value === undefined || value === null) {
225
+ throw fail(400, 'FLOW_INVALID', where + ': "' + op + '" needs a `when.value`.');
226
+ }
227
+ var type = typeof value;
228
+ if (type !== 'string' && type !== 'number' && type !== 'boolean') {
229
+ throw fail(400, 'FLOW_INVALID', where + ': `when.value` must be a string, number or boolean.');
230
+ }
231
+ node.right = { value: value };
232
+ return node;
233
+ }
234
+
235
+ /**
236
+ * The stored condition node → the `when` the designer sent.
237
+ *
238
+ * The inverse of whenToCondition for everything this facade writes. A node it
239
+ * did not write (a hand-edited row, an older workflow) has no `when` that
240
+ * describes it, so it comes back as null — which reads in the designer as a
241
+ * catch-all rather than as a branch it would silently mistranslate. Publish
242
+ * validation is what stops such a row being created through this facade in the
243
+ * first place.
244
+ */
245
+ function conditionToWhen(node) {
246
+ if (!node || typeof node !== 'object') return null;
247
+ var op = OP_FROM_ENGINE[node.op];
248
+ var field = node.left && typeof node.left.field === 'string' ? node.left.field : '';
249
+ if (!op || field.indexOf('output.') !== 0) return null;
250
+
251
+ var when = { field: field.slice('output.'.length), op: op };
252
+ if (UNARY_OPS.indexOf(op) === -1) {
253
+ when.value = node.right && Object.prototype.hasOwnProperty.call(node.right, 'value') ? node.right.value : null;
254
+ }
255
+ return when;
256
+ }
257
+
258
+ // ── the flow document ───────────────────────────────────────────────────
259
+
260
+ function normaliseKey(key) {
261
+ var value = String(key == null ? '' : key).trim();
262
+ if (!value) throw fail(400, 'FLOW_KEY_INVALID', 'A flow needs a `key`.');
263
+ if (!KEY_PATTERN.test(value)) {
264
+ throw fail(400, 'FLOW_KEY_INVALID', '"' + value + '" is not a usable flow key — lowercase letters, digits, _ and -, starting with a letter or digit.');
265
+ }
266
+ if (RESERVED_KEYS.indexOf(value) > -1) {
267
+ throw fail(400, 'FLOW_KEY_INVALID', '"' + value + '" is reserved — /flows/runs/:runId already uses it.');
268
+ }
269
+ return value;
270
+ }
271
+
272
+ /**
273
+ * One step as the designer sees it. `transitions` always comes back as an
274
+ * array (never null) so a client never has to branch on the absence of one.
275
+ */
276
+ function stepView(step) {
277
+ var values = step.values || {};
278
+ return {
279
+ stepKey: step.stepKey,
280
+ // What the designer calls the step. Kept in the step's own `name`, so the
281
+ // workflow app shows the same words for it as Configure UI does.
282
+ label: step.name || null,
283
+ screen: values.screen || null,
284
+ layout: step.layout || null,
285
+ transitions: (step.transitions || []).map(function(t) {
286
+ return { when: conditionToWhen(t.condition), target: t.target };
287
+ })
288
+ };
289
+ }
290
+
291
+ function flowView(workflow, steps) {
292
+ return {
293
+ id: workflow.id,
294
+ key: workflow.key,
295
+ name: workflow.name,
296
+ status: workflow.status,
297
+ steps: (steps || []).map(stepView)
298
+ };
299
+ }
300
+
301
+ /** The list row. `steps` is a COUNT here, and the whole array on GET /flows/:key. */
302
+ function flowSummary(workflow, stepCount) {
303
+ return {
304
+ id: workflow.id,
305
+ key: workflow.key,
306
+ name: workflow.name,
307
+ status: workflow.status,
308
+ steps: stepCount
309
+ };
310
+ }
311
+
312
+ /**
313
+ * The flow row for a key, or a 404.
314
+ *
315
+ * Tenant-scoped by query() — a key that belongs to another company is not
316
+ * found rather than refused, because "no such flow" and "not yours" must read
317
+ * the same from outside. See BaseModel's tenant modifier.
318
+ */
319
+ async function loadFlow(key) {
320
+ var flow = await db.model('Workflow').query().where({ key: key, kind: FLOW_KIND }).first();
321
+ if (!flow) throw fail(404, 'FLOW_NOT_FOUND', 'No flow "' + key + '".');
322
+ return flow;
323
+ }
324
+
325
+ async function loadSteps(workflowId) {
326
+ return db.model('WorkflowStep').query().where({ workflowId: workflowId }).orderBy('position');
327
+ }
328
+
329
+ // ── the steps a PUT writes ──────────────────────────────────────────────
330
+
331
+ /**
332
+ * The designer's steps → workflow_steps rows.
333
+ *
334
+ * EVERYTHING IS VALIDATED BEFORE ANYTHING IS WRITTEN. The rows are built
335
+ * completely first and only then does putSteps touch the table, so a PUT that
336
+ * is refused leaves the draft exactly as it was rather than half-replaced.
337
+ *
338
+ * `screen` may be missing here and is required at PUBLISH: a draft is allowed
339
+ * to be unfinished, which is the difference between the two verbs.
340
+ */
341
+ function toStepRows(workflowId, steps) {
342
+ if (!Array.isArray(steps)) {
343
+ throw fail(400, 'FLOW_INVALID', '`steps` must be an array.');
344
+ }
345
+
346
+ var seen = {};
347
+ return steps.map(function(step, index) {
348
+ var where = 'step ' + (index + 1);
349
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
350
+ throw fail(400, 'FLOW_INVALID', where + ': each step must be an object.');
351
+ }
352
+
353
+ var stepKey = String(step.stepKey == null ? '' : step.stepKey).trim();
354
+ if (!stepKey) throw fail(400, 'FLOW_INVALID', where + ': `stepKey` is required.');
355
+ if (!STEP_KEY_PATTERN.test(stepKey)) {
356
+ throw fail(400, 'FLOW_INVALID', where + ': "' + stepKey + '" is not a usable stepKey (letters, digits, _ and -).');
357
+ }
358
+ // Two steps with one key would make `target: "x"` ambiguous and would
359
+ // collide on workflow_steps' own unique index — caught here so the answer
360
+ // names the step rather than the constraint.
361
+ if (seen[stepKey]) throw fail(400, 'FLOW_INVALID', 'Two steps share the stepKey "' + stepKey + '".');
362
+ seen[stepKey] = true;
363
+
364
+ var screen = step.screen == null ? '' : String(step.screen).trim();
365
+
366
+ var transitions = step.transitions === undefined || step.transitions === null ? [] : step.transitions;
367
+ if (!Array.isArray(transitions)) {
368
+ throw fail(400, 'FLOW_INVALID', 'step "' + stepKey + '": `transitions` must be an array.');
369
+ }
370
+
371
+ var compiled = transitions.map(function(t, ti) {
372
+ var label = 'step "' + stepKey + '", transition ' + (ti + 1);
373
+ if (!t || typeof t !== 'object' || Array.isArray(t)) {
374
+ throw fail(400, 'FLOW_INVALID', label + ': must be an object.');
375
+ }
376
+ var target = String(t.target == null ? '' : t.target).trim();
377
+ if (!target) throw fail(400, 'FLOW_INVALID', label + ': `target` is required.');
378
+ return {
379
+ condition: whenToCondition(t.when, label),
380
+ // Single, always. `each` fans a transition out into one child run per
381
+ // element, which has no meaning for a person filling in a screen —
382
+ // there is one of them and one run.
383
+ mode: 'single',
384
+ target: target
385
+ };
386
+ });
387
+
388
+ if (step.layout !== undefined && step.layout !== null &&
389
+ (typeof step.layout !== 'object' || Array.isArray(step.layout))) {
390
+ throw fail(400, 'FLOW_INVALID', 'step "' + stepKey + '": `layout` must be an object or null.');
391
+ }
392
+
393
+ return {
394
+ id: uid(),
395
+ workflowId: workflowId,
396
+ stepKey: stepKey,
397
+ name: typeof step.label === 'string' && step.label.trim() ? step.label.trim().slice(0, 255) : null,
398
+ actionName: SCREEN_ACTION,
399
+ // THE TWO FACTS THAT MAKE A SCREEN A STEP. `wait` is what parks the run
400
+ // and mints the resume key before the action runs; `stop` is the engine
401
+ // default, restated because a flow that walked on past a screen nobody
402
+ // filled in would be worse than one that stopped.
403
+ kind: 'wait',
404
+ onError: 'stop',
405
+ values: { screen: screen },
406
+ transitions: compiled.length ? compiled : null,
407
+ layout: step.layout || null,
408
+ position: index
409
+ };
410
+ });
411
+ }
412
+
413
+ // ── what publishing checks ──────────────────────────────────────────────
414
+
415
+ /**
416
+ * Everything that must be true before a flow can be run, as a list of
417
+ * { stepKey, message } — all of them, not the first one. A designer fixing a
418
+ * flow one refusal at a time is a designer publishing five times.
419
+ */
420
+ function validateFlow(steps) {
421
+ var problems = [];
422
+ function problem(stepKey, message) { problems.push({ stepKey: stepKey, message: message }); }
423
+
424
+ // A flow with no steps has no first step to start a run at.
425
+ if (!steps.length) {
426
+ problem(null, 'This flow has no screens yet.');
427
+ return problems;
428
+ }
429
+
430
+ var byKey = {};
431
+ steps.forEach(function(s) { byKey[s.stepKey] = s; });
432
+
433
+ steps.forEach(function(step, index) {
434
+ var values = step.values || {};
435
+ if (!values.screen) problem(step.stepKey, 'No screen is chosen for this step.');
436
+
437
+ var transitions = step.transitions || [];
438
+
439
+ // No transitions means the engine advances by position — which for the
440
+ // LAST step is "end the run", and for any other step is a fall-through
441
+ // nobody drew. A screen with no way out in the middle of a flow is a
442
+ // missing arrow, not a design.
443
+ if (!transitions.length) {
444
+ if (index !== steps.length - 1) {
445
+ problem(step.stepKey, 'This step has no next screen. Give it a transition, or make it the last step.');
446
+ }
447
+ return;
448
+ }
449
+
450
+ var catchAlls = 0;
451
+ transitions.forEach(function(t, ti) {
452
+ if (!t.condition) catchAlls++;
453
+ // First match wins (routeAfterStep), so anything after a catch-all can
454
+ // never be reached.
455
+ if (!t.condition && ti !== transitions.length - 1) {
456
+ problem(step.stepKey, 'The catch-all is not the last transition, so nothing after it can ever run.');
457
+ }
458
+ if (t.target && END_TARGETS.indexOf(t.target) === -1 && !byKey[t.target]) {
459
+ problem(step.stepKey, 'Transition ' + (ti + 1) + ' goes to "' + t.target + '", which is not a step of this flow.');
460
+ }
461
+ });
462
+ if (catchAlls > 1) {
463
+ problem(step.stepKey, 'This step has ' + catchAlls + ' catch-alls; only the first could ever match.');
464
+ }
465
+ });
466
+
467
+ // Every run starts at the first step, so a step nothing points at is a
468
+ // screen no one will ever see. Reported rather than pruned — an unreachable
469
+ // step is usually an arrow somebody forgot to draw, not a step they meant to
470
+ // delete.
471
+ var reachable = {};
472
+ var queue = [steps[0].stepKey];
473
+ while (queue.length) {
474
+ var key = queue.shift();
475
+ if (reachable[key] || !byKey[key]) continue;
476
+ reachable[key] = true;
477
+ (byKey[key].transitions || []).forEach(function(t) {
478
+ if (t.target && END_TARGETS.indexOf(t.target) === -1) queue.push(t.target);
479
+ });
480
+ }
481
+ steps.forEach(function(step) {
482
+ if (!reachable[step.stepKey]) {
483
+ problem(step.stepKey, 'Nothing leads to this step, so it can never be shown.');
484
+ }
485
+ });
486
+
487
+ return problems;
488
+ }
489
+
490
+ // ── a run, as the app sees it ───────────────────────────────────────────
491
+
492
+ // The engine's five statuses collapse to the four an app showing screens can
493
+ // act on. 'queued' and 'running' are both "the engine still has it"; a flow
494
+ // only ever pauses on a screen, so a caller sees 'waiting' or a terminal.
495
+ var STATUS = { queued: 'running', running: 'running', waiting: 'waiting', success: 'done', failed: 'failed' };
496
+
497
+ /**
498
+ * What the run is parked on, and what that step already holds.
499
+ *
500
+ * `values` merges every step run this run has recorded for that step key, in
501
+ * order. Normally that is the one waiting step run and its output is empty —
502
+ * but a flow that loops back to a screen already filled in reopens it with the
503
+ * answers from last time, which is the only behaviour anyone expects.
504
+ */
505
+ function runView(run, steps, stepRuns) {
506
+ var status = STATUS[run.status] || run.status;
507
+ var waiting = null;
508
+ for (var i = stepRuns.length - 1; i >= 0; i--) {
509
+ if (stepRuns[i].status === 'waiting') { waiting = stepRuns[i]; break; }
510
+ }
511
+
512
+ var view = {
513
+ runId: run.id,
514
+ status: status,
515
+ stepKey: waiting ? waiting.stepKey : null,
516
+ screen: null,
517
+ values: {}
518
+ };
519
+ if (!waiting) {
520
+ if (run.status === 'failed' && run.error) view.error = run.error;
521
+ return view;
522
+ }
523
+
524
+ var step = steps.filter(function(s) { return s.stepKey === waiting.stepKey; })[0];
525
+ view.screen = step && step.values ? (step.values.screen || null) : null;
526
+
527
+ stepRuns.forEach(function(sr) {
528
+ if (sr.stepKey !== waiting.stepKey) return;
529
+ Object.assign(view.values, sr.output || {});
530
+ });
531
+ return view;
532
+ }
533
+
534
+ async function stepRunsOf(runId) {
535
+ return db.model('WorkflowStepRun').query().where({ runId: runId }).orderBy('recordCreatedDate', 'asc');
536
+ }
537
+
538
+ /**
539
+ * A run of a flow, or a 404.
540
+ *
541
+ * Tenant-scoped by query(), and that IS the isolation: a runId from another
542
+ * company simply is not there, and every route below goes through here before
543
+ * it touches anything.
544
+ */
545
+ async function loadRun(runId) {
546
+ var run = await db.model('WorkflowRun').query().findById(runId);
547
+ if (!run) throw fail(404, 'RUN_NOT_FOUND', 'No run "' + runId + '".');
548
+ var flow = await db.model('Workflow').query().findById(run.workflowId);
549
+ if (!flow || flow.kind !== FLOW_KIND) {
550
+ throw fail(404, 'RUN_NOT_FOUND', 'No run "' + runId + '".');
551
+ }
552
+ return { run: run, flow: flow };
553
+ }
554
+
555
+ // ── the services the router is a thin wrapper over ──────────────────────
556
+
557
+ /** GET /flows */
558
+ async function listFlows() {
559
+ var flows = await db.model('Workflow').query().where({ kind: FLOW_KIND }).orderBy('name');
560
+ if (!flows.length) return [];
561
+ var steps = await db.model('WorkflowStep').query()
562
+ .whereIn('workflowId', flows.map(function(f) { return f.id; }));
563
+ var counts = {};
564
+ steps.forEach(function(s) { counts[s.workflowId] = (counts[s.workflowId] || 0) + 1; });
565
+ return flows.map(function(f) { return flowSummary(f, counts[f.id] || 0); });
566
+ }
567
+
568
+ /** POST /flows */
569
+ async function createFlow(body) {
570
+ body = body || {};
571
+ var key = normaliseKey(body.key);
572
+ var name = String(body.name == null ? '' : body.name).trim() || key;
573
+
574
+ var existing = await db.model('Workflow').query().where({ key: key }).first();
575
+ if (existing) throw fail(409, 'FLOW_KEY_TAKEN', 'A flow keyed "' + key + '" already exists.');
576
+
577
+ var flow = await db.model('Workflow').query().insert({
578
+ id: uid(),
579
+ key: key,
580
+ name: name,
581
+ kind: FLOW_KIND,
582
+ status: 'draft'
583
+ });
584
+ return flowSummary(flow, 0);
585
+ }
586
+
587
+ /** GET /flows/:key */
588
+ async function getFlow(key) {
589
+ var flow = await loadFlow(key);
590
+ return flowView(flow, await loadSteps(flow.id));
591
+ }
592
+
593
+ /**
594
+ * PUT /flows/:key — replace the steps of a DRAFT.
595
+ *
596
+ * A published flow is refused rather than edited. A run in flight is parked on
597
+ * a step row by id; rewriting those rows underneath it would move a person
598
+ * from the screen they are looking at to whatever now sits at that position,
599
+ * with no record that it happened. Publishing a new version is the edit.
600
+ */
601
+ async function putFlow(key, body) {
602
+ body = body || {};
603
+ var flow = await loadFlow(key);
604
+ if (flow.status !== 'draft') {
605
+ throw fail(409, 'FLOW_PUBLISHED', 'This flow is published; publish a new version instead of editing it.');
606
+ }
607
+
608
+ var rows = toStepRows(flow.id, body.steps);
609
+
610
+ var name = body.name === undefined ? null : String(body.name == null ? '' : body.name).trim();
611
+ if (name !== null) {
612
+ if (!name) throw fail(400, 'FLOW_INVALID', '`name` cannot be blank.');
613
+ await db.model('Workflow').query().findById(flow.id).patch({ name: name });
614
+ flow.name = name;
615
+ }
616
+
617
+ // HARD delete, not the soft one the document routes use: workflow_steps'
618
+ // unique index is (workflowId, stepKey) and it does not care about
619
+ // isActive, so a soft-deleted step would block a design that still uses its
620
+ // key — which is most of them.
621
+ await db.model('WorkflowStep').query().delete().where({ workflowId: flow.id });
622
+ for (var i = 0; i < rows.length; i++) {
623
+ await db.model('WorkflowStep').query().insert(rows[i]);
624
+ }
625
+
626
+ return flowView(flow, await loadSteps(flow.id));
627
+ }
628
+
629
+ /**
630
+ * POST /flows/:key/publish
631
+ *
632
+ * Idempotent: publishing an already-published flow answers with it rather than
633
+ * refusing, because a double click is not a mistake worth an error.
634
+ */
635
+ async function publishFlow(key) {
636
+ var flow = await loadFlow(key);
637
+ var steps = await loadSteps(flow.id);
638
+ if (flow.status === 'published') return flowSummary(flow, steps.length);
639
+
640
+ var problems = validateFlow(steps);
641
+ if (problems.length) {
642
+ throw fail(400, 'FLOW_INVALID', 'This flow cannot be published yet: ' + problems[0].message, problems);
643
+ }
644
+
645
+ await db.model('Workflow').query().findById(flow.id).patch({ status: 'published' });
646
+ flow.status = 'published';
647
+ return flowSummary(flow, steps.length);
648
+ }
649
+
650
+ /**
651
+ * POST /flows/:key/runs
652
+ *
653
+ * A DRAFT IS REFUSED. Everything publish checks — a screen on every step, a
654
+ * target that exists, a way out of every screen — is what stops a run failing
655
+ * halfway through in front of whoever was filling it in. Publishing is cheap;
656
+ * a half-designed run in someone's hands is not.
657
+ */
658
+ async function startFlowRun(key, body, user) {
659
+ var flow = await loadFlow(key);
660
+ if (flow.status !== 'published') {
661
+ throw fail(409, 'FLOW_NOT_PUBLISHED', 'This flow is still a draft — publish it before starting a run.');
662
+ }
663
+ var run;
664
+ try {
665
+ run = await workflowRunner.startRun(flow.id, (body && body.params) || {}, { user: user, trigger: 'flow' });
666
+ } catch (err) {
667
+ throw fail(400, err.code || 'RUN_NOT_STARTED', err.message, err.details);
668
+ }
669
+ var steps = await loadSteps(flow.id);
670
+ return runView(run, steps, await stepRunsOf(run.id));
671
+ }
672
+
673
+ /** GET /flows/runs/:runId */
674
+ async function getRun(runId) {
675
+ var loaded = await loadRun(runId);
676
+ return runView(loaded.run, await loadSteps(loaded.flow.id), await stepRunsOf(runId));
677
+ }
678
+
679
+ /**
680
+ * POST /flows/runs/:runId/submit
681
+ *
682
+ * THE RESUME KEY NEVER LEAVES THE SERVER. The engine's own resume route is
683
+ * public by design — a key arrives from an email client with no token — but a
684
+ * screen is submitted by somebody who is signed in and looking at the run, so
685
+ * this route is gated like every other and looks the key up from the run.
686
+ * A browser holding a resume key could resume a run it was never shown.
687
+ *
688
+ * `recordId` is kept on the step's output beside the submitted values: the app
689
+ * has already written the record to its own table, and a later step (or a
690
+ * transition) that needs to point at it can read `output.recordId` like any
691
+ * other field.
692
+ */
693
+ async function submitRun(runId, body) {
694
+ body = body || {};
695
+ var values = body.values === undefined || body.values === null ? {} : body.values;
696
+ if (typeof values !== 'object' || Array.isArray(values)) {
697
+ throw fail(400, 'SUBMIT_INVALID', '`values` must be an object.');
698
+ }
699
+
700
+ var loaded = await loadRun(runId);
701
+ if (loaded.run.status !== 'waiting') {
702
+ throw fail(409, 'RUN_NOT_WAITING', 'This run is not waiting on a screen (it is ' + (STATUS[loaded.run.status] || loaded.run.status) + ').');
703
+ }
704
+
705
+ var pending = await db.model('WorkflowResumeKey').query()
706
+ .where({ runId: runId, consumedDate: null }).first();
707
+ if (!pending) {
708
+ throw fail(409, 'RUN_NOT_WAITING', 'This run has no screen waiting to be submitted.');
709
+ }
710
+
711
+ var output = Object.assign({}, values);
712
+ if (body.recordId !== undefined && body.recordId !== null) {
713
+ output.recordId = body.recordId;
714
+ }
715
+
716
+ try {
717
+ await workflowRunner.resumeByKey(pending.key, output);
718
+ } catch (err) {
719
+ // RESUME_KEY_NOT_READY is the one worth retrying — the step's action has
720
+ // not finished parking yet. Passed through with its own code rather than
721
+ // flattened, so a client can tell "try again in a moment" from "this was
722
+ // already submitted".
723
+ throw fail(err.code === 'RESUME_KEY_NOT_READY' ? 409 : 400, err.code || 'SUBMIT_FAILED', err.message);
724
+ }
725
+
726
+ var run = await db.model('WorkflowRun').query().findById(runId);
727
+ return runView(run, await loadSteps(loaded.flow.id), await stepRunsOf(runId));
728
+ }
729
+
730
+ /**
731
+ * GET /flows/:key/runs?mine=1 — what is still in progress, newest first.
732
+ *
733
+ * `mine=1` is "started by me", which is what an app showing somebody their own
734
+ * unfinished work means by it. Without a signed-in user it is an empty list
735
+ * rather than everybody's runs — the safer reading of an unanswerable question.
736
+ */
737
+ async function listRuns(key, opts) {
738
+ opts = opts || {};
739
+ var flow = await loadFlow(key);
740
+
741
+ var query = db.model('WorkflowRun').query()
742
+ .where({ workflowId: flow.id })
743
+ .whereIn('status', LIVE_RUN_STATUSES)
744
+ .orderBy('recordCreatedDate', 'desc');
745
+ if (opts.mine) {
746
+ if (!opts.userId) return [];
747
+ query = query.where({ recordCreatedBy: opts.userId });
748
+ }
749
+ var runs = await query;
750
+ if (!runs.length) return [];
751
+
752
+ var steps = await loadSteps(flow.id);
753
+ var stepRuns = await db.model('WorkflowStepRun').query()
754
+ .whereIn('runId', runs.map(function(r) { return r.id; }))
755
+ .orderBy('recordCreatedDate', 'asc');
756
+ var byRun = {};
757
+ stepRuns.forEach(function(sr) { (byRun[sr.runId] = byRun[sr.runId] || []).push(sr); });
758
+
759
+ return runs.map(function(run) {
760
+ var view = runView(run, steps, byRun[run.id] || []);
761
+ return {
762
+ runId: view.runId,
763
+ status: view.status,
764
+ stepKey: view.stepKey,
765
+ screen: view.screen,
766
+ startedAt: run.startedAt || run.recordCreatedDate || null
767
+ };
768
+ });
769
+ }
770
+
771
+ // ── keeping the workflow document's routes off a flow ───────────────────
772
+
773
+ /**
774
+ * Express middleware for POST /workflows/save: refuse anything that touches a
775
+ * 'screens' workflow, and refuse creating one here.
776
+ *
777
+ * Not a permission check — whoever is here is allowed to edit workflows. It is
778
+ * that these particular rows are GENERATED from a design held elsewhere, so a
779
+ * hand edit survives only until the next PUT /flows/:key and then vanishes
780
+ * with no record. Refusing is the only outcome that does not lose work.
781
+ */
782
+ async function refuseScreensEdit(req, res, next) {
783
+ function refuse() {
784
+ res.status(400).json({ message: SCREENS_EDIT_MESSAGE, code: 'FLOW_READ_ONLY', details: [] });
785
+ }
786
+
787
+ var entries = Array.isArray(req.body) ? req.body : (req.body ? [req.body] : []);
788
+ // Creating one here is refused for the same reason editing one is: a flow
789
+ // whose steps this facade did not generate is a flow the designer cannot
790
+ // open.
791
+ if (entries.some(function(e) { return e && e.kind === FLOW_KIND; })) return refuse();
792
+
793
+ var ids = entries.map(function(e) { return e && e.id; }).filter(Boolean);
794
+ if (!ids.length) return next();
795
+
796
+ try {
797
+ var rows = await db.model('Workflow').query().whereIn('id', ids);
798
+ if (rows.some(function(r) { return r.kind === FLOW_KIND; })) return refuse();
799
+ next();
800
+ } catch (err) {
801
+ next(err);
802
+ }
803
+ }
804
+
805
+ module.exports = {
806
+ FLOW_KIND: FLOW_KIND,
807
+ SCREEN_ACTION: SCREEN_ACTION,
808
+ SCREENS_EDIT_MESSAGE: SCREENS_EDIT_MESSAGE,
809
+ OPS: OPS,
810
+ operators: operators,
811
+
812
+ // Pure, and exported for their own sake — the translation between what a
813
+ // designer says and what the engine evaluates is the part of this module
814
+ // that can be wrong without anything failing loudly.
815
+ whenToCondition: whenToCondition,
816
+ conditionToWhen: conditionToWhen,
817
+ toStepRows: toStepRows,
818
+ validateFlow: validateFlow,
819
+ runView: runView,
820
+
821
+ listFlows: listFlows,
822
+ createFlow: createFlow,
823
+ getFlow: getFlow,
824
+ putFlow: putFlow,
825
+ publishFlow: publishFlow,
826
+ startFlowRun: startFlowRun,
827
+ getRun: getRun,
828
+ submitRun: submitRun,
829
+ listRuns: listRuns,
830
+
831
+ refuseScreensEdit: refuseScreensEdit
832
+ };