@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.
- package/README.md +1 -1
- package/dist/declarative.d.ts +172 -10
- package/dist/declarative.js +213 -355
- package/dist/nodes/branch.d.ts +11 -0
- package/dist/nodes/branch.js +40 -0
- package/dist/nodes/break.d.ts +8 -0
- package/dist/nodes/break.js +19 -0
- package/dist/nodes/continue.d.ts +8 -0
- package/dist/nodes/continue.js +18 -0
- package/dist/nodes/forEach.d.ts +24 -0
- package/dist/nodes/forEach.js +104 -0
- package/dist/nodes/index.d.ts +11 -0
- package/dist/nodes/index.js +18 -0
- package/dist/nodes/loop.d.ts +9 -0
- package/dist/nodes/loop.js +25 -0
- package/dist/nodes/parallel.d.ts +9 -0
- package/dist/nodes/parallel.js +40 -0
- package/dist/nodes/registry.d.ts +33 -0
- package/dist/nodes/registry.js +71 -0
- package/dist/nodes/run.d.ts +10 -0
- package/dist/nodes/run.js +21 -0
- package/dist/nodes/signal.d.ts +11 -0
- package/dist/nodes/signal.js +21 -0
- package/dist/nodes/switch.d.ts +11 -0
- package/dist/nodes/switch.js +51 -0
- package/dist/nodes/task.d.ts +11 -0
- package/dist/nodes/task.js +21 -0
- package/dist/nodes/timer.d.ts +9 -0
- package/dist/nodes/timer.js +34 -0
- package/dist/test-support/diff.d.ts +8 -0
- package/dist/test-support/diff.js +70 -0
- package/dist/test-support/index.d.ts +8 -0
- package/dist/test-support/index.js +49 -0
- package/dist/test-support/normalize.d.ts +19 -0
- package/dist/test-support/normalize.js +245 -0
- package/dist/test-support/parity.d.ts +28 -0
- package/dist/test-support/parity.js +61 -0
- package/dist/test-support/xml.d.ts +17 -0
- package/dist/test-support/xml.js +135 -0
- package/dist/types.d.ts +20 -56
- package/package.json +14 -6
- package/src/declarative.ts +327 -359
- package/src/nodes/README.md +123 -0
- package/src/nodes/branch.ts +48 -0
- package/src/nodes/break.ts +25 -0
- package/src/nodes/continue.ts +24 -0
- package/src/nodes/forEach.ts +137 -0
- package/src/nodes/index.ts +19 -0
- package/src/nodes/loop.ts +34 -0
- package/src/nodes/parallel.ts +48 -0
- package/src/nodes/registry.ts +105 -0
- package/src/nodes/run.ts +29 -0
- package/src/nodes/signal.ts +29 -0
- package/src/nodes/switch.ts +56 -0
- package/src/nodes/task.ts +29 -0
- package/src/nodes/timer.ts +42 -0
- package/src/test-support/diff.ts +69 -0
- package/src/test-support/index.ts +53 -0
- package/src/test-support/normalize.ts +274 -0
- package/src/test-support/parity.ts +85 -0
- package/src/test-support/xml.ts +140 -0
- package/src/types.ts +20 -30
package/src/declarative.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
|
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:
|
|
314
|
+
const childScoped = (fn: (b: FlowBuilder) => void): FlowNode[] => {
|
|
269
315
|
const body: FlowNode[] = [];
|
|
270
|
-
fn(makeBuilder
|
|
316
|
+
fn(makeBuilder(id, body, { ...ctx, loopDepth: 0 }));
|
|
271
317
|
return body;
|
|
272
318
|
};
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
508
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1046
|
-
const
|
|
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)}"` : "";
|