@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 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. See
93
- **ADR 0047**.
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. |
@@ -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 FlowContracts> = (b: FlowBuilder<C>) => void;
26
- export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
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>;
@@ -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
- return { kind: "declarative", id, steps, handlers };
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 = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
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) => ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`,
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
- const live = this.edges.filter((e) => e.to !== undefined);
375
- const incomingOf = (id) => live.filter((e) => e.to === id).map((e) => e.id);
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
- nodeXml +
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";