@xeplr/workflow-engine 3.0.3 → 3.2.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.
package/README.md CHANGED
@@ -105,6 +105,18 @@ host gives workflow is the boundary.
105
105
  first match wins (`@xeplr/expression-handler`); a blank condition is the
106
106
  catch-all; targets are a `stepKey`, `end_success` or `end_failed`. No match
107
107
  and no catch-all **fails the run**.
108
+ - **Screens hand over to screens**: a flow's run view carries `previous` — what
109
+ every screen submitted earlier in the run, keyed by screen key, the saved row
110
+ as `values.id` (`lib/flows.js` `previousOf`). Read-only, built from the step
111
+ runs; nothing new is stored, and nothing is checked between screens.
112
+ - **Letting a function decide**: a transition `{ decide: 'routeByAmount', targets?: [...] }`
113
+ asks a function the app registered with `@xeplr/actions` under `event: 'decide'`
114
+ where to go. It is called with `{ output, values (= output), params, item, trigger,
115
+ step, runId, targets }` and returns a step key, `end_success` or `end_failed`;
116
+ `targets`, when listed, are the steps it may choose (none: any step). A missing
117
+ function, one written for another event, no answer, or an answer outside
118
+ `targets` **fails the run**, naming the function. Functions registered with an
119
+ `event` are screen functions, never steps: the action catalog leaves them out.
108
120
  - **Fan-out**: a matching `each` transition creates one child run per matching
109
121
  element (`item`), recorded in `workflow_run_edges`. With `joinStep` the parent
