@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.
@@ -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",
@@ -44,9 +46,10 @@ import type {
44
46
  StepContract,
45
47
  StepHandler,
46
48
  SwitchCase,
49
+ TimerStart,
47
50
  } from "./types.js";
48
51
  import type { Envelope, EnvelopeField } from "./envelope.js";
49
- import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
52
+ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
50
53
 
51
54
  // --- Authoring surface -------------------------------------------------------
52
55
 
@@ -70,9 +73,20 @@ type TypedHandler<V, R> = (job: {
70
73
  }) => Promise<R | void> | R | void;
71
74
 
72
75
  /** A block: the callback that populates a nested body (a case, arm, or loop). */
73
- type Block<C extends FlowContracts> = (b: FlowBuilder<C>) => void;
74
-
75
- export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
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> {
76
90
  /**
77
91
  * A durable activity served by a worker THIS program hosts (a BPMN service
78
92
  * task; the handler runs in the in-process `Worker`). If `name` is a key in
@@ -100,6 +114,26 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
100
114
  * payload envelope, if any, comes from the contract's `in`.
101
115
  */
102
116
  signal<K extends string>(name: K, opts: { correlationKey: string }): FlowBuilder<C>;
117
+ /**
118
+ * A durable wait for a point in time (a BPMN timer intermediate catch event).
119
+ * Pass exactly one of `{ after }` — an ISO-8601 delay (`PT1M30S`, `P1DT6H`) or
120
+ * FEEL `=`-expression measured from when the token arrives — or `{ at }` — an
121
+ * absolute ISO-8601 instant (or FEEL expression). The engine holds the token
122
+ * durably until the timer fires, then continues. Use it for in-flow delays and
123
+ * scheduled continuations (e.g. "wait 24h, then re-poll").
124
+ */
125
+ timer<K extends string>(name: K, opts: { after: string } | { at: string }): FlowBuilder<C>;
126
+ /**
127
+ * Make this flow's start event a durable TIMER start rather than an explicit
128
+ * `client.start(...)`. Must be the first statement, at the top level. Pass
129
+ * exactly one of `{ cycle }` — a recurring ISO-8601 interval (`R/PT1H`,
130
+ * `R5/PT30M`) the engine re-fires each period — `{ after }` — a one-shot delay
131
+ * from deployment — or `{ at }` — a one-shot absolute instant. A `cycle` start
132
+ * is the model-native, durable, single-fire-per-cluster replacement for an
133
+ * app-side cron: the schedule lives in the deployable model, and the engine
134
+ * (not each app replica) owns firing it exactly once.
135
+ */
136
+ startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C>;
103
137
  /**
104
138
  * A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
105
139
  * expression (usually a variable name); each case routes when `subject` equals
@@ -119,6 +153,41 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
119
153
  * the loop run once `break()` is reached.
120
154
  */
121
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>;
122
191
  /** Exit the enclosing loop (routes to whatever follows it). Only valid inside
123
192
  * a `loop`. */
124
193
  break(): FlowBuilder<C>;
@@ -132,12 +201,14 @@ interface BuilderCtx {
132
201
  handlers: Record<string, StepHandler>;
133
202
  seen: Set<string>;
134
203
  loopDepth: number;
204
+ /** Set by `startOn()` (root builder only); lifted onto the flow. */
205
+ startTimer?: TimerStart;
135
206
  }
136
207
 
137
208
  /** Ids the emitter generates for structural nodes / flows / messages. A step
138
209
  * name that collides with one of these would produce a duplicate BPMN id and an
139
210
  * invalid model, so reject them at authoring time. */
140
- const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
211
+ const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
141
212
 
142
213
  function claimName(ctx: BuilderCtx, id: string, name: string): void {
143
214
  assertIdent("step name", name);
@@ -146,7 +217,7 @@ function claimName(ctx: BuilderCtx, id: string, name: string): void {
146
217
  }
147
218
  if (RESERVED_PREFIXES.test(name)) {
148
219
  throw new Error(
149
- `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}"`,
150
221
  );
151
222
  }
152
223
  if (ctx.seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
@@ -163,13 +234,27 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
163
234
  /** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
164
235
  * `ctx` (contracts, handler registry, name set, loop nesting). Structural
165
236
  * combinators recurse with a fresh `out` array for each nested body. */
166
- function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx: BuilderCtx): FlowBuilder<C> {
237
+ function makeBuilder<C extends object>(
238
+ id: string,
239
+ out: FlowNode[],
240
+ ctx: BuilderCtx,
241
+ isRoot = false,
242
+ ): FlowBuilder<C> {
167
243
  const child = (fn: Block<C>, inLoop: boolean): FlowNode[] => {
168
244
  const body: FlowNode[] = [];
169
245
  const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
170
246
  fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: depth }));
171
247
  return body;
172
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
+ };
173
258
  const b = {
174
259
  run(name: string, handler: StepHandler): FlowBuilder<C> {
175
260
  claimName(ctx, id, name);
@@ -192,6 +277,49 @@ function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx:
192
277
  out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
193
278
  return b as unknown as FlowBuilder<C>;
194
279
  },
280
+ timer(name: string, opts: { after?: string; at?: string }): FlowBuilder<C> {
281
+ claimName(ctx, id, name);
282
+ const hasAfter = typeof opts?.after === "string";
283
+ const hasAt = typeof opts?.at === "string";
284
+ if (hasAfter === hasAt) {
285
+ throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
286
+ }
287
+ if (hasAfter) {
288
+ const after = (opts.after as string).trim();
289
+ assertTimerDuration(`timer("${name}") after`, after);
290
+ out.push({ kind: "timer", name, after });
291
+ } else {
292
+ const at = (opts.at as string).trim();
293
+ assertTimerDate(`timer("${name}") at`, at);
294
+ out.push({ kind: "timer", name, at });
295
+ }
296
+ return b as unknown as FlowBuilder<C>;
297
+ },
298
+ startOn(spec: { cycle?: string; after?: string; at?: string }): FlowBuilder<C> {
299
+ if (!isRoot) {
300
+ throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
301
+ }
302
+ if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
303
+ if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
304
+ const set = (["cycle", "after", "at"] as const).filter((k) => typeof spec?.[k] === "string");
305
+ if (set.length !== 1) {
306
+ throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
307
+ }
308
+ if (spec.cycle !== undefined) {
309
+ const cycle = spec.cycle.trim();
310
+ assertTimerCycle(`startOn cycle`, cycle);
311
+ ctx.startTimer = { cycle };
312
+ } else if (spec.after !== undefined) {
313
+ const after = spec.after.trim();
314
+ assertTimerDuration(`startOn after`, after);
315
+ ctx.startTimer = { after };
316
+ } else {
317
+ const at = (spec.at as string).trim();
318
+ assertTimerDate(`startOn at`, at);
319
+ ctx.startTimer = { at };
320
+ }
321
+ return b as unknown as FlowBuilder<C>;
322
+ },
195
323
  switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
196
324
  if (typeof subject !== "string" || subject.trim() === "") {
197
325
  throw new Error(`switch() needs a non-empty subject expression`);
@@ -221,6 +349,81 @@ function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx:
221
349
  out.push({ kind: "loop", body: child(body, true) });
222
350
  return b as unknown as FlowBuilder<C>;
223
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
+ },
224
427
  break(): FlowBuilder<C> {
225
428
  if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
226
429
  out.push({ kind: "break" });
@@ -263,9 +466,11 @@ export function defineFlow(
263
466
  const steps: FlowNode[] = [];
264
467
  const handlers: Record<string, StepHandler> = {};
265
468
  const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
266
- build(makeBuilder<FlowContracts>(id, steps, ctx));
469
+ build(makeBuilder<FlowContracts>(id, steps, ctx, true));
267
470
  if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
268
- return { kind: "declarative", id, steps, handlers };
471
+ const flow: DeclarativeFlow = { kind: "declarative", id, steps, handlers };
472
+ if (ctx.startTimer) flow.startTimer = ctx.startTimer;
473
+ return flow;
269
474
  }
270
475
 
271
476
  // --- Tree walkers ------------------------------------------------------------
@@ -287,6 +492,12 @@ export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void
287
492
  case "loop":
288
493
  walkNodes(n.body, visit);
289
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;
290
501
  default:
291
502
  break;
292
503
  }
@@ -318,6 +529,9 @@ interface RenderNode {
318
529
  render(incoming: string[], outgoing: string[]): string;
319
530
  /** For exclusive gateways: the flow id of the unconditional default edge. */
320
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;
321
535
  }
322
536
 
323
537
  interface Edge {
@@ -326,6 +540,9 @@ interface Edge {
326
540
  to?: string;
327
541
  condition?: string;
328
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;
329
546
  }
330
547
 
331
548
  interface LoopCtx {
@@ -338,13 +555,29 @@ class Compiler {
338
555
  private readonly edges: Edge[] = [];
339
556
  /** envelope name → its fields, deduped for lifting to a single nano:shape. */
340
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[] = [];
341
564
  private seq = 0;
342
565
  private gw = 0;
343
566
 
344
567
  constructor(private readonly flow: DeclarativeFlow) {}
345
568
 
569
+ private currentScope(): string | undefined {
570
+ return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
571
+ }
572
+
346
573
  private newEdge(from: string, opts: { condition?: string; name?: string } = {}): Edge {
347
- const e: Edge = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
574
+ const e: Edge = {
575
+ id: `f_${this.seq++}`,
576
+ from,
577
+ condition: opts.condition,
578
+ name: opts.name,
579
+ scope: this.currentScope(),
580
+ };
348
581
  this.edges.push(e);
349
582
  return e;
350
583
  }
@@ -367,7 +600,7 @@ class Compiler {
367
600
  this.envelopes.set(env.name, env.fields);
368
601
  }
369
602
 
370
- private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }): void {
603
+ private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi = ""): void {
371
604
  const type = node.jobType ?? jobType(this.flow.id, node.name);
372
605
  this.recordEnvelope(node.envelopes?.in);
373
606
  this.recordEnvelope(node.envelopes?.out);
@@ -382,11 +615,13 @@ class Compiler {
382
615
  const id = node.name;
383
616
  this.nodes.push({
384
617
  id,
618
+ scope: this.currentScope(),
385
619
  render: (inc, outg) =>
386
620
  ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
387
621
  ext +
388
622
  "\n" +
389
623
  incomingOutgoing(inc, outg) +
624
+ mi +
390
625
  ` </bpmn:serviceTask>`,
391
626
  });
392
627
  }
@@ -396,6 +631,7 @@ class Compiler {
396
631
  const msgId = `Msg_${id}`;
397
632
  this.nodes.push({
398
633
  id,
634
+ scope: this.currentScope(),
399
635
  render: (inc, outg) =>
400
636
  ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
401
637
  incomingOutgoing(inc, outg) +
@@ -404,9 +640,29 @@ class Compiler {
404
640
  });
405
641
  }
406
642
 
643
+ private addTimerCatchEvent(node: { name: string; after?: string; at?: string }): void {
644
+ const id = node.name;
645
+ const body =
646
+ node.after !== undefined
647
+ ? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
648
+ : ` <bpmn:timeDate>${escapeXml(node.at as string)}</bpmn:timeDate>\n`;
649
+ this.nodes.push({
650
+ id,
651
+ scope: this.currentScope(),
652
+ render: (inc, outg) =>
653
+ ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
654
+ incomingOutgoing(inc, outg) +
655
+ ` <bpmn:timerEventDefinition>\n` +
656
+ body +
657
+ ` </bpmn:timerEventDefinition>\n` +
658
+ ` </bpmn:intermediateCatchEvent>`,
659
+ });
660
+ }
661
+
407
662
  private addGateway(id: string, name?: string): RenderNode {
408
663
  const gwNode: RenderNode = {
409
664
  id,
665
+ scope: this.currentScope(),
410
666
  render: (inc, outg) => {
411
667
  const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
412
668
  const nm = name ? ` name="${escapeXml(name)}"` : "";
@@ -421,6 +677,73 @@ class Compiler {
421
677
  return gwNode;
422
678
  }
423
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
+
424
747
  private emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[] {
425
748
  let cur = incoming;
426
749
  for (const node of list) cur = this.emitNode(node, cur, loop);
@@ -441,6 +764,11 @@ class Compiler {
441
764
  this.recordEnvelope(node.payload);
442
765
  return [this.newEdge(node.name)];
443
766
  }
767
+ case "timer": {
768
+ this.addTimerCatchEvent(node);
769
+ this.connect(incoming, node.name);
770
+ return [this.newEdge(node.name)];
771
+ }
444
772
  case "switch": {
445
773
  const id = `Gw_${this.gw++}`;
446
774
  const gw = this.addGateway(id, node.subject);
@@ -491,14 +819,91 @@ class Compiler {
491
819
  this.connect(incoming, loop.headId);
492
820
  return [];
493
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
+ }
494
861
  }
495
862
  }
496
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
+
881
+ /** Render the Start event — a plain none start, or a timer start when the
882
+ * flow declared `startOn(...)`. */
883
+ private renderStart(outg: string[]): string {
884
+ const t = this.flow.startTimer;
885
+ if (!t) return ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`;
886
+ const body =
887
+ t.cycle !== undefined
888
+ ? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
889
+ : t.after !== undefined
890
+ ? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
891
+ : `<bpmn:timeDate>${escapeXml(t.at as string)}</bpmn:timeDate>`;
892
+ return (
893
+ ` <bpmn:startEvent id="Start">\n` +
894
+ ` ${outgoingOnly(outg)}\n` +
895
+ ` <bpmn:timerEventDefinition>\n` +
896
+ ` ${body}\n` +
897
+ ` </bpmn:timerEventDefinition>\n` +
898
+ ` </bpmn:startEvent>`
899
+ );
900
+ }
901
+
497
902
  compile(): string {
498
903
  // Start → top-level sequence → End.
499
904
  this.nodes.push({
500
905
  id: "Start",
501
- render: (_inc, outg) => ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`,
906
+ render: (_inc, outg) => this.renderStart(outg),
502
907
  });
503
908
  const s0 = this.newEdge("Start");
504
909
  const finalDanglers = this.emitList(this.flow.steps, [s0], null);
@@ -509,12 +914,9 @@ class Compiler {
509
914
  this.connect(finalDanglers, "End");
510
915
 
511
916
  // A sequence flow needs both ends; drop any edge that never got a target.
512
- const live = this.edges.filter((e) => e.to !== undefined);
513
- const incomingOf = (id: string) => live.filter((e) => e.to === id).map((e) => e.id);
514
- const outgoingOf = (id: string) => live.filter((e) => e.from === id).map((e) => e.id);
917
+ this.live = this.edges.filter((e) => e.to !== undefined);
515
918
 
516
- const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
517
- const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
919
+ const bodyXml = this.renderScope(undefined);
518
920
  const messageXml = this.emitMessages();
519
921
  const shapeXml = this.emitShapes();
520
922
 
@@ -526,9 +928,7 @@ class Compiler {
526
928
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
527
929
  ` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
528
930
  (shapeXml ? shapeXml + "\n" : "") +
529
- nodeXml +
530
- "\n" +
531
- flowXml +
931
+ bodyXml +
532
932
  "\n" +
533
933
  ` </bpmn:process>\n` +
534
934
  messageXml +
package/src/index.ts CHANGED
@@ -73,6 +73,7 @@ export type {
73
73
  DeclarativeStep,
74
74
  FlowNode,
75
75
  SwitchCase,
76
+ TimerStart,
76
77
  NodeEnvelopes,
77
78
  StepContract,
78
79
  FlowContracts,