@nanobpm/workflow 0.8.1 → 0.9.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.
Files changed (62) hide show
  1. package/README.md +1 -1
  2. package/dist/declarative.d.ts +172 -10
  3. package/dist/declarative.js +213 -355
  4. package/dist/nodes/branch.d.ts +11 -0
  5. package/dist/nodes/branch.js +40 -0
  6. package/dist/nodes/break.d.ts +8 -0
  7. package/dist/nodes/break.js +19 -0
  8. package/dist/nodes/continue.d.ts +8 -0
  9. package/dist/nodes/continue.js +18 -0
  10. package/dist/nodes/forEach.d.ts +24 -0
  11. package/dist/nodes/forEach.js +104 -0
  12. package/dist/nodes/index.d.ts +11 -0
  13. package/dist/nodes/index.js +18 -0
  14. package/dist/nodes/loop.d.ts +9 -0
  15. package/dist/nodes/loop.js +25 -0
  16. package/dist/nodes/parallel.d.ts +9 -0
  17. package/dist/nodes/parallel.js +40 -0
  18. package/dist/nodes/registry.d.ts +33 -0
  19. package/dist/nodes/registry.js +71 -0
  20. package/dist/nodes/run.d.ts +10 -0
  21. package/dist/nodes/run.js +21 -0
  22. package/dist/nodes/signal.d.ts +11 -0
  23. package/dist/nodes/signal.js +21 -0
  24. package/dist/nodes/switch.d.ts +11 -0
  25. package/dist/nodes/switch.js +51 -0
  26. package/dist/nodes/task.d.ts +11 -0
  27. package/dist/nodes/task.js +21 -0
  28. package/dist/nodes/timer.d.ts +9 -0
  29. package/dist/nodes/timer.js +34 -0
  30. package/dist/test-support/diff.d.ts +8 -0
  31. package/dist/test-support/diff.js +70 -0
  32. package/dist/test-support/index.d.ts +8 -0
  33. package/dist/test-support/index.js +49 -0
  34. package/dist/test-support/normalize.d.ts +19 -0
  35. package/dist/test-support/normalize.js +245 -0
  36. package/dist/test-support/parity.d.ts +28 -0
  37. package/dist/test-support/parity.js +61 -0
  38. package/dist/test-support/xml.d.ts +17 -0
  39. package/dist/test-support/xml.js +135 -0
  40. package/dist/types.d.ts +20 -56
  41. package/package.json +14 -6
  42. package/src/declarative.ts +327 -359
  43. package/src/nodes/README.md +123 -0
  44. package/src/nodes/branch.ts +48 -0
  45. package/src/nodes/break.ts +25 -0
  46. package/src/nodes/continue.ts +24 -0
  47. package/src/nodes/forEach.ts +137 -0
  48. package/src/nodes/index.ts +19 -0
  49. package/src/nodes/loop.ts +34 -0
  50. package/src/nodes/parallel.ts +48 -0
  51. package/src/nodes/registry.ts +105 -0
  52. package/src/nodes/run.ts +29 -0
  53. package/src/nodes/signal.ts +29 -0
  54. package/src/nodes/switch.ts +56 -0
  55. package/src/nodes/task.ts +29 -0
  56. package/src/nodes/timer.ts +42 -0
  57. package/src/test-support/diff.ts +69 -0
  58. package/src/test-support/index.ts +53 -0
  59. package/src/test-support/normalize.ts +274 -0
  60. package/src/test-support/parity.ts +85 -0
  61. package/src/test-support/xml.ts +140 -0
  62. package/src/types.ts +20 -30
@@ -36,7 +36,13 @@
36
36
  // Typed data envelopes are LIFTED into the model (nano:shape + dataEnvelope
37
37
  // zeebe:property), so the generated .bpmn is ejectable to model-first with its
38
38
  // typed contracts intact.