110
122
  waits and resumes at the join once all children are terminal (guarded by an
@@ -173,7 +173,10 @@ function catalog() {
173
173
  // absent here, so no palette ever draws it. Filtered at the point the list
174
174
  // leaves the process rather than by not registering it — the two questions
175
175
  // ("can this run" and "should anyone pick it") have different answers.
176
- return HIDDEN_NAMES.indexOf(a.name) === -1;
176
+ // A SCREEN FUNCTION (registered with an `event`: beforeSave, decide, …) is
177
+ // not a step — a screen's events and a flow's arrows pick it by name. A
178
+ // palette offering it as a step would run it with the wrong context.
179
+ return HIDDEN_NAMES.indexOf(a.name) === -1 && !a.event;
177
180
  }).map(function(a) {
178
181
  return {
179
182
  name: a.name,
package/lib/flows.js CHANGED
@@ -72,6 +72,9 @@ var FIELD_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
72
72
  // key. Restated here because they are part of this facade's contract too.
73
73
  var END_TARGETS = ['end_success', 'end_failed'];
74
74
 
75
+ // A registered function's name — the same rule @xeplr/ui-factory's FUNCTION_NAME applies.
76
+ var FUNCTION_NAME = /^[A-Za-z_][\w.-]{0,99}$/;
77
+
75
78
  // Which run statuses mean "still going". `queued` is only ever a fanned-out
76
79
  // child's starting state, and a screens flow does not fan out — it is in the
77
80
  // list because the column has it, not because a flow reaches it.
@@ -298,6 +301,9 @@ function stepView(step) {
298
301
  screen: values.screen || null,
299
302
  layout: step.layout || null,
300
303
  transitions: (step.transitions || []).map(function(t) {
304
+ // A function deciding is said as itself: its name, and the steps it may
305
+ // choose from (none listed: any step of the flow).
306
+ if (t.decide) return { when: conditionToWhen(t.condition), decide: t.decide, targets: t.targets || [] };
301
307
  return { when: conditionToWhen(t.condition), target: t.target };
302
308
  })
303
309
  };
@@ -408,8 +414,26 @@ function toStepRows(workflowId, steps) {
408
414
  if (!t || typeof t !== 'object' || Array.isArray(t)) {
409
415
  throw fail(400, 'FLOW_INVALID', label + ': must be an object.');
410
416
  }
417
+ // LET A FUNCTION DECIDE: `decide` names a registered function (event
418
+ // 'decide') that returns the next step; `targets`, optional, are the
419
+ // steps it may choose from. Its own arrow has no single target.
420
+ if (t.decide !== undefined && t.decide !== null && t.decide !== '') {
421
+ var fn = String(t.decide).trim();
422
+ if (!FUNCTION_NAME.test(fn)) throw fail(400, 'FLOW_INVALID', label + ': `decide` must be the name of a registered function, e.g. "routeByAmount".');
423
+ var targets = t.targets === undefined || t.targets === null ? [] : t.targets;
424
+ if (!Array.isArray(targets) || !targets.every(function(x) { return typeof x === 'string' && x.trim(); })) {
425
+ throw fail(400, 'FLOW_INVALID', label + ': `targets` must be a list of step keys the function may choose from.');
426
+ }
427
+ return {
428
+ condition: whenToCondition(t.when, label),
429
+ mode: 'single',
430
+ decide: fn,
431
+ targets: targets.map(function(x) { return x.trim(); }),
432
+ target: null
433
+ };
434
+ }
411
435
  var target = String(t.target == null ? '' : t.target).trim();
412
- if (!target) throw fail(400, 'FLOW_INVALID', label + ': `target` is required.');
436
+ if (!target) throw fail(400, 'FLOW_INVALID', label + ': `target` is required — or `decide`, to let a function choose.');
413
437
  return {
414
438
  condition: whenToCondition(t.when, label),
415
439
  // Single, always. `each` fans a transition out into one child run per
@@ -447,6 +471,11 @@ function toStepRows(workflowId, steps) {
447
471
 
448
472
  // ── what publishing checks ──────────────────────────────────────────────
449
473
 
474
+ /** A function in the app's registry (@xeplr/actions), or null. */
475
+ function registered(name) {
476
+ return require('@xeplr/actions').get(name) || null;
477
+ }
478
+
450
479
  /**
451
480
  * Everything that must be true before a flow can be run, as a list of
452
481
  * { stepKey, message } — all of them, not the first one. A designer fixing a
@@ -493,6 +522,14 @@ function validateFlow(steps) {
493
522
  if (t.target && END_TARGETS.indexOf(t.target) === -1 && !byKey[t.target]) {
494
523
  problem(step.stepKey, 'Transition ' + (ti + 1) + ' goes to "' + t.target + '", which is not a step of this flow.');
495
524
  }
525
+ if (t.decide) {
526
+ var def = registered(t.decide);
527
+ if (!def) problem(step.stepKey, 'Transition ' + (ti + 1) + ' lets "' + t.decide + '" decide, but no function of that name is registered.');
528
+ else if (def.event !== 'decide') problem(step.stepKey, 'Transition ' + (ti + 1) + ' lets "' + t.decide + '" decide, but it is written for ' + (def.event ? '"' + def.event + '"' : 'a workflow step') + ', not "decide".');
529
+ (t.targets || []).forEach(function(x) {
530
+ if (END_TARGETS.indexOf(x) === -1 && !byKey[x]) problem(step.stepKey, 'Transition ' + (ti + 1) + ': "' + t.decide + '" may choose "' + x + '", which is not a step of this flow.');
531
+ });
532
+ }
496
533
  });
497
534
  if (catchAlls > 1) {
498
535
  problem(step.stepKey, 'This step has ' + catchAlls + ' catch-alls; only the first could ever match.');
@@ -511,6 +548,8 @@ function validateFlow(steps) {
511
548
  reachable[key] = true;
512
549
  (byKey[key].transitions || []).forEach(function(t) {
513
550
  if (t.target && END_TARGETS.indexOf(t.target) === -1) queue.push(t.target);
551
+ // A function may lead to any step it may choose — all of them, when it lists none.
552
+ if (t.decide) (t.targets && t.targets.length ? t.targets : Object.keys(byKey)).forEach(function(x) { if (END_TARGETS.indexOf(x) === -1) queue.push(x); });
514
553
  });
515
554
  }
516
555
  steps.forEach(function(step) {
@@ -558,7 +597,9 @@ function runView(run, steps, stepRuns) {
558
597
  // the resolved input is kept (workflowRunner patches `input` as it parks)
559
598
  // — the step row holds the tokens, and a browser has nothing to resolve
560
599
  // them with.
561
- given: {}
600
+ given: {},
601
+ // WHAT THE SCREENS BEFORE THIS ONE SAVED, by screen key — see previousOf.
602
+ previous: {}
562
603
  };
563
604
  if (!waiting) {
564
605
  if (run.status === 'failed' && run.error) view.error = run.error;
@@ -575,9 +616,43 @@ function runView(run, steps, stepRuns) {
575
616
  if (sr.stepKey !== waiting.stepKey) return;
576
617
  Object.assign(view.values, sr.output || {});
577
618
  });
619
+ view.previous = previousOf(steps, stepRuns, waiting);
578
620
  return view;
579
621
  }
580
622
 
623
+ /**
624
+ * THE HAND-OVER BETWEEN SCREENS — what every screen submitted earlier in this
625
+ * run, keyed by the SCREEN's key:
626
+ *
627
+ * { department_edit: { values: { id: "6f2a…", name: "Finance", … } } }
628
+ *
629
+ * A field on the next screen fills itself from it with a default of
630
+ * `{previous.department_edit.values.name}` (@xeplr/ui-factory). By screen and
631
+ * not by step, because the binding lives ON the screen, and a screen is the
632
+ * same form in every flow that shows it. `id` is the row that screen saved —
633
+ * part of the values, as it is of the row.
634
+ *
635
+ * Nothing is checked: a screen not reached on this run's branch is simply
636
+ * absent, and a binding to it comes out empty. A screen shown twice (a loop)
637
+ * hands over its latest. The step being shown now is not its own "previous".
638
+ */
639
+ function previousOf(steps, stepRuns, waiting) {
640
+ var screenOf = {};
641
+ steps.forEach(function(st) {
642
+ if (st.actionName === SCREEN_ACTION && st.values && st.values.screen) screenOf[st.stepKey] = st.values.screen;
643
+ });
644
+ var out = {};
645
+ stepRuns.forEach(function(sr) {
646
+ if (sr.status !== 'success' || (waiting && sr.id === waiting.id)) return;
647
+ var screen = screenOf[sr.stepKey];
648
+ if (!screen) return;
649
+ var values = Object.assign({}, sr.output || {});
650
+ if (values.recordId !== undefined) { values.id = values.recordId; delete values.recordId; }
651
+ out[screen] = { values: values };
652
+ });
653
+ return out;
654
+ }
655
+
581
656
  async function stepRunsOf(runId) {
582
657
  return db.model('WorkflowStepRun').query().where({ runId: runId }).orderBy('recordCreatedDate', 'asc');
583
658
  }
@@ -898,6 +973,7 @@ module.exports = {
898
973
  fail: fail,
899
974
  END_TARGETS: END_TARGETS,
900
975
  conditionToWhen: conditionToWhen,
976
+ previousOf: previousOf,
901
977
  toStepRows: toStepRows,
902
978
  validateFlow: validateFlow,
903
979
  runView: runView,
@@ -27,7 +27,8 @@
27
27
  var crypto = require('crypto');
28
28
  var { generateId } = require('@xeplr/utils/lib/helpers');
29
29
  var { runWithMt } = require('@xeplr/db');
30
- var { runAction } = require('@xeplr/actions');
30
+ var actions = require('@xeplr/actions');
31
+ var { runAction } = actions;
31
32
  var { interpolateAll, getPath, applySchema } = require('@xeplr/schema-handler');
32
33
  var xf = require('@xeplr/expression-handler');
33
34
  var db = require('./db');
@@ -455,6 +456,23 @@ async function routeAfterStep(workflow, steps, run, step, output) {
455
456
  var matches = !t.condition || xf.evaluate(t.condition, row);
456
457
  if (!matches) continue;
457
458
 
459
+ // LET A FUNCTION DECIDE: the arrow names a registered `decide` function,
460
+ // and its answer is the next step. It must be a step of this workflow —
461
+ // one of the arrow's `targets` when it lists them — or an end; anything
462
+ // else fails the run, by name, rather than wandering somewhere nobody drew.
463
+ if (t.decide) {
464
+ var decided;
465
+ try {
466
+ decided = await decideTarget(t, row, step, byKey, run);
467
+ } catch (err) {
468
+ await finishRun(run, 'failed', { message: err.message });
469
+ return { done: true };
470
+ }
471
+ if (decided === 'end_success') { await finishRun(run, 'success', null); return { done: true }; }
472
+ if (decided === 'end_failed') { await finishRun(run, 'failed', { message: 'Ended by "' + t.decide + '" on ' + step.stepKey }); return { done: true }; }
473
+ return { done: false, next: decided };
474
+ }
475
+
458
476
  if (t.mode === 'each') {
459
477
  var fannedOut = await handleFanOut(workflow, steps, run, step, t, output);
460
478
  if (fannedOut) return { done: true };
@@ -471,6 +489,29 @@ async function routeAfterStep(workflow, steps, run, step, output) {
471
489
  return { done: true };
472
490
  }
473
491
 
492
+ /**
493
+ * Ask an arrow's `decide` function where to go. It is called with what the
494
+ * step produced (`output` — for a screen, what was filled in — also as
495
+ * `values`), the run's `params`, `item` and `trigger`, the step it is leaving
496
+ * and the steps it may choose from; it returns a step key, 'end_success' or
497
+ * 'end_failed'. Throws, with the function's name, for anything else.
498
+ */
499
+ async function decideTarget(t, row, step, byKey, run) {
500
+ var def = actions.get(t.decide);
501
+ if (!def) throw new Error('Step ' + step.stepKey + ' asks "' + t.decide + '" where to go next, but no function of that name is registered');
502
+ if (def.event !== 'decide') throw new Error('Step ' + step.stepKey + ' asks "' + t.decide + '" where to go next, but it is written for ' + (def.event ? '"' + def.event + '"' : 'a workflow step'));
503
+ var allowed = Array.isArray(t.targets) && t.targets.length ? t.targets : Object.keys(byKey).concat(['end_success', 'end_failed']);
504
+ var answer = await def.execute({
505
+ output: row.output, values: row.output, params: row.params, item: row.item, trigger: row.trigger,
506
+ step: step.stepKey, runId: run.id, targets: allowed.slice()
507
+ });
508
+ var key = typeof answer === 'string' ? answer.trim() : '';
509
+ if (!key) throw new Error('"' + t.decide + '" did not say where to go next from ' + step.stepKey);
510
+ if (allowed.indexOf(key) === -1) throw new Error('"' + t.decide + '" chose "' + key + '" from ' + step.stepKey + ', which is not one of: ' + allowed.join(', '));
511
+ if (key !== 'end_success' && key !== 'end_failed' && !byKey[key]) throw new Error('"' + t.decide + '" chose "' + key + '", which is not a step of this workflow');
512
+ return key;
513
+ }
514
+
474
515
  /**
475
516
  * `t.source` is a dotted path to the array to fan over (resolved against
476
517
  * { output, item, params } — usually `output.<field>`). Each element that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/workflow-engine",
3
- "version": "3.0.3",
3
+ "version": "3.2.0",
4
4
  "license": "MIT",
5
5
  "description": "The free workflow engine: runs, actions as steps, branching, waits, and triggers — manual, API, a schedule, a job finishing. No HTTP: @xeplr/workflow-api puts the routes on top.",
6
6
  "keywords": [
@@ -31,7 +31,7 @@
31
31
  "url": "https://github.com/Xeplr/xeplr-workflow-engine.git"
32
32
  },
33
33
  "peerDependencies": {
34
- "@xeplr/actions": "^1.1.0",
34
+ "@xeplr/actions": "^1.4.0",
35
35
  "@xeplr/auth": "^1.0.0",
36
36
  "@xeplr/db": "^1.0.0",
37
37
  "@xeplr/expression-handler": "^2.0.0",