@xeplr/workflow-engine 3.1.0 → 3.2.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.
package/README.md CHANGED
@@ -105,6 +105,10 @@ 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.
108
112
  - **Letting a function decide**: a transition `{ decide: 'routeByAmount', targets?: [...] }`
109
113
  asks a function the app registered with `@xeplr/actions` under `event: 'decide'`
110
114
  where to go. It is called with `{ output, values (= output), params, item, trigger,
@@ -113,7 +117,8 @@ host gives workflow is the boundary.
113
117
  function, one written for another event, no answer, or an answer outside
114
118
  `targets` **fails the run**, naming the function. Functions registered with an
115
119
  `event` are screen functions, never steps: the action catalog leaves them out.
116
- - **Fan-out**: a matching `each` transition creates one child run per matching
120
+ - **Fan-out**: a matching `each` transition goes through the list its `source`
121
+ names (`output.rows`, `item.lines`, `params.…`, `trigger.…`) and creates one child run per matching
117
122
  element (`item`), recorded in `workflow_run_edges`. With `joinStep` the parent
118
123
  waits and resumes at the join once all children are terminal (guarded by an
119
124
  atomic `waiting → running` patch); without it the parent finishes.
@@ -162,7 +167,9 @@ The same rules hold for every kind:
162
167
  - A trigger seen for the first time, or whose settings changed, is given its next due time and does **not** fire, so publishing never starts a run for a time already past.
163
168
  - A due trigger is **claimed** by a conditional update of its due time, so two processes running the tick start it once.
164
169
 
165
- **A host's own kinds.** `registerTrigger(kind, { validate, due?, matches? })`:
170
+ **What a designer offers.** `triggers.catalog()` lists every kind as `{ kind, label, description, fields }`, where `fields` are the form's boxes (`{ name, label, placeholder?, help?, required?, options? }`). The API serves it at `GET /workflow/triggers`. A designer builds its trigger form from it, so it never offers a kind this engine refuses (one once offered "A file arrives", which no engine had).
171
+
172
+ **A host's own kinds.** `registerTrigger(kind, { label?, description?, fields?, validate, due?, matches? })`:
166
173
  - a time kind gives `due(trigger, from)`, its next due `Date`;
167
174
  - an event kind gives `matches(trigger, event)`, and the host calls `triggers.emit(kind, event)` when the event happens.
168
175
 
@@ -180,6 +187,14 @@ The comparisons are the flows facade's: `=`, `!=`, `>`, `>=`, `<`, `<=`, `contai
180
187
 
