@nanobpm/workflow 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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",
@@ -52,9 +54,9 @@ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTi
52
54
  // --- Authoring surface -------------------------------------------------------
53
55
 
54
56
  /** The TS payload type of a contract's input envelope (untyped fallback). */
55
- type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] : JsonObject;
57
+ type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] & JsonObject : JsonObject;
56
58
  /** The TS payload type of a contract's output envelope (untyped fallback). */
57
- type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] : JsonObject;
59
+ type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] & JsonObject : JsonObject;
58
60
  /** The input payload type of step `K` under contracts `C`. */
59
61
  type VarsOf<C, K extends string> = K extends keyof C ? InPayload<C[K]> : JsonObject;
60
62
  /** The output payload type of step `K` under contracts `C`. */
@@ -62,7 +64,7 @@ type ResultOf<C, K extends string> = K extends keyof C ? OutPayload<C[K]> : Json
62
64
 
63
65
  /** A typed handler for a `run` step: its job variables and result are resolved
64
66
  * from the flow contracts by the step name. */
65
- type TypedHandler<V, R> = (job: {
67
+ type TypedHandler<V extends JsonObject, R extends JsonObject> = (job: {
66
68
  jobKey: string;
67
69
  processInstanceKey: string;
68
70
  elementId: string;
@@ -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 FlowContracts> = (b: FlowBuilder<C>) => void;
76
+ type Block<C extends object> = (b: FlowBuilder<C>) => void;
75
77
 
76
- export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
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,22 @@ 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_)/;
212
+ const TIMER_START_KEYS = ["cycle", "after", "at"] as const;
213
+
214
+ function timerStartValue(
215
+ spec: { cycle: string } | { after: string } | { at: string },
216
+ key: (typeof TIMER_START_KEYS)[number],
217
+ ): string | undefined {
218
+ switch (key) {
219
+ case "cycle":
220
+ return "cycle" in spec ? spec.cycle : undefined;
221
+ case "after":
222
+ return "after" in spec ? spec.after : undefined;
223
+ case "at":
224
+ return "at" in spec ? spec.at : undefined;
225
+ }
226
+ }
164
227
 
165
228
  function claimName(ctx: BuilderCtx, id: string, name: string): void {
166
229
  assertIdent("step name", name);
@@ -169,7 +232,7 @@ function claimName(ctx: BuilderCtx, id: string, name: string): void {
169
232
  }
170
233
  if (RESERVED_PREFIXES.test(name)) {
171
234
  throw new Error(
172
- `step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`,
235
+ `step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`,
173
236
  );
174
237
  }
175
238
  if (ctx.seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
@@ -186,7 +249,7 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
186
249
  /** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
187
250
  * `ctx` (contracts, handler registry, name set, loop nesting). Structural
188
251
  * combinators recurse with a fresh `out` array for each nested body. */
189
- function makeBuilder<C extends FlowContracts>(
252
+ function makeBuilder<C extends object>(
190
253
  id: string,
191
254
  out: FlowNode[],
192
255
  ctx: BuilderCtx,
@@ -198,81 +261,97 @@ function makeBuilder<C extends FlowContracts>(
198
261
  fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: depth }));
199
262
  return body;
200
263
  };
201
- const b = {
202
- run(name: string, handler: StepHandler): FlowBuilder<C> {
264
+ // A body that runs in its OWN token scope (an embedded sub-process): the
265
+ // enclosing loop is not reachable across the scope boundary, so `break`/
266
+ // `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
267
+ // INSIDE the body still works — it creates its own scope.
268
+ const childScoped = (fn: Block<C>): FlowNode[] => {
269
+ const body: FlowNode[] = [];
270
+ fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: 0 }));
271
+ return body;
272
+ };
273
+ const b: FlowBuilder<C> = {
274
+ run<K extends string>(
275
+ name: K,
276
+ handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>,
277
+ ): FlowBuilder<C> {
203
278
  claimName(ctx, id, name);
204
279
  if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
205
280
  ctx.handlers[name] = handler;
206
281
  out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
207
- return b as unknown as FlowBuilder<C>;
282
+ return b;
208
283
  },
209
284
  task(name: string, opts?: { jobType?: string }): FlowBuilder<C> {
210
285
  claimName(ctx, id, name);
211
286
  const override = opts?.jobType;
212
287
  if (override !== undefined) assertJobType("task jobType", override);
213
288
  out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
214
- return b as unknown as FlowBuilder<C>;
289
+ return b;
215
290
  },
216
291
  signal(name: string, opts: { correlationKey: string }): FlowBuilder<C> {
217
292
  claimName(ctx, id, name);
218
293
  if (!opts || !opts.correlationKey) throw new Error(`signal("${name}") needs { correlationKey }`);
219
294
  assertIdent("correlationKey", opts.correlationKey);
220
295
  out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
221
- return b as unknown as FlowBuilder<C>;
296
+ return b;
222
297
  },
223
- timer(name: string, opts: { after?: string; at?: string }): FlowBuilder<C> {
298
+ timer(name: string, opts: { after: string } | { at: string }): FlowBuilder<C> {
224
299
  claimName(ctx, id, name);
225
- const hasAfter = typeof opts?.after === "string";
226
- const hasAt = typeof opts?.at === "string";
300
+ const hasAfter = "after" in opts && typeof opts.after === "string";
301
+ const hasAt = "at" in opts && typeof opts.at === "string";
227
302
  if (hasAfter === hasAt) {
228
303
  throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
229
304
  }
230
- if (hasAfter) {
231
- const after = (opts.after as string).trim();
305
+ if ("after" in opts) {
306
+ const after = opts.after.trim();
232
307
  assertTimerDuration(`timer("${name}") after`, after);
233
308
  out.push({ kind: "timer", name, after });
234
309
  } else {
235
- const at = (opts.at as string).trim();
310
+ const at = opts.at.trim();
236
311
  assertTimerDate(`timer("${name}") at`, at);
237
312
  out.push({ kind: "timer", name, at });
238
313
  }
239
- return b as unknown as FlowBuilder<C>;
314
+ return b;
240
315
  },
241
- startOn(spec: { cycle?: string; after?: string; at?: string }): FlowBuilder<C> {
316
+ startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C> {
242
317
  if (!isRoot) {
243
318
  throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
244
319
  }
245
320
  if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
246
321
  if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
247
- const set = (["cycle", "after", "at"] as const).filter((k) => typeof spec?.[k] === "string");
248
- if (set.length !== 1) {
322
+ const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
323
+ if (setCount !== 1) {
249
324
  throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
250
325
  }
251
- if (spec.cycle !== undefined) {
326
+ if ("cycle" in spec) {
252
327
  const cycle = spec.cycle.trim();
253
328
  assertTimerCycle(`startOn cycle`, cycle);
254
329
  ctx.startTimer = { cycle };
255
- } else if (spec.after !== undefined) {
330
+ } else if ("after" in spec) {
256
331
  const after = spec.after.trim();
257
332
  assertTimerDuration(`startOn after`, after);
258
333
  ctx.startTimer = { after };
259
334
  } else {
260
- const at = (spec.at as string).trim();
335
+ const at = spec.at.trim();
261
336
  assertTimerDate(`startOn at`, at);
262
337
  ctx.startTimer = { at };
263
338
  }
264
- return b as unknown as FlowBuilder<C>;
339
+ return b;
265
340
  },
266
341
  switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
267
342
  if (typeof subject !== "string" || subject.trim() === "") {
268
343
  throw new Error(`switch() needs a non-empty subject expression`);
269
344
  }
270
- const entries = Object.entries(cases).filter(([k]) => k !== "default") as [string, Block<C>][];
271
- if (entries.length === 0) throw new Error(`switch("${subject}") needs at least one case`);
272
- const caseNodes: SwitchCase[] = entries.map(([value, fn]) => ({ value, body: child(fn, false) }));
345
+ const caseNodes: SwitchCase[] = [];
346
+ for (const [value, fn] of Object.entries(cases)) {
347
+ if (value === "default") continue;
348
+ if (typeof fn !== "function") throw new Error(`switch("${subject}") cases must be blocks (b) => {…}`);
349
+ caseNodes.push({ value, body: child(fn, false) });
350
+ }
351
+ if (caseNodes.length === 0) throw new Error(`switch("${subject}") needs at least one case`);
273
352
  const def = cases.default ? child(cases.default, false) : undefined;
274
353
  out.push({ kind: "switch", subject, cases: caseNodes, default: def });
275
- return b as unknown as FlowBuilder<C>;
354
+ return b;
276
355
  },
277
356
  branch(condition: string, arms: { then: Block<C>; else?: Block<C> }): FlowBuilder<C> {
278
357
  if (typeof condition !== "string" || condition.trim() === "") {
@@ -285,25 +364,100 @@ function makeBuilder<C extends FlowContracts>(
285
364
  then: child(arms.then, false),
286
365
  else: arms.else ? child(arms.else, false) : undefined,
287
366
  });
288
- return b as unknown as FlowBuilder<C>;
367
+ return b;
289
368
  },
290
369
  loop(body: Block<C>): FlowBuilder<C> {
291
370
  if (typeof body !== "function") throw new Error(`loop() needs a body function`);
292
371
  out.push({ kind: "loop", body: child(body, true) });
293
- return b as unknown as FlowBuilder<C>;
372
+ return b;
373
+ },
374
+ parallel(branches: Block<C>[]): FlowBuilder<C> {
375
+ if (!Array.isArray(branches) || branches.length < 2) {
376
+ throw new Error(`parallel() needs at least two branch blocks`);
377
+ }
378
+ for (const fn of branches) {
379
+ if (typeof fn !== "function") throw new Error(`parallel() branches must all be blocks (b) => {…}`);
380
+ }
381
+ // Each branch runs in its own token scope up to the AND-join, so a
382
+ // `break`/`continue` must not escape the fork/join into an enclosing
383
+ // loop. Scope branches (loopDepth resets to 0) so cross-boundary
384
+ // break/continue is rejected at build time, matching forEach.
385
+ out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
386
+ return b;
387
+ },
388
+ forEach(
389
+ collection: string,
390
+ itemVar: string,
391
+ body: Block<C>,
392
+ opts?: {
393
+ sequential?: boolean;
394
+ outputCollection?: string;
395
+ outputElement?: string;
396
+ completionCondition?: string;
397
+ },
398
+ ): FlowBuilder<C> {
399
+ if (typeof collection !== "string" || collection.trim() === "") {
400
+ throw new Error(`forEach() needs a non-empty FEEL collection expression`);
401
+ }
402
+ // Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip
403
+ // it so the emitter's own "=" prefix never produces an invalid "==...".
404
+ const collExpr = collection.trim().replace(/^=/, "").trim();
405
+ if (collExpr === "") {
406
+ throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
407
+ }
408
+ assertIdent("forEach itemVar", itemVar);
409
+ if (typeof body !== "function") throw new Error(`forEach("${itemVar}") needs a body function`);
410
+ const nodes = childScoped(body);
411
+ if (nodes.length === 0) throw new Error(`forEach("${itemVar}") body declared no steps`);
412
+ const node: Extract<FlowNode, { kind: "forEach" }> = {
413
+ kind: "forEach",
414
+ collection: collExpr,
415
+ itemVar,
416
+ body: nodes,
417
+ };
418
+ if (opts?.sequential) node.sequential = true;
419
+ if (opts?.outputCollection !== undefined) {
420
+ assertIdent("forEach outputCollection", opts.outputCollection);
421
+ node.outputCollection = opts.outputCollection;
422
+ }
423
+ if (opts?.outputElement !== undefined) {
424
+ if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
425
+ throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
426
+ }
427
+ const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
428
+ if (outEl === "") {
429
+ throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
430
+ }
431
+ node.outputElement = outEl;
432
+ }
433
+ if (opts?.completionCondition !== undefined) {
434
+ if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
435
+ throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
436
+ }
437
+ const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
438
+ if (compCond === "") {
439
+ throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
440
+ }
441
+ node.completionCondition = compCond;
442
+ }
443
+ if (node.outputElement && !node.outputCollection) {
444
+ throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
445
+ }
446
+ out.push(node);
447
+ return b;
294
448
  },
295
449
  break(): FlowBuilder<C> {
296
450
  if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
297
451
  out.push({ kind: "break" });
298
- return b as unknown as FlowBuilder<C>;
452
+ return b;
299
453
  },
300
454
  continue(): FlowBuilder<C> {
301
455
  if (ctx.loopDepth === 0) throw new Error(`continue() is only valid inside a loop`);
302
456
  out.push({ kind: "continue" });
303
- return b as unknown as FlowBuilder<C>;
457
+ return b;
304
458
  },
305
459
  };
306
- return b as unknown as FlowBuilder<C>;
460
+ return b;
307
461
  }
308
462
 
309
463
  /**
@@ -327,14 +481,21 @@ export function defineFlow(
327
481
  throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
328
482
  }
329
483
  const contracts: FlowContracts = typeof second === "function" ? {} : second;
330
- const build = (typeof second === "function" ? second : third) as (w: FlowBuilder<FlowContracts>) => void;
331
- if (typeof build !== "function") {
484
+ if (typeof second !== "function" && typeof third !== "function") {
332
485
  throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
333
486
  }
334
487
  const steps: FlowNode[] = [];
335
488
  const handlers: Record<string, StepHandler> = {};
336
489
  const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
337
- build(makeBuilder<FlowContracts>(id, steps, ctx, true));
490
+ if (typeof second === "function") {
491
+ second(makeBuilder(id, steps, ctx, true));
492
+ } else {
493
+ const build = third;
494
+ if (typeof build !== "function") {
495
+ throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
496
+ }
497
+ build(makeBuilder<FlowContracts>(id, steps, ctx, true));
498
+ }
338
499
  if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
339
500
  const flow: DeclarativeFlow = { kind: "declarative", id, steps, handlers };
340
501
  if (ctx.startTimer) flow.startTimer = ctx.startTimer;
@@ -360,6 +521,12 @@ export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void
360
521
  case "loop":
361
522
  walkNodes(n.body, visit);
362
523
  break;
524
+ case "parallel":
525
+ for (const branch of n.branches) walkNodes(branch, visit);
526
+ break;
527
+ case "forEach":
528
+ walkNodes(n.body, visit);
529
+ break;
363
530
  default:
364
531
  break;
365
532
  }
@@ -384,6 +551,16 @@ export function externalJobTypes(flow: DeclarativeFlow): string[] {
384
551
  return types;
385
552
  }
386
553
 
554
+ function requireTimerAt(node: { at?: string }): string {
555
+ if (node.at === undefined) throw new Error(`timer event is missing its { at } value`);
556
+ return node.at;
557
+ }
558
+
559
+ function requireStartAt(timer: TimerStart): string {
560
+ if (timer.at === undefined) throw new Error(`timer start is missing its { at } value`);
561
+ return timer.at;
562
+ }
563
+
387
564
  // --- Model emitter (two-phase graph compiler) --------------------------------
388
565
 
389
566
  interface RenderNode {
@@ -391,6 +568,9 @@ interface RenderNode {
391
568
  render(incoming: string[], outgoing: string[]): string;
392
569
  /** For exclusive gateways: the flow id of the unconditional default edge. */
393
570
  defaultFlow?: string;
571
+ /** Id of the embedded sub-process this node lives in, or undefined at the
572
+ * top level (the root process). */
573
+ scope?: string;
394
574
  }
395
575
 
396
576
  interface Edge {
@@ -399,6 +579,9 @@ interface Edge {
399
579
  to?: string;
400
580
  condition?: string;
401
581
  name?: string;
582
+ /** The scope (sub-process id, or undefined = root process) this flow is
583
+ * nested in — so it renders inside the right container. */
584
+ scope?: string;
402
585
  }
403
586
 
404
587
  interface LoopCtx {
@@ -411,13 +594,29 @@ class Compiler {
411
594
  private readonly edges: Edge[] = [];
412
595
  /** envelope name → its fields, deduped for lifting to a single nano:shape. */
413
596
  private readonly envelopes = new Map<string, EnvelopeField[]>();
597
+ /** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
598
+ * node/edge created now nests inside it). Empty at the top level. */
599
+ private readonly scopeStack: string[] = [];
600
+ /** Edges with a resolved target, indexed in `compile()` before rendering so a
601
+ * sub-process can render its own nested scope. */
602
+ private live: Edge[] = [];
414
603
  private seq = 0;
415
604
  private gw = 0;
416
605
 
417
606
  constructor(private readonly flow: DeclarativeFlow) {}
418
607
 
608
+ private currentScope(): string | undefined {
609
+ return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
610
+ }
611
+
419
612
  private newEdge(from: string, opts: { condition?: string; name?: string } = {}): Edge {
420
- const e: Edge = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
613
+ const e: Edge = {
614
+ id: `f_${this.seq++}`,
615
+ from,
616
+ condition: opts.condition,
617
+ name: opts.name,
618
+ scope: this.currentScope(),
619
+ };
421
620
  this.edges.push(e);
422
621
  return e;
423
622
  }
@@ -440,7 +639,7 @@ class Compiler {
440
639
  this.envelopes.set(env.name, env.fields);
441
640
  }
442
641
 
443
- private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }): void {
642
+ private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi = ""): void {
444
643
  const type = node.jobType ?? jobType(this.flow.id, node.name);
445
644
  this.recordEnvelope(node.envelopes?.in);
446
645
  this.recordEnvelope(node.envelopes?.out);
@@ -455,11 +654,13 @@ class Compiler {
455
654
  const id = node.name;
456
655
  this.nodes.push({
457
656
  id,
657
+ scope: this.currentScope(),
458
658
  render: (inc, outg) =>
459
659
  ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
460
660
  ext +
461
661
  "\n" +
462
662
  incomingOutgoing(inc, outg) +
663
+ mi +
463
664
  ` </bpmn:serviceTask>`,
464
665
  });
465
666
  }
@@ -469,6 +670,7 @@ class Compiler {
469
670
  const msgId = `Msg_${id}`;
470
671
  this.nodes.push({
471
672
  id,
673
+ scope: this.currentScope(),
472
674
  render: (inc, outg) =>
473
675
  ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
474
676
  incomingOutgoing(inc, outg) +
@@ -482,9 +684,10 @@ class Compiler {
482
684
  const body =
483
685
  node.after !== undefined
484
686
  ? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
485
- : ` <bpmn:timeDate>${escapeXml(node.at as string)}</bpmn:timeDate>\n`;
687
+ : ` <bpmn:timeDate>${escapeXml(requireTimerAt(node))}</bpmn:timeDate>\n`;
486
688
  this.nodes.push({
487
689
  id,
690
+ scope: this.currentScope(),
488
691
  render: (inc, outg) =>
489
692
  ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
490
693
  incomingOutgoing(inc, outg) +
@@ -498,6 +701,7 @@ class Compiler {
498
701
  private addGateway(id: string, name?: string): RenderNode {
499
702
  const gwNode: RenderNode = {
500
703
  id,
704
+ scope: this.currentScope(),
501
705
  render: (inc, outg) => {
502
706
  const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
503
707
  const nm = name ? ` name="${escapeXml(name)}"` : "";
@@ -512,6 +716,73 @@ class Compiler {
512
716
  return gwNode;
513
717
  }
514
718
 
719
+ private addParallelGateway(id: string): RenderNode {
720
+ const node: RenderNode = {
721
+ id,
722
+ scope: this.currentScope(),
723
+ render: (inc, outg) =>
724
+ ` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
725
+ };
726
+ this.nodes.push(node);
727
+ return node;
728
+ }
729
+
730
+ private addPlainStart(id: string): void {
731
+ this.nodes.push({
732
+ id,
733
+ scope: this.currentScope(),
734
+ render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
735
+ });
736
+ }
737
+
738
+ private addPlainEnd(id: string): void {
739
+ this.nodes.push({
740
+ id,
741
+ scope: this.currentScope(),
742
+ render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
743
+ });
744
+ }
745
+
746
+ /** An embedded sub-process whose body renders nested in its own scope, with the
747
+ * given multi-instance characteristics lifted onto it. */
748
+ private addSubProcess(id: string, mi: string): RenderNode {
749
+ const node: RenderNode = {
750
+ id,
751
+ scope: this.currentScope(),
752
+ render: (inc, outg) =>
753
+ ` <bpmn:subProcess id="${escapeXml(id)}">\n` +
754
+ incomingOutgoing(inc, outg) +
755
+ mi +
756
+ this.renderScope(id) +
757
+ `\n </bpmn:subProcess>`,
758
+ };
759
+ this.nodes.push(node);
760
+ return node;
761
+ }
762
+
763
+ /** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node —
764
+ * the same shape whether lifted onto a leaf service task or a sub-process. */
765
+ private miCharacteristics(node: Extract<FlowNode, { kind: "forEach" }>): string {
766
+ const seq = node.sequential ? "true" : "false";
767
+ const attrs = [
768
+ `inputCollection="${escapeXml(`=${node.collection}`)}"`,
769
+ `inputElement="${escapeXml(node.itemVar)}"`,
770
+ ];
771
+ if (node.outputCollection) attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
772
+ if (node.outputElement) attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
773
+ const completion = node.completionCondition
774
+ ? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
775
+ : "";
776
+ return (
777
+ ` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
778
+ ` <bpmn:extensionElements>\n` +
779
+ ` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
780
+ ` </bpmn:extensionElements>\n` +
781
+ completion +
782
+ ` </bpmn:multiInstanceLoopCharacteristics>\n`
783
+ );
784
+ }
785
+
515
786
  private emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[] {
516
787
  let cur = incoming;
517
788
  for (const node of list) cur = this.emitNode(node, cur, loop);
@@ -587,9 +858,65 @@ class Compiler {
587
858
  this.connect(incoming, loop.headId);
588
859
  return [];
589
860
  }
861
+ case "parallel": {
862
+ // A diverging parallel gateway forks a token onto every branch; a
863
+ // converging one joins them (AND-join) into a single continuation.
864
+ const splitId = `Gw_${this.gw++}`;
865
+ this.addParallelGateway(splitId);
866
+ this.connect(incoming, splitId);
867
+ const joinId = `Gw_${this.gw++}`;
868
+ this.addParallelGateway(joinId);
869
+ for (const branch of node.branches) {
870
+ const e = this.newEdge(splitId);
871
+ const branchOut = this.emitList(branch, [e], loop);
872
+ this.connect(branchOut, joinId);
873
+ }
874
+ return [this.newEdge(joinId)];
875
+ }
876
+ case "forEach": {
877
+ const mi = this.miCharacteristics(node);
878
+ const only = node.body.length === 1 ? node.body[0] : undefined;
879
+ // A single service-task body carries the MI characteristics directly; a
880
+ // multi-step body is wrapped in an embedded MI sub-process (its own token
881
+ // scope, with its own start/end).
882
+ if (only && (only.kind === "run" || only.kind === "task")) {
883
+ this.addServiceTask(only, mi);
884
+ this.connect(incoming, only.name);
885
+ return [this.newEdge(only.name)];
886
+ }
887
+ const subId = `Sub_${this.gw++}`;
888
+ this.addSubProcess(subId, mi);
889
+ this.connect(incoming, subId);
890
+ this.scopeStack.push(subId);
891
+ const startId = `${subId}_start`;
892
+ this.addPlainStart(startId);
893
+ const innerOut = this.emitList(node.body, [this.newEdge(startId)], null);
894
+ const endId = `${subId}_end`;
895
+ this.addPlainEnd(endId);
896
+ this.connect(innerOut, endId);
897
+ this.scopeStack.pop();
898
+ return [this.newEdge(subId)];
899
+ }
590
900
  }
591
901
  }
592
902
 
903
+ /** Render every node + sequence flow that belongs to `scope` (undefined = the
904
+ * root process, a sub-process id otherwise). Called for the process body and,
905
+ * recursively, from each sub-process's render closure. */
906
+ private renderScope(scope: string | undefined): string {
907
+ const incomingOf = (id: string) => this.live.filter((e) => e.to === id).map((e) => e.id);
908
+ const outgoingOf = (id: string) => this.live.filter((e) => e.from === id).map((e) => e.id);
909
+ const nodeXml = this.nodes
910
+ .filter((n) => n.scope === scope)
911
+ .map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
912
+ .join("\n");
913
+ const flowXml = this.live
914
+ .filter((e) => e.scope === scope)
915
+ .map((e) => sequenceFlow(e))
916
+ .join("\n");
917
+ return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
918
+ }
919
+
593
920
  /** Render the Start event — a plain none start, or a timer start when the
594
921
  * flow declared `startOn(...)`. */
595
922
  private renderStart(outg: string[]): string {
@@ -600,7 +927,7 @@ class Compiler {
600
927
  ? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
601
928
  : t.after !== undefined
602
929
  ? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
603
- : `<bpmn:timeDate>${escapeXml(t.at as string)}</bpmn:timeDate>`;
930
+ : `<bpmn:timeDate>${escapeXml(requireStartAt(t))}</bpmn:timeDate>`;
604
931
  return (
605
932
  ` <bpmn:startEvent id="Start">\n` +
606
933
  ` ${outgoingOnly(outg)}\n` +
@@ -626,12 +953,9 @@ class Compiler {
626
953
  this.connect(finalDanglers, "End");
627
954
 
628
955
  // A sequence flow needs both ends; drop any edge that never got a target.
629
- const live = this.edges.filter((e) => e.to !== undefined);
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);
956
+ this.live = this.edges.filter((e) => e.to !== undefined);
632
957
 
633
- const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
634
- const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
958
+ const bodyXml = this.renderScope(undefined);
635
959
  const messageXml = this.emitMessages();
636
960
  const shapeXml = this.emitShapes();
637
961
 
@@ -643,9 +967,7 @@ class Compiler {
643
967
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
644
968
  ` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
645
969
  (shapeXml ? shapeXml + "\n" : "") +
646
- nodeXml +
647
- "\n" +
648
- flowXml +
970
+ bodyXml +
649
971
  "\n" +
650
972
  ` </bpmn:process>\n` +
651
973
  messageXml +