@nanobpm/workflow 0.6.0 → 0.8.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.
- package/README.md +13 -3
- package/dist/declarative.d.ts +73 -2
- package/dist/declarative.js +324 -18
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +57 -1
- package/dist/worker.js +3 -2
- package/dist/xml.d.ts +3 -0
- package/dist/xml.js +53 -0
- package/package.json +4 -4
- package/src/declarative.ts +421 -21
- package/src/index.ts +1 -0
- package/src/types.ts +46 -3
- package/src/worker.ts +3 -2
- package/src/xml.ts +65 -0
package/README.md
CHANGED
|
@@ -45,6 +45,14 @@ human-in-the-loop path), composed with control-flow combinators:
|
|
|
45
45
|
- `w.branch(condition, { then, else? })` — a two-way choice on a FEEL boolean.
|
|
46
46
|
- `w.loop(body)` — a durable loop (back-edge to the loop head).
|
|
47
47
|
- `w.break()` / `w.continue()` — exit the enclosing loop, or jump back to its head.
|
|
48
|
+
- `w.parallel([blockA, blockB, …])` — a static fork/join: every block runs
|
|
49
|
+
concurrently on its own branch, rejoined by an AND-join (all branches must
|
|
50
|
+
arrive before the flow continues).
|
|
51
|
+
- `w.forEach(collection, itemVar, body, opts?)` — a data-driven fan-out over a
|
|
52
|
+
FEEL `collection`: one child instance of `body` runs per item (the item bound to
|
|
53
|
+
`itemVar`). `{ sequential }` runs children one at a time; `{ outputCollection,
|
|
54
|
+
outputElement }` collects each child's result into a list; `{ completionCondition }`
|
|
55
|
+
completes the body early.
|
|
48
56
|
|
|
49
57
|
```ts
|
|
50
58
|
import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
@@ -89,8 +97,10 @@ const convergence = defineFlow("convergence-loop", (w) => {
|
|
|
89
97
|
Each combinator compiles to a BPMN primitive the engine already runs: `switch` /
|
|
90
98
|
`branch` → an exclusive gateway (in-order conditions, first match wins, default =
|
|
91
99
|
unconditional flow); `loop` → a convergent gateway whose body falls through back to
|
|
92
|
-
the head; nodes with multiple incoming flows are an implicit XOR merge
|
|
93
|
-
**
|
|
100
|
+
the head; nodes with multiple incoming flows are an implicit XOR merge; `parallel`
|
|
101
|
+
→ a diverging/converging **parallel gateway** pair (AND fork/join); `forEach` → a
|
|
102
|
+
**parallel multi-instance** activity (a single-step body) or an embedded
|
|
103
|
+
multi-instance sub-process (a multi-step body). See **ADR 0047**.
|
|
94
104
|
|
|
95
105
|
#### Typed data envelopes (eject to model-first with contracts intact)
|
|
96
106
|
|
|
@@ -178,7 +188,7 @@ once.
|
|
|
178
188
|
| Export | Purpose |
|
|
179
189
|
| --- | --- |
|
|
180
190
|
| `defineWorkflow(id, orchFn)` | Imperative (replayed) workflow — experimental/internal. |
|
|
181
|
-
| `defineFlow(id, [contracts,] build)` | Declarative flow: `run`/`task`/`signal` + `switch`/`branch`/`loop`/`break`/`continue`, with an optional typed contracts map. |
|
|
191
|
+
| `defineFlow(id, [contracts,] build)` | Declarative flow: `run`/`task`/`signal`/`timer` + `switch`/`branch`/`loop`/`break`/`continue` + `parallel`/`forEach`, with an optional typed contracts map. |
|
|
182
192
|
| `envelope(name, fields)` | A typed data envelope; lifted into the model as a `nano:shape` + `dataEnvelope` wiring. |
|
|
183
193
|
| `externalJobTypes(flow)` | The job types of a flow's external `task` steps (each overridable per-step via `w.task(name, { jobType })`). |
|
|
184
194
|
| `WorkflowClient` | `deploy`, `start`, `signal`, `getInstance` over REST v2. |
|
package/dist/declarative.d.ts
CHANGED
|
@@ -22,8 +22,19 @@ type TypedHandler<V, R> = (job: {
|
|
|
22
22
|
variables: V;
|
|
23
23
|
}) => Promise<R | void> | R | void;
|
|
24
24
|
/** A block: the callback that populates a nested body (a case, arm, or loop). */
|
|
25
|
-
type Block<C extends
|
|
26
|
-
|
|
25
|
+
type Block<C extends object> = (b: FlowBuilder<C>) => void;
|
|
26
|
+
/** The typed builder for a flow body.
|
|
27
|
+
*
|
|
28
|
+
* `C` is the flow's contracts map (step name → `{ in, out }` envelopes). The
|
|
29
|
+
* default is `object`, NOT `Record<string, never>`: with a `never`-valued record
|
|
30
|
+
* `keyof C` is `string`, so `VarsOf`/`ResultOf` route EVERY step name into
|
|
31
|
+
* `InPayload<never>`/`OutPayload<never>`, which distribute over `never` to
|
|
32
|
+
* `never` — typing an untyped flow's `job.variables` as `never` and forcing its
|
|
33
|
+
* handler return to `void` (so no untyped flow that returns data compiles). With
|
|
34
|
+
* `object`, `keyof C` is `never`, so every step falls back to the intended
|
|
35
|
+
* `JsonObject`. The public `defineFlow<C extends FlowContracts>` overload keeps
|
|
36
|
+
* the stricter contracts constraint; this is only the untyped-builder default. */
|
|
37
|
+
export interface FlowBuilder<C extends object = object> {
|
|
27
38
|
/**
|
|
28
39
|
* A durable activity served by a worker THIS program hosts (a BPMN service
|
|
29
40
|
* task; the handler runs in the in-process `Worker`). If `name` is a key in
|
|
@@ -55,6 +66,36 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
55
66
|
signal<K extends string>(name: K, opts: {
|
|
56
67
|
correlationKey: string;
|
|
57
68
|
}): FlowBuilder<C>;
|
|
69
|
+
/**
|
|
70
|
+
* A durable wait for a point in time (a BPMN timer intermediate catch event).
|
|
71
|
+
* Pass exactly one of `{ after }` — an ISO-8601 delay (`PT1M30S`, `P1DT6H`) or
|
|
72
|
+
* FEEL `=`-expression measured from when the token arrives — or `{ at }` — an
|
|
73
|
+
* absolute ISO-8601 instant (or FEEL expression). The engine holds the token
|
|
74
|
+
* durably until the timer fires, then continues. Use it for in-flow delays and
|
|
75
|
+
* scheduled continuations (e.g. "wait 24h, then re-poll").
|
|
76
|
+
*/
|
|
77
|
+
timer<K extends string>(name: K, opts: {
|
|
78
|
+
after: string;
|
|
79
|
+
} | {
|
|
80
|
+
at: string;
|
|
81
|
+
}): FlowBuilder<C>;
|
|
82
|
+
/**
|
|
83
|
+
* Make this flow's start event a durable TIMER start rather than an explicit
|
|
84
|
+
* `client.start(...)`. Must be the first statement, at the top level. Pass
|
|
85
|
+
* exactly one of `{ cycle }` — a recurring ISO-8601 interval (`R/PT1H`,
|
|
86
|
+
* `R5/PT30M`) the engine re-fires each period — `{ after }` — a one-shot delay
|
|
87
|
+
* from deployment — or `{ at }` — a one-shot absolute instant. A `cycle` start
|
|
88
|
+
* is the model-native, durable, single-fire-per-cluster replacement for an
|
|
89
|
+
* app-side cron: the schedule lives in the deployable model, and the engine
|
|
90
|
+
* (not each app replica) owns firing it exactly once.
|
|
91
|
+
*/
|
|
92
|
+
startOn(spec: {
|
|
93
|
+
cycle: string;
|
|
94
|
+
} | {
|
|
95
|
+
after: string;
|
|
96
|
+
} | {
|
|
97
|
+
at: string;
|
|
98
|
+
}): FlowBuilder<C>;
|
|
58
99
|
/**
|
|
59
100
|
* A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
|
|
60
101
|
* expression (usually a variable name); each case routes when `subject` equals
|
|
@@ -79,6 +120,36 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
79
120
|
* the loop run once `break()` is reached.
|
|
80
121
|
*/
|
|
81
122
|
loop(body: Block<C>): FlowBuilder<C>;
|
|
123
|
+
/**
|
|
124
|
+
* A static parallel fork/join (a pair of BPMN parallel gateways). Every block
|
|
125
|
+
* runs concurrently on its own branch; control continues past `parallel` only
|
|
126
|
+
* once ALL branches have reached the joining gateway (AND-join). Pass at least
|
|
127
|
+
* two branch blocks. Unlike `switch`/`branch` (exclusive choice), no branch is
|
|
128
|
+
* conditional — all of them run.
|
|
129
|
+
*/
|
|
130
|
+
parallel(branches: Block<C>[]): FlowBuilder<C>;
|
|
131
|
+
/**
|
|
132
|
+
* A data-driven fan-out over a runtime collection (a BPMN parallel
|
|
133
|
+
* multi-instance activity). `collection` is a FEEL expression (no leading `=`)
|
|
134
|
+
* evaluating to a list; one child instance of `body` runs per item, with the
|
|
135
|
+
* item bound to the `itemVar` variable in the child's scope. A single-activity
|
|
136
|
+
* body attaches the multi-instance characteristics to that activity; a
|
|
137
|
+
* multi-step body is wrapped in an embedded multi-instance sub-process. Options:
|
|
138
|
+
* - `sequential` — run children one at a time (a sequential MI) instead of all
|
|
139
|
+
* at once (the default: a parallel MI).
|
|
140
|
+
* - `outputCollection` / `outputElement` — collect each child's `outputElement`
|
|
141
|
+
* (a FEEL expression, no leading `=`) into the `outputCollection` list
|
|
142
|
+
* variable, in item order.
|
|
143
|
+
* - `completionCondition` — a FEEL boolean (no leading `=`); when it holds
|
|
144
|
+
* after a child completes, the remaining children are cancelled and the body
|
|
145
|
+
* completes early.
|
|
146
|
+
*/
|
|
147
|
+
forEach(collection: string, itemVar: string, body: Block<C>, opts?: {
|
|
148
|
+
sequential?: boolean;
|
|
149
|
+
outputCollection?: string;
|
|
150
|
+
outputElement?: string;
|
|
151
|
+
completionCondition?: string;
|
|
152
|
+
}): FlowBuilder<C>;
|
|
82
153
|
/** Exit the enclosing loop (routes to whatever follows it). Only valid inside
|
|
83
154
|
* a `loop`. */
|
|
84
155
|
break(): FlowBuilder<C>;
|
package/dist/declarative.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// (`switch`/`branch`/`loop`) that compile to exclusive gateways + back-edges the
|
|
4
4
|
// engine already runs (engine-core supports XOR gateways with in-order condition
|
|
5
5
|
// evaluation and a default flow, and tasks with multiple incoming flows act as
|
|
6
|
-
// an implicit XOR merge)
|
|
6
|
+
// an implicit XOR merge), plus concurrency combinators — `parallel` (an AND
|
|
7
|
+
// fork/join over parallel gateways) and `forEach` (a data-driven fan-out lowering
|
|
8
|
+
// to a parallel/sequential multi-instance activity or sub-process).
|
|
7
9
|
//
|
|
8
10
|
// const convergence = defineFlow(
|
|
9
11
|
// "convergence-loop",
|
|
@@ -34,18 +36,18 @@
|
|
|
34
36
|
// Typed data envelopes are LIFTED into the model (nano:shape + dataEnvelope
|
|
35
37
|
// zeebe:property), so the generated .bpmn is ejectable to model-first with its
|
|
36
38
|
// typed contracts intact.
|
|
37
|
-
import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
|
|
39
|
+
import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
|
|
38
40
|
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
39
41
|
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
40
42
|
* invalid model, so reject them at authoring time. */
|
|
41
|
-
const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
|
|
43
|
+
const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
|
|
42
44
|
function claimName(ctx, id, name) {
|
|
43
45
|
assertIdent("step name", name);
|
|
44
46
|
if (name === "Start" || name === "End" || name === id) {
|
|
45
47
|
throw new Error(`step name "${name}" is reserved (collides with a generated BPMN id) in flow "${id}"`);
|
|
46
48
|
}
|
|
47
49
|
if (RESERVED_PREFIXES.test(name)) {
|
|
48
|
-
throw new Error(`step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`);
|
|
50
|
+
throw new Error(`step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`);
|
|
49
51
|
}
|
|
50
52
|
if (ctx.seen.has(name))
|
|
51
53
|
throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
@@ -61,13 +63,22 @@ function contractEnvelopes(ctx, name) {
|
|
|
61
63
|
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
62
64
|
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
63
65
|
* combinators recurse with a fresh `out` array for each nested body. */
|
|
64
|
-
function makeBuilder(id, out, ctx) {
|
|
66
|
+
function makeBuilder(id, out, ctx, isRoot = false) {
|
|
65
67
|
const child = (fn, inLoop) => {
|
|
66
68
|
const body = [];
|
|
67
69
|
const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
|
|
68
70
|
fn(makeBuilder(id, body, { ...ctx, loopDepth: depth }));
|
|
69
71
|
return body;
|
|
70
72
|
};
|
|
73
|
+
// A body that runs in its OWN token scope (an embedded sub-process): the
|
|
74
|
+
// enclosing loop is not reachable across the scope boundary, so `break`/
|
|
75
|
+
// `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
|
|
76
|
+
// INSIDE the body still works — it creates its own scope.
|
|
77
|
+
const childScoped = (fn) => {
|
|
78
|
+
const body = [];
|
|
79
|
+
fn(makeBuilder(id, body, { ...ctx, loopDepth: 0 }));
|
|
80
|
+
return body;
|
|
81
|
+
};
|
|
71
82
|
const b = {
|
|
72
83
|
run(name, handler) {
|
|
73
84
|
claimName(ctx, id, name);
|
|
@@ -93,6 +104,54 @@ function makeBuilder(id, out, ctx) {
|
|
|
93
104
|
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
94
105
|
return b;
|
|
95
106
|
},
|
|
107
|
+
timer(name, opts) {
|
|
108
|
+
claimName(ctx, id, name);
|
|
109
|
+
const hasAfter = typeof opts?.after === "string";
|
|
110
|
+
const hasAt = typeof opts?.at === "string";
|
|
111
|
+
if (hasAfter === hasAt) {
|
|
112
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
113
|
+
}
|
|
114
|
+
if (hasAfter) {
|
|
115
|
+
const after = opts.after.trim();
|
|
116
|
+
assertTimerDuration(`timer("${name}") after`, after);
|
|
117
|
+
out.push({ kind: "timer", name, after });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const at = opts.at.trim();
|
|
121
|
+
assertTimerDate(`timer("${name}") at`, at);
|
|
122
|
+
out.push({ kind: "timer", name, at });
|
|
123
|
+
}
|
|
124
|
+
return b;
|
|
125
|
+
},
|
|
126
|
+
startOn(spec) {
|
|
127
|
+
if (!isRoot) {
|
|
128
|
+
throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
|
|
129
|
+
}
|
|
130
|
+
if (out.length !== 0)
|
|
131
|
+
throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
132
|
+
if (ctx.startTimer)
|
|
133
|
+
throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
134
|
+
const set = ["cycle", "after", "at"].filter((k) => typeof spec?.[k] === "string");
|
|
135
|
+
if (set.length !== 1) {
|
|
136
|
+
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
137
|
+
}
|
|
138
|
+
if (spec.cycle !== undefined) {
|
|
139
|
+
const cycle = spec.cycle.trim();
|
|
140
|
+
assertTimerCycle(`startOn cycle`, cycle);
|
|
141
|
+
ctx.startTimer = { cycle };
|
|
142
|
+
}
|
|
143
|
+
else if (spec.after !== undefined) {
|
|
144
|
+
const after = spec.after.trim();
|
|
145
|
+
assertTimerDuration(`startOn after`, after);
|
|
146
|
+
ctx.startTimer = { after };
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const at = spec.at.trim();
|
|
150
|
+
assertTimerDate(`startOn at`, at);
|
|
151
|
+
ctx.startTimer = { at };
|
|
152
|
+
}
|
|
153
|
+
return b;
|
|
154
|
+
},
|
|
96
155
|
switch(subject, cases) {
|
|
97
156
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
98
157
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
@@ -125,6 +184,75 @@ function makeBuilder(id, out, ctx) {
|
|
|
125
184
|
out.push({ kind: "loop", body: child(body, true) });
|
|
126
185
|
return b;
|
|
127
186
|
},
|
|
187
|
+
parallel(branches) {
|
|
188
|
+
if (!Array.isArray(branches) || branches.length < 2) {
|
|
189
|
+
throw new Error(`parallel() needs at least two branch blocks`);
|
|
190
|
+
}
|
|
191
|
+
for (const fn of branches) {
|
|
192
|
+
if (typeof fn !== "function")
|
|
193
|
+
throw new Error(`parallel() branches must all be blocks (b) => {…}`);
|
|
194
|
+
}
|
|
195
|
+
// Each branch runs in its own token scope up to the AND-join, so a
|
|
196
|
+
// `break`/`continue` must not escape the fork/join into an enclosing
|
|
197
|
+
// loop. Scope branches (loopDepth resets to 0) so cross-boundary
|
|
198
|
+
// break/continue is rejected at build time, matching forEach.
|
|
199
|
+
out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
|
|
200
|
+
return b;
|
|
201
|
+
},
|
|
202
|
+
forEach(collection, itemVar, body, opts) {
|
|
203
|
+
if (typeof collection !== "string" || collection.trim() === "") {
|
|
204
|
+
throw new Error(`forEach() needs a non-empty FEEL collection expression`);
|
|
205
|
+
}
|
|
206
|
+
// Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip
|
|
207
|
+
// it so the emitter's own "=" prefix never produces an invalid "==...".
|
|
208
|
+
const collExpr = collection.trim().replace(/^=/, "").trim();
|
|
209
|
+
if (collExpr === "") {
|
|
210
|
+
throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
|
|
211
|
+
}
|
|
212
|
+
assertIdent("forEach itemVar", itemVar);
|
|
213
|
+
if (typeof body !== "function")
|
|
214
|
+
throw new Error(`forEach("${itemVar}") needs a body function`);
|
|
215
|
+
const nodes = childScoped(body);
|
|
216
|
+
if (nodes.length === 0)
|
|
217
|
+
throw new Error(`forEach("${itemVar}") body declared no steps`);
|
|
218
|
+
const node = {
|
|
219
|
+
kind: "forEach",
|
|
220
|
+
collection: collExpr,
|
|
221
|
+
itemVar,
|
|
222
|
+
body: nodes,
|
|
223
|
+
};
|
|
224
|
+
if (opts?.sequential)
|
|
225
|
+
node.sequential = true;
|
|
226
|
+
if (opts?.outputCollection !== undefined) {
|
|
227
|
+
assertIdent("forEach outputCollection", opts.outputCollection);
|
|
228
|
+
node.outputCollection = opts.outputCollection;
|
|
229
|
+
}
|
|
230
|
+
if (opts?.outputElement !== undefined) {
|
|
231
|
+
if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
|
|
232
|
+
throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
|
|
233
|
+
}
|
|
234
|
+
const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
|
|
235
|
+
if (outEl === "") {
|
|
236
|
+
throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
|
|
237
|
+
}
|
|
238
|
+
node.outputElement = outEl;
|
|
239
|
+
}
|
|
240
|
+
if (opts?.completionCondition !== undefined) {
|
|
241
|
+
if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
|
|
242
|
+
throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
|
|
243
|
+
}
|
|
244
|
+
const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
|
|
245
|
+
if (compCond === "") {
|
|
246
|
+
throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
|
|
247
|
+
}
|
|
248
|
+
node.completionCondition = compCond;
|
|
249
|
+
}
|
|
250
|
+
if (node.outputElement && !node.outputCollection) {
|
|
251
|
+
throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
|
|
252
|
+
}
|
|
253
|
+
out.push(node);
|
|
254
|
+
return b;
|
|
255
|
+
},
|
|
128
256
|
break() {
|
|
129
257
|
if (ctx.loopDepth === 0)
|
|
130
258
|
throw new Error(`break() is only valid inside a loop`);
|
|
@@ -153,10 +281,13 @@ export function defineFlow(id, second, third) {
|
|
|
153
281
|
const steps = [];
|
|
154
282
|
const handlers = {};
|
|
155
283
|
const ctx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
156
|
-
build(makeBuilder(id, steps, ctx));
|
|
284
|
+
build(makeBuilder(id, steps, ctx, true));
|
|
157
285
|
if (steps.length === 0)
|
|
158
286
|
throw new Error(`flow "${id}" declared no steps`);
|
|
159
|
-
|
|
287
|
+
const flow = { kind: "declarative", id, steps, handlers };
|
|
288
|
+
if (ctx.startTimer)
|
|
289
|
+
flow.startTimer = ctx.startTimer;
|
|
290
|
+
return flow;
|
|
160
291
|
}
|
|
161
292
|
// --- Tree walkers ------------------------------------------------------------
|
|
162
293
|
/** Depth-first visit of every node in a flow tree (structural combinators
|
|
@@ -179,6 +310,13 @@ export function walkNodes(nodes, visit) {
|
|
|
179
310
|
case "loop":
|
|
180
311
|
walkNodes(n.body, visit);
|
|
181
312
|
break;
|
|
313
|
+
case "parallel":
|
|
314
|
+
for (const branch of n.branches)
|
|
315
|
+
walkNodes(branch, visit);
|
|
316
|
+
break;
|
|
317
|
+
case "forEach":
|
|
318
|
+
walkNodes(n.body, visit);
|
|
319
|
+
break;
|
|
182
320
|
default:
|
|
183
321
|
break;
|
|
184
322
|
}
|
|
@@ -209,13 +347,28 @@ class Compiler {
|
|
|
209
347
|
edges = [];
|
|
210
348
|
/** envelope name → its fields, deduped for lifting to a single nano:shape. */
|
|
211
349
|
envelopes = new Map();
|
|
350
|
+
/** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
|
|
351
|
+
* node/edge created now nests inside it). Empty at the top level. */
|
|
352
|
+
scopeStack = [];
|
|
353
|
+
/** Edges with a resolved target, indexed in `compile()` before rendering so a
|
|
354
|
+
* sub-process can render its own nested scope. */
|
|
355
|
+
live = [];
|
|
212
356
|
seq = 0;
|
|
213
357
|
gw = 0;
|
|
214
358
|
constructor(flow) {
|
|
215
359
|
this.flow = flow;
|
|
216
360
|
}
|
|
361
|
+
currentScope() {
|
|
362
|
+
return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
|
|
363
|
+
}
|
|
217
364
|
newEdge(from, opts = {}) {
|
|
218
|
-
const e = {
|
|
365
|
+
const e = {
|
|
366
|
+
id: `f_${this.seq++}`,
|
|
367
|
+
from,
|
|
368
|
+
condition: opts.condition,
|
|
369
|
+
name: opts.name,
|
|
370
|
+
scope: this.currentScope(),
|
|
371
|
+
};
|
|
219
372
|
this.edges.push(e);
|
|
220
373
|
return e;
|
|
221
374
|
}
|
|
@@ -235,7 +388,7 @@ class Compiler {
|
|
|
235
388
|
}
|
|
236
389
|
this.envelopes.set(env.name, env.fields);
|
|
237
390
|
}
|
|
238
|
-
addServiceTask(node) {
|
|
391
|
+
addServiceTask(node, mi = "") {
|
|
239
392
|
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
240
393
|
this.recordEnvelope(node.envelopes?.in);
|
|
241
394
|
this.recordEnvelope(node.envelopes?.out);
|
|
@@ -251,10 +404,12 @@ class Compiler {
|
|
|
251
404
|
const id = node.name;
|
|
252
405
|
this.nodes.push({
|
|
253
406
|
id,
|
|
407
|
+
scope: this.currentScope(),
|
|
254
408
|
render: (inc, outg) => ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
255
409
|
ext +
|
|
256
410
|
"\n" +
|
|
257
411
|
incomingOutgoing(inc, outg) +
|
|
412
|
+
mi +
|
|
258
413
|
` </bpmn:serviceTask>`,
|
|
259
414
|
});
|
|
260
415
|
}
|
|
@@ -263,15 +418,33 @@ class Compiler {
|
|
|
263
418
|
const msgId = `Msg_${id}`;
|
|
264
419
|
this.nodes.push({
|
|
265
420
|
id,
|
|
421
|
+
scope: this.currentScope(),
|
|
266
422
|
render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
267
423
|
incomingOutgoing(inc, outg) +
|
|
268
424
|
` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
|
|
269
425
|
` </bpmn:intermediateCatchEvent>`,
|
|
270
426
|
});
|
|
271
427
|
}
|
|
428
|
+
addTimerCatchEvent(node) {
|
|
429
|
+
const id = node.name;
|
|
430
|
+
const body = node.after !== undefined
|
|
431
|
+
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
432
|
+
: ` <bpmn:timeDate>${escapeXml(node.at)}</bpmn:timeDate>\n`;
|
|
433
|
+
this.nodes.push({
|
|
434
|
+
id,
|
|
435
|
+
scope: this.currentScope(),
|
|
436
|
+
render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
437
|
+
incomingOutgoing(inc, outg) +
|
|
438
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
439
|
+
body +
|
|
440
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
441
|
+
` </bpmn:intermediateCatchEvent>`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
272
444
|
addGateway(id, name) {
|
|
273
445
|
const gwNode = {
|
|
274
446
|
id,
|
|
447
|
+
scope: this.currentScope(),
|
|
275
448
|
render: (inc, outg) => {
|
|
276
449
|
const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
|
|
277
450
|
const nm = name ? ` name="${escapeXml(name)}"` : "";
|
|
@@ -283,6 +456,66 @@ class Compiler {
|
|
|
283
456
|
this.nodes.push(gwNode);
|
|
284
457
|
return gwNode;
|
|
285
458
|
}
|
|
459
|
+
addParallelGateway(id) {
|
|
460
|
+
const node = {
|
|
461
|
+
id,
|
|
462
|
+
scope: this.currentScope(),
|
|
463
|
+
render: (inc, outg) => ` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
|
|
464
|
+
};
|
|
465
|
+
this.nodes.push(node);
|
|
466
|
+
return node;
|
|
467
|
+
}
|
|
468
|
+
addPlainStart(id) {
|
|
469
|
+
this.nodes.push({
|
|
470
|
+
id,
|
|
471
|
+
scope: this.currentScope(),
|
|
472
|
+
render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
addPlainEnd(id) {
|
|
476
|
+
this.nodes.push({
|
|
477
|
+
id,
|
|
478
|
+
scope: this.currentScope(),
|
|
479
|
+
render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/** An embedded sub-process whose body renders nested in its own scope, with the
|
|
483
|
+
* given multi-instance characteristics lifted onto it. */
|
|
484
|
+
addSubProcess(id, mi) {
|
|
485
|
+
const node = {
|
|
486
|
+
id,
|
|
487
|
+
scope: this.currentScope(),
|
|
488
|
+
render: (inc, outg) => ` <bpmn:subProcess id="${escapeXml(id)}">\n` +
|
|
489
|
+
incomingOutgoing(inc, outg) +
|
|
490
|
+
mi +
|
|
491
|
+
this.renderScope(id) +
|
|
492
|
+
`\n </bpmn:subProcess>`,
|
|
493
|
+
};
|
|
494
|
+
this.nodes.push(node);
|
|
495
|
+
return node;
|
|
496
|
+
}
|
|
497
|
+
/** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node —
|
|
498
|
+
* the same shape whether lifted onto a leaf service task or a sub-process. */
|
|
499
|
+
miCharacteristics(node) {
|
|
500
|
+
const seq = node.sequential ? "true" : "false";
|
|
501
|
+
const attrs = [
|
|
502
|
+
`inputCollection="${escapeXml(`=${node.collection}`)}"`,
|
|
503
|
+
`inputElement="${escapeXml(node.itemVar)}"`,
|
|
504
|
+
];
|
|
505
|
+
if (node.outputCollection)
|
|
506
|
+
attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
|
|
507
|
+
if (node.outputElement)
|
|
508
|
+
attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
|
|
509
|
+
const completion = node.completionCondition
|
|
510
|
+
? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
|
|
511
|
+
: "";
|
|
512
|
+
return (` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
|
|
513
|
+
` <bpmn:extensionElements>\n` +
|
|
514
|
+
` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
|
|
515
|
+
` </bpmn:extensionElements>\n` +
|
|
516
|
+
completion +
|
|
517
|
+
` </bpmn:multiInstanceLoopCharacteristics>\n`);
|
|
518
|
+
}
|
|
286
519
|
emitList(list, incoming, loop) {
|
|
287
520
|
let cur = incoming;
|
|
288
521
|
for (const node of list)
|
|
@@ -303,6 +536,11 @@ class Compiler {
|
|
|
303
536
|
this.recordEnvelope(node.payload);
|
|
304
537
|
return [this.newEdge(node.name)];
|
|
305
538
|
}
|
|
539
|
+
case "timer": {
|
|
540
|
+
this.addTimerCatchEvent(node);
|
|
541
|
+
this.connect(incoming, node.name);
|
|
542
|
+
return [this.newEdge(node.name)];
|
|
543
|
+
}
|
|
306
544
|
case "switch": {
|
|
307
545
|
const id = `Gw_${this.gw++}`;
|
|
308
546
|
const gw = this.addGateway(id, node.subject);
|
|
@@ -355,13 +593,86 @@ class Compiler {
|
|
|
355
593
|
this.connect(incoming, loop.headId);
|
|
356
594
|
return [];
|
|
357
595
|
}
|
|
596
|
+
case "parallel": {
|
|
597
|
+
// A diverging parallel gateway forks a token onto every branch; a
|
|
598
|
+
// converging one joins them (AND-join) into a single continuation.
|
|
599
|
+
const splitId = `Gw_${this.gw++}`;
|
|
600
|
+
this.addParallelGateway(splitId);
|
|
601
|
+
this.connect(incoming, splitId);
|
|
602
|
+
const joinId = `Gw_${this.gw++}`;
|
|
603
|
+
this.addParallelGateway(joinId);
|
|
604
|
+
for (const branch of node.branches) {
|
|
605
|
+
const e = this.newEdge(splitId);
|
|
606
|
+
const branchOut = this.emitList(branch, [e], loop);
|
|
607
|
+
this.connect(branchOut, joinId);
|
|
608
|
+
}
|
|
609
|
+
return [this.newEdge(joinId)];
|
|
610
|
+
}
|
|
611
|
+
case "forEach": {
|
|
612
|
+
const mi = this.miCharacteristics(node);
|
|
613
|
+
const only = node.body.length === 1 ? node.body[0] : undefined;
|
|
614
|
+
// A single service-task body carries the MI characteristics directly; a
|
|
615
|
+
// multi-step body is wrapped in an embedded MI sub-process (its own token
|
|
616
|
+
// scope, with its own start/end).
|
|
617
|
+
if (only && (only.kind === "run" || only.kind === "task")) {
|
|
618
|
+
this.addServiceTask(only, mi);
|
|
619
|
+
this.connect(incoming, only.name);
|
|
620
|
+
return [this.newEdge(only.name)];
|
|
621
|
+
}
|
|
622
|
+
const subId = `Sub_${this.gw++}`;
|
|
623
|
+
this.addSubProcess(subId, mi);
|
|
624
|
+
this.connect(incoming, subId);
|
|
625
|
+
this.scopeStack.push(subId);
|
|
626
|
+
const startId = `${subId}_start`;
|
|
627
|
+
this.addPlainStart(startId);
|
|
628
|
+
const innerOut = this.emitList(node.body, [this.newEdge(startId)], null);
|
|
629
|
+
const endId = `${subId}_end`;
|
|
630
|
+
this.addPlainEnd(endId);
|
|
631
|
+
this.connect(innerOut, endId);
|
|
632
|
+
this.scopeStack.pop();
|
|
633
|
+
return [this.newEdge(subId)];
|
|
634
|
+
}
|
|
358
635
|
}
|
|
359
636
|
}
|
|
637
|
+
/** Render every node + sequence flow that belongs to `scope` (undefined = the
|
|
638
|
+
* root process, a sub-process id otherwise). Called for the process body and,
|
|
639
|
+
* recursively, from each sub-process's render closure. */
|
|
640
|
+
renderScope(scope) {
|
|
641
|
+
const incomingOf = (id) => this.live.filter((e) => e.to === id).map((e) => e.id);
|
|
642
|
+
const outgoingOf = (id) => this.live.filter((e) => e.from === id).map((e) => e.id);
|
|
643
|
+
const nodeXml = this.nodes
|
|
644
|
+
.filter((n) => n.scope === scope)
|
|
645
|
+
.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
|
|
646
|
+
.join("\n");
|
|
647
|
+
const flowXml = this.live
|
|
648
|
+
.filter((e) => e.scope === scope)
|
|
649
|
+
.map((e) => sequenceFlow(e))
|
|
650
|
+
.join("\n");
|
|
651
|
+
return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
|
|
652
|
+
}
|
|
653
|
+
/** Render the Start event — a plain none start, or a timer start when the
|
|
654
|
+
* flow declared `startOn(...)`. */
|
|
655
|
+
renderStart(outg) {
|
|
656
|
+
const t = this.flow.startTimer;
|
|
657
|
+
if (!t)
|
|
658
|
+
return ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`;
|
|
659
|
+
const body = t.cycle !== undefined
|
|
660
|
+
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
661
|
+
: t.after !== undefined
|
|
662
|
+
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
663
|
+
: `<bpmn:timeDate>${escapeXml(t.at)}</bpmn:timeDate>`;
|
|
664
|
+
return (` <bpmn:startEvent id="Start">\n` +
|
|
665
|
+
` ${outgoingOnly(outg)}\n` +
|
|
666
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
667
|
+
` ${body}\n` +
|
|
668
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
669
|
+
` </bpmn:startEvent>`);
|
|
670
|
+
}
|
|
360
671
|
compile() {
|
|
361
672
|
// Start → top-level sequence → End.
|
|
362
673
|
this.nodes.push({
|
|
363
674
|
id: "Start",
|
|
364
|
-
render: (_inc, outg) =>
|
|
675
|
+
render: (_inc, outg) => this.renderStart(outg),
|
|
365
676
|
});
|
|
366
677
|
const s0 = this.newEdge("Start");
|
|
367
678
|
const finalDanglers = this.emitList(this.flow.steps, [s0], null);
|
|
@@ -371,11 +682,8 @@ class Compiler {
|
|
|
371
682
|
});
|
|
372
683
|
this.connect(finalDanglers, "End");
|
|
373
684
|
// A sequence flow needs both ends; drop any edge that never got a target.
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
const outgoingOf = (id) => live.filter((e) => e.from === id).map((e) => e.id);
|
|
377
|
-
const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
|
|
378
|
-
const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
|
|
685
|
+
this.live = this.edges.filter((e) => e.to !== undefined);
|
|
686
|
+
const bodyXml = this.renderScope(undefined);
|
|
379
687
|
const messageXml = this.emitMessages();
|
|
380
688
|
const shapeXml = this.emitShapes();
|
|
381
689
|
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
@@ -385,9 +693,7 @@ class Compiler {
|
|
|
385
693
|
`id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
386
694
|
` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
|
|
387
695
|
(shapeXml ? shapeXml + "\n" : "") +
|
|
388
|
-
|
|
389
|
-
"\n" +
|
|
390
|
-
flowXml +
|
|
696
|
+
bodyXml +
|
|
391
697
|
"\n" +
|
|
392
698
|
` </bpmn:process>\n` +
|
|
393
699
|
messageXml +
|
package/dist/index.d.ts
CHANGED
|
@@ -9,4 +9,4 @@ export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./clien
|
|
|
9
9
|
export type { WorkflowClientOptions, NanoSdkClient, NanoJobWorker, JobWorkerConfig, ActivatedJob, } from "./client.js";
|
|
10
10
|
export { Worker } from "./worker.js";
|
|
11
11
|
export type { WorkerOptions, ActivityEvent } from "./worker.js";
|
|
12
|
-
export type { Json, JsonObject, Job, StepHandler, DeclarativeFlow, DeclarativeStep, FlowNode, SwitchCase, NodeEnvelopes, StepContract, FlowContracts, ImperativeWorkflow, Orchestration, WorkflowContext, Workflow, DeployResult, StartResult, } from "./types.js";
|
|
12
|
+
export type { Json, JsonObject, Job, StepHandler, DeclarativeFlow, DeclarativeStep, FlowNode, SwitchCase, TimerStart, NodeEnvelopes, StepContract, FlowContracts, ImperativeWorkflow, Orchestration, WorkflowContext, Workflow, DeployResult, StartResult, } from "./types.js";
|