@xeplr/workflow-engine 3.0.0 → 3.0.2

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
@@ -62,7 +62,7 @@ engine/
62
62
  ├─ models/ Company, Workspace, Workflow, WorkflowStep, WorkflowRun,
63
63
  │ WorkflowStepRun, WorkflowResumeKey, WorkflowRunEdge,
64
64
  │ WorkflowTriggerState
65
- ├─ migrations/ 0003–0015, workflow's own tables
65
+ ├─ migrations/ 0003–0016, workflow's own tables
66
66
  ├─ migrations-tenants/ 0001–0002: standalone workflow's companies / workspaces
67
67
  └─ test/ run.mjs + *.test.mjs
68
68
  ```
@@ -207,6 +207,7 @@ Branch on how a job ended with a `decide` step: it does nothing itself, and the
207
207
  - **The period flows.** The next job gets the finished job's `window` unless the step's `inputs` set one, so a chain of workflows covers one period from its first job to its last.
208
208
  - **The next job is offered both earlier ranges**, as `@xeplr/jobs`' `previous.given` / `previous.used`.
209
209
  - **`carry`** (a `job-run` input) adds values for the jobs after it.
210
+ - **`inputs` and `carry` keep their types.** A binding that is the whole value (`"{trigger.output.totalRows}"`) passes `7`, not `"7"`, and an object stays an object. Every other step's input is text, as bindings make it.
210
211
  - **Loops stop.** A job already on the path, or a path 25 jobs long, fails the step with a message.
211
212
 
212
213
  The next job takes only `window` and the inputs it lists in `acceptsInputs`; `@xeplr/jobs` refuses the rest, naming them.
@@ -220,11 +221,13 @@ await jobs.init({ /* … */ onFinish: (report) => engine.jobFinished(report) });
220
221
 
221
222
  `jobFinished(report)` takes `@xeplr/jobs`' finish envelope. It resumes the `job-run` step that started the job (retrying while that step parks) and starts every workflow the job triggers. Without `startJob`, `job-run` starts jobs over HTTP (`jobsUrl`, and `WORKFLOW_PUBLIC_URL` for the callback).
222
223
 
223
- **Job chains are gone.** Migration 0015 converted each "After this job" chain (kind `job-chain`) into an ordinary workflow:
224
- - it gets a `job` trigger (`when: finished`);
225
- - its `decide` and `job-run` steps keep the same behaviour;
224
+ **Job chains are gone.** Migration 0015 converts each "After this job" chain (kind `job-chain`) into an ordinary workflow:
225
+ - it gets a `job` trigger (`when: finished`) and the key `after-job-<jobId>`, the one the jobs screen's shortcut uses;
226
+ - its `decide` and `job-run` steps keep the same behaviour, and each arrow keeps its rule's `when`;
226
227
  - conditions and bindings now read `trigger.*`.
227
228
 
229
+ Migration 0016 repairs a database that ran 0015's first release, which left the key empty and the arrows without `when`. A single comparison gets its `when` back. A nested one still runs as before, and the jobs screen says it was changed elsewhere.
230
+
228
231
  ## Tests
229
232
 
230
233
  ```bash
@@ -244,7 +247,8 @@ No database or network needed, except `triggers.test.mjs`, which runs on a real
244
247
  - a loop stopped
245
248
  - a workflow that cannot start, recorded as a skip
246
249
  - the schedule's first sight, its due time, two ticks starting it once, a changed cron
247
- - migration 0015 turning an old job chain into a workflow that runs |
250
+ - migration 0015 turning an old job chain into a workflow that runs, with its key and `when` arrows
251
+ - migration 0016 repairing one converted early, and changing nothing on a second run |
248
252
  | `actionCatalog.test.mjs` | Ready vs placeholder detection; registered names are kebab-case; `spawnProgram` not offered; `decide` and `job-run` offered |
249
253
  | `envExposed.test.mjs` | Nothing exposed by default; exact, case-sensitive matching; globs fail closed (against real `interpolateAll`) |
