@nanobpm/workflow 0.7.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 +43 -2
- package/dist/declarative.js +230 -13
- package/dist/types.d.ts +18 -0
- package/dist/worker.js +3 -2
- package/package.json +3 -3
- package/src/declarative.ts +300 -17
- package/src/types.ts +21 -3
- package/src/worker.ts +3 -2
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
|
|
@@ -109,6 +120,36 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
109
120
|
* the loop run once `break()` is reached.
|
|
110
121
|
*/
|
|
111
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>;
|
|
112
153
|
/** Exit the enclosing loop (routes to whatever follows it). Only valid inside
|
|
113
154
|
* a `loop`. */
|
|
114
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",
|
|
@@ -38,14 +40,14 @@ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTi
|
|
|
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}"`);
|
|
@@ -68,6 +70,15 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
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);
|
|
@@ -173,6 +184,75 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
173
184
|
out.push({ kind: "loop", body: child(body, true) });
|
|
174
185
|
return b;
|
|
175
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
|
+
},
|
|
176
256
|
break() {
|
|
177
257
|
if (ctx.loopDepth === 0)
|
|
178
258
|
throw new Error(`break() is only valid inside a loop`);
|
|
@@ -230,6 +310,13 @@ export function walkNodes(nodes, visit) {
|
|
|
230
310
|
case "loop":
|
|
231
311
|
walkNodes(n.body, visit);
|
|
232
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;
|
|
233
320
|
default:
|
|
234
321
|
break;
|
|
235
322
|
}
|
|
@@ -260,13 +347,28 @@ class Compiler {
|
|
|
260
347
|
edges = [];
|
|
261
348
|
/** envelope name → its fields, deduped for lifting to a single nano:shape. */
|
|
262
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 = [];
|
|
263
356
|
seq = 0;
|
|
264
357
|
gw = 0;
|
|
265
358
|
constructor(flow) {
|
|
266
359
|
this.flow = flow;
|
|
267
360
|
}
|
|
361
|
+
currentScope() {
|
|
362
|
+
return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
|
|
363
|
+
}
|
|
268
364
|
newEdge(from, opts = {}) {
|
|
269
|
-
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
|
+
};
|
|
270
372
|
this.edges.push(e);
|
|
271
373
|
return e;
|
|
272
374
|
}
|
|
@@ -286,7 +388,7 @@ class Compiler {
|
|
|
286
388
|
}
|
|
287
389
|
this.envelopes.set(env.name, env.fields);
|
|
288
390
|
}
|
|
289
|
-
addServiceTask(node) {
|
|
391
|
+
addServiceTask(node, mi = "") {
|
|
290
392
|
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
291
393
|
this.recordEnvelope(node.envelopes?.in);
|
|
292
394
|
this.recordEnvelope(node.envelopes?.out);
|
|
@@ -302,10 +404,12 @@ class Compiler {
|
|
|
302
404
|
const id = node.name;
|
|
303
405
|
this.nodes.push({
|
|
304
406
|
id,
|
|
407
|
+
scope: this.currentScope(),
|
|
305
408
|
render: (inc, outg) => ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
306
409
|
ext +
|
|
307
410
|
"\n" +
|
|
308
411
|
incomingOutgoing(inc, outg) +
|
|
412
|
+
mi +
|
|
309
413
|
` </bpmn:serviceTask>`,
|
|
310
414
|
});
|
|
311
415
|
}
|
|
@@ -314,6 +418,7 @@ class Compiler {
|
|
|
314
418
|
const msgId = `Msg_${id}`;
|
|
315
419
|
this.nodes.push({
|
|
316
420
|
id,
|
|
421
|
+
scope: this.currentScope(),
|
|
317
422
|
render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
318
423
|
incomingOutgoing(inc, outg) +
|
|
319
424
|
` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
|
|
@@ -327,6 +432,7 @@ class Compiler {
|
|
|
327
432
|
: ` <bpmn:timeDate>${escapeXml(node.at)}</bpmn:timeDate>\n`;
|
|
328
433
|
this.nodes.push({
|
|
329
434
|
id,
|
|
435
|
+
scope: this.currentScope(),
|
|
330
436
|
render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
331
437
|
incomingOutgoing(inc, outg) +
|
|
332
438
|
` <bpmn:timerEventDefinition>\n` +
|
|
@@ -338,6 +444,7 @@ class Compiler {
|
|
|
338
444
|
addGateway(id, name) {
|
|
339
445
|
const gwNode = {
|
|
340
446
|
id,
|
|
447
|
+
scope: this.currentScope(),
|
|
341
448
|
render: (inc, outg) => {
|
|
342
449
|
const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
|
|
343
450
|
const nm = name ? ` name="${escapeXml(name)}"` : "";
|
|
@@ -349,6 +456,66 @@ class Compiler {
|
|
|
349
456
|
this.nodes.push(gwNode);
|
|
350
457
|
return gwNode;
|
|
351
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
|
+
}
|
|
352
519
|
emitList(list, incoming, loop) {
|
|
353
520
|
let cur = incoming;
|
|
354
521
|
for (const node of list)
|
|
@@ -426,8 +593,63 @@ class Compiler {
|
|
|
426
593
|
this.connect(incoming, loop.headId);
|
|
427
594
|
return [];
|
|
428
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
|
+
}
|
|
429
635
|
}
|
|
430
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
|
+
}
|
|
431
653
|
/** Render the Start event — a plain none start, or a timer start when the
|
|
432
654
|
* flow declared `startOn(...)`. */
|
|
433
655
|
renderStart(outg) {
|
|
@@ -460,11 +682,8 @@ class Compiler {
|
|
|
460
682
|
});
|
|
461
683
|
this.connect(finalDanglers, "End");
|
|
462
684
|
// A sequence flow needs both ends; drop any edge that never got a target.
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
const outgoingOf = (id) => live.filter((e) => e.from === id).map((e) => e.id);
|
|
466
|
-
const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
|
|
467
|
-
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);
|
|
468
687
|
const messageXml = this.emitMessages();
|
|
469
688
|
const shapeXml = this.emitShapes();
|
|
470
689
|
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
@@ -474,9 +693,7 @@ class Compiler {
|
|
|
474
693
|
`id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
475
694
|
` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
|
|
476
695
|
(shapeXml ? shapeXml + "\n" : "") +
|
|
477
|
-
|
|
478
|
-
"\n" +
|
|
479
|
-
flowXml +
|
|
696
|
+
bodyXml +
|
|
480
697
|
"\n" +
|
|
481
698
|
` </bpmn:process>\n` +
|
|
482
699
|
messageXml +
|
package/dist/types.d.ts
CHANGED
|
@@ -72,6 +72,24 @@ export type FlowNode = {
|
|
|
72
72
|
kind: "break";
|
|
73
73
|
} | {
|
|
74
74
|
kind: "continue";
|
|
75
|
+
} | {
|
|
76
|
+
kind: "parallel";
|
|
77
|
+
branches: FlowNode[][];
|
|
78
|
+
} | {
|
|
79
|
+
kind: "forEach";
|
|
80
|
+
/** FEEL expression evaluating to the list to fan out over. */
|
|
81
|
+
collection: string;
|
|
82
|
+
/** Variable each item is bound to in the child's scope (`inputElement`). */
|
|
83
|
+
itemVar: string;
|
|
84
|
+
body: FlowNode[];
|
|
85
|
+
/** Run children one at a time (sequential MI) instead of all at once. */
|
|
86
|
+
sequential?: boolean;
|
|
87
|
+
/** List variable that each child's `outputElement` is collected into. */
|
|
88
|
+
outputCollection?: string;
|
|
89
|
+
/** FEEL expression producing each child's contribution to `outputCollection`. */
|
|
90
|
+
outputElement?: string;
|
|
91
|
+
/** FEEL boolean that, once true, completes the body early. */
|
|
92
|
+
completionCondition?: string;
|
|
75
93
|
};
|
|
76
94
|
/** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
|
|
77
95
|
* delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
|
package/dist/worker.js
CHANGED
|
@@ -60,8 +60,9 @@ export class Worker {
|
|
|
60
60
|
else {
|
|
61
61
|
// Walk the flow tree; only `run` steps are hosted locally. `signal`
|
|
62
62
|
// (message catch) and `task` (external worker) contribute no in-process
|
|
63
|
-
// route, and structural combinators (`switch`/`branch`/`loop
|
|
64
|
-
// routing with no job of their own
|
|
63
|
+
// route, and structural combinators (`switch`/`branch`/`loop`/`parallel`/
|
|
64
|
+
// `forEach`) are pure routing with no job of their own — but the `run`
|
|
65
|
+
// steps NESTED inside them are hosted, since `walkNodes` recurses.
|
|
65
66
|
walkNodes(wf.steps, (s) => {
|
|
66
67
|
if (s.kind !== "run")
|
|
67
68
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Code-first durable orchestration for nanobpmn (ADR 0044/0045). Author a durable workflow as a declarative flow — `defineFlow(id, w => { w.run(...); w.signal(...); w.task(...) })` — and the SDK derives the executable BPMN model, the job types, and the message/correlation wiring, then hosts a generic worker. `w.run` is a local step, `w.signal` is a durable human-in-the-loop wait, `w.task` is a step serviced by an external worker. `defineFlow` is the single code-first surface; the imperative `defineWorkflow` replay function is experimental/internal, not a recommended API. No diagram, no task-type wiring, no correlation plumbing authored by hand — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway through @nanobpm/nano-sdk (the shared engine-transport spine — ADR 0055), which negotiates the Falcon protocol on a Nano server and REST elsewhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"sideEffects": false,
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@nanobpm/nano-sdk": "^1.2.
|
|
31
|
+
"@nanobpm/nano-sdk": "^1.2.3"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|
|
35
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.types.json",
|
|
36
36
|
"test": "npm run build && npm run test:ci",
|
|
37
37
|
"test:ci": "node --experimental-strip-types --test test/*.test.ts",
|
|
38
38
|
"test:integration": "node --experimental-strip-types --test test/integration.test.ts",
|
package/src/declarative.ts
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",
|
|
@@ -71,9 +73,20 @@ type TypedHandler<V, R> = (job: {
|
|
|
71
73
|
}) => Promise<R | void> | R | void;
|
|
72
74
|
|
|
73
75
|
/** A block: the callback that populates a nested body (a case, arm, or loop). */
|
|
74
|
-
type Block<C extends
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
type Block<C extends object> = (b: FlowBuilder<C>) => void;
|
|
77
|
+
|
|
78
|
+
/** The typed builder for a flow body.
|
|
79
|
+
*
|
|
80
|
+
* `C` is the flow's contracts map (step name → `{ in, out }` envelopes). The
|
|
81
|
+
* default is `object`, NOT `Record<string, never>`: with a `never`-valued record
|
|
82
|
+
* `keyof C` is `string`, so `VarsOf`/`ResultOf` route EVERY step name into
|
|
83
|
+
* `InPayload<never>`/`OutPayload<never>`, which distribute over `never` to
|
|
84
|
+
* `never` — typing an untyped flow's `job.variables` as `never` and forcing its
|
|
85
|
+
* handler return to `void` (so no untyped flow that returns data compiles). With
|
|
86
|
+
* `object`, `keyof C` is `never`, so every step falls back to the intended
|
|
87
|
+
* `JsonObject`. The public `defineFlow<C extends FlowContracts>` overload keeps
|
|
88
|
+
* the stricter contracts constraint; this is only the untyped-builder default. */
|
|
89
|
+
export interface FlowBuilder<C extends object = object> {
|
|
77
90
|
/**
|
|
78
91
|
* A durable activity served by a worker THIS program hosts (a BPMN service
|
|
79
92
|
* task; the handler runs in the in-process `Worker`). If `name` is a key in
|
|
@@ -140,6 +153,41 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
140
153
|
* the loop run once `break()` is reached.
|
|
141
154
|
*/
|
|
142
155
|
loop(body: Block<C>): FlowBuilder<C>;
|
|
156
|
+
/**
|
|
157
|
+
* A static parallel fork/join (a pair of BPMN parallel gateways). Every block
|
|
158
|
+
* runs concurrently on its own branch; control continues past `parallel` only
|
|
159
|
+
* once ALL branches have reached the joining gateway (AND-join). Pass at least
|
|
160
|
+
* two branch blocks. Unlike `switch`/`branch` (exclusive choice), no branch is
|
|
161
|
+
* conditional — all of them run.
|
|
162
|
+
*/
|
|
163
|
+
parallel(branches: Block<C>[]): FlowBuilder<C>;
|
|
164
|
+
/**
|
|
165
|
+
* A data-driven fan-out over a runtime collection (a BPMN parallel
|
|
166
|
+
* multi-instance activity). `collection` is a FEEL expression (no leading `=`)
|
|
167
|
+
* evaluating to a list; one child instance of `body` runs per item, with the
|
|
168
|
+
* item bound to the `itemVar` variable in the child's scope. A single-activity
|
|
169
|
+
* body attaches the multi-instance characteristics to that activity; a
|
|
170
|
+
* multi-step body is wrapped in an embedded multi-instance sub-process. Options:
|
|
171
|
+
* - `sequential` — run children one at a time (a sequential MI) instead of all
|
|
172
|
+
* at once (the default: a parallel MI).
|
|
173
|
+
* - `outputCollection` / `outputElement` — collect each child's `outputElement`
|
|
174
|
+
* (a FEEL expression, no leading `=`) into the `outputCollection` list
|
|
175
|
+
* variable, in item order.
|
|
176
|
+
* - `completionCondition` — a FEEL boolean (no leading `=`); when it holds
|
|
177
|
+
* after a child completes, the remaining children are cancelled and the body
|
|
178
|
+
* completes early.
|
|
179
|
+
*/
|
|
180
|
+
forEach(
|
|
181
|
+
collection: string,
|
|
182
|
+
itemVar: string,
|
|
183
|
+
body: Block<C>,
|
|
184
|
+
opts?: {
|
|
185
|
+
sequential?: boolean;
|
|
186
|
+
outputCollection?: string;
|
|
187
|
+
outputElement?: string;
|
|
188
|
+
completionCondition?: string;
|
|
189
|
+
},
|
|
190
|
+
): FlowBuilder<C>;
|
|
143
191
|
/** Exit the enclosing loop (routes to whatever follows it). Only valid inside
|
|
144
192
|
* a `loop`. */
|
|
145
193
|
break(): FlowBuilder<C>;
|
|
@@ -160,7 +208,7 @@ interface BuilderCtx {
|
|
|
160
208
|
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
161
209
|
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
162
210
|
* invalid model, so reject them at authoring time. */
|
|
163
|
-
const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
|
|
211
|
+
const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
|
|
164
212
|
|
|
165
213
|
function claimName(ctx: BuilderCtx, id: string, name: string): void {
|
|
166
214
|
assertIdent("step name", name);
|
|
@@ -169,7 +217,7 @@ function claimName(ctx: BuilderCtx, id: string, name: string): void {
|
|
|
169
217
|
}
|
|
170
218
|
if (RESERVED_PREFIXES.test(name)) {
|
|
171
219
|
throw new Error(
|
|
172
|
-
`step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`,
|
|
220
|
+
`step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`,
|
|
173
221
|
);
|
|
174
222
|
}
|
|
175
223
|
if (ctx.seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
@@ -186,7 +234,7 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
|
|
|
186
234
|
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
187
235
|
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
188
236
|
* combinators recurse with a fresh `out` array for each nested body. */
|
|
189
|
-
function makeBuilder<C extends
|
|
237
|
+
function makeBuilder<C extends object>(
|
|
190
238
|
id: string,
|
|
191
239
|
out: FlowNode[],
|
|
192
240
|
ctx: BuilderCtx,
|
|
@@ -198,6 +246,15 @@ function makeBuilder<C extends FlowContracts>(
|
|
|
198
246
|
fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: depth }));
|
|
199
247
|
return body;
|
|
200
248
|
};
|
|
249
|
+
// A body that runs in its OWN token scope (an embedded sub-process): the
|
|
250
|
+
// enclosing loop is not reachable across the scope boundary, so `break`/
|
|
251
|
+
// `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
|
|
252
|
+
// INSIDE the body still works — it creates its own scope.
|
|
253
|
+
const childScoped = (fn: Block<C>): FlowNode[] => {
|
|
254
|
+
const body: FlowNode[] = [];
|
|
255
|
+
fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: 0 }));
|
|
256
|
+
return body;
|
|
257
|
+
};
|
|
201
258
|
const b = {
|
|
202
259
|
run(name: string, handler: StepHandler): FlowBuilder<C> {
|
|
203
260
|
claimName(ctx, id, name);
|
|
@@ -292,6 +349,81 @@ function makeBuilder<C extends FlowContracts>(
|
|
|
292
349
|
out.push({ kind: "loop", body: child(body, true) });
|
|
293
350
|
return b as unknown as FlowBuilder<C>;
|
|
294
351
|
},
|
|
352
|
+
parallel(branches: Block<C>[]): FlowBuilder<C> {
|
|
353
|
+
if (!Array.isArray(branches) || branches.length < 2) {
|
|
354
|
+
throw new Error(`parallel() needs at least two branch blocks`);
|
|
355
|
+
}
|
|
356
|
+
for (const fn of branches) {
|
|
357
|
+
if (typeof fn !== "function") throw new Error(`parallel() branches must all be blocks (b) => {…}`);
|
|
358
|
+
}
|
|
359
|
+
// Each branch runs in its own token scope up to the AND-join, so a
|
|
360
|
+
// `break`/`continue` must not escape the fork/join into an enclosing
|
|
361
|
+
// loop. Scope branches (loopDepth resets to 0) so cross-boundary
|
|
362
|
+
// break/continue is rejected at build time, matching forEach.
|
|
363
|
+
out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
|
|
364
|
+
return b as unknown as FlowBuilder<C>;
|
|
365
|
+
},
|
|
366
|
+
forEach(
|
|
367
|
+
collection: string,
|
|
368
|
+
itemVar: string,
|
|
369
|
+
body: Block<C>,
|
|
370
|
+
opts?: {
|
|
371
|
+
sequential?: boolean;
|
|
372
|
+
outputCollection?: string;
|
|
373
|
+
outputElement?: string;
|
|
374
|
+
completionCondition?: string;
|
|
375
|
+
},
|
|
376
|
+
): FlowBuilder<C> {
|
|
377
|
+
if (typeof collection !== "string" || collection.trim() === "") {
|
|
378
|
+
throw new Error(`forEach() needs a non-empty FEEL collection expression`);
|
|
379
|
+
}
|
|
380
|
+
// Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip
|
|
381
|
+
// it so the emitter's own "=" prefix never produces an invalid "==...".
|
|
382
|
+
const collExpr = collection.trim().replace(/^=/, "").trim();
|
|
383
|
+
if (collExpr === "") {
|
|
384
|
+
throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
|
|
385
|
+
}
|
|
386
|
+
assertIdent("forEach itemVar", itemVar);
|
|
387
|
+
if (typeof body !== "function") throw new Error(`forEach("${itemVar}") needs a body function`);
|
|
388
|
+
const nodes = childScoped(body);
|
|
389
|
+
if (nodes.length === 0) throw new Error(`forEach("${itemVar}") body declared no steps`);
|
|
390
|
+
const node: Extract<FlowNode, { kind: "forEach" }> = {
|
|
391
|
+
kind: "forEach",
|
|
392
|
+
collection: collExpr,
|
|
393
|
+
itemVar,
|
|
394
|
+
body: nodes,
|
|
395
|
+
};
|
|
396
|
+
if (opts?.sequential) node.sequential = true;
|
|
397
|
+
if (opts?.outputCollection !== undefined) {
|
|
398
|
+
assertIdent("forEach outputCollection", opts.outputCollection);
|
|
399
|
+
node.outputCollection = opts.outputCollection;
|
|
400
|
+
}
|
|
401
|
+
if (opts?.outputElement !== undefined) {
|
|
402
|
+
if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
|
|
403
|
+
throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
|
|
404
|
+
}
|
|
405
|
+
const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
|
|
406
|
+
if (outEl === "") {
|
|
407
|
+
throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
|
|
408
|
+
}
|
|
409
|
+
node.outputElement = outEl;
|
|
410
|
+
}
|
|
411
|
+
if (opts?.completionCondition !== undefined) {
|
|
412
|
+
if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
|
|
413
|
+
throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
|
|
414
|
+
}
|
|
415
|
+
const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
|
|
416
|
+
if (compCond === "") {
|
|
417
|
+
throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
|
|
418
|
+
}
|
|
419
|
+
node.completionCondition = compCond;
|
|
420
|
+
}
|
|
421
|
+
if (node.outputElement && !node.outputCollection) {
|
|
422
|
+
throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
|
|
423
|
+
}
|
|
424
|
+
out.push(node);
|
|
425
|
+
return b as unknown as FlowBuilder<C>;
|
|
426
|
+
},
|
|
295
427
|
break(): FlowBuilder<C> {
|
|
296
428
|
if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
|
|
297
429
|
out.push({ kind: "break" });
|
|
@@ -360,6 +492,12 @@ export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void
|
|
|
360
492
|
case "loop":
|
|
361
493
|
walkNodes(n.body, visit);
|
|
362
494
|
break;
|
|
495
|
+
case "parallel":
|
|
496
|
+
for (const branch of n.branches) walkNodes(branch, visit);
|
|
497
|
+
break;
|
|
498
|
+
case "forEach":
|
|
499
|
+
walkNodes(n.body, visit);
|
|
500
|
+
break;
|
|
363
501
|
default:
|
|
364
502
|
break;
|
|
365
503
|
}
|
|
@@ -391,6 +529,9 @@ interface RenderNode {
|
|
|
391
529
|
render(incoming: string[], outgoing: string[]): string;
|
|
392
530
|
/** For exclusive gateways: the flow id of the unconditional default edge. */
|
|
393
531
|
defaultFlow?: string;
|
|
532
|
+
/** Id of the embedded sub-process this node lives in, or undefined at the
|
|
533
|
+
* top level (the root process). */
|
|
534
|
+
scope?: string;
|
|
394
535
|
}
|
|
395
536
|
|
|
396
537
|
interface Edge {
|
|
@@ -399,6 +540,9 @@ interface Edge {
|
|
|
399
540
|
to?: string;
|
|
400
541
|
condition?: string;
|
|
401
542
|
name?: string;
|
|
543
|
+
/** The scope (sub-process id, or undefined = root process) this flow is
|
|
544
|
+
* nested in — so it renders inside the right container. */
|
|
545
|
+
scope?: string;
|
|
402
546
|
}
|
|
403
547
|
|
|
404
548
|
interface LoopCtx {
|
|
@@ -411,13 +555,29 @@ class Compiler {
|
|
|
411
555
|
private readonly edges: Edge[] = [];
|
|
412
556
|
/** envelope name → its fields, deduped for lifting to a single nano:shape. */
|
|
413
557
|
private readonly envelopes = new Map<string, EnvelopeField[]>();
|
|
558
|
+
/** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
|
|
559
|
+
* node/edge created now nests inside it). Empty at the top level. */
|
|
560
|
+
private readonly scopeStack: string[] = [];
|
|
561
|
+
/** Edges with a resolved target, indexed in `compile()` before rendering so a
|
|
562
|
+
* sub-process can render its own nested scope. */
|
|
563
|
+
private live: Edge[] = [];
|
|
414
564
|
private seq = 0;
|
|
415
565
|
private gw = 0;
|
|
416
566
|
|
|
417
567
|
constructor(private readonly flow: DeclarativeFlow) {}
|
|
418
568
|
|
|
569
|
+
private currentScope(): string | undefined {
|
|
570
|
+
return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
419
573
|
private newEdge(from: string, opts: { condition?: string; name?: string } = {}): Edge {
|
|
420
|
-
const e: Edge = {
|
|
574
|
+
const e: Edge = {
|
|
575
|
+
id: `f_${this.seq++}`,
|
|
576
|
+
from,
|
|
577
|
+
condition: opts.condition,
|
|
578
|
+
name: opts.name,
|
|
579
|
+
scope: this.currentScope(),
|
|
580
|
+
};
|
|
421
581
|
this.edges.push(e);
|
|
422
582
|
return e;
|
|
423
583
|
}
|
|
@@ -440,7 +600,7 @@ class Compiler {
|
|
|
440
600
|
this.envelopes.set(env.name, env.fields);
|
|
441
601
|
}
|
|
442
602
|
|
|
443
|
-
private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }): void {
|
|
603
|
+
private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi = ""): void {
|
|
444
604
|
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
445
605
|
this.recordEnvelope(node.envelopes?.in);
|
|
446
606
|
this.recordEnvelope(node.envelopes?.out);
|
|
@@ -455,11 +615,13 @@ class Compiler {
|
|
|
455
615
|
const id = node.name;
|
|
456
616
|
this.nodes.push({
|
|
457
617
|
id,
|
|
618
|
+
scope: this.currentScope(),
|
|
458
619
|
render: (inc, outg) =>
|
|
459
620
|
` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
460
621
|
ext +
|
|
461
622
|
"\n" +
|
|
462
623
|
incomingOutgoing(inc, outg) +
|
|
624
|
+
mi +
|
|
463
625
|
` </bpmn:serviceTask>`,
|
|
464
626
|
});
|
|
465
627
|
}
|
|
@@ -469,6 +631,7 @@ class Compiler {
|
|
|
469
631
|
const msgId = `Msg_${id}`;
|
|
470
632
|
this.nodes.push({
|
|
471
633
|
id,
|
|
634
|
+
scope: this.currentScope(),
|
|
472
635
|
render: (inc, outg) =>
|
|
473
636
|
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
474
637
|
incomingOutgoing(inc, outg) +
|
|
@@ -485,6 +648,7 @@ class Compiler {
|
|
|
485
648
|
: ` <bpmn:timeDate>${escapeXml(node.at as string)}</bpmn:timeDate>\n`;
|
|
486
649
|
this.nodes.push({
|
|
487
650
|
id,
|
|
651
|
+
scope: this.currentScope(),
|
|
488
652
|
render: (inc, outg) =>
|
|
489
653
|
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
490
654
|
incomingOutgoing(inc, outg) +
|
|
@@ -498,6 +662,7 @@ class Compiler {
|
|
|
498
662
|
private addGateway(id: string, name?: string): RenderNode {
|
|
499
663
|
const gwNode: RenderNode = {
|
|
500
664
|
id,
|
|
665
|
+
scope: this.currentScope(),
|
|
501
666
|
render: (inc, outg) => {
|
|
502
667
|
const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
|
|
503
668
|
const nm = name ? ` name="${escapeXml(name)}"` : "";
|
|
@@ -512,6 +677,73 @@ class Compiler {
|
|
|
512
677
|
return gwNode;
|
|
513
678
|
}
|
|
514
679
|
|
|
680
|
+
private addParallelGateway(id: string): RenderNode {
|
|
681
|
+
const node: RenderNode = {
|
|
682
|
+
id,
|
|
683
|
+
scope: this.currentScope(),
|
|
684
|
+
render: (inc, outg) =>
|
|
685
|
+
` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
|
|
686
|
+
};
|
|
687
|
+
this.nodes.push(node);
|
|
688
|
+
return node;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private addPlainStart(id: string): void {
|
|
692
|
+
this.nodes.push({
|
|
693
|
+
id,
|
|
694
|
+
scope: this.currentScope(),
|
|
695
|
+
render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private addPlainEnd(id: string): void {
|
|
700
|
+
this.nodes.push({
|
|
701
|
+
id,
|
|
702
|
+
scope: this.currentScope(),
|
|
703
|
+
render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** An embedded sub-process whose body renders nested in its own scope, with the
|
|
708
|
+
* given multi-instance characteristics lifted onto it. */
|
|
709
|
+
private addSubProcess(id: string, mi: string): RenderNode {
|
|
710
|
+
const node: RenderNode = {
|
|
711
|
+
id,
|
|
712
|
+
scope: this.currentScope(),
|
|
713
|
+
render: (inc, outg) =>
|
|
714
|
+
` <bpmn:subProcess id="${escapeXml(id)}">\n` +
|
|
715
|
+
incomingOutgoing(inc, outg) +
|
|
716
|
+
mi +
|
|
717
|
+
this.renderScope(id) +
|
|
718
|
+
`\n </bpmn:subProcess>`,
|
|
719
|
+
};
|
|
720
|
+
this.nodes.push(node);
|
|
721
|
+
return node;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node —
|
|
725
|
+
* the same shape whether lifted onto a leaf service task or a sub-process. */
|
|
726
|
+
private miCharacteristics(node: Extract<FlowNode, { kind: "forEach" }>): string {
|
|
727
|
+
const seq = node.sequential ? "true" : "false";
|
|
728
|
+
const attrs = [
|
|
729
|
+
`inputCollection="${escapeXml(`=${node.collection}`)}"`,
|
|
730
|
+
`inputElement="${escapeXml(node.itemVar)}"`,
|
|
731
|
+
];
|
|
732
|
+
if (node.outputCollection) attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
|
|
733
|
+
if (node.outputElement) attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
|
|
734
|
+
const completion = node.completionCondition
|
|
735
|
+
? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
|
|
736
|
+
: "";
|
|
737
|
+
return (
|
|
738
|
+
` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
|
|
739
|
+
` <bpmn:extensionElements>\n` +
|
|
740
|
+
` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
|
|
741
|
+
` </bpmn:extensionElements>\n` +
|
|
742
|
+
completion +
|
|
743
|
+
` </bpmn:multiInstanceLoopCharacteristics>\n`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
515
747
|
private emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[] {
|
|
516
748
|
let cur = incoming;
|
|
517
749
|
for (const node of list) cur = this.emitNode(node, cur, loop);
|
|
@@ -587,9 +819,65 @@ class Compiler {
|
|
|
587
819
|
this.connect(incoming, loop.headId);
|
|
588
820
|
return [];
|
|
589
821
|
}
|
|
822
|
+
case "parallel": {
|
|
823
|
+
// A diverging parallel gateway forks a token onto every branch; a
|
|
824
|
+
// converging one joins them (AND-join) into a single continuation.
|
|
825
|
+
const splitId = `Gw_${this.gw++}`;
|
|
826
|
+
this.addParallelGateway(splitId);
|
|
827
|
+
this.connect(incoming, splitId);
|
|
828
|
+
const joinId = `Gw_${this.gw++}`;
|
|
829
|
+
this.addParallelGateway(joinId);
|
|
830
|
+
for (const branch of node.branches) {
|
|
831
|
+
const e = this.newEdge(splitId);
|
|
832
|
+
const branchOut = this.emitList(branch, [e], loop);
|
|
833
|
+
this.connect(branchOut, joinId);
|
|
834
|
+
}
|
|
835
|
+
return [this.newEdge(joinId)];
|
|
836
|
+
}
|
|
837
|
+
case "forEach": {
|
|
838
|
+
const mi = this.miCharacteristics(node);
|
|
839
|
+
const only = node.body.length === 1 ? node.body[0] : undefined;
|
|
840
|
+
// A single service-task body carries the MI characteristics directly; a
|
|
841
|
+
// multi-step body is wrapped in an embedded MI sub-process (its own token
|
|
842
|
+
// scope, with its own start/end).
|
|
843
|
+
if (only && (only.kind === "run" || only.kind === "task")) {
|
|
844
|
+
this.addServiceTask(only, mi);
|
|
845
|
+
this.connect(incoming, only.name);
|
|
846
|
+
return [this.newEdge(only.name)];
|
|
847
|
+
}
|
|
848
|
+
const subId = `Sub_${this.gw++}`;
|
|
849
|
+
this.addSubProcess(subId, mi);
|
|
850
|
+
this.connect(incoming, subId);
|
|
851
|
+
this.scopeStack.push(subId);
|
|
852
|
+
const startId = `${subId}_start`;
|
|
853
|
+
this.addPlainStart(startId);
|
|
854
|
+
const innerOut = this.emitList(node.body, [this.newEdge(startId)], null);
|
|
855
|
+
const endId = `${subId}_end`;
|
|
856
|
+
this.addPlainEnd(endId);
|
|
857
|
+
this.connect(innerOut, endId);
|
|
858
|
+
this.scopeStack.pop();
|
|
859
|
+
return [this.newEdge(subId)];
|
|
860
|
+
}
|
|
590
861
|
}
|
|
591
862
|
}
|
|
592
863
|
|
|
864
|
+
/** Render every node + sequence flow that belongs to `scope` (undefined = the
|
|
865
|
+
* root process, a sub-process id otherwise). Called for the process body and,
|
|
866
|
+
* recursively, from each sub-process's render closure. */
|
|
867
|
+
private renderScope(scope: string | undefined): string {
|
|
868
|
+
const incomingOf = (id: string) => this.live.filter((e) => e.to === id).map((e) => e.id);
|
|
869
|
+
const outgoingOf = (id: string) => this.live.filter((e) => e.from === id).map((e) => e.id);
|
|
870
|
+
const nodeXml = this.nodes
|
|
871
|
+
.filter((n) => n.scope === scope)
|
|
872
|
+
.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
|
|
873
|
+
.join("\n");
|
|
874
|
+
const flowXml = this.live
|
|
875
|
+
.filter((e) => e.scope === scope)
|
|
876
|
+
.map((e) => sequenceFlow(e))
|
|
877
|
+
.join("\n");
|
|
878
|
+
return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
|
|
879
|
+
}
|
|
880
|
+
|
|
593
881
|
/** Render the Start event — a plain none start, or a timer start when the
|
|
594
882
|
* flow declared `startOn(...)`. */
|
|
595
883
|
private renderStart(outg: string[]): string {
|
|
@@ -626,12 +914,9 @@ class Compiler {
|
|
|
626
914
|
this.connect(finalDanglers, "End");
|
|
627
915
|
|
|
628
916
|
// A sequence flow needs both ends; drop any edge that never got a target.
|
|
629
|
-
|
|
630
|
-
const incomingOf = (id: string) => live.filter((e) => e.to === id).map((e) => e.id);
|
|
631
|
-
const outgoingOf = (id: string) => live.filter((e) => e.from === id).map((e) => e.id);
|
|
917
|
+
this.live = this.edges.filter((e) => e.to !== undefined);
|
|
632
918
|
|
|
633
|
-
const
|
|
634
|
-
const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
|
|
919
|
+
const bodyXml = this.renderScope(undefined);
|
|
635
920
|
const messageXml = this.emitMessages();
|
|
636
921
|
const shapeXml = this.emitShapes();
|
|
637
922
|
|
|
@@ -643,9 +928,7 @@ class Compiler {
|
|
|
643
928
|
`id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
644
929
|
` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
|
|
645
930
|
(shapeXml ? shapeXml + "\n" : "") +
|
|
646
|
-
|
|
647
|
-
"\n" +
|
|
648
|
-
flowXml +
|
|
931
|
+
bodyXml +
|
|
649
932
|
"\n" +
|
|
650
933
|
` </bpmn:process>\n` +
|
|
651
934
|
messageXml +
|
package/src/types.ts
CHANGED
|
@@ -21,8 +21,9 @@ export interface Job<V extends JsonObject = JsonObject> {
|
|
|
21
21
|
// --- Declarative surface (Strategy A: compile a step tree to a BPMN model) ----
|
|
22
22
|
//
|
|
23
23
|
// A flow is a TREE of nodes, not a flat list: leaf activities (`run`/`task`/
|
|
24
|
-
// `signal`) plus structural combinators (`switch`/`branch`/`loop`
|
|
25
|
-
//
|
|
24
|
+
// `signal`) plus structural combinators (`switch`/`branch`/`loop` — XOR gateways
|
|
25
|
+
// + back-edges — and `parallel`/`forEach` — parallel gateways + multi-instance)
|
|
26
|
+
// that compile to real BPMN the engine runs. See declarative.ts for the compiler.
|
|
26
27
|
|
|
27
28
|
/** The input/output data envelopes lifted onto an activity node. */
|
|
28
29
|
export interface NodeEnvelopes {
|
|
@@ -58,7 +59,24 @@ export type FlowNode =
|
|
|
58
59
|
| { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
|
|
59
60
|
| { kind: "loop"; body: FlowNode[] }
|
|
60
61
|
| { kind: "break" }
|
|
61
|
-
| { kind: "continue" }
|
|
62
|
+
| { kind: "continue" }
|
|
63
|
+
| { kind: "parallel"; branches: FlowNode[][] }
|
|
64
|
+
| {
|
|
65
|
+
kind: "forEach";
|
|
66
|
+
/** FEEL expression evaluating to the list to fan out over. */
|
|
67
|
+
collection: string;
|
|
68
|
+
/** Variable each item is bound to in the child's scope (`inputElement`). */
|
|
69
|
+
itemVar: string;
|
|
70
|
+
body: FlowNode[];
|
|
71
|
+
/** Run children one at a time (sequential MI) instead of all at once. */
|
|
72
|
+
sequential?: boolean;
|
|
73
|
+
/** List variable that each child's `outputElement` is collected into. */
|
|
74
|
+
outputCollection?: string;
|
|
75
|
+
/** FEEL expression producing each child's contribution to `outputCollection`. */
|
|
76
|
+
outputElement?: string;
|
|
77
|
+
/** FEEL boolean that, once true, completes the body early. */
|
|
78
|
+
completionCondition?: string;
|
|
79
|
+
};
|
|
62
80
|
|
|
63
81
|
/** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
|
|
64
82
|
* delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
|
package/src/worker.ts
CHANGED
|
@@ -112,8 +112,9 @@ export class Worker {
|
|
|
112
112
|
} else {
|
|
113
113
|
// Walk the flow tree; only `run` steps are hosted locally. `signal`
|
|
114
114
|
// (message catch) and `task` (external worker) contribute no in-process
|
|
115
|
-
// route, and structural combinators (`switch`/`branch`/`loop
|
|
116
|
-
// routing with no job of their own
|
|
115
|
+
// route, and structural combinators (`switch`/`branch`/`loop`/`parallel`/
|
|
116
|
+
// `forEach`) are pure routing with no job of their own — but the `run`
|
|
117
|
+
// steps NESTED inside them are hosted, since `walkNodes` recurses.
|
|
117
118
|
walkNodes(wf.steps, (s) => {
|
|
118
119
|
if (s.kind !== "run") return;
|
|
119
120
|
const handler = wf.handlers[s.name];
|