@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
@@ -0,0 +1,649 @@
1
+ // The engine. Two public entry points:
2
+ //
3
+ // startRun(workflowId, params, opts) — begin a new occurrence
4
+ // resumeByKey(key, output) — the ENTIRE consumer-side contract
5
+ // for a 'wait' step. The caller
6
+ // never needs to know which run or
7
+ // step a key belongs to — that
8
+ // lookup lives in
9
+ // workflow_resume_keys alone.
10
+ //
11
+ // Everything else here is internal plumbing: resolving a step's `values`
12
+ // against the run's context, calling the action through @xeplr/actions'
13
+ // runAction (which validates input against the action's own inputSchema —
14
+ // this module never duplicates that), routing via transitions
15
+ // (@xeplr/expression-handler evaluates the structured condition), and
16
+ // fanning an 'each' transition out into child runs (see
17
+ // workflow_run_edges.sql) that this same engine drives independently.
18
+ //
19
+ // TENANT CONTEXT: every DB touch after the very first lookup runs inside
20
+ // runWithMt(mtOf(row), ...) using the RUN's own stored mtIds — never the
21
+ // caller's ambient context. A run can resume long after the request that
22
+ // started it has ended (a resume click days later, a fanned-out child
23
+ // finishing on its own), so there may be no ambient context at all — see
24
+ // @xeplr/jobs' lib/execute.js, which this follows exactly for the same
25
+ // reason.
26
+
27
+ var crypto = require('crypto');
28
+ var { generateId } = require('@xeplr/utils/lib/helpers');
29
+ var { runWithMt } = require('@xeplr/db');
30
+ var { runAction } = require('@xeplr/actions');
31
+ var { interpolateAll, getPath, applySchema } = require('@xeplr/schema-handler');
32
+ var xf = require('@xeplr/expression-handler');
33
+ var db = require('./db');
34
+ var { exposedEnv } = require('./envExposed');
35
+
36
+ // EVERY MODEL COMES THROUGH db.model(), never from models/index.js directly.
37
+ //
38
+ // This module is required before connectDb has run, so a class captured here
39
+ // at load time would carry the process's GLOBAL Objection binding — which,
40
+ // embedded in a host app, is the HOST's database. Every query would then look
41
+ // for workflow's tables somewhere they have never existed.
42
+ //
43
+ // Written out at each call site rather than hidden behind locals, so it is
44
+ // visible that these are bound lookups and not module-level classes. Same
45
+ // shape as @xeplr/auth's `auth.model('User').query()`. Objection caches per
46
+ // (Model, knex) pair, so the lookup is a map hit rather than a new class.
47
+
48
+ function uid() { return generateId(); }
49
+
50
+ function mintResumeKey() {
51
+ return crypto.randomBytes(24).toString('base64url');
52
+ }
53
+
54
+ /**
55
+ * Where a resume key can be reached from OUTSIDE this process.
56
+ *
57
+ * Read at the moment a wait step runs rather than captured at load, so the
58
+ * same rule holds as everywhere else in this package: nothing about the
59
+ * environment is frozen at require time. Null when unset — the refusal
60
+ * belongs to whichever action actually needed it, which can say what it was
61
+ * trying to do.
62
+ *
63
+ * The path is fixed because the route is: see router.js's
64
+ * POST /public/resume/:key. WORKFLOW_PUBLIC_URL is the base a caller can
65
+ * reach this service on, mount path included when a host mounted it under
66
+ * one — e.g. https://bi.example.com/workflow
67
+ */
68
+ function publicResumeUrl(key) {
69
+ var base = process.env.WORKFLOW_PUBLIC_URL;
70
+ if (!base) return null;
71
+ return base.replace(/\/+$/, '') + '/public/resume/' + key;
72
+ }
73
+
74
+ // Every table here uses all four slots regardless of how many levels this
75
+ // app registered — an unregistered level is simply never read. See
76
+ // @xeplr/jobs' lib/execute.js mtOf() for the same reasoning.
77
+ function mtOf(row) {
78
+ var ctx = {};
79
+ for (var n = 1; n <= 4; n++) {
80
+ var key = 'mtId' + n;
81
+ if (row && row[key]) ctx[key] = row[key];
82
+ }
83
+ return ctx;
84
+ }
85
+
86
+ function stepMap(steps) {
87
+ var byKey = {};
88
+ steps.forEach(function(s) { byKey[s.stepKey] = s; });
89
+ return byKey;
90
+ }
91
+
92
+ function nextByPosition(steps, step) {
93
+ var idx = steps.findIndex(function(s) { return s.stepKey === step.stepKey; });
94
+ return (idx > -1 && idx < steps.length - 1) ? steps[idx + 1].stepKey : null;
95
+ }
96
+
97
+ async function finishRun(run, status, error) {
98
+ var startedAt = run.startedAt ? new Date(run.startedAt).getTime() : Date.now();
99
+ var finishedAt = Date.now();
100
+ await db.model('WorkflowRun').query().findById(run.id).patch({
101
+ status: status,
102
+ error: error || null,
103
+ finishedAt: new Date(finishedAt).toISOString(),
104
+ durationMs: finishedAt - startedAt
105
+ });
106
+ }
107
+
108
+ /**
109
+ * The run's context for resolving a step's `values` and evaluating its
110
+ * transitions: previous_step / steps.<key> come from what has actually run
111
+ * IN THIS RUN so far (not from array position — a jump via transitions means
112
+ * the two can differ), params/item are the run's own, resumeKey is injected
113
+ * separately, only for the specific step that needs it.
114
+ */
115
+ async function buildContext(run) {
116
+ var stepRuns = await db.model('WorkflowStepRun').query()
117
+ .where({ runId: run.id })
118
+ .whereIn('status', ['success', 'waiting'])
119
+ .orderBy('recordCreatedDate', 'asc');
120
+
121
+ var byKey = {};
122
+ stepRuns.forEach(function(sr) { byKey[sr.stepKey] = { output: sr.output || {} }; });
123
+ var previous = stepRuns.length ? stepRuns[stepRuns.length - 1] : null;
124
+
125
+ return {
126
+ params: run.params || {},
127
+ item: run.item || null,
128
+ steps: byKey,
129
+ previous_step: previous ? { output: previous.output || {} } : {},
130
+ // Only what WORKFLOW_ENV_EXPOSED names, never process.env itself — a
131
+ // resolved input is echoed to the builder and persisted on the step run,
132
+ // so anything reachable here is readable by anyone who can author a step.
133
+ // See lib/envExposed.js.
134
+ env: exposedEnv(),
135
+ resumeKey: null,
136
+ // The full URL that key resolves to. Set beside resumeKey on a wait step,
137
+ // for the same reason and at the same moment — see executeFrom.
138
+ resumeUrl: null
139
+ };
140
+ }
141
+
142
+ /**
143
+ * The schema a RUN is validated against: the union of what every step declares
144
+ * it needs from the caller.
145
+ *
146
+ * Declared per STEP because the step is where you find out you need it — you
147
+ * are binding `{params.customerEmail}` into an email-send's `to`, and that is
148
+ * the moment to say the workflow requires it. Keeping a separate workflow-
149
+ * level list means adding the requirement and adding the binding are two
150
+ * actions in two places, and the day somebody does only the first the run
151
+ * demands a parameter nothing reads.
152
+ *
153
+ * UNION RULES, for a name declared by more than one step:
154
+ *
155
+ * required ANY step requiring it makes it required. A caller cannot supply
156
+ * it to one step and not another — there is one value, asked for
157
+ * once, before any of them run.
158
+ * otherwise the FIRST declaration by step position wins (type, default,
159
+ * description). Two steps disagreeing about the type of one name
160
+ * is an authoring mistake, and picking the earlier one at least
161
+ * makes it deterministic rather than dependent on row order.
162
+ *
163
+ * `workflows.params` is folded in FIRST when present, so workflows that
164
+ * declared at the workflow level before steps could carry it keep working —
165
+ * and keep priority, since they are the older statement of intent.
166
+ */
167
+ function collectParams(workflow, steps) {
168
+ var byName = {};
169
+ var order = [];
170
+
171
+ function fold(declared) {
172
+ (Array.isArray(declared) ? declared : []).forEach(function(f) {
173
+ if (!f || !f.name) return;
174
+ if (!byName[f.name]) {
175
+ byName[f.name] = Object.assign({}, f);
176
+ order.push(f.name);
177
+ return;
178
+ }
179
+ // Already seen: the earlier declaration stands, except that requiring
180
+ // it anywhere requires it everywhere.
181
+ if (f.required) byName[f.name].required = true;
182
+ });
183
+ }
184
+
185
+ fold(workflow.params);
186
+ (steps || []).forEach(function(step) { fold(step.params); });
187
+
188
+ return order.map(function(name, i) {
189
+ return Object.assign({}, byName[name], { order: i + 1 });
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Validate a caller's params against what the workflow's steps declare, and
195
+ * return the set the run should actually use (declared defaults filled in).
196
+ *
197
+ * A workflow whose steps declare NOTHING takes whatever it is given,
198
+ * unchanged. That is not a loophole — it is the state every workflow built
199
+ * before this existed is in, and silently rejecting their params would break
200
+ * them all. Declaring is how a workflow opts INTO being strict.
201
+ *
202
+ * Undeclared keys are dropped by applySchema, which is the same rule a step's
203
+ * input follows. A caller passing `custmerId` gets told `customerId` is
204
+ * missing rather than having the typo travel to the first step that reads it.
205
+ */
206
+ function resolveParams(workflow, steps, params) {
207
+ var declared = collectParams(workflow, steps);
208
+ if (!declared.length) return params || {};
209
+ try {
210
+ return applySchema(declared, params || {}, 'params');
211
+ } catch (err) {
212
+ // Re-thrown with the workflow named. The caller asked to run "Onboard
213
+ // customer", not to satisfy a schema, and the message is what a person
214
+ // reads in a snackbar. `details` is carried through untouched so the run
215
+ // dialog can mark the individual fields.
216
+ var wrapped = new Error('Cannot run "' + workflow.name + '": ' + err.message);
217
+ wrapped.details = err.details || [];
218
+ wrapped.code = 'PARAMS_INVALID';
219
+ throw wrapped;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Begin a new occurrence. Runs inside whatever tenant context is already
225
+ * ambient (an authenticated HTTP request already has it, via mtMiddleware) —
226
+ * BaseModel's $beforeInsert fills mtId1-4 on the new run from that context.
227
+ */
228
+ async function startRun(workflowId, params, opts) {
229
+ opts = opts || {};
230
+ var workflow = await db.model('Workflow').query().findById(workflowId);
231
+ if (!workflow) throw new Error('Workflow not found: ' + workflowId);
232
+ var steps = await db.model('WorkflowStep').query().where({ workflowId: workflowId }).orderBy('position');
233
+ if (!steps.length) throw new Error('Workflow "' + workflow.name + '" has no steps');
234
+
235
+ // A WORKFLOW DECLARES ITS INPUTS THE WAY AN ACTION DOES, and refuses to
236
+ // start without them. `workflows.params` is deliberately the same field
237
+ // shape as an action's inputSchema (see 0003_workflows.sql), so this is the
238
+ // same applySchema that runAction validates a step's input with — one
239
+ // definition of what "required" means, for both levels of the product.
240
+ //
241
+ // BEFORE the run row is inserted. A refused start must leave nothing
242
+ // behind: an occurrence recorded for a call that never ran a step is a row
243
+ // somebody has to explain, and it would show up in the run list as a
244
+ // failure of the workflow rather than of the caller.
245
+ //
246
+ // Missing `to` on a step is caught by the action, at the moment that step
247
+ // runs — which can be the fourth step, twenty minutes in, after three
248
+ // others have already written to a database. Missing `customerId` on the
249
+ // WORKFLOW is caught here, before anything happens at all. That is the
250
+ // whole reason this belongs at the top rather than in the step.
251
+ var resolved = resolveParams(workflow, steps, params);
252
+
253
+ var run = await db.model('WorkflowRun').query().insert({
254
+ id: uid(),
255
+ workflowId: workflowId,
256
+ status: 'running',
257
+ // The RESOLVED set, not what the caller typed: applySchema fills declared
258
+ // defaults, and those are what the run actually used. Storing the raw
259
+ // input would make workflow_runs.params ("the values the run was started
260
+ // with, so a result can be explained") describe a run that never happened.
261
+ params: resolved,
262
+ trigger: opts.trigger || 'manual',
263
+ // WHO STARTED IT. Every caller already passes opts.user (the router hands
264
+ // it req.user), and without this the column stayed null on every run —
265
+ // which made "the runs I have not finished" an unanswerable question, and
266
+ // that is the one question somebody halfway through a flow of screens
267
+ // actually has. A fanned-out child is inserted elsewhere and inherits
268
+ // nothing here on purpose: nobody started it.
269
+ recordCreatedBy: (opts.user && opts.user.id) || null,
270
+ startedAt: new Date().toISOString()
271
+ });
272
+
273
+ await executeFrom(workflow, steps, run, steps[0].stepKey);
274
+ return db.model('WorkflowRun').query().findById(run.id);
275
+ }
276
+
277
+ /**
278
+ * Drive a run forward from `startStepKey` until it either pauses on a wait
279
+ * step, reaches an end (success/failed, by falling off the end of the list
280
+ * or by an explicit transition), or fans out (see handleFanOut — that also
281
+ * returns control here, since the parent's own linear progress stops at the
282
+ * fan-out point).
283
+ */
284
+ async function executeFrom(workflow, steps, run, startStepKey) {
285
+ return runWithMt(mtOf(run), async function() {
286
+ var byKey = stepMap(steps);
287
+ var cursor = startStepKey;
288
+
289
+ while (cursor) {
290
+ var step = byKey[cursor];
291
+ if (!step) { await finishRun(run, 'failed', { message: 'Unknown step: ' + cursor }); return; }
292
+
293
+ var context = await buildContext(run);
294
+ var resolvedValues = interpolateAll(step.values || {}, context);
295
+
296
+ var stepRun = await db.model('WorkflowStepRun').query().insert({
297
+ id: uid(), runId: run.id, stepId: step.id, stepKey: step.stepKey,
298
+ actionName: step.actionName, status: 'running', input: resolvedValues, position: step.position
299
+ });
300
+
301
+ // A wait step's resume key has to exist BEFORE the action runs, since
302
+ // the whole point is handing it to the action as input (a confirm
303
+ // link, an approval URL) — so it's minted, folded into the context,
304
+ // and the values are re-resolved with it available as {resumeKey}.
305
+ //
306
+ // THE ROW IS WRITTEN BEFORE THE ACTION TOO, not after it returns. The
307
+ // action is what puts the key in front of somebody — the moment
308
+ // email-send hands off, a click can arrive, and it routinely does:
309
+ // mail scanners pre-fetch links within seconds of delivery. Inserting
310
+ // afterwards left a window where the key was live in an inbox and
311
+ // absent from the table, and resumeByKey answers that with "already
312
+ // used or not valid" — which is the opposite of true and sends anyone
313
+ // debugging it looking for a double-click that never happened.
314
+ var resumeKey = null;
315
+ var resumeKeyRowId = null;
316
+ if (step.kind === 'wait') {
317
+ resumeKey = mintResumeKey();
318
+ context.resumeKey = resumeKey;
319
+ // {resumeUrl} — THE WHOLE ADDRESS, not just the key.
320
+ //
321
+ // A step that hands its key to something OUTSIDE this process (a job,
322
+ // an API, anything that will call back later) needs a URL, and the
323
+ // author must not be the one assembling it: hardcoding this service's
324
+ // own hostname into a step's values means every environment gets a
325
+ // step that works in exactly one of them, and the failure is a
326
+ // callback that quietly never arrives.
327
+ //
328
+ // Null when WORKFLOW_PUBLIC_URL is unset, and deliberately NOT
329
+ // defaulted to localhost — a wrong-but-present address produces a
330
+ // callback that goes nowhere and a run that waits forever, which is
331
+ // far worse than an action refusing at the point of use and naming
332
+ // the variable. Same rule the workspace keeps for database names.
333
+ context.resumeUrl = publicResumeUrl(resumeKey);
334
+ resolvedValues = interpolateAll(step.values || {}, context);
335
+ await db.model('WorkflowStepRun').query().findById(stepRun.id).patch({ input: resolvedValues });
336
+ resumeKeyRowId = uid();
337
+ await db.model('WorkflowResumeKey').query().insert({
338
+ id: resumeKeyRowId, runId: run.id, stepId: step.id, key: resumeKey
339
+ });
340
+ }
341
+
342
+ var result = await runAction({
343
+ name: step.actionName,
344
+ input: resolvedValues,
345
+ system: { runId: run.id, stepId: step.id, stepKey: step.stepKey }
346
+ });
347
+
348
+ if (result.status !== 'success') {
349
+ await db.model('WorkflowStepRun').query().findById(stepRun.id).patch({
350
+ status: 'failed', error: result.error, durationMs: result.durationMs
351
+ });
352
+ // The action never delivered the key, so burn it. Consumed rather
353
+ // than deleted: the row is the record that this step DID reach the
354
+ // point of minting one, which is worth keeping when working out why
355
+ // a run stalled. Either way it can no longer resolve a call.
356
+ if (resumeKeyRowId) {
357
+ await db.model('WorkflowResumeKey').query().findById(resumeKeyRowId)
358
+ .patch({ consumedDate: new Date().toISOString() });
359
+ }
360
+ if (step.onError === 'continue') { cursor = nextByPosition(steps, step); continue; }
361
+ await finishRun(run, 'failed', result.error);
362
+ return;
363
+ }
364
+
365
+ await db.model('WorkflowStepRun').query().findById(stepRun.id).patch({
366
+ status: step.kind === 'wait' ? 'waiting' : 'success',
367
+ output: result.output, durationMs: result.durationMs
368
+ });
369
+
370
+ if (step.kind === 'wait') {
371
+ // The key row already exists (written before the action ran, above).
372
+ await db.model('WorkflowRun').query().findById(run.id).patch({ status: 'waiting' });
373
+ return; // paused — resumeByKey continues from exactly here
374
+ }
375
+
376
+ var routed = await routeAfterStep(workflow, steps, run, step, result.output);
377
+ if (routed.done) return;
378
+ cursor = routed.next;
379
+ }
380
+
381
+ await finishRun(run, 'success', null);
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Decide what runs next after a step's OWN successful result. No
387
+ * transitions declared → the old default, advance by position. Transitions
388
+ * declared → first matching row wins; an 'each' row that matches at least
389
+ * one element fans out (see handleFanOut) and ends this run's own linear
390
+ * progress; a row matching zero elements is treated as no match, same as a
391
+ * false condition. No row matching, and no blank catch-all row, fails the
392
+ * step — a silent fall-through would hide a branch nobody accounted for.
393
+ */
394
+ async function routeAfterStep(workflow, steps, run, step, output) {
395
+ var byKey = stepMap(steps);
396
+
397
+ if (!step.transitions || !step.transitions.length) {
398
+ var next = nextByPosition(steps, step);
399
+ if (!next) { await finishRun(run, 'success', null); return { done: true }; }
400
+ return { done: false, next: next };
401
+ }
402
+
403
+ var row = { output: output, item: run.item, params: run.params };
404
+
405
+ for (var i = 0; i < step.transitions.length; i++) {
406
+ var t = step.transitions[i];
407
+ var matches = !t.condition || xf.evaluate(t.condition, row);
408
+ if (!matches) continue;
409
+
410
+ if (t.mode === 'each') {
411
+ var fannedOut = await handleFanOut(workflow, steps, run, step, t, output);
412
+ if (fannedOut) return { done: true };
413
+ continue; // 0 elements matched — not a real match, keep looking
414
+ }
415
+
416
+ if (t.target === 'end_success') { await finishRun(run, 'success', null); return { done: true }; }
417
+ if (t.target === 'end_failed') { await finishRun(run, 'failed', { message: 'Ended by transition on ' + step.stepKey }); return { done: true }; }
418
+ if (!byKey[t.target]) { await finishRun(run, 'failed', { message: 'Transition on ' + step.stepKey + ' targets unknown step: ' + t.target }); return { done: true }; }
419
+ return { done: false, next: t.target };
420
+ }
421
+
422
+ await finishRun(run, 'failed', { message: 'No transition matched on step ' + step.stepKey + ' and there is no catch-all row' });
423
+ return { done: true };
424
+ }
425
+
426
+ /**
427
+ * `t.source` is a dotted path to the array to fan over (resolved against
428
+ * { output, item, params } — usually `output.<field>`). Each element that
429
+ * also satisfies `t.condition` becomes its own child run, seeded with that
430
+ * element as `item`, starting at `t.target`. The parent's own progress stops
431
+ * here: it either finishes immediately (no joinStep — its job was just to
432
+ * spawn the children) or sits at status 'waiting' until every child reaches
433
+ * a terminal state, at which point maybeCompleteParent resumes it at
434
+ * `step.joinStep`.
435
+ */
436
+ async function handleFanOut(workflow, steps, run, step, t, output) {
437
+ var array = t.source ? getPath({ output: output, item: run.item, params: run.params }, t.source) : null;
438
+ if (!Array.isArray(array)) return false;
439
+
440
+ var matches = [];
441
+ array.forEach(function(el, idx) {
442
+ var elRow = { output: output, item: el, params: run.params };
443
+ if (!t.condition || xf.evaluate(t.condition, elRow)) matches.push({ el: el, idx: idx });
444
+ });
445
+ if (!matches.length) return false;
446
+
447
+ var childIds = [];
448
+ for (var i = 0; i < matches.length; i++) {
449
+ var m = matches[i];
450
+ var itemKey = m.el && typeof m.el === 'object' ? (m.el.id || m.el.messageId || m.el.key) : null;
451
+ var child = await db.model('WorkflowRun').query().insert(Object.assign({
452
+ id: uid(), workflowId: workflow.id, status: 'queued', params: run.params,
453
+ item: m.el, trigger: 'fanout', startedAt: new Date().toISOString()
454
+ }, mtOf(run)));
455
+ await db.model('WorkflowRunEdge').query().insert({
456
+ id: uid(), parentRunId: run.id, childRunId: child.id,
457
+ sourceStepKey: step.stepKey, itemIndex: m.idx,
458
+ itemKey: itemKey != null ? String(itemKey) : null, item: m.el
459
+ });
460
+ childIds.push(child.id);
461
+ }
462
+
463
+ if (step.joinStep) {
464
+ await db.model('WorkflowRun').query().findById(run.id).patch({ status: 'waiting' });
465
+ } else {
466
+ await finishRun(run, 'success', null);
467
+ }
468
+
469
+ // Not awaited — a child may sit on a wait step for days, and the parent's
470
+ // own fate (done above, or waiting on the join) does not depend on how
471
+ // long that takes.
472
+ childIds.forEach(function(childId) {
473
+ executeChildRun(workflow, steps, childId, t.target, run.id, step.stepKey).catch(function(err) {
474
+ console.error('[workflowRunner] child run ' + childId + ' failed to start:', err.message);
475
+ });
476
+ });
477
+
478
+ return true;
479
+ }
480
+
481
+ async function executeChildRun(workflow, steps, runId, startStepKey, parentRunId, sourceStepKey) {
482
+ var run = await db.model('WorkflowRun').unscopedQuery().findById(runId);
483
+ await runWithMt(mtOf(run), async function() {
484
+ await db.model('WorkflowRun').query().findById(runId).patch({ status: 'running' });
485
+ var fresh = await db.model('WorkflowRun').query().findById(runId);
486
+ await executeFrom(workflow, steps, fresh, startStepKey);
487
+ });
488
+ await maybeCompleteParent(parentRunId, sourceStepKey, workflow, steps);
489
+ }
490
+
491
+ /**
492
+ * Called by every sibling as it finishes. Only the sibling that observes ALL
493
+ * of them terminal actually proceeds — guarded by an atomic
494
+ * waiting → running patch (same compare-and-swap shape as @xeplr/jobs'
495
+ * per-job lock) so two children finishing near-simultaneously cannot both
496
+ * run the join step.
497
+ */
498
+ async function maybeCompleteParent(parentRunId, sourceStepKey, workflow, steps) {
499
+ var parent = await db.model('WorkflowRun').unscopedQuery().findById(parentRunId);
500
+ if (!parent || parent.status !== 'waiting') return; // no join configured, or already handled
501
+
502
+ return runWithMt(mtOf(parent), async function() {
503
+ var edges = await db.model('WorkflowRunEdge').query().where({ parentRunId: parentRunId, sourceStepKey: sourceStepKey });
504
+ var childIds = edges.map(function(e) { return e.childRunId; });
505
+ var children = childIds.length ? await db.model('WorkflowRun').query().whereIn('id', childIds) : [];
506
+ var allDone = children.length > 0 && children.every(function(c) { return c.status === 'success' || c.status === 'failed'; });
507
+ if (!allDone) return;
508
+
509
+ var claimed = await db.model('WorkflowRun').query().patch({ status: 'running' }).where({ id: parentRunId, status: 'waiting' });
510
+ if (claimed === 0) return; // another sibling already won the race
511
+
512
+ var byKey = stepMap(steps);
513
+ var sourceStep = byKey[sourceStepKey];
514
+ if (!sourceStep || !sourceStep.joinStep) {
515
+ await finishRun({ id: parentRunId, startedAt: parent.startedAt }, 'success', null);
516
+ return;
517
+ }
518
+
519
+ var fresh = await db.model('WorkflowRun').query().findById(parentRunId);
520
+ await executeFrom(workflow, steps, fresh, sourceStep.joinStep);
521
+ });
522
+ }
523
+
524
+ /**
525
+ * The entire consumer-side contract for a wait step. The caller supplies
526
+ * only what it was handed — the key — and whatever output the resolution
527
+ * carries (an approval decision, submitted form fields; can be omitted for a
528
+ * bare "this happened" signal like an email confirmation click).
529
+ */
530
+ function invalidKey() {
531
+ var err = new Error('This link has already been used or is not valid.');
532
+ // So a HOST can tell the two apart. Its activation route is called for
533
+ // every user, including ones who never came from a workflow, and "no
534
+ // workflow was waiting on this" is a normal outcome there — not something
535
+ // to surface as a failure. Without a code the only way to classify it is
536
+ // matching on the message, which is a sentence written to be read by a
537
+ // person and will be reworded.
538
+ err.code = 'RESUME_KEY_INVALID';
539
+ return err;
540
+ }
541
+
542
+ function notReadyKey() {
543
+ var err = new Error('This step is still running — try again in a moment.');
544
+ err.code = 'RESUME_KEY_NOT_READY';
545
+ return err;
546
+ }
547
+
548
+ /**
549
+ * A RESUME CAN BE A FAILURE.
550
+ *
551
+ * `opts.status === 'failed'` resolves the parked step as failed instead of
552
+ * succeeded, and from there the step's ordinary `onError` decides what
553
+ * happens — 'stop' (the default) fails the run, 'continue' moves on by
554
+ * position. No new concept: it is the same treatment a step gets when its own
555
+ * action fails, arriving down a different road.
556
+ *
557
+ * This is what a job chain needs. A workflow step that starts a twenty-minute
558
+ * movement parks on its key; the job fails, times out, or is refused because
559
+ * the job was already running; and without this the step would resolve as
560
+ * SUCCESSFUL carrying a failure in its output. The next step would then run
561
+ * against data that was never moved — an aggregation over a period nothing
562
+ * loaded, a deletion of rows that were never replaced. Silently, because
563
+ * every status on the screen would read green.
564
+ *
565
+ * The caller says which it is, because only the caller knows. @xeplr/jobs
566
+ * decides that 'skipped', 'timedOut' and 'interrupted' all mean "the work did
567
+ * not happen" and sends `status: 'failed'`; this engine never learns jobs'
568
+ * vocabulary.
569
+ *
570
+ * OPTIONAL, and absent means success — every existing caller (an approval
571
+ * click, a confirmation link) resolves a step that succeeded, and none of
572
+ * them should have to start saying so.
573
+ */
574
+ async function resumeByKey(key, output, opts) {
575
+ opts = opts || {};
576
+ var pending = await db.model('WorkflowResumeKey').unscopedQuery().where({ key: key, consumedDate: null }).first();
577
+ if (!pending) throw invalidKey();
578
+
579
+ var run = await db.model('WorkflowRun').unscopedQuery().findById(pending.runId);
580
+ if (!run) throw invalidKey();
581
+
582
+ return runWithMt(mtOf(run), async function() {
583
+ var step = await db.model('WorkflowStep').query().findById(pending.stepId);
584
+ var workflow = await db.model('Workflow').query().findById(run.workflowId);
585
+ var steps = await db.model('WorkflowStep').query().where({ workflowId: run.workflowId }).orderBy('position');
586
+
587
+ // READ BEFORE CLAIMING, and refuse if the step has not actually parked
588
+ // yet. The key row is written before the action runs (see executeFrom),
589
+ // so between email-send handing off and the step being marked 'waiting'
590
+ // there is a live key attached to a step that is still executing. A
591
+ // resume in that window would route the run onward WHILE the wait step's
592
+ // own action is mid-flight. Rejecting without consuming is what makes it
593
+ // recoverable: the caller retries in a moment and it works.
594
+ var existingStepRun = await db.model('WorkflowStepRun').query()
595
+ .where({ runId: run.id, stepId: step.id, status: 'waiting' }).first();
596
+ if (!existingStepRun) throw notReadyKey();
597
+
598
+ // Claim is still the atomic conditional patch, so two concurrent calls
599
+ // that both saw 'waiting' cannot both win.
600
+ var claim = await db.model('WorkflowResumeKey').query()
601
+ .patch({ consumedDate: new Date().toISOString() })
602
+ .where({ id: pending.id, consumedDate: null });
603
+ if (claim === 0) throw invalidKey();
604
+
605
+ var mergedOutput = Object.assign({}, existingStepRun.output, output || {});
606
+ var failed = opts.status === 'failed';
607
+
608
+ await db.model('WorkflowStepRun').query().findById(existingStepRun.id).patch({
609
+ status: failed ? 'failed' : 'success',
610
+ output: mergedOutput,
611
+ // KEPT even on a failure. The job's own output — rows moved, the window
612
+ // it actually covered — is what makes the failure diagnosable, and on a
613
+ // partial movement it is also what says how far it got.
614
+ error: failed ? (opts.error || { message: 'The waiting step was resolved as failed.' }) : null
615
+ });
616
+
617
+ await db.model('WorkflowRun').query().findById(run.id).patch({ status: 'running' });
618
+ var fresh = await db.model('WorkflowRun').query().findById(run.id);
619
+
620
+ if (failed) {
621
+ // EXACTLY the branch executeFrom takes when a step's own action fails —
622
+ // written out rather than shared, because the two arrive with different
623
+ // things already done (there is no action result here, and the step run
624
+ // is already patched above). The RULE is what matters and it is the
625
+ // same one: onError decides, and it defaults to stopping.
626
+ if (step.onError !== 'continue') {
627
+ await finishRun(fresh, 'failed', opts.error || { message: 'Step ' + step.stepKey + ' was resolved as failed.' });
628
+ return { runId: run.id, stepKey: step.stepKey, status: 'failed' };
629
+ }
630
+ var after = nextByPosition(steps, step);
631
+ if (!after) {
632
+ await finishRun(fresh, 'success', null);
633
+ return { runId: run.id, stepKey: step.stepKey, status: 'failed' };
634
+ }
635
+ await executeFrom(workflow, steps, fresh, after);
636
+ return { runId: run.id, stepKey: step.stepKey, status: 'failed' };
637
+ }
638
+
639
+ var routed = await routeAfterStep(workflow, steps, fresh, step, mergedOutput);
640
+ if (!routed.done) await executeFrom(workflow, steps, fresh, routed.next);
641
+
642
+ return { runId: run.id, stepKey: step.stepKey, status: 'success' };
643
+ });
644
+ }
645
+
646
+ // resolveParams and collectParams are exported for their own sake: they are
647
+ // the pieces of the engine that are pure — rows in, a schema or a resolved
648
+ // set out, or a throw — and testing them needs no database.
649
+ module.exports = { startRun: startRun, resumeByKey: resumeByKey, resolveParams: resolveParams, collectParams: collectParams };
@@ -0,0 +1,23 @@
1
+ -- 0001_companies.sql
2
+ -- companies — the top-level tenant (a customer of the workflow product). A
3
+ -- company is the hard multi-tenancy boundary: mtId1 = company.id on every
4
+ -- workspace-scoped row (see registerMTs() in db/setup.js — l1 = companyId).
5
+ -- Access grants live in the auth DB (userTenantsMapping), not filtered here.
6
+
7
+ CREATE TABLE "companies" (
8
+ "id" varchar(25) PRIMARY KEY,
9
+ "name" varchar(255) NOT NULL,
10
+ "code" varchar(50),
11
+ "description" varchar(500),
12
+ "isActive" boolean DEFAULT true,
13
+ "mtId1" varchar(25),
14
+ "mtId2" varchar(25),
15
+ "mtId3" varchar(25),
16
+ "mtId4" varchar(25),
17
+ "recordCreatedDate" timestamp,
18
+ "recordModifiedDate" timestamp,
19
+ "recordCreatedBy" varchar(25),
20
+ "recordModifiedBy" varchar(25)
21
+ );
22
+
23
+ CREATE INDEX "companies_code_index" ON "companies" ("code");