@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",
@@ -38,14 +40,25 @@ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTi
38
40
  /** Ids the emitter generates for structural nodes / flows / messages. A step
39
41
  * name that collides with one of these would produce a duplicate BPMN id and an
40
42
  * invalid model, so reject them at authoring time. */
41
- const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
43
+ const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
44
+ const TIMER_START_KEYS = ["cycle", "after", "at"];
45
+ function timerStartValue(spec, key) {
46
+ switch (key) {
47
+ case "cycle":
48
+ return "cycle" in spec ? spec.cycle : undefined;
49
+ case "after":
50
+ return "after" in spec ? spec.after : undefined;
51
+ case "at":
52
+ return "at" in spec ? spec.at : undefined;
53
+ }
54
+ }
42
55
  function claimName(ctx, id, name) {
43
56
  assertIdent("step name", name);
44
57
  if (name === "Start" || name === "End" || name === id) {
45
58
  throw new Error(`step name "${name}" is reserved (collides with a generated BPMN id) in flow "${id}"`);
46
59
  }
47
60
  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}"`);
61
+ throw new Error(`step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`);
49
62
  }
50
63
  if (ctx.seen.has(name))
51
64
  throw new Error(`duplicate step name "${name}" in flow "${id}"`);
@@ -68,6 +81,15 @@ function makeBuilder(id, out, ctx, isRoot = false) {
68
81
  fn(makeBuilder(id, body, { ...ctx, loopDepth: depth }));
69
82
  return body;
70
83
  };
