@xeplr/workflow-engine 1.0.1 → 3.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.
@@ -0,0 +1,299 @@
1
+ // TRIGGERS — WHAT STARTS A WORKFLOW, FOR EVERY KIND OF START.
2
+ //
3
+ // A workflow's `trigger` (0014) says what is supposed to start it. This module
4
+ // is what acts on it: one registry of kinds, the same way actions are one
5
+ // registry of steps. A kind is a name and up to three functions:
6
+ //
7
+ // validate(trigger) → the trigger normalised, or throws naming the problem
8
+ // due(trigger, from) → for a kind that fires on time: the next moment it is
9
+ // due after `from` (Date), or null
10
+ // matches(trigger, event) → for a kind that fires on an event: whether this
11
+ // event is the one it waits for
12
+ //
13
+ // Built in: manual and api (nothing to arm — someone or something calls the
14
+ // run), schedule (a cron expression), and job (a job finished). Folder and
15
+ // mailbox watchers are later kinds of the same registry; a host can register
16
+ // its own with registerTrigger().
17
+ //
18
+ // THE SAME RULES FOR EVERY KIND:
19
+ // * only a PUBLISHED workflow fires; a draft never does
20
+ // * `enabled: false` switches a trigger off without losing it
21
+ // * a run is started with what fired it as `triggerData`, which steps read
22
+ // as {trigger.*} ({trigger.status}, {trigger.window.from}, {trigger.dueAt})
23
+ // * every fire and every skip is recorded on workflow_trigger_states, so
24
+ // "when did it last run, when is it next due, why did it not start" is a
25
+ // row, not a log search
26
+ var { runWithMt } = require('@xeplr/db');
27
+ var { generateId } = require('@xeplr/utils/lib/helpers');
28
+ var parser = require('cron-parser');
29
+ var db = require('./db');
30
+
31
+ var KINDS = {};
32
+ var JOB_ID_PATTERN = /^[A-Za-z0-9_-]{1,25}$/;
33
+ var JOB_WHEN = ['success', 'failed', 'finished'];
34
+
35
+ // A model validation error by NAME (as Objection's is), so the generic save
36
+ // route answers it as one — 422 with the reason under `fields.trigger` —
37
+ // rather than as a server error. status/code are for callers of the engine.
38
+ function invalid(message) {
39
+ var err = new Error(message);
40
+ err.name = 'ValidationError';
41
+ err.type = 'ModelValidation';
42
+ err.data = { trigger: [{ message: message, keyword: 'trigger' }] };
43
+ err.statusCode = 400;
44
+ err.status = 400;
45
+ err.code = 'TRIGGER_INVALID';
46
+ return err;
47
+ }
48
+
49
+ /** Add (or replace) a kind of trigger. */
50
+ function registerTrigger(kind, def) {
51
+ if (!kind || typeof kind !== 'string') throw new Error('registerTrigger: a kind name is required');
52
+ KINDS[kind] = Object.assign({ kind: kind }, def || {});
53
+ }
54
+
55
+ function kinds() { return Object.keys(KINDS); }
56
+
57
+ function common(trigger) {
58
+ var out = { kind: trigger.kind };
59
+ if (trigger.enabled === false) out.enabled = false;
60
+ return out;
61
+ }
62
+
63
+ registerTrigger('manual', {
64
+ description: 'Someone presses Start.',
65
+ validate: common
66
+ });
67
+
68
+ registerTrigger('api', {
69
+ description: 'Another system starts it through the API.',
70
+ validate: common
71
+ });
72
+
73
+ registerTrigger('schedule', {
74
+ description: 'On a schedule — a cron expression, e.g. "0 9 * * 1" for Mondays at 9.',
75
+ validate: function(trigger) {
76
+ var cron = String(trigger.cron == null ? '' : trigger.cron).trim();
77
+ if (!cron) throw invalid('A schedule needs `cron`, e.g. "0 9 * * 1" (Mondays at 9:00).');
78
+ var opts = trigger.timezone ? { tz: String(trigger.timezone) } : undefined;
79
+ try { parser.parseExpression(cron, opts); } catch (e) {
80
+ throw invalid('"' + cron + '" is not a cron expression this can read: ' + e.message);
81
+ }
82
+ var out = common(trigger);
83
+ out.cron = cron;
84
+ if (trigger.timezone) out.timezone = String(trigger.timezone);
85
+ return out;
86
+ },
87
+ due: function(trigger, from) {
88
+ var opts = { currentDate: from };
89
+ if (trigger.timezone) opts.tz = trigger.timezone;
90
+ return parser.parseExpression(trigger.cron, opts).next().toDate();
91
+ }
92
+ });
93
+
94
+ registerTrigger('job', {
95
+ description: 'When a job finishes — successfully, with a failure, or either way.',
96
+ validate: function(trigger) {
97
+ var jobId = String(trigger.jobId == null ? '' : trigger.jobId).trim();
98
+ if (!jobId) throw invalid('A job trigger needs `jobId`: the job whose finish starts this workflow.');
99
+ if (!JOB_ID_PATTERN.test(jobId)) throw invalid('"' + jobId + '" is not a job id.');
100
+ var when = trigger.when == null ? 'success' : String(trigger.when);
101
+ if (JOB_WHEN.indexOf(when) === -1) {
102
+ throw invalid('`when` must be one of ' + JOB_WHEN.join(', ') + ' — got "' + when + '".');
103
+ }
104
+ var out = common(trigger);
105
+ out.jobId = jobId;
106
+ out.when = when;
107
+ return out;
108
+ },
109
+ matches: function(trigger, event) {
110
+ if (!event || event.jobId !== trigger.jobId) return false;
111
+ if (trigger.when === 'finished') return true;
112
+ return event.status === trigger.when;
113
+ }
114
+ });
115
+
116
+ /**
117
+ * A workflow's trigger, checked and normalised. null/undefined stays null
118
+ * ("started however it is called"). Throws a 400 naming the problem.
119
+ */
120
+ function validate(trigger) {
121
+ if (trigger === null || trigger === undefined) return null;
122
+ if (typeof trigger !== 'object' || Array.isArray(trigger)) {
123
+ throw invalid('`trigger` must be an object such as { "kind": "schedule", "cron": "0 9 * * 1" }.');
124
+ }
125
+ var def = KINDS[trigger.kind];
126
+ if (!def) throw invalid('"' + trigger.kind + '" is not a kind of trigger. Kinds: ' + kinds().join(', ') + '.');
127
+ return def.validate ? def.validate(trigger) : common(trigger);
128
+ }
129
+
130
+ function isOn(workflow) {
131
+ return Boolean(workflow && workflow.status === 'published' && workflow.trigger &&
132
+ workflow.trigger.enabled !== false && KINDS[workflow.trigger.kind]);
133
+ }
134
+
135
+ function mtOf(row) {
136
+ return { mtId1: row.mtId1 || null, mtId2: row.mtId2 || null, mtId3: row.mtId3 || null, mtId4: row.mtId4 || null };
137
+ }
138
+
139
+ // ── the record of what each trigger did ─────────────────────────────────
140
+
141
+ async function stateOf(workflowId) {
142
+ return db.model('WorkflowTriggerState').query().where({ workflowId: workflowId }).first();
143
+ }
144
+
145
+ async function writeState(workflow, patch) {
146
+ var State = db.model('WorkflowTriggerState');
147
+ var row = await stateOf(workflow.id);
148
+ var values = Object.assign({ kind: workflow.trigger ? workflow.trigger.kind : null }, patch);
149
+ if (row) return State.query().patchAndFetchById(row.id, values);
150
+ return State.query().insert(Object.assign({ id: generateId(), workflowId: workflow.id }, values));
151
+ }
152
+
153
+ /**
154
+ * Start a run because a trigger fired, inside the workflow's own tenant
155
+ * context. A run that cannot start (a required parameter nobody supplies, no
156
+ * steps) is recorded as a skip with the reason, never thrown at the caller —
157
+ * a finished job must not fail because a workflow after it is broken.
158
+ */
159
+ async function fire(workflow, triggerData) {
160
+ var runner = require('./workflowRunner');
161
+ return runWithMt(mtOf(workflow), async function() {
162
+ try {
163
+ var run = await runner.startRun(workflow.id, {}, { trigger: workflow.trigger.kind, triggerData: triggerData });
164
+ await writeState(workflow, { lastFiredAt: new Date().toISOString(), lastRunId: run && run.id });
165
+ return { fired: true, runId: run && run.id };
166
+ } catch (err) {
167
+ await writeState(workflow, { lastSkippedAt: new Date().toISOString(), lastSkipReason: err.message });
168
+ return { fired: false, reason: err.message };
169
+ }
170
+ });
171
+ }
172
+
173
+ // ── event kinds: a job finished ─────────────────────────────────────────
174
+
175
+ // A chain of jobs longer than this is a loop nobody drew on purpose.
176
+ var MAX_DEPTH = 25;
177
+
178
+ /**
179
+ * An event happened (today: a job finished). Every published, enabled
180
+ * workflow whose trigger is of `kind` and matches it starts, in the caller's
181
+ * tenant context — the event's own. Returns what each one did.
182
+ */
183
+ async function emit(kind, event) {
184
+ var def = KINDS[kind];
185
+ if (!def || !def.matches) return [];
186
+ var rows = await db.model('Workflow').query()
187
+ .where({ status: 'published' })
188
+ .whereRaw("\"trigger\"->>'kind' = ?", [kind]);
189
+ var out = [];
190
+ for (var i = 0; i < rows.length; i++) {
191
+ var w = rows[i];
192
+ if (!isOn(w) || !def.matches(w.trigger, event)) continue;
193
+ var r = await fire(w, Object.assign({ kind: kind }, event));
194
+ out.push(Object.assign({ workflowId: w.id }, r));
195
+ }
196
+ return out;
197
+ }
198
+
199
+ // ── time kinds: the schedule tick ───────────────────────────────────────
200
+
201
+ /**
202
+ * One pass over every timed trigger, across every tenant (a tick is a timer:
203
+ * there is no request and so no tenant — rows are picked unscoped and each
204
+ * run is started in its own workflow's context, as @xeplr/jobs' scheduler
205
+ * does).
206
+ *
207
+ * A trigger seen for the first time, or whose settings changed, is given its
208
+ * next due time and does not fire — so publishing a workflow never starts a
209
+ * run for a time that has already passed. A due one is CLAIMED by moving its
210
+ * due time forward with a conditional update; only the process whose update
211
+ * landed fires it, so two processes running the tick start it once.
212
+ *
213
+ * `now` is for tests; production passes nothing.
214
+ */
215
+ async function tick(now) {
216
+ now = now || new Date();
217
+ var timed = kinds().filter(function(k) { return KINDS[k].due; });
218
+ if (!timed.length) return [];
219
+ var Workflow = db.model('Workflow');
220
+ var State = db.model('WorkflowTriggerState');
221
+ var rows = await Workflow.unscopedQuery()
222
+ .where({ status: 'published', isActive: true })
223
+ .whereRaw("\"trigger\"->>'kind' in (" + timed.map(function() { return '?'; }).join(',') + ')', timed);
224
+ var fired = [];
225
+ for (var i = 0; i < rows.length; i++) {
226
+ var w = rows[i];
227
+ if (!isOn(w)) continue;
228
+ var def = KINDS[w.trigger.kind];
229
+ var settings = JSON.stringify(w.trigger);
230
+ await runWithMt(mtOf(w), async function() {
231
+ var state = await stateOf(w.id);
232
+ if (!state || !state.nextDueAt || state.settings !== settings) {
233
+ await writeState(w, { settings: settings, nextDueAt: def.due(w.trigger, now).toISOString() });
234
+ return;
235
+ }
236
+ var dueAt = new Date(state.nextDueAt);
237
+ if (dueAt > now) return;
238
+ var next = def.due(w.trigger, now).toISOString();
239
+ var claimed = await State.query()
240
+ .where({ id: state.id, nextDueAt: state.nextDueAt })
241
+ .patch({ nextDueAt: next });
242
+ if (!claimed) return;
243
+ var r = await fire(w, { kind: w.trigger.kind, dueAt: dueAt.toISOString(), firedAt: now.toISOString() });
244
+ fired.push(Object.assign({ workflowId: w.id }, r));
245
+ });
246
+ }
247
+ return fired;
248
+ }
249
+
250
+ var _timer = null;
251
+
252
+ /** Run the tick every `intervalMs` (default 30 s). The timer does not keep the process alive. */
253
+ function startTicking(intervalMs) {
254
+ stopTicking();
255
+ var every = intervalMs || 30000;
256
+ var running = false;
257
+ _timer = setInterval(function() {
258
+ if (running) return;
259
+ running = true;
260
+ tick().catch(function(err) {
261
+ console.error('[workflow] trigger tick failed — no scheduled workflow is starting: ' + err.message);
262
+ }).finally(function() { running = false; });
263
+ }, every);
264
+ if (_timer.unref) _timer.unref();
265
+ }
266
+
267
+ function stopTicking() {
268
+ if (_timer) clearInterval(_timer);
269
+ _timer = null;
270
+ }
271
+
272
+ /** What a workflow's trigger is and what it last did — for a screen. */
273
+ async function describe(workflow) {
274
+ var state = workflow ? await stateOf(workflow.id) : null;
275
+ return {
276
+ trigger: (workflow && workflow.trigger) || null,
277
+ on: isOn(workflow),
278
+ lastFiredAt: (state && state.lastFiredAt) || null,
279
+ lastRunId: (state && state.lastRunId) || null,
280
+ lastSkippedAt: (state && state.lastSkippedAt) || null,
281
+ lastSkipReason: (state && state.lastSkipReason) || null,
282
+ nextDueAt: (state && isOn(workflow) && state.nextDueAt) || null
283
+ };
284
+ }
285
+
286
+ module.exports = {
287
+ registerTrigger: registerTrigger,
288
+ kinds: kinds,
289
+ validate: validate,
290
+ isOn: isOn,
291
+ fire: fire,
292
+ emit: emit,
293
+ tick: tick,
294
+ startTicking: startTicking,
295
+ stopTicking: stopTicking,
296
+ describe: describe,
297
+ MAX_DEPTH: MAX_DEPTH,
298
+ JOB_WHEN: JOB_WHEN
299
+ };
@@ -112,6 +112,12 @@ async function finishRun(run, status, error) {
112
112
  * the two can differ), params/item are the run's own, resumeKey is injected