181
188
  A bad `when` is refused when it is saved, naming the step and the arrow. That is how a screen of your own (and the jobs screen's "After this job") writes branches without knowing the node format.
182
189
 
190
+ **An arrow the runner could not follow is refused when it is saved** (`checkArrow`, code `TRANSITION_INVALID`, naming the step and arrow):
191
+ - `mode` is `single` (the default) or `each`;
192
+ - an arrow needs a `target` (a step key, `end_success` or `end_failed`), unless it names a `decide` function;
193
+ - an `each` arrow needs `source`, the list it goes through, starting with `output`, `item`, `params` or `trigger`;
194
+ - an arrow either decides or goes through a list, not both, and `targets` is a list of step keys.
195
+
196
+ Why: each of these used to save, and then the live run ended with "No transition matched". A designer once saved every `each` arrow without its `source`, so nothing ever fanned out.
197
+
183
198
  ## Jobs as steps and as triggers (`lib/jobs.js`)
184
199
 
185
200
  A job meets a workflow in two places, and both are ordinary workflows:
package/lib/conditions.js CHANGED
@@ -62,11 +62,51 @@ function whenToCondition(when, where) {
62
62
  function compileTransitions(transitions, stepName) {
63
63
  if (!Array.isArray(transitions)) return transitions;
64
64
  return transitions.map(function(t, i) {
65
- if (!t || typeof t !== 'object' || t.when === undefined) return t;
65
+ var where = (stepName ? 'Step "' + stepName + '"' : 'A step') + ', arrow ' + (i + 1);
66
+ checkArrow(t, where);
67
+ if (t.when === undefined) return t;
66
68
  var out = Object.assign({}, t);
67
- out.condition = whenToCondition(t.when, (stepName ? 'Step "' + stepName + '"' : 'A step') + ', arrow ' + (i + 1));
69
+ out.condition = whenToCondition(t.when, where);
68
70
  return out;
69
71
  });
70
72
  }
71
73
 
72
- module.exports = { whenToCondition: whenToCondition, compileTransitions: compileTransitions };
74
+ // What an arrow may be. Each of these used to save and then fail on the live
75
+ // run instead: an "each" arrow without its list matched nothing, and the run
76
+ // ended with "No transition matched" (workflowRunner's routeAfterStep).
77
+ var ARROW_MODES = ['single', 'each'];
78
+ var SOURCE_ROOTS = ['output', 'item', 'params', 'trigger'];
79
+
80
+ function invalidArrow(message) {
81
+ var err = invalid(message);
82
+ err.code = 'TRANSITION_INVALID';
83
+ err.data = { transitions: [{ message: message, keyword: 'transition' }] };
84
+ return err;
85
+ }
86
+
87
+ /** Refuses an arrow the runner could not follow, naming the step and arrow. */
88
+ function checkArrow(t, where) {
89
+ if (!t || typeof t !== 'object' || Array.isArray(t)) throw invalidArrow(where + ': an arrow must be an object such as { "target": "next-step" }.');
90
+ if (t.mode !== undefined && t.mode !== null && ARROW_MODES.indexOf(t.mode) === -1) {
91
+ throw invalidArrow(where + ': "' + t.mode + '" is not a kind of arrow. Kinds: ' + ARROW_MODES.join(', ') + '.');
92
+ }
93
+ if (t.decide !== undefined && t.decide !== null) {
94
+ if (typeof t.decide !== 'string' || !t.decide.trim()) throw invalidArrow(where + ': `decide` names a registered function.');
95
+ if (t.mode === 'each') throw invalidArrow(where + ': an arrow either lets a function decide or goes through a list, not both.');
96
+ if (t.targets !== undefined && t.targets !== null &&
97
+ (!Array.isArray(t.targets) || t.targets.some(function(k) { return typeof k !== 'string' || !k; }))) {
98
+ throw invalidArrow(where + ': `targets` is a list of step keys.');
99
+ }
100
+ return;
101
+ }
102
+ if (typeof t.target !== 'string' || !t.target.trim()) throw invalidArrow(where + ': say where it goes (`target`): a step key, end_success or end_failed.');
103
+ if (t.mode === 'each') {
104
+ var source = typeof t.source === 'string' ? t.source.trim() : '';
105
+ if (!source) throw invalidArrow(where + ': an arrow for each item needs the list to go through (`source`), e.g. "output.rows".');
106
+ if (!FIELD_PATTERN.test(source) || SOURCE_ROOTS.indexOf(source.split('.')[0]) === -1) {
107
+ throw invalidArrow(where + ': "' + source + '" is not a list it can read. Start it with ' + SOURCE_ROOTS.join(', ') + ', e.g. "output.rows".');
108
+ }
109
+ }
110
+ }
111
+
112
+ module.exports = { whenToCondition: whenToCondition, compileTransitions: compileTransitions, checkArrow: checkArrow, ARROW_MODES: ARROW_MODES };
package/lib/flows.js CHANGED
@@ -597,7 +597,9 @@ function runView(run, steps, stepRuns) {
597
597
  // the resolved input is kept (workflowRunner patches `input` as it parks)
598
598
  // — the step row holds the tokens, and a browser has nothing to resolve
599
599
  // them with.
600
- given: {}
600
+ given: {},
601
+ // WHAT THE SCREENS BEFORE THIS ONE SAVED, by screen key — see previousOf.
602
+ previous: {}
601
603
  };
602
604
  if (!waiting) {
603
605
  if (run.status === 'failed' && run.error) view.error = run.error;
@@ -614,9 +616,43 @@ function runView(run, steps, stepRuns) {
614
616
  if (sr.stepKey !== waiting.stepKey) return;
615
617
  Object.assign(view.values, sr.output || {});
616
618
  });
619
+ view.previous = previousOf(steps, stepRuns, waiting);
617
620
  return view;
618
621
  }
619
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
+
620
656
  async function stepRunsOf(runId) {
621
657
  return db.model('WorkflowStepRun').query().where({ runId: runId }).orderBy('recordCreatedDate', 'asc');
622
658
  }
@@ -937,6 +973,7 @@ module.exports = {
937
973
  fail: fail,
938
974
  END_TARGETS: END_TARGETS,
939
975
  conditionToWhen: conditionToWhen,
976
+ previousOf: previousOf,
940
977
  toStepRows: toStepRows,
941
978
  validateFlow: validateFlow,
942
979
  runView: runView,
package/lib/triggers.js CHANGED
@@ -54,6 +54,19 @@ function registerTrigger(kind, def) {
54
54
 
55
55
  function kinds() { return Object.keys(KINDS); }
56
56
 
57
+ /**
58
+ * Every kind, as a designer offers it: { kind, label, description, fields }.
59
+ * `fields` is what the form asks for ({ name, label, placeholder?, help?,
60
+ * required?, options? }). A designer builds its trigger form from this rather
61
+ * than from its own list, so it never offers a kind this engine refuses.
62
+ */
63
+ function catalog() {
64
+ return Object.keys(KINDS).map(function(k) {
65
+ var d = KINDS[k];
66
+ return { kind: k, label: d.label || k, description: d.description || '', fields: d.fields || [] };
67
+ });
68
+ }
69
+
57
70
  function common(trigger) {
58
71
  var out = { kind: trigger.kind };
59
72
  if (trigger.enabled === false) out.enabled = false;
@@ -61,17 +74,26 @@ function common(trigger) {
61
74
  }
62
75
 
63
76
  registerTrigger('manual', {
77
+ label: 'Someone presses Start',
64
78
  description: 'Someone presses Start.',
79
+ fields: [],
65
80
  validate: common
66
81
  });
67
82
 
68
83
  registerTrigger('api', {
84
+ label: 'Another system calls it',
69
85
  description: 'Another system starts it through the API.',
86
+ fields: [],
70
87
  validate: common
71
88
  });
72
89
 
73
90
  registerTrigger('schedule', {
91
+ label: 'On a schedule',
74
92
  description: 'On a schedule — a cron expression, e.g. "0 9 * * 1" for Mondays at 9.',
93
+ fields: [
94
+ { name: 'cron', label: 'When', placeholder: '0 9 * * 1', help: 'Minute hour day month weekday', required: true },
95
+ { name: 'timezone', label: 'Time zone', placeholder: 'Asia/Kolkata', help: 'Empty: the server\'s' }
96
+ ],
75
97
  validate: function(trigger) {
76
98
  var cron = String(trigger.cron == null ? '' : trigger.cron).trim();
77
99
  if (!cron) throw invalid('A schedule needs `cron`, e.g. "0 9 * * 1" (Mondays at 9:00).');
@@ -92,7 +114,14 @@ registerTrigger('schedule', {
92
114
  });
93
115
 
94
116
  registerTrigger('job', {
117
+ label: 'A job finishes',
95
118
  description: 'When a job finishes — successfully, with a failure, or either way.',
119
+ fields: [
120
+ { name: 'jobId', label: 'Job', placeholder: 'the job\'s id', required: true },
121
+ { name: 'when', label: 'When it', options: [
122
+ { value: 'success', label: 'succeeds' }, { value: 'failed', label: 'fails' }, { value: 'finished', label: 'finishes either way' }
123
+ ] }
124
+ ],
96
125
  validate: function(trigger) {
97
126
  var jobId = String(trigger.jobId == null ? '' : trigger.jobId).trim();
98
127
  if (!jobId) throw invalid('A job trigger needs `jobId`: the job whose finish starts this workflow.');
@@ -286,6 +315,7 @@ async function describe(workflow) {
286
315
  module.exports = {
287
316
  registerTrigger: registerTrigger,
288
317
  kinds: kinds,
318
+ catalog: catalog,
289
319
  validate: validate,
290
320
  isOn: isOn,
291
321
  fire: fire,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/workflow-engine",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
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": [