250
254
  | `requires.test.mjs` | Every relative `require` in this package points at a file (Node only notices a missing one when the line runs; a bundler refuses it up front) |
@@ -77,7 +77,14 @@ module.exports = {
77
77
  // this job unless the step sets one, carried values and the path travel
78
78
  // with it, and a job already on the path is refused as a loop
79
79
  // (jobs.nextJobCall). Any other run passes the step's inputs as they are.
80
- var call = jobs.nextJobCall(ctx.system && ctx.system.trigger, input.jobId, input.inputs, input.carry);
80
+ // Inputs and carry KEEP THEIR TYPES ("{trigger.output.totalRows}" is 7,
81
+ // not "7"): they are handed on to a job, not printed.
82
+ var sys = ctx.system || {};
83
+ var raw = sys.rawValues || {};
84
+ var typed = typeof sys.resolveTyped === 'function';
85
+ var call = jobs.nextJobCall(sys.trigger, input.jobId,
86
+ typed && raw.inputs !== undefined ? sys.resolveTyped(raw.inputs) : input.inputs,
87
+ typed && raw.carry !== undefined ? sys.resolveTyped(raw.carry) : input.carry);
81
88
  var startJob = jobs.jobStarter();
82
89
  if (startJob) {
83
90
  call.chain.resumeKey = (ctx.system && ctx.system.resumeKey) || null;
@@ -112,6 +112,30 @@ 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
+ /**
116
+ * Resolve `{path}` bindings KEEPING TYPES: a string that is exactly one
117
+ * binding takes the bound value as it is (an object stays an object, a number
118
+ * a number); anything else is text, as interpolate makes it.
119
+ */
120
+ function resolveTyped(value, context) {
121
+ if (value == null) return value;
122
+ if (typeof value === 'string') {
123
+ var whole = /^\{([^{}]+)\}$/.exec(value.trim());
124
+ if (whole) {
125
+ var v = getPath(context, whole[1].trim());
126
+ return v === undefined ? null : v;
127
+ }
128
+ return interpolateAll(value, context);
129
+ }
130
+ if (Array.isArray(value)) return value.map(function(v) { return resolveTyped(v, context); });
131
+ if (typeof value === 'object') {
132
+ var out = {};
133
+ Object.keys(value).forEach(function(k) { out[k] = resolveTyped(value[k], context); });
134
+ return out;
135
+ }
136
+ return value;
137
+ }
138
+
115
139
  // What started a run, as {trigger.*} reads it — in bindings AND in the
116
140
  // conditions on arrows. A run started by hand has just its kind.
117
141
  function triggerOf(run) {
@@ -359,8 +383,14 @@ async function executeFrom(workflow, steps, run, startStepKey) {
359
383
  // started in-process reports back through jobs.jobFinished).
360
384
  // trigger: what started the run, for an action that carries it on
361
385
  // (job-run passes a job's period to the next job).
386
+ // resolveTyped: a binding that is the WHOLE value keeps its type
387
+ // ("{trigger.output.totalRows}" stays 7, "{trigger.window}" an object),
388
+ // for an action that passes values on rather than printing them
389
+ // (job-run's inputs and carry). Every other action takes `input`, as
390
+ // resolved above, unchanged.
362
391
  system: { runId: run.id, stepId: step.id, stepKey: step.stepKey, resumeKey: resumeKey,
363
- trigger: triggerOf(run) }
392
+ trigger: triggerOf(run), rawValues: step.values || {},
393
+ resolveTyped: function(value) { return resolveTyped(value, context); } }
364
394
  });
365
395
 
366
396
  if (result.status !== 'success') {
@@ -664,4 +694,5 @@ async function resumeByKey(key, output, opts) {
664
694
  // resolveParams and collectParams are exported for their own sake: they are
665
695
  // the pieces of the engine that are pure — rows in, a schema or a resolved
666
696
  // set out, or a throw — and testing them needs no database.
667
- module.exports = { startRun: startRun, resumeByKey: resumeByKey, resolveParams: resolveParams, collectParams: collectParams };
697
+ module.exports = {
698
+ resolveTyped: resolveTyped, startRun: startRun, resumeByKey: resumeByKey, resolveParams: resolveParams, collectParams: collectParams };
@@ -53,8 +53,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS "workflow_trigger_states_workflow_unique" ON "
53
53
  -- kind 'job-chain' → 'workflow', with trigger
54
54
  -- { kind: 'job', jobId, when: 'finished' }
55
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)
56
+ -- key 'job-chain-<jobId>' → 'after-job-<jobId>', the key the jobs
57
+ -- screen's "After this job" shortcut keeps
58
+ -- 'job-chain-decide' step → 'decide' (a plain branching step), each
59
+ -- arrow given its rule's `when` as written
58
60
  -- 'job-chain-next' steps → 'job-run', which carries the period on
59
61
  -- conditions on params.x → trigger.x (status, output, window, carry…)
60
62
  -- bindings {output.x} etc. → {trigger.output.x}
@@ -65,7 +67,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS "workflow_trigger_states_workflow_unique" ON "
65
67
  UPDATE "workflow_steps" s
66
68
  SET "actionName" = 'decide',
67
69
  "values" = '{}'::jsonb,
68
- "transitions" = replace(s."transitions"::text, '"params.', '"trigger.')::jsonb
70
+ -- Arrow N is rule N, so it takes that rule's `when` (fields now read
71
+ -- the trigger); the closing "nothing matched" arrow is "otherwise".
72
+ "transitions" = (
73
+ SELECT COALESCE(jsonb_agg(
74
+ CASE WHEN x.ord <= COALESCE(jsonb_array_length(s."values"->'rules'), 0)
75
+ THEN x.t || jsonb_build_object('when', regexp_replace(
76
+ COALESCE(s."values"->'rules'->((x.ord - 1)::int)->'when', 'null'::jsonb)::text,
77
+ '"field"\s*:\s*"(status|error|output|carry|window|jobId|given|used)', '"field": "trigger.\1', 'g')::jsonb)
78
+ ELSE x.t || '{"when": null}'::jsonb END
79
+ ORDER BY x.ord), '[]'::jsonb)
80
+ FROM jsonb_array_elements(replace(s."transitions"::text, '"params.', '"trigger.')::jsonb) WITH ORDINALITY AS x(t, ord))
69
81
  FROM "workflows" w
70
82
  WHERE w."id" = s."workflowId" AND w."kind" = 'job-chain' AND s."actionName" = 'job-chain-decide';
71
83
 
@@ -81,5 +93,5 @@ UPDATE "workflow_steps" s
81
93
  UPDATE "workflows"
82
94
  SET "trigger" = jsonb_build_object('kind', 'job', 'jobId', substring("key" from 11), 'when', 'finished'),
83
95
  "kind" = 'workflow',
84
- "key" = NULL
96
+ "key" = 'after-job-' || substring("key" from 11)
85
97
  WHERE "kind" = 'job-chain' AND "key" LIKE 'job-chain-%';
@@ -0,0 +1,65 @@
1
+ -- 0016_after_job_shortcut.sql
2
+ --
3
+ -- CONVERTED CHAINS, READABLE BY THE JOBS SCREEN AGAIN.
4
+ --
5
+ -- The first release of 0015 converted each job chain into an ordinary
6
+ -- workflow, but with no key and with arrows holding only their compiled
7
+ -- conditions. The jobs screen's "After this job" finds a job's rules by the
8
+ -- key `after-job-<jobId>` and reads each arrow's `when`, so a converted chain
9
+ -- showed as "changed in the workflow screens" instead of as its rules. 0015
10
+ -- now does both; this repairs a database that ran it before it did. On any
11
+ -- other database it changes nothing.
12
+ --
13
+ -- (No jsonb key-exists operator anywhere: the migrator's driver takes that
14
+ -- character as a parameter placeholder. jsonb_typeof(x->'k') is NULL exactly
15
+ -- when k is absent.)
16
+ --
17
+ -- 1. The key, for a converted chain that has none (and only if the job has
18
+ -- no shortcut already).
19
+ UPDATE "workflows" w
20
+ SET "key" = 'after-job-' || (w."trigger"->>'jobId')
21
+ WHERE w."key" IS NULL
22
+ AND w."trigger"->>'kind' = 'job'
23
+ AND w."description" LIKE 'Made from the "After this job" rules of job %'
24
+ AND NOT EXISTS (
25
+ SELECT 1 FROM "workflows" o
26
+ WHERE o."key" = 'after-job-' || (w."trigger"->>'jobId')
27
+ AND COALESCE(o."mtId1", '') = COALESCE(w."mtId1", '')
28
+ AND COALESCE(o."mtId2", '') = COALESCE(w."mtId2", ''));
29
+
30
+ -- 2. `when` on each of their decide step's arrows, rebuilt from the compiled
31
+ -- condition: none is "otherwise"; a single comparison is { field, op,
32
+ -- value } in the jobs screen's own words. A nested all/any is left as it
33
+ -- is — the jobs screen then says it was changed elsewhere, and it still
34
+ -- runs exactly as before.
35
+ UPDATE "workflow_steps" s
36
+ SET "transitions" = (
37
+ SELECT COALESCE(jsonb_agg(
38
+ CASE
39
+ WHEN jsonb_typeof(x.t->'when') IS NOT NULL THEN x.t
40
+ WHEN jsonb_typeof(x.t->'condition') IS NULL OR jsonb_typeof(x.t->'condition') = 'null' THEN x.t || '{"when": null}'::jsonb
41
+ WHEN jsonb_typeof(x.t->'condition'->'left') IS NOT NULL AND (CASE x.t->'condition'->>'op'
42
+ WHEN 'eq' THEN '=' WHEN 'neq' THEN '!=' WHEN 'gt' THEN '>' WHEN 'gte' THEN '>='
43
+ WHEN 'lt' THEN '<' WHEN 'lte' THEN '<=' WHEN 'contains' THEN 'contains'
44
+ WHEN 'notContains' THEN 'notContains' WHEN 'startsWith' THEN 'startsWith'
45
+ WHEN 'endsWith' THEN 'endsWith' WHEN 'in' THEN 'in' WHEN 'notIn' THEN 'notIn'
46
+ WHEN 'between' THEN 'between' WHEN 'isNull' THEN 'isEmpty' WHEN 'isNotNull' THEN 'isNotEmpty'
47
+ END) IS NOT NULL
48
+ THEN x.t || jsonb_build_object('when', jsonb_strip_nulls(jsonb_build_object(
49
+ 'field', x.t->'condition'->'left'->>'field',
50
+ 'op', CASE x.t->'condition'->>'op'
51
+ WHEN 'eq' THEN '=' WHEN 'neq' THEN '!=' WHEN 'gt' THEN '>' WHEN 'gte' THEN '>='
52
+ WHEN 'lt' THEN '<' WHEN 'lte' THEN '<=' WHEN 'contains' THEN 'contains'
53
+ WHEN 'notContains' THEN 'notContains' WHEN 'startsWith' THEN 'startsWith'
54
+ WHEN 'endsWith' THEN 'endsWith' WHEN 'in' THEN 'in' WHEN 'notIn' THEN 'notIn'
55
+ WHEN 'between' THEN 'between' WHEN 'isNull' THEN 'isEmpty' WHEN 'isNotNull' THEN 'isNotEmpty'
56
+ END,
57
+ 'value', x.t->'condition'->'right'->'value')))
58
+ ELSE x.t
59
+ END ORDER BY x.ord), '[]'::jsonb)
60
+ FROM jsonb_array_elements(s."transitions") WITH ORDINALITY AS x(t, ord))
61
+ FROM "workflows" w
62
+ WHERE w."id" = s."workflowId"
63
+ AND w."key" LIKE 'after-job-%'
64
+ AND s."stepKey" = 'decide' AND s."actionName" = 'decide'
65
+ AND s."transitions" IS NOT NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/workflow-engine",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
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",