113
113
  * separately, only for the specific step that needs it.
114
114
  */
115
+ // What started a run, as {trigger.*} reads it — in bindings AND in the
116
+ // conditions on arrows. A run started by hand has just its kind.
117
+ function triggerOf(run) {
118
+ return run.triggerData || { kind: run.trigger || 'manual' };
119
+ }
120
+
115
121
  async function buildContext(run) {
116
122
  var stepRuns = await db.model('WorkflowStepRun').query()
117
123
  .where({ runId: run.id })
@@ -124,6 +130,10 @@ async function buildContext(run) {
124
130
 
125
131
  return {
126
132
  params: run.params || {},
133
+ // WHAT STARTED THIS RUN (lib/triggers.js): a finished job's status,
134
+ // window and output, a schedule's due time. {trigger.*} in a binding or a
135
+ // condition. A run started by hand has just its kind.
136
+ trigger: triggerOf(run),
127
137
  item: run.item || null,
128
138
  steps: byKey,
129
139
  previous_step: previous ? { output: previous.output || {} } : {},
@@ -260,6 +270,8 @@ async function startRun(workflowId, params, opts) {
260
270
  // with, so a result can be explained") describe a run that never happened.
261
271
  params: resolved,
262
272
  trigger: opts.trigger || 'manual',
273
+ // What fired it, in full — read by steps as {trigger.*}.
274
+ triggerData: opts.triggerData || null,
263
275
  // WHO STARTED IT. Every caller already passes opts.user (the router hands
264
276
  // it req.user), and without this the column stayed null on every run —
265
277
  // which made "the runs I have not finished" an unanswerable question, and
@@ -344,8 +356,11 @@ async function executeFrom(workflow, steps, run, startStepKey) {
344
356
  input: resolvedValues,
345
357
  // resumeKey: a wait step's own key, for an action that hands it to
346
358
  // something in this same process rather than over a URL (a job
347
- // started in-process reports back through jobChains.jobFinished).
348
- system: { runId: run.id, stepId: step.id, stepKey: step.stepKey, resumeKey: resumeKey }
359
+ // started in-process reports back through jobs.jobFinished).
360
+ // trigger: what started the run, for an action that carries it on
361
+ // (job-run passes a job's period to the next job).
362
+ system: { runId: run.id, stepId: step.id, stepKey: step.stepKey, resumeKey: resumeKey,
363
+ trigger: triggerOf(run) }
349
364
  });
350
365
 
351
366
  if (result.status !== 'success') {
@@ -403,7 +418,7 @@ async function routeAfterStep(workflow, steps, run, step, output) {
403
418
  return { done: false, next: next };
404
419
  }
405
420
 
406
- var row = { output: output, item: run.item, params: run.params };
421
+ var row = { output: output, item: run.item, params: run.params, trigger: triggerOf(run) };
407
422
 
408
423
  for (var i = 0; i < step.transitions.length; i++) {
409
424
  var t = step.transitions[i];
@@ -437,12 +452,12 @@ async function routeAfterStep(workflow, steps, run, step, output) {
437
452
  * `step.joinStep`.
438
453
  */
439
454
  async function handleFanOut(workflow, steps, run, step, t, output) {
440
- var array = t.source ? getPath({ output: output, item: run.item, params: run.params }, t.source) : null;
455
+ var array = t.source ? getPath({ output: output, item: run.item, params: run.params, trigger: triggerOf(run) }, t.source) : null;
441
456
  if (!Array.isArray(array)) return false;
442
457
 
443
458
  var matches = [];
444
459
  array.forEach(function(el, idx) {
445
- var elRow = { output: output, item: el, params: run.params };
460
+ var elRow = { output: output, item: el, params: run.params, trigger: triggerOf(run) };
446
461
  if (!t.condition || xf.evaluate(t.condition, elRow)) matches.push({ el: el, idx: idx });
447
462
  });
448
463
  if (!matches.length) return false;
@@ -452,7 +467,7 @@ async function handleFanOut(workflow, steps, run, step, t, output) {
452
467
  var m = matches[i];
453
468
  var itemKey = m.el && typeof m.el === 'object' ? (m.el.id || m.el.messageId || m.el.key) : null;
454
469
  var child = await db.model('WorkflowRun').query().insert(Object.assign({
455
- id: uid(), workflowId: workflow.id, status: 'queued', params: run.params,
470
+ id: uid(), workflowId: workflow.id, status: 'queued', params: run.params, triggerData: run.triggerData || null,
456
471
  item: m.el, trigger: 'fanout', startedAt: new Date().toISOString()
457
472
  }, mtOf(run)));
458
473
  await db.model('WorkflowRunEdge').query().insert({
@@ -0,0 +1,85 @@
1
+ -- 0015_workflow_triggers.sql
2
+ --
3
+ -- TRIGGERS START RUNS — and a job finishing is one of them.
4
+ --
5
+ -- 0014 recorded what is supposed to start a workflow; nothing acted on it.
6
+ -- lib/triggers.js now does, for every kind through one registry: a schedule,
7
+ -- a job finishing, and later a folder or a mailbox. Three things here.
8
+ --
9
+ -- 1. WHAT FIRED A RUN, IN FULL. workflow_runs.trigger is the kind ('job',
10
+ -- 'schedule'); triggerData is what came with it — a finished job's status,
11
+ -- period and output, a schedule's due time — read by steps as {trigger.*}.
12
+ ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "triggerData" jsonb;
13
+
14
+ -- 2. WHAT EACH TRIGGER LAST DID: when it fired and which run that was, when
15
+ -- it is next due, and when and why it did not start a run (a required
16
+ -- parameter nobody supplies, a workflow with no steps). One row per
17
+ -- workflow, so "why did this not run last night" is a SELECT.
18
+ --
19
+ -- Its times are timestamptz: a due time is an instant, and a plain
20
+ -- timestamp read back in another server timezone would fire hours off.
21
+ --
22
+ -- `settings` is the trigger as it was when nextDueAt was computed; a
23
+ -- changed schedule is noticed by comparing it, and given a fresh due time
24
+ -- rather than firing for one computed from the old expression.
25
+ CREATE TABLE IF NOT EXISTS "workflow_trigger_states" (
26
+ "id" varchar(25) PRIMARY KEY,
27
+ "workflowId" varchar(25) NOT NULL,
28
+ "kind" varchar(32),
29
+ "settings" text,
30
+ "nextDueAt" timestamptz,
31
+ "lastFiredAt" timestamptz,
32
+ "lastRunId" varchar(25),
33
+ "lastSkippedAt" timestamptz,
34
+ "lastSkipReason" text,
35
+ "isActive" boolean DEFAULT true,
36
+ "mtId1" varchar(25),
37
+ "mtId2" varchar(25),
38
+ "mtId3" varchar(25),
39
+ "mtId4" varchar(25),
40
+ "recordCreatedDate" timestamp,
41
+ "recordModifiedDate" timestamp,
42
+ "recordCreatedBy" varchar(25),
43
+ "recordModifiedBy" varchar(25)
44
+ );
45
+ CREATE UNIQUE INDEX IF NOT EXISTS "workflow_trigger_states_workflow_unique" ON "workflow_trigger_states" ("workflowId");
46
+
47
+ -- 3. JOB CHAINS BECOME ORDINARY WORKFLOWS.
48
+ --
49
+ -- A job's "After this job" rules used to be a hidden, locked workflow of
50
+ -- kind 'job-chain' with two private actions and their own routes. It is now
51
+ -- what it always was underneath: a workflow, triggered by the job finishing.
52
+ --
53
+ -- kind 'job-chain' → 'workflow', with trigger
54
+ -- { kind: 'job', jobId, when: 'finished' }
55
+ -- (the decide step still routes on status)
56
+ -- key 'job-chain-<jobId>' → none; the trigger says which job
57
+ -- 'job-chain-decide' step → 'decide' (a plain branching step)
58
+ -- 'job-chain-next' steps → 'job-run', which carries the period on
59
+ -- conditions on params.x → trigger.x (status, output, window, carry…)
60
+ -- bindings {output.x} etc. → {trigger.output.x}
61
+ --
62
+ -- A rule input of exactly "{window}" is dropped: job-run passes the finished
63
+ -- job's period on by itself, as an object, which a binding would turn into
64
+ -- text.
65
+ UPDATE "workflow_steps" s
66
+ SET "actionName" = 'decide',
67
+ "values" = '{}'::jsonb,
68
+ "transitions" = replace(s."transitions"::text, '"params.', '"trigger.')::jsonb
69
+ FROM "workflows" w
70
+ WHERE w."id" = s."workflowId" AND w."kind" = 'job-chain' AND s."actionName" = 'job-chain-decide';
71
+
72
+ UPDATE "workflow_steps" s
73
+ SET "actionName" = 'job-run',
74
+ "values" = regexp_replace(
75
+ (CASE WHEN s."values"->'inputs'->>'window' = '{window}'
76
+ THEN s."values" #- '{inputs,window}' ELSE s."values" END)::text,
77
+ '\{\s*(status|error|output|carry|window|jobId|given|used)\M', '{trigger.\1', 'g')::jsonb
78
+ FROM "workflows" w
79
+ WHERE w."id" = s."workflowId" AND w."kind" = 'job-chain' AND s."actionName" = 'job-chain-next';
80
+
81
+ UPDATE "workflows"
82
+ SET "trigger" = jsonb_build_object('kind', 'job', 'jobId', substring("key" from 11), 'when', 'finished'),
83
+ "kind" = 'workflow',
84
+ "key" = NULL
85
+ WHERE "kind" = 'job-chain' AND "key" LIKE 'job-chain-%';
@@ -27,9 +27,10 @@ class Workflow extends BaseModel {
27
27
  description: { type: ['string', 'null'], maxLength: 1000 },
28
28
  // [{ name, type, required, default, description, order }]
29
29
  params: { type: ['array', 'null'] },
30
- // WHAT STARTS A RUN, as the designer recorded it — { kind, … }, one
31
- // shape per kind (see migrations/0014). The engine does not arm
32
- // anything from it yet; it is the intent the arming work will read.
30
+ // WHAT STARTS A RUN — { kind, … }, one shape per kind (migrations/0014),
31
+ // acted on by lib/triggers.js: manual, api, schedule, job, and any kind
32
+ // a host registers. Checked on every write (below), so a trigger that
33
+ // could never fire is refused when it is saved, not discovered later.
33
34
  trigger: { type: ['object', 'null'] },
34
35
  status: { type: 'string', maxLength: 20 },
35
36
  // WHAT THE BUILDER OFFERS, not what the engine does — 'workflow' (the
@@ -37,7 +38,7 @@ class Workflow extends BaseModel {
37
38
  // designed in another app entirely through the /flows facade and
38
39
  // read-only in this product's own builder). workflowRunner never reads
39
40
  // it. See migrations/0012 and 0013.
40
- kind: { type: 'string', enum: ['workflow', 'jobs', 'screens', 'job-chain'] },
41
+ kind: { type: 'string', enum: ['workflow', 'jobs', 'screens'] },
41
42
  isActive: { type: 'boolean' },
42
43
  recordCreatedDate: { type: ['string', 'null'] },
43
44
  recordModifiedDate: { type: ['string', 'null'] },
@@ -50,6 +51,12 @@ class Workflow extends BaseModel {
50
51
  $beforeInsert() {
51
52
  super.$beforeInsert();
52
53
  if (!this.status) this.status = 'draft';
54
+ if (this.trigger !== undefined) this.trigger = require('../lib/triggers').validate(this.trigger);
55
+ }
56
+
57
+ $beforeUpdate(opt, ctx) {
58
+ super.$beforeUpdate(opt, ctx);
59
+ if (this.trigger !== undefined) this.trigger = require('../lib/triggers').validate(this.trigger);
53
60
  }
54
61
 
55
62
  static get relationMappings() {
@@ -24,6 +24,8 @@ class WorkflowRun extends BaseModel {
24
24
  // element this occurrence exists to process. See WorkflowRunEdge.
25
25
  item: { type: ['object', 'null'] },
26
26
  trigger: { type: ['string', 'null'], maxLength: 64 },
27
+ // What fired the run, in full (lib/triggers.js) — steps read it as {trigger.*}.
28
+ triggerData: { type: ['object', 'null'] },
27
29
  startedAt: { type: ['string', 'null'] },
28
30
  finishedAt: { type: ['string', 'null'] },
29
31
  durationMs: { type: ['integer', 'null'] },
@@ -49,6 +49,20 @@ class WorkflowStep extends BaseModel {
49
49
  super.$beforeInsert();
50
50
  if (!this.kind) this.kind = 'auto';
51
51
  if (!this.onError) this.onError = 'stop';
52
+ this.compileWhen();
53
+ }
54
+
55
+ $beforeUpdate(opt, ctx) {
56
+ super.$beforeUpdate(opt, ctx);
57
+ this.compileWhen();
58
+ }
59
+
60
+ // An arrow written as `when` ({ field, op, value }) gets its engine
61
+ // `condition` here, on every write — lib/conditions.js.
62
+ compileWhen() {
63
+ if (this.transitions !== undefined) {
64
+ this.transitions = require('../lib/conditions').compileTransitions(this.transitions, this.name || this.stepKey);
65
+ }
52
66
  }
53
67
 
54
68
  static get relationMappings() {
@@ -0,0 +1,35 @@
1
+ var { BaseModel } = require('@xeplr/db');
2
+
3
+ // What a workflow's trigger last did — see 0015_workflow_triggers.sql and
4
+ // lib/triggers.js. One row per workflow; written by the engine, never by a
5
+ // document save.
6
+
7
+ class WorkflowTriggerState extends BaseModel {
8
+ static get tableName() { return 'workflow_trigger_states'; }
9
+ static get idColumn() { return 'id'; }
10
+
11
+ static get jsonSchema() {
12
+ return {
13
+ type: 'object',
14
+ required: ['id', 'workflowId'],
15
+ properties: {
16
+ id: { type: 'string', maxLength: 25 },
17
+ workflowId: { type: 'string', maxLength: 25 },
18
+ kind: { type: ['string', 'null'], maxLength: 32 },
19
+ settings: { type: ['string', 'null'] },
20
+ nextDueAt: { type: ['string', 'null'] },
21
+ lastFiredAt: { type: ['string', 'null'] },
22
+ lastRunId: { type: ['string', 'null'], maxLength: 25 },
23
+ lastSkippedAt: { type: ['string', 'null'] },
24
+ lastSkipReason: { type: ['string', 'null'] },
25
+ isActive: { type: 'boolean' },
26
+ recordCreatedDate: { type: ['string', 'null'] },
27
+ recordModifiedDate: { type: ['string', 'null'] },
28
+ recordCreatedBy: { type: ['string', 'null'], maxLength: 25 },
29
+ recordModifiedBy: { type: ['string', 'null'], maxLength: 25 }
30
+ }
31
+ };
32
+ }
33
+ }
34
+
35
+ module.exports = WorkflowTriggerState;
package/models/index.js CHANGED
@@ -12,6 +12,7 @@ var WorkflowRun = require('./WorkflowRun');
12
12
  var WorkflowStepRun = require('./WorkflowStepRun');
13
13
  var WorkflowResumeKey = require('./WorkflowResumeKey');
14
14
  var WorkflowRunEdge = require('./WorkflowRunEdge');
15
+ var WorkflowTriggerState = require('./WorkflowTriggerState');
15
16
 
16
17
  module.exports = {
17
18
  Company: Company,
@@ -21,5 +22,6 @@ module.exports = {
21
22
  WorkflowRun: WorkflowRun,
22
23
  WorkflowStepRun: WorkflowStepRun,
23
24
  WorkflowResumeKey: WorkflowResumeKey,
24
- WorkflowRunEdge: WorkflowRunEdge
25
+ WorkflowRunEdge: WorkflowRunEdge,
26
+ WorkflowTriggerState: WorkflowTriggerState
25
27
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/workflow-engine",
3
- "version": "1.0.1",
3
+ "version": "3.0.0",
4
4
  "license": "MIT",
5
5
  "description": "The workflow engine — workflow's own database and tables, the action catalog, starting and resuming runs, and the flows facade. No HTTP: @xeplr/workflow-api puts the routes on top.",
6
6
  "main": "index.js",
@@ -45,5 +45,8 @@
45
45
  "devDependencies": {
46
46
  "@xeplr/jobs": "^2.3.0",
47
47
  "knex": "^3.3.0"
48
+ },
49
+ "dependencies": {
50
+ "cron-parser": "^4.9.0"
48
51
  }
49
52
  }