@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
@@ -45,26 +45,31 @@ import type {
45
45
  NodeEnvelopes,
46
46
  StepContract,
47
47
  StepHandler,
48
- SwitchCase,
49
48
  TimerStart,
50
49
  } from "./types.js";
51
50
  import type { Envelope, EnvelopeField } from "./envelope.js";
52
- import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
51
+ import { assertIdent, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
52
+ import { eachNodeKind, requireNodeKind } from "./nodes/registry.js";
53
+ // Import the generated barrel purely for its registration side effects: every
54
+ // built-in (and slice-added) kind module registers its FlowNode variant, builder
55
+ // method, and walk/emit handlers at import time. MUST run before `makeBuilder`
56
+ // or the `Compiler` dispatch through the registry.
57
+ import "./nodes/index.js";
53
58
 
54
59
  // --- Authoring surface -------------------------------------------------------
55
60
 
56
61
  /** The TS payload type of a contract's input envelope (untyped fallback). */
57
- type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] & JsonObject : JsonObject;
62
+ export type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] & JsonObject : JsonObject;
58
63
  /** The TS payload type of a contract's output envelope (untyped fallback). */
59
- type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] & JsonObject : JsonObject;
64
+ export type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] & JsonObject : JsonObject;
60
65
  /** The input payload type of step `K` under contracts `C`. */
61
- type VarsOf<C, K extends string> = K extends keyof C ? InPayload<C[K]> : JsonObject;
66
+ export type VarsOf<C, K extends string> = K extends keyof C ? InPayload<C[K]> : JsonObject;
62
67
  /** The output payload type of step `K` under contracts `C`. */
63
- type ResultOf<C, K extends string> = K extends keyof C ? OutPayload<C[K]> : JsonObject;
68
+ export type ResultOf<C, K extends string> = K extends keyof C ? OutPayload<C[K]> : JsonObject;
64
69
 
65
70
  /** A typed handler for a `run` step: its job variables and result are resolved
66
71
  * from the flow contracts by the step name. */