84
+ // A body that runs in its OWN token scope (an embedded sub-process): the
85
+ // enclosing loop is not reachable across the scope boundary, so `break`/
86
+ // `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
87
+ // INSIDE the body still works — it creates its own scope.
88
+ const childScoped = (fn) => {
89
+ const body = [];
90
+ fn(makeBuilder(id, body, { ...ctx, loopDepth: 0 }));
91
+ return body;
92
+ };
71
93
  const b = {
72
94
  run(name, handler) {
73
95
  claimName(ctx, id, name);
@@ -95,12 +117,12 @@ function makeBuilder(id, out, ctx, isRoot = false) {
95
117
  },
96
118
  timer(name, opts) {
97
119
  claimName(ctx, id, name);
98
- const hasAfter = typeof opts?.after === "string";
99
- const hasAt = typeof opts?.at === "string";
120
+ const hasAfter = "after" in opts && typeof opts.after === "string";
121
+ const hasAt = "at" in opts && typeof opts.at === "string";
100
122
  if (hasAfter === hasAt) {
101
123
  throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
102
124
  }
103
- if (hasAfter) {
125
+ if ("after" in opts) {
104
126
  const after = opts.after.trim();
105
127
  assertTimerDuration(`timer("${name}") after`, after);
106
128
  out.push({ kind: "timer", name, after });
@@ -120,16 +142,16 @@ function makeBuilder(id, out, ctx, isRoot = false) {
120
142
  throw new Error(`startOn() must be the first statement in flow "${id}"`);
121
143
  if (ctx.startTimer)
122
144
  throw new Error(`startOn() may be called only once in flow "${id}"`);
123
- const set = ["cycle", "after", "at"].filter((k) => typeof spec?.[k] === "string");
124
- if (set.length !== 1) {
145
+ const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
146
+ if (setCount !== 1) {
125
147
  throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
126
148
  }
127
- if (spec.cycle !== undefined) {
149
+ if ("cycle" in spec) {
128
150
  const cycle = spec.cycle.trim();
129
151
  assertTimerCycle(`startOn cycle`, cycle);
130
152
  ctx.startTimer = { cycle };
131
153
  }
132
- else if (spec.after !== undefined) {
154
+ else if ("after" in spec) {
133
155
  const after = spec.after.trim();
134
156
  assertTimerDuration(`startOn after`, after);
135
157
  ctx.startTimer = { after };
@@ -145,10 +167,16 @@ function makeBuilder(id, out, ctx, isRoot = false) {
145
167
  if (typeof subject !== "string" || subject.trim() === "") {
146
168
  throw new Error(`switch() needs a non-empty subject expression`);
147
169
  }
148
- const entries = Object.entries(cases).filter(([k]) => k !== "default");
149
- if (entries.length === 0)
170
+ const caseNodes = [];
171
+ for (const [value, fn] of Object.entries(cases)) {
172
+ if (value === "default")
173
+ continue;
174
+ if (typeof fn !== "function")
175
+ throw new Error(`switch("${subject}") cases must be blocks (b) => {…}`);
176
+ caseNodes.push({ value, body: child(fn, false) });
177
+ }
178
+ if (caseNodes.length === 0)
150
179
  throw new Error(`switch("${subject}") needs at least one case`);
151
- const caseNodes = entries.map(([value, fn]) => ({ value, body: child(fn, false) }));
152
180
  const def = cases.default ? child(cases.default, false) : undefined;
153
181
  out.push({ kind: "switch", subject, cases: caseNodes, default: def });
154
182
  return b;
@@ -173,6 +201,75 @@ function makeBuilder(id, out, ctx, isRoot = false) {
173
201
  out.push({ kind: "loop", body: child(body, true) });
174
202
  return b;
175
203
  },
204
+ parallel(branches) {
205
+ if (!Array.isArray(branches) || branches.length < 2) {
206
+ throw new Error(`parallel() needs at least two branch blocks`);
207
+ }
208
+ for (const fn of branches) {
209
+ if (typeof fn !== "function")
210
+ throw new Error(`parallel() branches must all be blocks (b) => {…}`);
211
+ }
212
+ // Each branch runs in its own token scope up to the AND-join, so a
213
+ // `break`/`continue` must not escape the fork/join into an enclosing
214
+ // loop. Scope branches (loopDepth resets to 0) so cross-boundary
215
+ // break/continue is rejected at build time, matching forEach.
216
+ out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
217
+ return b;
218
+ },
219
+ forEach(collection, itemVar, body, opts) {
220
+ if (typeof collection !== "string" || collection.trim() === "") {
221
+ throw new Error(`forEach() needs a non-empty FEEL collection expression`);
222
+ }
223
+ // Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip
224
+ // it so the emitter's own "=" prefix never produces an invalid "==...".
225
+ const collExpr = collection.trim().replace(/^=/, "").trim();
226
+ if (collExpr === "") {
227
+ throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
228
+ }
229
+ assertIdent("forEach itemVar", itemVar);
230
+ if (typeof body !== "function")
231
+ throw new Error(`forEach("${itemVar}") needs a body function`);
232
+ const nodes = childScoped(body);
233
+ if (nodes.length === 0)
234
+ throw new Error(`forEach("${itemVar}") body declared no steps`);
235
+ const node = {
236
+ kind: "forEach",
237
+ collection: collExpr,
238
+ itemVar,
239
+ body: nodes,
240
+ };
241
+ if (opts?.sequential)
242
+ node.sequential = true;
243
+ if (opts?.outputCollection !== undefined) {
244
+ assertIdent("forEach outputCollection", opts.outputCollection);
245
+ node.outputCollection = opts.outputCollection;
246
+ }
247
+ if (opts?.outputElement !== undefined) {
248
+ if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
249
+ throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
250
+ }
251
+ const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
252
+ if (outEl === "") {
253
+ throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
254
+ }
255
+ node.outputElement = outEl;
256
+ }
257
+ if (opts?.completionCondition !== undefined) {
258
+ if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
259
+ throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
260
+ }
261
+ const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
262
+ if (compCond === "") {
263
+ throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
264
+ }
265
+ node.completionCondition = compCond;
266
+ }
267
+ if (node.outputElement && !node.outputCollection) {
268
+ throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
269
+ }
270
+ out.push(node);
271
+ return b;
272
+ },
176
273
  break() {
177
274
  if (ctx.loopDepth === 0)
178
275
  throw new Error(`break() is only valid inside a loop`);
@@ -194,14 +291,22 @@ export function defineFlow(id, second, third) {
194
291
  throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
195
292
  }
196
293
  const contracts = typeof second === "function" ? {} : second;
197
- const build = (typeof second === "function" ? second : third);
198
- if (typeof build !== "function") {
294
+ if (typeof second !== "function" && typeof third !== "function") {
199
295
  throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
200
296
  }
201
297
  const steps = [];
202
298
  const handlers = {};
203
299
  const ctx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
204
- build(makeBuilder(id, steps, ctx, true));
300
+ if (typeof second === "function") {
301
+ second(makeBuilder(id, steps, ctx, true));
302
+ }
303
+ else {
304
+ const build = third;
305
+ if (typeof build !== "function") {
306
+ throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
307
+ }
308
+ build(makeBuilder(id, steps, ctx, true));
309
+ }
205
310
  if (steps.length === 0)
206
311
  throw new Error(`flow "${id}" declared no steps`);
207
312
  const flow = { kind: "declarative", id, steps, handlers };
@@ -230,6 +335,13 @@ export function walkNodes(nodes, visit) {
230
335
  case "loop":
231
336
  walkNodes(n.body, visit);
232
337
  break;
338
+ case "parallel":
339
+ for (const branch of n.branches)
340
+ walkNodes(branch, visit);
341
+ break;
342
+ case "forEach":
343
+ walkNodes(n.body, visit);
344
+ break;
233
345
  default:
234
346
  break;
235
347
  }
@@ -254,19 +366,44 @@ export function externalJobTypes(flow) {
254
366
  });
255
367
  return types;
256
368
  }
369
+ function requireTimerAt(node) {
370
+ if (node.at === undefined)
371
+ throw new Error(`timer event is missing its { at } value`);
372
+ return node.at;
373
+ }
374
+ function requireStartAt(timer) {
375
+ if (timer.at === undefined)
376
+ throw new Error(`timer start is missing its { at } value`);
377
+ return timer.at;
378
+ }
257
379
  class Compiler {
258
380
  flow;
259
381
  nodes = [];
260
382
  edges = [];
261
383
  /** envelope name → its fields, deduped for lifting to a single nano:shape. */
262
384
  envelopes = new Map();
385
+ /** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
386
+ * node/edge created now nests inside it). Empty at the top level. */
387
+ scopeStack = [];
388
+ /** Edges with a resolved target, indexed in `compile()` before rendering so a
389
+ * sub-process can render its own nested scope. */
390
+ live = [];
263
391
  seq = 0;
264
392
  gw = 0;
265
393
  constructor(flow) {
266
394
  this.flow = flow;
267
395
  }
396
+ currentScope() {
397
+ return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
398
+ }
268
399
  newEdge(from, opts = {}) {
269
- const e = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
400
+ const e = {
401
+ id: `f_${this.seq++}`,
402
+ from,
403
+ condition: opts.condition,
404
+ name: opts.name,
405
+ scope: this.currentScope(),
406
+ };
270
407
  this.edges.push(e);
271
408
  return e;
272
409
  }
@@ -286,7 +423,7 @@ class Compiler {
286
423
  }
287
424
  this.envelopes.set(env.name, env.fields);
288
425
  }
289
- addServiceTask(node) {
426
+ addServiceTask(node, mi = "") {
290
427
  const type = node.jobType ?? jobType(this.flow.id, node.name);
291
428
  this.recordEnvelope(node.envelopes?.in);
292
429
  this.recordEnvelope(node.envelopes?.out);
@@ -302,10 +439,12 @@ class Compiler {
302
439
  const id = node.name;
303
440
  this.nodes.push({
304
441
  id,
442
+ scope: this.currentScope(),
305
443
  render: (inc, outg) => ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
306
444
  ext +
307
445
  "\n" +
308
446
  incomingOutgoing(inc, outg) +
447
+ mi +
309
448
  ` </bpmn:serviceTask>`,
310
449
  });
311
450
  }
@@ -314,6 +453,7 @@ class Compiler {
314
453
  const msgId = `Msg_${id}`;
315
454
  this.nodes.push({
316
455
  id,
456
+ scope: this.currentScope(),
317
457
  render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
318
458
  incomingOutgoing(inc, outg) +
319
459
  ` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
@@ -324,9 +464,10 @@ class Compiler {
324
464
  const id = node.name;
325
465
  const body = node.after !== undefined
326
466
  ? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
327
- : ` <bpmn:timeDate>${escapeXml(node.at)}</bpmn:timeDate>\n`;
467
+ : ` <bpmn:timeDate>${escapeXml(requireTimerAt(node))}</bpmn:timeDate>\n`;
328
468
  this.nodes.push({
329
469
  id,
470
+ scope: this.currentScope(),
330
471
  render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
331
472
  incomingOutgoing(inc, outg) +
332
473
  ` <bpmn:timerEventDefinition>\n` +
@@ -338,6 +479,7 @@ class Compiler {
338
479
  addGateway(id, name) {
339
480
  const gwNode = {
340
481
  id,
482
+ scope: this.currentScope(),
341
483
  render: (inc, outg) => {
342
484
  const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
343
485
  const nm = name ? ` name="${escapeXml(name)}"` : "";
@@ -349,6 +491,66 @@ class Compiler {
349
491
  this.nodes.push(gwNode);
350
492
  return gwNode;
351
493
  }
494
+ addParallelGateway(id) {
495
+ const node = {
496
+ id,
497
+ scope: this.currentScope(),
498
+ render: (inc, outg) => ` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
499
+ };
500
+ this.nodes.push(node);
501
+ return node;
502
+ }
503
+ addPlainStart(id) {
504
+ this.nodes.push({
505
+ id,
506
+ scope: this.currentScope(),
507
+ render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
508
+ });
509
+ }
510
+ addPlainEnd(id) {
511
+ this.nodes.push({
512
+ id,
513
+ scope: this.currentScope(),
514
+ render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
515
+ });
516
+ }
517
+ /** An embedded sub-process whose body renders nested in its own scope, with the
518
+ * given multi-instance characteristics lifted onto it. */
519
+ addSubProcess(id, mi) {
520
+ const node = {
521
+ id,
522
+ scope: this.currentScope(),
523
+ render: (inc, outg) => ` <bpmn:subProcess id="${escapeXml(id)}">\n` +
524
+ incomingOutgoing(inc, outg) +
525
+ mi +
526
+ this.renderScope(id) +
527
+ `\n </bpmn:subProcess>`,
528
+ };
529
+ this.nodes.push(node);
530
+ return node;
531
+ }
532
+ /** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node —
533
+ * the same shape whether lifted onto a leaf service task or a sub-process. */
534
+ miCharacteristics(node) {
535
+ const seq = node.sequential ? "true" : "false";
536
+ const attrs = [
537
+ `inputCollection="${escapeXml(`=${node.collection}`)}"`,
538
+ `inputElement="${escapeXml(node.itemVar)}"`,
539
+ ];
540
+ if (node.outputCollection)
541
+ attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
542
+ if (node.outputElement)
543
+ attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
544
+ const completion = node.completionCondition
545
+ ? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
546
+ : "";
547
+ return (` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
548
+ ` <bpmn:extensionElements>\n` +
549
+ ` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
550
+ ` </bpmn:extensionElements>\n` +
551
+ completion +
552
+ ` </bpmn:multiInstanceLoopCharacteristics>\n`);
553
+ }
352
554
  emitList(list, incoming, loop) {
353
555
  let cur = incoming;
354
556
  for (const node of list)
@@ -426,8 +628,63 @@ class Compiler {
426
628
  this.connect(incoming, loop.headId);
427
629
  return [];
428
630
  }
631
+ case "parallel": {
632
+ // A diverging parallel gateway forks a token onto every branch; a
633
+ // converging one joins them (AND-join) into a single continuation.
634
+ const splitId = `Gw_${this.gw++}`;
635
+ this.addParallelGateway(splitId);
636
+ this.connect(incoming, splitId);
637
+ const joinId = `Gw_${this.gw++}`;
638
+ this.addParallelGateway(joinId);
639
+ for (const branch of node.branches) {
640
+ const e = this.newEdge(splitId);
641
+ const branchOut = this.emitList(branch, [e], loop);
642
+ this.connect(branchOut, joinId);
643
+ }
644
+ return [this.newEdge(joinId)];
645
+ }
646
+ case "forEach": {
647
+ const mi = this.miCharacteristics(node);
648
+ const only = node.body.length === 1 ? node.body[0] : undefined;
649
+ // A single service-task body carries the MI characteristics directly; a
650
+ // multi-step body is wrapped in an embedded MI sub-process (its own token
651
+ // scope, with its own start/end).
652
+ if (only && (only.kind === "run" || only.kind === "task")) {
653
+ this.addServiceTask(only, mi);
654
+ this.connect(incoming, only.name);
655
+ return [this.newEdge(only.name)];
656
+ }
657
+ const subId = `Sub_${this.gw++}`;
658
+ this.addSubProcess(subId, mi);
659
+ this.connect(incoming, subId);
660
+ this.scopeStack.push(subId);
661
+ const startId = `${subId}_start`;
662
+ this.addPlainStart(startId);
663
+ const innerOut = this.emitList(node.body, [this.newEdge(startId)], null);
664
+ const endId = `${subId}_end`;
665
+ this.addPlainEnd(endId);
666
+ this.connect(innerOut, endId);
667
+ this.scopeStack.pop();
668
+ return [this.newEdge(subId)];
669
+ }
429
670
  }
430
671
  }
672
+ /** Render every node + sequence flow that belongs to `scope` (undefined = the
673
+ * root process, a sub-process id otherwise). Called for the process body and,
674
+ * recursively, from each sub-process's render closure. */
675
+ renderScope(scope) {
676
+ const incomingOf = (id) => this.live.filter((e) => e.to === id).map((e) => e.id);
677
+ const outgoingOf = (id) => this.live.filter((e) => e.from === id).map((e) => e.id);
678
+ const nodeXml = this.nodes
679
+ .filter((n) => n.scope === scope)
680
+ .map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
681
+ .join("\n");
682
+ const flowXml = this.live
683
+ .filter((e) => e.scope === scope)
684
+ .map((e) => sequenceFlow(e))
685
+ .join("\n");
686
+ return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
687
+ }
431
688
  /** Render the Start event — a plain none start, or a timer start when the
432
689
  * flow declared `startOn(...)`. */
433
690
  renderStart(outg) {
@@ -438,7 +695,7 @@ class Compiler {
438
695
  ? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
439
696
  : t.after !== undefined
440
697
  ? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
441
- : `<bpmn:timeDate>${escapeXml(t.at)}</bpmn:timeDate>`;
698
+ : `<bpmn:timeDate>${escapeXml(requireStartAt(t))}</bpmn:timeDate>`;
442
699
  return (` <bpmn:startEvent id="Start">\n` +
443
700
  ` ${outgoingOnly(outg)}\n` +
444
701
  ` <bpmn:timerEventDefinition>\n` +
@@ -460,11 +717,8 @@ class Compiler {
460
717
  });
461
718
  this.connect(finalDanglers, "End");
462
719
  // A sequence flow needs both ends; drop any edge that never got a target.
463
- const live = this.edges.filter((e) => e.to !== undefined);
464
- const incomingOf = (id) => live.filter((e) => e.to === id).map((e) => e.id);
465
- const outgoingOf = (id) => live.filter((e) => e.from === id).map((e) => e.id);
466
- const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
467
- const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
720
+ this.live = this.edges.filter((e) => e.to !== undefined);
721
+ const bodyXml = this.renderScope(undefined);
468
722
  const messageXml = this.emitMessages();
469
723
  const shapeXml = this.emitShapes();
470
724
  return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
@@ -474,9 +728,7 @@ class Compiler {
474
728
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
475
729
  ` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
476
730
  (shapeXml ? shapeXml + "\n" : "") +
477
- nodeXml +
478
- "\n" +
479
- flowXml +
731
+ bodyXml +
480
732
  "\n" +
481
733
  ` </bpmn:process>\n` +
482
734
  messageXml +
package/dist/envelope.js CHANGED
@@ -29,6 +29,15 @@ const SCALARS = new Set([
29
29
  "boolean",
30
30
  "datetime",
31
31
  ]);
32
+ class EnvelopeDef {
33
+ name;
34
+ fields;
35
+ type;
36
+ constructor(name, fields) {
37
+ this.name = name;
38
+ this.fields = fields;
39
+ }
40
+ }
32
41
  function normaliseField(name, spec) {
33
42
  if (spec === null || (typeof spec !== "string" && typeof spec !== "object")) {
34
43
  throw new Error(`envelope field "${name}": must be a scalar type or a { type, optional?, list? } object`);
@@ -56,7 +65,5 @@ export function envelope(name, fields) {
56
65
  throw new Error(`envelope "${name}" declares no fields`);
57
66
  for (const f of list)
58
67
  assertIdent("envelope field name", f.name);
59
- // `type` is phantom: never read at runtime; the cast keeps the value shape
60
- // minimal while the declared type carries the inference.
61
- return { name, fields: list, type: undefined };
68
+ return new EnvelopeDef(name, list);
62
69
  }
@@ -55,6 +55,17 @@ export function imperativeToBpmn(wf) {
55
55
  `</bpmn:definitions>\n`);
56
56
  }
57
57
  const SUSPEND = Symbol("nanobpm.workflow.suspend");
58
+ class SuspendError extends Error {
59
+ [SUSPEND];
60
+ constructor(key, result) {
61
+ super("nanobpm.workflow suspend");
62
+ this[SUSPEND] = { key, result };
63
+ }
64
+ }
65
+ function assertReplayValue(_value, _name) {
66
+ // The journal value was recorded from the same ordinal/name run call on an
67
+ // earlier pass; TypeScript cannot express that cross-replay invariant.
68
+ }
58
69
  /**
59
70
  * Replay the orchestration function against a journal. Returns `{ done: true }`
60
71
  * if the function ran to completion, or the frontier step (the one un-recorded
@@ -70,12 +81,13 @@ export async function replayOnce(wf, input, journal) {
70
81
  async run(name, fn) {
71
82
  const key = `${++ordinal}:${name}`;
72
83
  if (Object.prototype.hasOwnProperty.call(journal, key)) {
73
- return journal[key]; // replay: return recorded value, no side effect
84
+ const value = journal[key];
85
+ assertReplayValue(value, name);
86
+ return value; // replay: return recorded value, no side effect
74
87
  }
75
- const result = ((await fn()) ?? null); // frontier: the ONE real side effect this turn
76
- const carrier = new Error("nanobpm.workflow suspend");
77
- carrier[SUSPEND] = { key, result };
78
- throw carrier;
88
+ const result = (await fn()) ?? null; // frontier: the ONE real side effect this turn
89
+ assertReplayValue(result, name);
90
+ throw new SuspendError(key, result);
79
91
  },
80
92
  };
81
93
  try {
@@ -83,9 +95,8 @@ export async function replayOnce(wf, input, journal) {
83
95
  return { done: true };
84
96
  }
85
97
  catch (e) {
86
- const s = e[SUSPEND];
87
- if (s)
88
- return { done: false, frontier: s };
98
+ if (e instanceof SuspendError)
99
+ return { done: false, frontier: e[SUSPEND] };
89
100
  throw e; // a genuine error in the orchestration or a handler
90
101
  }
91
102
  }
package/dist/layout.js CHANGED
@@ -16,6 +16,9 @@
16
16
  // stays authoritative. Re-run layout whenever the flow changes rather than
17
17
  // hand-editing the generated diagram.
18
18
  import { declarativeToBpmn } from "./declarative.js";
19
+ function isRecord(v) {
20
+ return typeof v === "object" && v !== null && !Array.isArray(v);
21
+ }
19
22
  /**
20
23
  * Generate BPMN diagram interchange (DI) for a BPMN XML string using bpmn-io's
21
24
  * `bpmn-auto-layout`, returning the same model with an auto-laid-out diagram
@@ -36,7 +39,7 @@ export async function layoutBpmn(bpmnXml) {
36
39
  // genuinely cannot be resolved. A different failure (the package is present
37
40
  // but fails to load — syntax, a broken transitive dep, a runtime throw at
38
41
  // import time) must surface as-is so it isn't masked as a missing dependency.
39
- if (cause?.code !== "ERR_MODULE_NOT_FOUND") {
42
+ if (!isRecord(cause) || cause.code !== "ERR_MODULE_NOT_FOUND") {
40
43
  throw cause;
41
44
  }
42
45
  throw new Error('layoutBpmn requires the optional peer dependency "bpmn-auto-layout". ' +
package/dist/types.d.ts CHANGED
@@ -34,7 +34,9 @@ export interface StepContract {
34
34
  * untyped `JsonObject`. */
35
35
  export type FlowContracts = Record<string, StepContract>;
36
36
  /** Handler for a declarative `run` step: does real work, returns variables. */
37
- export type StepHandler = (job: Job) => Promise<JsonObject | void> | JsonObject | void;
37
+ export type StepHandler<V extends JsonObject = JsonObject, R extends JsonObject = JsonObject> = {
38
+ bivarianceHack(job: Job<V>): Promise<R | void> | R | void;
39
+ }["bivarianceHack"];
38
40
  /** A node in a declarative flow tree. Leaf activities carry optional data
39
41
  * envelopes (lifted to `nano:shape` + `dataEnvelope` in the model); structural
40
42
  * combinators carry nested `FlowNode[]` bodies. */
@@ -72,6 +74,24 @@ export type FlowNode = {
72
74
  kind: "break";
73
75
  } | {
74
76
  kind: "continue";
77
+ } | {
78
+ kind: "parallel";
79
+ branches: FlowNode[][];
80
+ } | {
81
+ kind: "forEach";
82
+ /** FEEL expression evaluating to the list to fan out over. */
83
+ collection: string;
84
+ /** Variable each item is bound to in the child's scope (`inputElement`). */
85
+ itemVar: string;
86
+ body: FlowNode[];
87
+ /** Run children one at a time (sequential MI) instead of all at once. */
88
+ sequential?: boolean;
89
+ /** List variable that each child's `outputElement` is collected into. */
90
+ outputCollection?: string;
91
+ /** FEEL expression producing each child's contribution to `outputCollection`. */
92
+ outputElement?: string;
93
+ /** FEEL boolean that, once true, completes the body early. */
94
+ completionCondition?: string;
75
95
  };
76
96
  /** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
77
97
  * delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
package/dist/worker.js CHANGED
@@ -17,6 +17,9 @@ import { WorkflowClient } from "./client.js";
17
17
  import { replayOnce } from "./imperative.js";
18
18
  import { walkNodes } from "./declarative.js";
19
19
  import { jobType } from "./xml.js";
20
+ function isJsonObject(v) {
21
+ return typeof v === "object" && v !== null && !Array.isArray(v);
22
+ }
20
23
  export class Worker {
21
24
  client;
22
25
  name;
@@ -47,21 +50,25 @@ export class Worker {
47
50
  this.addRoute(wf.orchestrateType, {
48
51
  workflowId: wf.id,
49
52
  handle: async (job) => {
50
- const input = job.variables.input ?? {};
51
- const journal = job.variables.journal ?? {};
53
+ const input = isJsonObject(job.variables.input) ? job.variables.input : {};
54
+ const journal = isJsonObject(job.variables.journal) ? job.variables.journal : {};
52
55
  const step = await replayOnce(wf, input, journal);
53
- if (step.done)
54
- return { variables: { wfDone: true }, step: "__done" };
56
+ if (step.done) {
57
+ const variables = { wfDone: true };
58
+ return { variables, step: "__done" };
59
+ }
55
60
  const next = { ...journal, [step.frontier.key]: step.frontier.result };
56
- return { variables: { journal: next, wfDone: false }, step: step.frontier.key };
61
+ const variables = { journal: next, wfDone: false };
62
+ return { variables, step: step.frontier.key };
57
63
  },
58
64
  });
59
65
  }
60
66
  else {
61
67
  // Walk the flow tree; only `run` steps are hosted locally. `signal`
62
68
  // (message catch) and `task` (external worker) contribute no in-process
63
- // route, and structural combinators (`switch`/`branch`/`loop`) are pure
64
- // routing with no job of their own.
69
+ // route, and structural combinators (`switch`/`branch`/`loop`/`parallel`/
70
+ // `forEach`) are pure routing with no job of their own — but the `run`
71
+ // steps NESTED inside them are hosted, since `walkNodes` recurses.
65
72
  walkNodes(wf.steps, (s) => {
66
73
  if (s.kind !== "run")
67
74
  return;
@@ -75,7 +82,10 @@ export class Worker {
75
82
  }
76
83
  this.addRoute(jobType(wf.id, s.name), {
77
84
  workflowId: wf.id,
78
- handle: async (job) => ({ variables: ((await handler(job)) ?? {}) }),
85
+ handle: async (job) => {
86
+ const variables = (await handler(job)) ?? {};
87
+ return { variables };
88
+ },
79
89
  });
80
90
  });
81
91
  }