39
- import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
39
+ import { assertIdent, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
40
+ import { eachNodeKind, requireNodeKind } from "./nodes/registry.js";
41
+ // Import the generated barrel purely for its registration side effects: every
42
+ // built-in (and slice-added) kind module registers its FlowNode variant, builder
43
+ // method, and walk/emit handlers at import time. MUST run before `makeBuilder`
44
+ // or the `Compiler` dispatch through the registry.
45
+ import "./nodes/index.js";
40
46
  /** Ids the emitter generates for structural nodes / flows / messages. A step
41
47
  * name that collides with one of these would produce a duplicate BPMN id and an
42
48
  * invalid model, so reject them at authoring time. */
@@ -90,200 +96,166 @@ function makeBuilder(id, out, ctx, isRoot = false) {
90
96
  fn(makeBuilder(id, body, { ...ctx, loopDepth: 0 }));
91
97
  return body;
92
98
  };
93
- const b = {
94
- run(name, handler) {
95
- claimName(ctx, id, name);
96
- if (typeof handler !== "function")
97
- throw new Error(`run("${name}") needs a handler function`);
98
- ctx.handlers[name] = handler;
99
- out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
100
- return b;
99
+ const setStartTimer = (t) => {
100
+ if (!isRoot) {
101
+ throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
102
+ }
103
+ if (out.length !== 0)
104
+ throw new Error(`startOn() must be the first statement in flow "${id}"`);
105
+ if (ctx.startTimer)
106
+ throw new Error(`startOn() may be called only once in flow "${id}"`);
107
+ ctx.startTimer = t;
108
+ };
109
+ let builderRef;
110
+ const api = {
111
+ id,
112
+ isRoot,
113
+ out,
114
+ contracts: ctx.contracts,
115
+ handlers: ctx.handlers,
116
+ get loopDepth() {
117
+ return ctx.loopDepth;
101
118
  },
102
- task(name, opts) {
103
- claimName(ctx, id, name);
104
- const override = opts?.jobType;
105
- if (override !== undefined)
106
- assertJobType("task jobType", override);
107
- out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
108
- return b;
119
+ self() {
120
+ if (!builderRef)
121
+ throw new Error("internal: builder method invoked before assembly");
122
+ return builderRef;
109
123
  },
110
- signal(name, opts) {
124
+ child,
125
+ childScoped,
126
+ claim(name) {
111
127
  claimName(ctx, id, name);
112
- if (!opts || !opts.correlationKey)
113
- throw new Error(`signal("${name}") needs { correlationKey }`);
114
- assertIdent("correlationKey", opts.correlationKey);
115
- out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
116
- return b;
117
- },
118
- timer(name, opts) {
119
- claimName(ctx, id, name);
120
- const hasAfter = "after" in opts && typeof opts.after === "string";
121
- const hasAt = "at" in opts && typeof opts.at === "string";
122
- if (hasAfter === hasAt) {
123
- throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
124
- }
125
- if ("after" in opts) {
126
- const after = opts.after.trim();
127
- assertTimerDuration(`timer("${name}") after`, after);
128
- out.push({ kind: "timer", name, after });
129
- }
130
- else {
131
- const at = opts.at.trim();
132
- assertTimerDate(`timer("${name}") at`, at);
133
- out.push({ kind: "timer", name, at });
134
- }
135
- return b;
136
- },
137
- startOn(spec) {
138
- if (!isRoot) {
139
- throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
140
- }
141
- if (out.length !== 0)
142
- throw new Error(`startOn() must be the first statement in flow "${id}"`);
143
- if (ctx.startTimer)
144
- throw new Error(`startOn() may be called only once in flow "${id}"`);
145
- const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
146
- if (setCount !== 1) {
147
- throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
148
- }
149
- if ("cycle" in spec) {
150
- const cycle = spec.cycle.trim();
151
- assertTimerCycle(`startOn cycle`, cycle);
152
- ctx.startTimer = { cycle };
153
- }
154
- else if ("after" in spec) {
155
- const after = spec.after.trim();
156
- assertTimerDuration(`startOn after`, after);
157
- ctx.startTimer = { after };
158
- }
159
- else {
160
- const at = spec.at.trim();
161
- assertTimerDate(`startOn at`, at);
162
- ctx.startTimer = { at };
163
- }
164
- return b;
165
- },
166
- switch(subject, cases) {
167
- if (typeof subject !== "string" || subject.trim() === "") {
168
- throw new Error(`switch() needs a non-empty subject expression`);
169
- }
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)
179
- throw new Error(`switch("${subject}") needs at least one case`);
180
- const def = cases.default ? child(cases.default, false) : undefined;
181
- out.push({ kind: "switch", subject, cases: caseNodes, default: def });
182
- return b;
183
128
  },
184
- branch(condition, arms) {
185
- if (typeof condition !== "string" || condition.trim() === "") {
186
- throw new Error(`branch() needs a non-empty FEEL condition`);
187
- }
188
- if (!arms || typeof arms.then !== "function")
189
- throw new Error(`branch("${condition}") needs a then arm`);
190
- out.push({
191
- kind: "branch",
192
- condition,
193
- then: child(arms.then, false),
194
- else: arms.else ? child(arms.else, false) : undefined,
195
- });
196
- return b;
197
- },
198
- loop(body) {
199
- if (typeof body !== "function")
200
- throw new Error(`loop() needs a body function`);
201
- out.push({ kind: "loop", body: child(body, true) });
202
- return b;
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
- },
273
- break() {
274
- if (ctx.loopDepth === 0)
275
- throw new Error(`break() is only valid inside a loop`);
276
- out.push({ kind: "break" });
277
- return b;
278
- },
279
- continue() {
280
- if (ctx.loopDepth === 0)
281
- throw new Error(`continue() is only valid inside a loop`);
282
- out.push({ kind: "continue" });
283
- return b;
129
+ contractEnvelopes(name) {
130
+ return contractEnvelopes(ctx, name);
284
131
  },
132
+ setStartTimer,
133
+ };
134
+ // `startOn` is not a node KIND — it lifts a timer onto the start event and
135
+ // emits no `FlowNode` — so it is installed centrally. Every actual node kind's
136
+ // builder method is contributed by its own module through the registry.
137
+ // A NULL-PROTOTYPE table: collision detection below is an OWN-property check,
138
+ // and building on `Object.prototype` would make both `in`/`Object.hasOwn`
139
+ // spuriously see inherited names (`toString`, `constructor`, …) as registered
140
+ // methods — so a kind whose builder method is legitimately named `toString`
141
+ // would be rejected as a "duplicate" — and would turn a `__proto__` method
142
+ // name into a prototype-mutating footgun. A null prototype removes both.
143
+ const methods = Object.create(null);
144
+ methods.startOn = startOnMethod(api);
145
+ for (const [kind, handlers] of eachNodeKind()) {
146
+ if (!handlers.build)
147
+ continue;
148
+ const method = handlers.builderMethod ?? kind;
149
+ if (Object.hasOwn(methods, method)) {
150
+ throw new Error(`two flow-node kinds both contribute the builder method "${method}"`);
151
+ }
152
+ methods[method] = handlers.build(api);
153
+ }
154
+ if (!isFlowBuilder(methods)) {
155
+ throw new Error(assemblyFailureMessage(methods));
156
+ }
157
+ // `methods` is now the untyped `FlowBuilder` (object) stored for `api.self()`;
158
+ // a chaining method's precise return type is the interface's, not this value's.
159
+ builderRef = methods;
160
+ if (!isFlowBuilder(methods)) {
161
+ throw new Error(assemblyFailureMessage(methods));
162
+ }
163
+ return methods;
164
+ }
165
+ /** The STABLE built-in authoring surface every assembled `FlowBuilder` must
166
+ * expose: `startOn` (installed centrally by {@link startOnMethod}) plus every
167
+ * core method a wave-0 built-in node-kind module under `src/nodes/`
168
+ * contributes. The runtime guard below fails fast if any of these is missing
169
+ * (e.g. a built-in module was tree-shaken away).
170
+ *
171
+ * `satisfies readonly (keyof FlowBuilder)[]` pins each listed name to a real
172
+ * `FlowBuilder` method — a typo or a renamed built-in is caught at compile
173
+ * time. This is deliberately a ONE-DIRECTIONAL pin: it does NOT assert the
174
+ * list mirrors *every* `FlowBuilder` key. `FlowBuilder<C>` is an OPEN,
175
+ * declaration-merged interface — the whole point of the `src/nodes/` seam is
176
+ * that a feature slice adds a builder method by merging into `FlowBuilder`
177
+ * from its own module with NO central edit (see `src/nodes/README.md`). An
178
+ * exhaustiveness check here would force every such slice to also edit this
179
+ * central list, defeating that seam; a slice's own method is instead installed
180
+ * and exercised through its `registerNodeKind` registration and tests. */
181
+ const BUILTIN_BUILDER_METHODS = [
182
+ "startOn",
183
+ "run",
184
+ "task",
185
+ "signal",
186
+ "timer",
187
+ "switch",
188
+ "branch",
189
+ "loop",
190
+ "parallel",
191
+ "forEach",
192
+ "break",
193
+ "continue",
194
+ ];
195
+ /** A dynamically-assembled builder satisfies `FlowBuilder<C>` once every core
196
+ * built-in method is installed. The check is a runtime type guard (not an `as`
197
+ * cast): each own-property must be a function, AND every built-in method in
198
+ * {@link BUILTIN_BUILDER_METHODS} must be present — so a missing or
199
+ * tree-shaken node-kind registration fails fast here at builder assembly (a
200
+ * clear "missing registered methods" error) instead of surfacing later as an
201
+ * opaque `w.run is not a function` at authoring time. */
202
+ export function isFlowBuilder(x) {
203
+ return missingBuilderMethods(x).length === 0 && nonFunctionOwnProperties(x).length === 0;
204
+ }
205
+ /** The subset of {@link BUILTIN_BUILDER_METHODS} not installed as a function on
206
+ * `x`. Single source of truth for both the {@link isFlowBuilder} guard and the
207
+ * assembly-failure diagnostic below, so the two never drift. */
208
+ function missingBuilderMethods(x) {
209
+ return BUILTIN_BUILDER_METHODS.filter((method) => typeof Reflect.get(x, method) !== "function");
210
+ }
211
+ /** The own-property names of `x` whose value is not a function — the second
212
+ * half of the {@link isFlowBuilder} invariant, factored out so the
213
+ * assembly-failure diagnostic can name the offending property without drifting
214
+ * from the guard. */
215
+ function nonFunctionOwnProperties(x) {
216
+ return Object.getOwnPropertyNames(x).filter((k) => typeof Reflect.get(x, k) !== "function");
217
+ }
218
+ /** Diagnostic for a failed builder assembly. Names the missing built-in
219
+ * method(s) when a registration was tree-shaken/omitted so the failure is
220
+ * actionable; otherwise, when every built-in is present but some own-property
221
+ * isn't a function, names that offending property instead. Exported (like
222
+ * {@link isFlowBuilder}) so the assembly invariant is unit-testable. */
223
+ export function assemblyFailureMessage(x) {
224
+ const missing = missingBuilderMethods(x);
225
+ if (missing.length > 0) {
226
+ return `internal: assembled FlowBuilder is missing registered methods: ${missing.join(", ")}`;
227
+ }
228
+ const nonFunctions = nonFunctionOwnProperties(x);
229
+ if (nonFunctions.length > 0) {
230
+ const label = nonFunctions.length === 1 ? "property" : "properties";
231
+ return `internal: assembled FlowBuilder has non-function own-${label}: ${nonFunctions.join(", ")}`;
232
+ }
233
+ return "internal: assembled FlowBuilder failed its assembly invariant";
234
+ }
235
+ /** The central `startOn` builder method (not a node kind). */
236
+ function startOnMethod(api) {
237
+ return (spec) => {
238
+ const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
239
+ if (setCount !== 1) {
240
+ throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${api.id}"`);
241
+ }
242
+ if ("cycle" in spec) {
243
+ const cycle = spec.cycle.trim();
244
+ assertTimerCycle(`startOn cycle`, cycle);
245
+ api.setStartTimer({ cycle });
246
+ }
247
+ else if ("after" in spec) {
248
+ const after = spec.after.trim();
249
+ assertTimerDuration(`startOn after`, after);
250
+ api.setStartTimer({ after });
251
+ }
252
+ else {
253
+ const at = spec.at.trim();
254
+ assertTimerDate(`startOn at`, at);
255
+ api.setStartTimer({ at });
256
+ }
257
+ return api.self();
285
258
  };
286
- return b;
287
259
  }
288
260
  export function defineFlow(id, second, third) {
289
261
  assertIdent("workflow id", id);
@@ -315,36 +287,17 @@ export function defineFlow(id, second, third) {
315
287
  return flow;
316
288
  }
317
289
  // --- Tree walkers ------------------------------------------------------------
318
- /** Depth-first visit of every node in a flow tree (structural combinators
319
- * recurse into their bodies). */
290
+ /** Depth-first visit of every node in a flow tree. Recursion into a kind's
291
+ * nested bodies is DISPATCHED through the registry (each kind's `walk` handler),
292
+ * so a slice's structural combinator recurses without editing a central switch.
293
+ * Fails fast (via `requireNodeKind`) on an unregistered kind — matching the
294
+ * emitter — so a tree-shaken/missing registration surfaces as a clear error
295
+ * rather than silently skipping recursion into that node's nested bodies. A
296
+ * registered leaf kind with no `walk` handler is fine (nothing to recurse). */
320
297
  export function walkNodes(nodes, visit) {
321
298
  for (const n of nodes) {
322
299
  visit(n);
323
- switch (n.kind) {
324
- case "switch":
325
- for (const c of n.cases)
326
- walkNodes(c.body, visit);
327
- if (n.default)
328
- walkNodes(n.default, visit);
329
- break;
330
- case "branch":
331
- walkNodes(n.then, visit);
332
- if (n.else)
333
- walkNodes(n.else, visit);
334
- break;
335
- case "loop":
336
- walkNodes(n.body, visit);
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;
345
- default:
346
- break;
347
- }
300
+ requireNodeKind(n.kind).walk?.(n, (body) => walkNodes(body, visit));
348
301
  }
349
302
  }
350
303
  /** The job types of a flow's external `task` steps (anywhere in the tree) — the
@@ -390,8 +343,36 @@ class Compiler {
390
343
  live = [];
391
344
  seq = 0;
392
345
  gw = 0;
346
+ /** The emit surface handed to each kind's registered `emit` handler. */
347
+ emitApi;
393
348
  constructor(flow) {
394
349
  this.flow = flow;
350
+ this.emitApi = {
351
+ flowId: flow.id,
352
+ newEdge: (from, opts) => this.newEdge(from, opts),
353
+ connect: (incoming, toId) => this.connect(incoming, toId),
354
+ recordEnvelope: (env) => this.recordEnvelope(env),
355
+ emitList: (list, incoming, loop) => this.emitList(list, incoming, loop),
356
+ addNode: (node) => {
357
+ this.nodes.push({ ...node, scope: node.scope ?? this.currentScope() });
358
+ },
359
+ addServiceTask: (node, mi) => this.addServiceTask(node, mi),
360
+ addCatchEvent: (node) => this.addCatchEvent(node),
361
+ addTimerCatchEvent: (node) => this.addTimerCatchEvent(node),
362
+ addGateway: (id, name) => this.addGateway(id, name),
363
+ addParallelGateway: (id) => this.addParallelGateway(id),
364
+ addSubProcess: (id, mi) => this.addSubProcess(id, mi),
365
+ addPlainStart: (id) => this.addPlainStart(id),
366
+ addPlainEnd: (id) => this.addPlainEnd(id),
367
+ nextGw: () => this.gw++,
368
+ currentScope: () => this.currentScope(),
369
+ pushScope: (id) => {
370
+ this.scopeStack.push(id);
371
+ },
372
+ popScope: () => {
373
+ this.scopeStack.pop();
374
+ },
375
+ };
395
376
  }
396
377
  currentScope() {
397
378
  return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
@@ -529,145 +510,16 @@ class Compiler {
529
510
  this.nodes.push(node);
530
511
  return node;
531
512
  }
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
- }
554
513
  emitList(list, incoming, loop) {
555
514
  let cur = incoming;
556
515
  for (const node of list)
557
516
  cur = this.emitNode(node, cur, loop);
558
517
  return cur;
559
518
  }
519
+ /** Emit one node by DISPATCHING to its registered `emit` handler — no central
520
+ * switch on `node.kind`. A slice's kind is emitted by its own module. */
560
521
  emitNode(node, incoming, loop) {
561
- switch (node.kind) {
562
- case "run":
563
- case "task": {
564
- this.addServiceTask(node);
565
- this.connect(incoming, node.name);
566
- return [this.newEdge(node.name)];
567
- }
568
- case "signal": {
569
- this.addCatchEvent(node);
570
- this.connect(incoming, node.name);
571
- this.recordEnvelope(node.payload);
572
- return [this.newEdge(node.name)];
573
- }
574
- case "timer": {
575
- this.addTimerCatchEvent(node);
576
- this.connect(incoming, node.name);
577
- return [this.newEdge(node.name)];
578
- }
579
- case "switch": {
580
- const id = `Gw_${this.gw++}`;
581
- const gw = this.addGateway(id, node.subject);
582
- this.connect(incoming, id);
583
- const out = [];
584
- for (const c of node.cases) {
585
- const e = this.newEdge(id, { condition: feelEquals(node.subject, c.value), name: c.value });
586
- out.push(...this.emitList(c.body, [e], loop));
587
- }
588
- // The default (or a synthesised fall-through) is the unconditional edge.
589
- const de = this.newEdge(id, { name: "default" });
590
- gw.defaultFlow = de.id;
591
- out.push(...this.emitList(node.default ?? [], [de], loop));
592
- return out;
593
- }
594
- case "branch": {
595
- const id = `Gw_${this.gw++}`;
596
- const gw = this.addGateway(id);
597
- this.connect(incoming, id);
598
- const te = this.newEdge(id, { condition: feel(node.condition), name: "then" });
599
- const out = this.emitList(node.then, [te], loop);
600
- const ee = this.newEdge(id, { name: "else" });
601
- gw.defaultFlow = ee.id;
602
- out.push(...this.emitList(node.else ?? [], [ee], loop));
603
- return out;
604
- }
605
- case "loop": {
606
- const headId = `Loop_${this.gw++}`;
607
- this.addGateway(headId);
608
- this.connect(incoming, headId);
609
- const ctx = { headId, breaks: [] };
610
- const headOut = this.newEdge(headId);
611
- const bodyOut = this.emitList(node.body, [headOut], ctx);
612
- // Normal fall-through of the body loops back to the head (continue).
613
- this.connect(bodyOut, headId);
614
- // The loop exits only via break edges.
615
- return ctx.breaks;
616
- }
617
- case "break": {
618
- if (!loop)
619
- throw new Error(`break outside a loop in flow "${this.flow.id}"`);
620
- // The incoming edges (from the previous node) become the loop's exit
621
- // danglers; this path does not fall through.
622
- loop.breaks.push(...incoming);
623
- return [];
624
- }
625
- case "continue": {
626
- if (!loop)
627
- throw new Error(`continue outside a loop in flow "${this.flow.id}"`);
628
- this.connect(incoming, loop.headId);
629
- return [];
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
- }
670
- }
522
+ return requireNodeKind(node.kind).emit(node, incoming, loop, this.emitApi);
671
523
  }
672
524
  /** Render every node + sequence flow that belongs to `scope` (undefined = the
673
525
  * root process, a sub-process id otherwise). Called for the process body and,
@@ -780,16 +632,22 @@ export function declarativeToBpmn(flow) {
780
632
  }
781
633
  // --- small XML / FEEL helpers ------------------------------------------------
782
634
  const envelopeProp = (dir, value) => ` <zeebe:property name="io.nanobpm.dataEnvelope.${dir}" value="${escapeXml(value)}" />`;
783
- /** Wrap a raw FEEL expression as a Zeebe condition body (leading `=`). */
784
- const feel = (expr) => `=${expr}`;
785
- /** A FEEL equality test `subject = "value"`, with the value as a FEEL string. */
786
- const feelEquals = (subject, value) => `=${subject} = "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
787
- function incomingOutgoing(inc, outg) {
635
+ /** Wrap a raw FEEL expression as a Zeebe condition body (leading `=`). Exported
636
+ * for slice-added combinators that emit conditional sequence flows. */
637
+ export const feel = (expr) => `=${expr}`;
638
+ /** A FEEL equality test `subject = "value"`, with the value as a FEEL string.
639
+ * Exported for slice-added combinators that emit an XOR gateway. */
640
+ export const feelEquals = (subject, value) => `=${subject} = "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
641
+ /** Render the `<bpmn:incoming>`/`<bpmn:outgoing>` refs of a flow node. Exported
642
+ * for slice `RenderNode.render` closures. */
643
+ export function incomingOutgoing(inc, outg) {
788
644
  return (inc.map((f) => ` <bpmn:incoming>${f}</bpmn:incoming>\n`).join("") +
789
645
  outg.map((f) => ` <bpmn:outgoing>${f}</bpmn:outgoing>\n`).join(""));
790
646
  }
791
- const incomingOnly = (inc) => inc.map((f) => `<bpmn:incoming>${f}</bpmn:incoming>`).join("");
792
- const outgoingOnly = (outg) => outg.map((f) => `<bpmn:outgoing>${f}</bpmn:outgoing>`).join("");
647
+ /** Render only the `<bpmn:incoming>` refs (for end-like events). Exported. */
648
+ export const incomingOnly = (inc) => inc.map((f) => `<bpmn:incoming>${f}</bpmn:incoming>`).join("");
649
+ /** Render only the `<bpmn:outgoing>` refs (for start-like events). Exported. */
650
+ export const outgoingOnly = (outg) => outg.map((f) => `<bpmn:outgoing>${f}</bpmn:outgoing>`).join("");
793
651
  function sequenceFlow(e) {
794
652
  const nm = e.name ? ` name="${escapeXml(e.name)}"` : "";
795
653
  if (e.condition) {
@@ -0,0 +1,11 @@
1
+ declare module "../types.js" {
2
+ interface FlowNodeRegistry {
3
+ branch: {
4
+ kind: "branch";
5
+ condition: string;
6
+ then: FlowNode[];
7
+ else?: FlowNode[];
8
+ };
9
+ }
10
+ }
11
+ export {};