@xeplr/workflow-engine 3.2.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
@@ -117,7 +117,8 @@ host gives workflow is the boundary.
117
117
  function, one written for another event, no answer, or an answer outside
118
118
  `targets` **fails the run**, naming the function. Functions registered with an
119
119
  `event` are screen functions, never steps: the action catalog leaves them out.
120
- - **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
121
122
  element (`item`), recorded in `workflow_run_edges`. With `joinStep` the parent
122
123
  waits and resumes at the join once all children are terminal (guarded by an
123
124
  atomic `waiting → running` patch); without it the parent finishes.
@@ -166,7 +167,9 @@ The same rules hold for every kind:
166
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.
167
168
  - A due trigger is **claimed** by a conditional update of its due time, so two processes running the tick start it once.
168
169
 
169
- **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? })`:
170
173
  - a time kind gives `due(trigger, from)`, its next due `Date`;
171
174
  - an event kind gives `matches(trigger, event)`, and the host calls `triggers.emit(kind, event)` when the event happens.
172
175
 
@@ -184,6 +187,14 @@ The comparisons are the flows facade's: `=`, `!=`, `>`, `>=`, `<`, `<=`, `contai
184
187
 
185
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.
186
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
+
187
198
  ## Jobs as steps and as triggers (`lib/jobs.js`)
188
199
 
189
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/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.2.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": [