67
- type TypedHandler<V extends JsonObject, R extends JsonObject> = (job: {
72
+ export type TypedHandler<V extends JsonObject, R extends JsonObject> = (job: {
68
73
  jobKey: string;
69
74
  processInstanceKey: string;
70
75
  elementId: string;
@@ -73,7 +78,7 @@ type TypedHandler<V extends JsonObject, R extends JsonObject> = (job: {
73
78
  }) => Promise<R | void> | R | void;
74
79
 
75
80
  /** A block: the callback that populates a nested body (a case, arm, or loop). */
76
- type Block<C extends object> = (b: FlowBuilder<C>) => void;
81
+ export type Block<C extends object = object> = (b: FlowBuilder<C>) => void;
77
82
 
78
83
  /** The typed builder for a flow body.
79
84
  *
@@ -246,6 +251,47 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
246
251
  return { in: c.in, out: c.out };
247
252
  }
248
253
 
254
+ /** A builder method as stored on the dynamically-assembled builder. Its precise,
255
+ * generic user-facing signature comes from the `FlowBuilder<C>` interface (a
256
+ * built-in method declared centrally, a slice method declaration-merged from
257
+ * its module); this loose shape is only what the registry stores/installs. */
258
+ export type BuilderMethod = (...args: never[]) => unknown;
259
+
260
+ /** The per-body authoring context a kind module's `build` factory closes over to
261
+ * implement its builder method. Everything a method needs to append its node —
262
+ * claim its step name, resolve contract envelopes, recurse into nested bodies,
263
+ * and return the builder for chaining — without reaching into `declarative.ts`
264
+ * internals or the shared `FlowNode` union. */
265
+ export interface BuildApi {
266
+ /** The flow id (for error messages). */
267
+ readonly id: string;
268
+ /** Whether this is the root (top-level) builder — e.g. `startOn` is root-only. */
269
+ readonly isRoot: boolean;
270
+ /** The node list this builder appends to (`api.out.push(node)`). */
271
+ readonly out: FlowNode[];
272
+ /** The flow's typed I/O contracts, keyed by step name. */
273
+ readonly contracts: FlowContracts;
274
+ /** The flow's handler registry — a `run`-like kind registers its handler here. */
275
+ readonly handlers: Record<string, StepHandler>;
276
+ /** The current loop-nesting depth (0 outside any loop) — `break`/`continue`
277
+ * guard on it. */
278
+ readonly loopDepth: number;
279
+ /** The builder itself, for chaining (`return api.self()`). */
280
+ self(): FlowBuilder;
281
+ /** Populate a nested body block, returning its node list. `inLoop` deepens the
282
+ * loop nesting (a `loop` body); use it for bodies that can `break`/`continue`. */
283
+ child(fn: (b: FlowBuilder) => void, inLoop: boolean): FlowNode[];
284
+ /** Populate a nested body that runs in its OWN token scope (an embedded
285
+ * sub-process): `break`/`continue` cannot cross the boundary (loop depth 0). */
286
+ childScoped(fn: (b: FlowBuilder) => void): FlowNode[];
287
+ /** Claim a step name (rejects duplicates / reserved ids), as every leaf does. */
288
+ claim(name: string): void;
289
+ /** Resolve the declared envelopes for a step name from the contracts. */
290
+ contractEnvelopes(name: string): { in?: Envelope; out?: Envelope } | undefined;
291
+ /** Lift a durable timer start onto the flow (used only by `startOn`). */
292
+ setStartTimer(t: TimerStart): void;
293
+ }
294
+
249
295
  /** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
250
296
  * `ctx` (contracts, handler registry, name set, loop nesting). Structural
251
297
  * combinators recurse with a fresh `out` array for each nested body. */
@@ -255,209 +301,184 @@ function makeBuilder<C extends object>(
255
301
  ctx: BuilderCtx,
256
302
  isRoot = false,
257
303
  ): FlowBuilder<C> {
258
- const child = (fn: Block<C>, inLoop: boolean): FlowNode[] => {
304
+ const child = (fn: (b: FlowBuilder) => void, inLoop: boolean): FlowNode[] => {
259
305
  const body: FlowNode[] = [];
260
306
  const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
261
- fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: depth }));
307
+ fn(makeBuilder(id, body, { ...ctx, loopDepth: depth }));
262
308
  return body;
263
309
  };
264
310
  // A body that runs in its OWN token scope (an embedded sub-process): the
265
311
  // enclosing loop is not reachable across the scope boundary, so `break`/
266
312
  // `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
267
313
  // INSIDE the body still works — it creates its own scope.
268
- const childScoped = (fn: Block<C>): FlowNode[] => {
314
+ const childScoped = (fn: (b: FlowBuilder) => void): FlowNode[] => {
269
315
  const body: FlowNode[] = [];
270
- fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: 0 }));
316
+ fn(makeBuilder(id, body, { ...ctx, loopDepth: 0 }));
271
317
  return body;
272
318
  };
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> {
278
- claimName(ctx, id, name);
279
- if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
280
- ctx.handlers[name] = handler;
281
- out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
282
- return b;
283
- },
284
- task(name: string, opts?: { jobType?: string }): FlowBuilder<C> {
285
- claimName(ctx, id, name);
286
- const override = opts?.jobType;
287
- if (override !== undefined) assertJobType("task jobType", override);
288
- out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
289
- return b;
319
+ const setStartTimer = (t: TimerStart): void => {
320
+ if (!isRoot) {
321
+ throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
322
+ }
323
+ if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
324
+ if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
325
+ ctx.startTimer = t;
326
+ };
327
+
328
+ let builderRef: FlowBuilder | undefined;
329
+ const api: BuildApi = {
330
+ id,
331
+ isRoot,
332
+ out,
333
+ contracts: ctx.contracts,
334
+ handlers: ctx.handlers,
335
+ get loopDepth() {
336
+ return ctx.loopDepth;
290
337
  },
291
- signal(name: string, opts: { correlationKey: string }): FlowBuilder<C> {
292
- claimName(ctx, id, name);
293
- if (!opts || !opts.correlationKey) throw new Error(`signal("${name}") needs { correlationKey }`);
294
- assertIdent("correlationKey", opts.correlationKey);
295
- out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
296
- return b;
338
+ self() {
339
+ if (!builderRef) throw new Error("internal: builder method invoked before assembly");
340
+ return builderRef;
297
341
  },
298
- timer(name: string, opts: { after: string } | { at: string }): FlowBuilder<C> {
342
+ child,
343
+ childScoped,
344
+ claim(name) {
299
345
  claimName(ctx, id, name);
300
- const hasAfter = "after" in opts && typeof opts.after === "string";
301
- const hasAt = "at" in opts && typeof opts.at === "string";
302
- if (hasAfter === hasAt) {
303
- throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
304
- }
305
- if ("after" in opts) {
306
- const after = opts.after.trim();
307
- assertTimerDuration(`timer("${name}") after`, after);
308
- out.push({ kind: "timer", name, after });
309
- } else {
310
- const at = opts.at.trim();
311
- assertTimerDate(`timer("${name}") at`, at);
312
- out.push({ kind: "timer", name, at });
313
- }
314
- return b;
315
- },
316
- startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C> {
317
- if (!isRoot) {
318
- throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
319
- }
320
- if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
321
- if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
322
- const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
323
- if (setCount !== 1) {
324
- throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
325
- }
326
- if ("cycle" in spec) {
327
- const cycle = spec.cycle.trim();
328
- assertTimerCycle(`startOn cycle`, cycle);
329
- ctx.startTimer = { cycle };
330
- } else if ("after" in spec) {
331
- const after = spec.after.trim();
332
- assertTimerDuration(`startOn after`, after);
333
- ctx.startTimer = { after };
334
- } else {
335
- const at = spec.at.trim();
336
- assertTimerDate(`startOn at`, at);
337
- ctx.startTimer = { at };
338
- }
339
- return b;
340
- },
341
- switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
342
- if (typeof subject !== "string" || subject.trim() === "") {
343
- throw new Error(`switch() needs a non-empty subject expression`);
344
- }
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`);
352
- const def = cases.default ? child(cases.default, false) : undefined;
353
- out.push({ kind: "switch", subject, cases: caseNodes, default: def });
354
- return b;
355
- },
356
- branch(condition: string, arms: { then: Block<C>; else?: Block<C> }): FlowBuilder<C> {
357
- if (typeof condition !== "string" || condition.trim() === "") {
358
- throw new Error(`branch() needs a non-empty FEEL condition`);
359
- }
360
- if (!arms || typeof arms.then !== "function") throw new Error(`branch("${condition}") needs a then arm`);
361
- out.push({
362
- kind: "branch",
363
- condition,
364
- then: child(arms.then, false),
365
- else: arms.else ? child(arms.else, false) : undefined,
366
- });
367
- return b;
368
- },
369
- loop(body: Block<C>): FlowBuilder<C> {
370
- if (typeof body !== "function") throw new Error(`loop() needs a body function`);
371
- out.push({ kind: "loop", body: child(body, true) });
372
- return b;
373
346
  },
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;
448
- },
449
- break(): FlowBuilder<C> {
450
- if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
451
- out.push({ kind: "break" });
452
- return b;
453
- },
454
- continue(): FlowBuilder<C> {
455
- if (ctx.loopDepth === 0) throw new Error(`continue() is only valid inside a loop`);
456
- out.push({ kind: "continue" });
457
- return b;
347
+ contractEnvelopes(name) {
348
+ return contractEnvelopes(ctx, name);
458
349
  },
350
+ setStartTimer,
351
+ };
352
+
353
+ // `startOn` is not a node KIND — it lifts a timer onto the start event and
354
+ // emits no `FlowNode` — so it is installed centrally. Every actual node kind's
355
+ // builder method is contributed by its own module through the registry.
356
+ // A NULL-PROTOTYPE table: collision detection below is an OWN-property check,
357
+ // and building on `Object.prototype` would make both `in`/`Object.hasOwn`
358
+ // spuriously see inherited names (`toString`, `constructor`, …) as registered
359
+ // methods — so a kind whose builder method is legitimately named `toString`
360
+ // would be rejected as a "duplicate" — and would turn a `__proto__` method
361
+ // name into a prototype-mutating footgun. A null prototype removes both.
362
+ const methods: Record<string, BuilderMethod> = Object.create(null);
363
+ methods.startOn = startOnMethod(api);
364
+ for (const [kind, handlers] of eachNodeKind()) {
365
+ if (!handlers.build) continue;
366
+ const method = handlers.builderMethod ?? kind;
367
+ if (Object.hasOwn(methods, method)) {
368
+ throw new Error(`two flow-node kinds both contribute the builder method "${method}"`);
369
+ }
370
+ methods[method] = handlers.build(api);
371
+ }
372
+
373
+ if (!isFlowBuilder(methods)) {
374
+ throw new Error(assemblyFailureMessage(methods));
375
+ }
376
+ // `methods` is now the untyped `FlowBuilder` (object) stored for `api.self()`;
377
+ // a chaining method's precise return type is the interface's, not this value's.
378
+ builderRef = methods;
379
+ if (!isFlowBuilder<C>(methods)) {
380
+ throw new Error(assemblyFailureMessage(methods));
381
+ }
382
+ return methods;
383
+ }
384
+
385
+ /** The STABLE built-in authoring surface every assembled `FlowBuilder` must
386
+ * expose: `startOn` (installed centrally by {@link startOnMethod}) plus every
387
+ * core method a wave-0 built-in node-kind module under `src/nodes/`
388
+ * contributes. The runtime guard below fails fast if any of these is missing
389
+ * (e.g. a built-in module was tree-shaken away).
390
+ *
391
+ * `satisfies readonly (keyof FlowBuilder)[]` pins each listed name to a real
392
+ * `FlowBuilder` method — a typo or a renamed built-in is caught at compile
393
+ * time. This is deliberately a ONE-DIRECTIONAL pin: it does NOT assert the
394
+ * list mirrors *every* `FlowBuilder` key. `FlowBuilder<C>` is an OPEN,
395
+ * declaration-merged interface — the whole point of the `src/nodes/` seam is
396
+ * that a feature slice adds a builder method by merging into `FlowBuilder`
397
+ * from its own module with NO central edit (see `src/nodes/README.md`). An
398
+ * exhaustiveness check here would force every such slice to also edit this
399
+ * central list, defeating that seam; a slice's own method is instead installed
400
+ * and exercised through its `registerNodeKind` registration and tests. */
401
+ const BUILTIN_BUILDER_METHODS = [
402
+ "startOn",
403
+ "run",
404
+ "task",
405
+ "signal",
406
+ "timer",
407
+ "switch",
408
+ "branch",
409
+ "loop",
410
+ "parallel",
411
+ "forEach",
412
+ "break",
413
+ "continue",
414
+ ] as const satisfies readonly (keyof FlowBuilder)[];
415
+
416
+ /** A dynamically-assembled builder satisfies `FlowBuilder<C>` once every core
417
+ * built-in method is installed. The check is a runtime type guard (not an `as`
418
+ * cast): each own-property must be a function, AND every built-in method in
419
+ * {@link BUILTIN_BUILDER_METHODS} must be present — so a missing or
420
+ * tree-shaken node-kind registration fails fast here at builder assembly (a
421
+ * clear "missing registered methods" error) instead of surfacing later as an
422
+ * opaque `w.run is not a function` at authoring time. */
423
+ export function isFlowBuilder<C extends object = object>(x: object): x is FlowBuilder<C> {
424
+ return missingBuilderMethods(x).length === 0 && nonFunctionOwnProperties(x).length === 0;
425
+ }
426
+
427
+ /** The subset of {@link BUILTIN_BUILDER_METHODS} not installed as a function on
428
+ * `x`. Single source of truth for both the {@link isFlowBuilder} guard and the
429
+ * assembly-failure diagnostic below, so the two never drift. */
430
+ function missingBuilderMethods(x: object): readonly string[] {
431
+ return BUILTIN_BUILDER_METHODS.filter((method) => typeof Reflect.get(x, method) !== "function");
432
+ }
433
+
434
+ /** The own-property names of `x` whose value is not a function — the second
435
+ * half of the {@link isFlowBuilder} invariant, factored out so the
436
+ * assembly-failure diagnostic can name the offending property without drifting
437
+ * from the guard. */
438
+ function nonFunctionOwnProperties(x: object): readonly string[] {
439
+ return Object.getOwnPropertyNames(x).filter((k) => typeof Reflect.get(x, k) !== "function");
440
+ }
441
+
442
+ /** Diagnostic for a failed builder assembly. Names the missing built-in
443
+ * method(s) when a registration was tree-shaken/omitted so the failure is
444
+ * actionable; otherwise, when every built-in is present but some own-property
445
+ * isn't a function, names that offending property instead. Exported (like
446
+ * {@link isFlowBuilder}) so the assembly invariant is unit-testable. */
447
+ export function assemblyFailureMessage(x: object): string {
448
+ const missing = missingBuilderMethods(x);
449
+ if (missing.length > 0) {
450
+ return `internal: assembled FlowBuilder is missing registered methods: ${missing.join(", ")}`;
451
+ }
452
+ const nonFunctions = nonFunctionOwnProperties(x);
453
+ if (nonFunctions.length > 0) {
454
+ const label = nonFunctions.length === 1 ? "property" : "properties";
455
+ return `internal: assembled FlowBuilder has non-function own-${label}: ${nonFunctions.join(", ")}`;
456
+ }
457
+ return "internal: assembled FlowBuilder failed its assembly invariant";
458
+ }
459
+
460
+ /** The central `startOn` builder method (not a node kind). */
461
+ function startOnMethod(api: BuildApi): BuilderMethod {
462
+ return (spec: { cycle: string } | { after: string } | { at: string }) => {
463
+ const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
464
+ if (setCount !== 1) {
465
+ throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${api.id}"`);
466
+ }
467
+ if ("cycle" in spec) {
468
+ const cycle = spec.cycle.trim();
469
+ assertTimerCycle(`startOn cycle`, cycle);
470
+ api.setStartTimer({ cycle });
471
+ } else if ("after" in spec) {
472
+ const after = spec.after.trim();
473
+ assertTimerDuration(`startOn after`, after);
474
+ api.setStartTimer({ after });
475
+ } else {
476
+ const at = spec.at.trim();
477
+ assertTimerDate(`startOn at`, at);
478
+ api.setStartTimer({ at });
479
+ }
480
+ return api.self();
459
481
  };
460
- return b;
461
482
  }
462
483
 
463
484
  /**
@@ -504,32 +525,17 @@ export function defineFlow(
504
525
 
505
526
  // --- Tree walkers ------------------------------------------------------------
506
527
 
507
- /** Depth-first visit of every node in a flow tree (structural combinators
508
- * recurse into their bodies). */
528
+ /** Depth-first visit of every node in a flow tree. Recursion into a kind's
529
+ * nested bodies is DISPATCHED through the registry (each kind's `walk` handler),
530
+ * so a slice's structural combinator recurses without editing a central switch.
531
+ * Fails fast (via `requireNodeKind`) on an unregistered kind — matching the
532
+ * emitter — so a tree-shaken/missing registration surfaces as a clear error
533
+ * rather than silently skipping recursion into that node's nested bodies. A
534
+ * registered leaf kind with no `walk` handler is fine (nothing to recurse). */
509
535
  export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void {
510
536
  for (const n of nodes) {
511
537
  visit(n);
512
- switch (n.kind) {
513
- case "switch":
514
- for (const c of n.cases) walkNodes(c.body, visit);
515
- if (n.default) walkNodes(n.default, visit);
516
- break;
517
- case "branch":
518
- walkNodes(n.then, visit);
519
- if (n.else) walkNodes(n.else, visit);
520
- break;
521
- case "loop":
522
- walkNodes(n.body, visit);
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;
530
- default:
531
- break;
532
- }
538
+ requireNodeKind(n.kind).walk?.(n, (body) => walkNodes(body, visit));
533
539
  }
534
540
  }
535
541
 
@@ -563,7 +569,10 @@ function requireStartAt(timer: TimerStart): string {
563
569
 
564
570
  // --- Model emitter (two-phase graph compiler) --------------------------------
565
571
 
566
- interface RenderNode {
572
+ /** A renderable BPMN element the emitter has placed. A kind's `emit` handler
573
+ * adds one (or more) via `api.addNode(...)` (or a typed helper like
574
+ * `api.addServiceTask`). */
575
+ export interface RenderNode {
567
576
  id: string;
568
577
  render(incoming: string[], outgoing: string[]): string;
569
578
  /** For exclusive gateways: the flow id of the unconditional default edge. */
@@ -573,7 +582,9 @@ interface RenderNode {
573
582
  scope?: string;
574
583
  }
575
584
 
576
- interface Edge {
585
+ /** A BPMN sequence flow being wired. A kind's `emit` handler creates danglers
586
+ * with `api.newEdge(from)` and resolves them with `api.connect(edges, toId)`. */
587
+ export interface Edge {
577
588
  id: string;
578
589
  from: string;
579
590
  to?: string;
@@ -584,11 +595,59 @@ interface Edge {
584
595
  scope?: string;
585
596
  }
586
597
 
587
- interface LoopCtx {
598
+ /** The enclosing-loop context threaded through `emit`: `break`/`continue` route
599
+ * to `headId`, and `break` danglers collect in `breaks`. `null` outside a loop. */
600
+ export interface LoopCtx {
588
601
  headId: string;
589
602
  breaks: Edge[];
590
603
  }
591
604
 
605
+ /** The emitter primitives a kind's `emit` handler uses to place its BPMN nodes
606
+ * and wire its sequence flows — WITHOUT reaching into `Compiler` internals or a
607
+ * central emit switch. Built-in kinds and slice-added kinds emit through this
608
+ * same surface. Node-render string helpers (`incomingOutgoing`, `escapeXml`, …)
609
+ * are exported alongside for building custom `RenderNode.render` closures. */
610
+ export interface EmitApi {
611
+ /** The flow id (for error messages). */
612
+ readonly flowId: string;
613
+ /** Create a new (dangling) sequence flow from `from`; resolve its target with
614
+ * `connect`. */
615
+ newEdge(from: string, opts?: { condition?: string; name?: string }): Edge;
616
+ /** Point every one of `incoming` at `toId`. */
617
+ connect(incoming: Edge[], toId: string): void;
618
+ /** Record a referenced data envelope so it is lifted to a `nano:shape`. */
619
+ recordEnvelope(env?: Envelope): void;
620
+ /** Emit a sequence of nodes, threading danglers; used to emit nested bodies. */
621
+ emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[];
622
+ /** Place a fully custom BPMN element (a slice's own render closure). */
623
+ addNode(node: RenderNode): void;
624
+ /** Place a `<bpmn:serviceTask>` with the standard taskDefinition/envelope
625
+ * extension elements (optionally with multi-instance characteristics). */
626
+ addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi?: string): void;
627
+ /** Place a `<bpmn:intermediateCatchEvent>` with a message event definition. */
628
+ addCatchEvent(node: { name: string }): void;
629
+ /** Place a `<bpmn:intermediateCatchEvent>` with a timer event definition. */
630
+ addTimerCatchEvent(node: { name: string; after?: string; at?: string }): void;
631
+ /** Place a `<bpmn:exclusiveGateway>`, returning it so `defaultFlow` can be set. */
632
+ addGateway(id: string, name?: string): RenderNode;
633
+ /** Place a `<bpmn:parallelGateway>`. */
634
+ addParallelGateway(id: string): RenderNode;
635
+ /** Place an embedded `<bpmn:subProcess>` whose body renders in its own scope. */
636
+ addSubProcess(id: string, mi: string): RenderNode;
637
+ /** Place a plain none `<bpmn:startEvent>` (for an embedded sub-process). */
638
+ addPlainStart(id: string): void;
639
+ /** Place a plain `<bpmn:endEvent>` (for an embedded sub-process). */
640
+ addPlainEnd(id: string): void;
641
+ /** A fresh monotonic counter value for generated gateway/loop/sub ids. */
642
+ nextGw(): number;
643
+ /** The current scope (enclosing sub-process id, or undefined at the top level). */
644
+ currentScope(): string | undefined;
645
+ /** Open a sub-process scope (subsequent nodes/edges nest inside it). */
646
+ pushScope(id: string): void;
647
+ /** Close the current sub-process scope. */
648
+ popScope(): void;
649
+ }
650
+
592
651
  class Compiler {
593
652
  private readonly nodes: RenderNode[] = [];
594
653
  private readonly edges: Edge[] = [];
@@ -602,8 +661,37 @@ class Compiler {
602
661
  private live: Edge[] = [];
603
662
  private seq = 0;
604
663
  private gw = 0;
664
+ /** The emit surface handed to each kind's registered `emit` handler. */
665
+ private readonly emitApi: EmitApi;
605
666
 
606
- constructor(private readonly flow: DeclarativeFlow) {}
667
+ constructor(private readonly flow: DeclarativeFlow) {
668
+ this.emitApi = {
669
+ flowId: flow.id,
670
+ newEdge: (from, opts) => this.newEdge(from, opts),
671
+ connect: (incoming, toId) => this.connect(incoming, toId),
672
+ recordEnvelope: (env) => this.recordEnvelope(env),
673
+ emitList: (list, incoming, loop) => this.emitList(list, incoming, loop),
674
+ addNode: (node) => {
675
+ this.nodes.push({ ...node, scope: node.scope ?? this.currentScope() });
676
+ },
677
+ addServiceTask: (node, mi) => this.addServiceTask(node, mi),
678
+ addCatchEvent: (node) => this.addCatchEvent(node),
679
+ addTimerCatchEvent: (node) => this.addTimerCatchEvent(node),
680
+ addGateway: (id, name) => this.addGateway(id, name),
681
+ addParallelGateway: (id) => this.addParallelGateway(id),
682
+ addSubProcess: (id, mi) => this.addSubProcess(id, mi),
683
+ addPlainStart: (id) => this.addPlainStart(id),
684
+ addPlainEnd: (id) => this.addPlainEnd(id),
685
+ nextGw: () => this.gw++,
686
+ currentScope: () => this.currentScope(),
687
+ pushScope: (id) => {
688
+ this.scopeStack.push(id);
689
+ },
690
+ popScope: () => {
691
+ this.scopeStack.pop();
692
+ },
693
+ };
694
+ }
607
695
 
608
696
  private currentScope(): string | undefined {
609
697
  return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
@@ -760,144 +848,16 @@ class Compiler {
760
848
  return node;
761
849
  }
762
850
 
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
-
786
851
  private emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[] {
787
852
  let cur = incoming;
788
853
  for (const node of list) cur = this.emitNode(node, cur, loop);
789
854
  return cur;
790
855
  }
791
856
 
857
+ /** Emit one node by DISPATCHING to its registered `emit` handler — no central
858
+ * switch on `node.kind`. A slice's kind is emitted by its own module. */
792
859
  private emitNode(node: FlowNode, incoming: Edge[], loop: LoopCtx | null): Edge[] {
793
- switch (node.kind) {
794
- case "run":
795
- case "task": {
796
- this.addServiceTask(node);
797
- this.connect(incoming, node.name);
798
- return [this.newEdge(node.name)];
799
- }
800
- case "signal": {
801
- this.addCatchEvent(node);
802
- this.connect(incoming, node.name);
803
- this.recordEnvelope(node.payload);
804
- return [this.newEdge(node.name)];
805
- }
806
- case "timer": {
807
- this.addTimerCatchEvent(node);
808
- this.connect(incoming, node.name);
809
- return [this.newEdge(node.name)];
810
- }
811
- case "switch": {
812
- const id = `Gw_${this.gw++}`;
813
- const gw = this.addGateway(id, node.subject);
814
- this.connect(incoming, id);
815
- const out: Edge[] = [];
816
- for (const c of node.cases) {
817
- const e = this.newEdge(id, { condition: feelEquals(node.subject, c.value), name: c.value });
818
- out.push(...this.emitList(c.body, [e], loop));
819
- }
820
- // The default (or a synthesised fall-through) is the unconditional edge.
821
- const de = this.newEdge(id, { name: "default" });
822
- gw.defaultFlow = de.id;
823
- out.push(...this.emitList(node.default ?? [], [de], loop));
824
- return out;
825
- }
826
- case "branch": {
827
- const id = `Gw_${this.gw++}`;
828
- const gw = this.addGateway(id);
829
- this.connect(incoming, id);
830
- const te = this.newEdge(id, { condition: feel(node.condition), name: "then" });
831
- const out = this.emitList(node.then, [te], loop);
832
- const ee = this.newEdge(id, { name: "else" });
833
- gw.defaultFlow = ee.id;
834
- out.push(...this.emitList(node.else ?? [], [ee], loop));
835
- return out;
836
- }
837
- case "loop": {
838
- const headId = `Loop_${this.gw++}`;
839
- this.addGateway(headId);
840
- this.connect(incoming, headId);
841
- const ctx: LoopCtx = { headId, breaks: [] };
842
- const headOut = this.newEdge(headId);
843
- const bodyOut = this.emitList(node.body, [headOut], ctx);
844
- // Normal fall-through of the body loops back to the head (continue).
845
- this.connect(bodyOut, headId);
846
- // The loop exits only via break edges.
847
- return ctx.breaks;
848
- }
849
- case "break": {
850
- if (!loop) throw new Error(`break outside a loop in flow "${this.flow.id}"`);
851
- // The incoming edges (from the previous node) become the loop's exit
852
- // danglers; this path does not fall through.
853
- loop.breaks.push(...incoming);
854
- return [];
855
- }
856
- case "continue": {
857
- if (!loop) throw new Error(`continue outside a loop in flow "${this.flow.id}"`);
858
- this.connect(incoming, loop.headId);
859
- return [];
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
- }
900
- }
860
+ return requireNodeKind(node.kind).emit(node, incoming, loop, this.emitApi);
901
861
  }
902
862
 
903
863
  /** Render every node + sequence flow that belongs to `scope` (undefined = the
@@ -1029,21 +989,29 @@ export function declarativeToBpmn(flow: DeclarativeFlow): string {
1029
989
  const envelopeProp = (dir: "in" | "out", value: string): string =>
1030
990
  ` <zeebe:property name="io.nanobpm.dataEnvelope.${dir}" value="${escapeXml(value)}" />`;
1031
991
 
1032
- /** Wrap a raw FEEL expression as a Zeebe condition body (leading `=`). */
1033
- const feel = (expr: string): string => `=${expr}`;
992
+ /** Wrap a raw FEEL expression as a Zeebe condition body (leading `=`). Exported
993
+ * for slice-added combinators that emit conditional sequence flows. */
994
+ export const feel = (expr: string): string => `=${expr}`;
1034
995
 
1035
- /** A FEEL equality test `subject = "value"`, with the value as a FEEL string. */
1036
- const feelEquals = (subject: string, value: string): string =>
996
+ /** A FEEL equality test `subject = "value"`, with the value as a FEEL string.
997
+ * Exported for slice-added combinators that emit an XOR gateway. */
998
+ export const feelEquals = (subject: string, value: string): string =>
1037
999
  `=${subject} = "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1038
1000
 
1039
- function incomingOutgoing(inc: string[], outg: string[]): string {
1001
+ /** Render the `<bpmn:incoming>`/`<bpmn:outgoing>` refs of a flow node. Exported
1002
+ * for slice `RenderNode.render` closures. */
1003
+ export function incomingOutgoing(inc: string[], outg: string[]): string {
1040
1004
  return (
1041
1005
  inc.map((f) => ` <bpmn:incoming>${f}</bpmn:incoming>\n`).join("") +
1042
1006
  outg.map((f) => ` <bpmn:outgoing>${f}</bpmn:outgoing>\n`).join("")
1043
1007
  );
1044
1008
  }
1045
- const incomingOnly = (inc: string[]): string => inc.map((f) => `<bpmn:incoming>${f}</bpmn:incoming>`).join("");
1046
- const outgoingOnly = (outg: string[]): string => outg.map((f) => `<bpmn:outgoing>${f}</bpmn:outgoing>`).join("");
1009
+ /** Render only the `<bpmn:incoming>` refs (for end-like events). Exported. */
1010
+ export const incomingOnly = (inc: string[]): string =>
1011
+ inc.map((f) => `<bpmn:incoming>${f}</bpmn:incoming>`).join("");
1012
+ /** Render only the `<bpmn:outgoing>` refs (for start-like events). Exported. */
1013
+ export const outgoingOnly = (outg: string[]): string =>
1014
+ outg.map((f) => `<bpmn:outgoing>${f}</bpmn:outgoing>`).join("");
1047
1015
 
1048
1016
  function sequenceFlow(e: Edge): string {
1049
1017
  const nm = e.name ? ` name="${escapeXml(e.name)}"` : "";