@nanobpm/workflow 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/dist/declarative.d.ts +73 -2
- package/dist/declarative.js +324 -18
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +57 -1
- package/dist/worker.js +3 -2
- package/dist/xml.d.ts +3 -0
- package/dist/xml.js +53 -0
- package/package.json +4 -4
- package/src/declarative.ts +421 -21
- package/src/index.ts +1 -0
- package/src/types.ts +46 -3
- package/src/worker.ts +3 -2
- package/src/xml.ts +65 -0
package/src/declarative.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// (`switch`/`branch`/`loop`) that compile to exclusive gateways + back-edges the
|
|
4
4
|
// engine already runs (engine-core supports XOR gateways with in-order condition
|
|
5
5
|
// evaluation and a default flow, and tasks with multiple incoming flows act as
|
|
6
|
-
// an implicit XOR merge)
|
|
6
|
+
// an implicit XOR merge), plus concurrency combinators — `parallel` (an AND
|
|
7
|
+
// fork/join over parallel gateways) and `forEach` (a data-driven fan-out lowering
|
|
8
|
+
// to a parallel/sequential multi-instance activity or sub-process).
|
|
7
9
|
//
|
|
8
10
|
// const convergence = defineFlow(
|
|
9
11
|
// "convergence-loop",
|
|
@@ -44,9 +46,10 @@ import type {
|
|
|
44
46
|
StepContract,
|
|
45
47
|
StepHandler,
|
|
46
48
|
SwitchCase,
|
|
49
|
+
TimerStart,
|
|
47
50
|
} from "./types.js";
|
|
48
51
|
import type { Envelope, EnvelopeField } from "./envelope.js";
|
|
49
|
-
import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
|
|
52
|
+
import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
|
|
50
53
|
|
|
51
54
|
// --- Authoring surface -------------------------------------------------------
|
|
52
55
|
|
|
@@ -70,9 +73,20 @@ type TypedHandler<V, R> = (job: {
|
|
|
70
73
|
}) => Promise<R | void> | R | void;
|
|
71
74
|
|
|
72
75
|
/** A block: the callback that populates a nested body (a case, arm, or loop). */
|
|
73
|
-
type Block<C extends
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
type Block<C extends object> = (b: FlowBuilder<C>) => void;
|
|
77
|
+
|
|
78
|
+
/** The typed builder for a flow body.
|
|
79
|
+
*
|
|
80
|
+
* `C` is the flow's contracts map (step name → `{ in, out }` envelopes). The
|
|
81
|
+
* default is `object`, NOT `Record<string, never>`: with a `never`-valued record
|
|
82
|
+
* `keyof C` is `string`, so `VarsOf`/`ResultOf` route EVERY step name into
|
|
83
|
+
* `InPayload<never>`/`OutPayload<never>`, which distribute over `never` to
|
|
84
|
+
* `never` — typing an untyped flow's `job.variables` as `never` and forcing its
|
|
85
|
+
* handler return to `void` (so no untyped flow that returns data compiles). With
|
|
86
|
+
* `object`, `keyof C` is `never`, so every step falls back to the intended
|
|
87
|
+
* `JsonObject`. The public `defineFlow<C extends FlowContracts>` overload keeps
|
|
88
|
+
* the stricter contracts constraint; this is only the untyped-builder default. */
|
|
89
|
+
export interface FlowBuilder<C extends object = object> {
|
|
76
90
|
/**
|
|
77
91
|
* A durable activity served by a worker THIS program hosts (a BPMN service
|
|
78
92
|
* task; the handler runs in the in-process `Worker`). If `name` is a key in
|
|
@@ -100,6 +114,26 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
100
114
|
* payload envelope, if any, comes from the contract's `in`.
|
|
101
115
|
*/
|
|
102
116
|
signal<K extends string>(name: K, opts: { correlationKey: string }): FlowBuilder<C>;
|
|
117
|
+
/**
|
|
118
|
+
* A durable wait for a point in time (a BPMN timer intermediate catch event).
|
|
119
|
+
* Pass exactly one of `{ after }` — an ISO-8601 delay (`PT1M30S`, `P1DT6H`) or
|
|
120
|
+
* FEEL `=`-expression measured from when the token arrives — or `{ at }` — an
|
|
121
|
+
* absolute ISO-8601 instant (or FEEL expression). The engine holds the token
|
|
122
|
+
* durably until the timer fires, then continues. Use it for in-flow delays and
|
|
123
|
+
* scheduled continuations (e.g. "wait 24h, then re-poll").
|
|
124
|
+
*/
|
|
125
|
+
timer<K extends string>(name: K, opts: { after: string } | { at: string }): FlowBuilder<C>;
|
|
126
|
+
/**
|
|
127
|
+
* Make this flow's start event a durable TIMER start rather than an explicit
|
|
128
|
+
* `client.start(...)`. Must be the first statement, at the top level. Pass
|
|
129
|
+
* exactly one of `{ cycle }` — a recurring ISO-8601 interval (`R/PT1H`,
|
|
130
|
+
* `R5/PT30M`) the engine re-fires each period — `{ after }` — a one-shot delay
|
|
131
|
+
* from deployment — or `{ at }` — a one-shot absolute instant. A `cycle` start
|
|
132
|
+
* is the model-native, durable, single-fire-per-cluster replacement for an
|
|
133
|
+
* app-side cron: the schedule lives in the deployable model, and the engine
|
|
134
|
+
* (not each app replica) owns firing it exactly once.
|
|
135
|
+
*/
|
|
136
|
+
startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C>;
|
|
103
137
|
/**
|
|
104
138
|
* A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
|
|
105
139
|
* expression (usually a variable name); each case routes when `subject` equals
|
|
@@ -119,6 +153,41 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
119
153
|
* the loop run once `break()` is reached.
|
|
120
154
|
*/
|
|
121
155
|
loop(body: Block<C>): FlowBuilder<C>;
|
|
156
|
+
/**
|
|
157
|
+
* A static parallel fork/join (a pair of BPMN parallel gateways). Every block
|
|
158
|
+
* runs concurrently on its own branch; control continues past `parallel` only
|
|
159
|
+
* once ALL branches have reached the joining gateway (AND-join). Pass at least
|
|
160
|
+
* two branch blocks. Unlike `switch`/`branch` (exclusive choice), no branch is
|
|
161
|
+
* conditional — all of them run.
|
|
162
|
+
*/
|
|
163
|
+
parallel(branches: Block<C>[]): FlowBuilder<C>;
|
|
164
|
+
/**
|
|
165
|
+
* A data-driven fan-out over a runtime collection (a BPMN parallel
|
|
166
|
+
* multi-instance activity). `collection` is a FEEL expression (no leading `=`)
|
|
167
|
+
* evaluating to a list; one child instance of `body` runs per item, with the
|
|
168
|
+
* item bound to the `itemVar` variable in the child's scope. A single-activity
|
|
169
|
+
* body attaches the multi-instance characteristics to that activity; a
|
|
170
|
+
* multi-step body is wrapped in an embedded multi-instance sub-process. Options:
|
|
171
|
+
* - `sequential` — run children one at a time (a sequential MI) instead of all
|
|
172
|
+
* at once (the default: a parallel MI).
|
|
173
|
+
* - `outputCollection` / `outputElement` — collect each child's `outputElement`
|
|
174
|
+
* (a FEEL expression, no leading `=`) into the `outputCollection` list
|
|
175
|
+
* variable, in item order.
|
|
176
|
+
* - `completionCondition` — a FEEL boolean (no leading `=`); when it holds
|
|
177
|
+
* after a child completes, the remaining children are cancelled and the body
|
|
178
|
+
* completes early.
|
|
179
|
+
*/
|
|
180
|
+
forEach(
|
|
181
|
+
collection: string,
|
|
182
|
+
itemVar: string,
|
|
183
|
+
body: Block<C>,
|
|
184
|
+
opts?: {
|
|
185
|
+
sequential?: boolean;
|
|
186
|
+
outputCollection?: string;
|
|
187
|
+
outputElement?: string;
|
|
188
|
+
completionCondition?: string;
|
|
189
|
+
},
|
|
190
|
+
): FlowBuilder<C>;
|
|
122
191
|
/** Exit the enclosing loop (routes to whatever follows it). Only valid inside
|
|
123
192
|
* a `loop`. */
|
|
124
193
|
break(): FlowBuilder<C>;
|
|
@@ -132,12 +201,14 @@ interface BuilderCtx {
|
|
|
132
201
|
handlers: Record<string, StepHandler>;
|
|
133
202
|
seen: Set<string>;
|
|
134
203
|
loopDepth: number;
|
|
204
|
+
/** Set by `startOn()` (root builder only); lifted onto the flow. */
|
|
205
|
+
startTimer?: TimerStart;
|
|
135
206
|
}
|
|
136
207
|
|
|
137
208
|
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
138
209
|
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
139
210
|
* invalid model, so reject them at authoring time. */
|
|
140
|
-
const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
|
|
211
|
+
const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
|
|
141
212
|
|
|
142
213
|
function claimName(ctx: BuilderCtx, id: string, name: string): void {
|
|
143
214
|
assertIdent("step name", name);
|
|
@@ -146,7 +217,7 @@ function claimName(ctx: BuilderCtx, id: string, name: string): void {
|
|
|
146
217
|
}
|
|
147
218
|
if (RESERVED_PREFIXES.test(name)) {
|
|
148
219
|
throw new Error(
|
|
149
|
-
`step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`,
|
|
220
|
+
`step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`,
|
|
150
221
|
);
|
|
151
222
|
}
|
|
152
223
|
if (ctx.seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
@@ -163,13 +234,27 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
|
|
|
163
234
|
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
164
235
|
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
165
236
|
* combinators recurse with a fresh `out` array for each nested body. */
|
|
166
|
-
function makeBuilder<C extends
|
|
237
|
+
function makeBuilder<C extends object>(
|
|
238
|
+
id: string,
|
|
239
|
+
out: FlowNode[],
|
|
240
|
+
ctx: BuilderCtx,
|
|
241
|
+
isRoot = false,
|
|
242
|
+
): FlowBuilder<C> {
|
|
167
243
|
const child = (fn: Block<C>, inLoop: boolean): FlowNode[] => {
|
|
168
244
|
const body: FlowNode[] = [];
|
|
169
245
|
const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
|
|
170
246
|
fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: depth }));
|
|
171
247
|
return body;
|
|
172
248
|
};
|
|
249
|
+
// A body that runs in its OWN token scope (an embedded sub-process): the
|
|
250
|
+
// enclosing loop is not reachable across the scope boundary, so `break`/
|
|
251
|
+
// `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
|
|
252
|
+
// INSIDE the body still works — it creates its own scope.
|
|
253
|
+
const childScoped = (fn: Block<C>): FlowNode[] => {
|
|
254
|
+
const body: FlowNode[] = [];
|
|
255
|
+
fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: 0 }));
|
|
256
|
+
return body;
|
|
257
|
+
};
|
|
173
258
|
const b = {
|
|
174
259
|
run(name: string, handler: StepHandler): FlowBuilder<C> {
|
|
175
260
|
claimName(ctx, id, name);
|
|
@@ -192,6 +277,49 @@ function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx:
|
|
|
192
277
|
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
193
278
|
return b as unknown as FlowBuilder<C>;
|
|
194
279
|
},
|
|
280
|
+
timer(name: string, opts: { after?: string; at?: string }): FlowBuilder<C> {
|
|
281
|
+
claimName(ctx, id, name);
|
|
282
|
+
const hasAfter = typeof opts?.after === "string";
|
|
283
|
+
const hasAt = typeof opts?.at === "string";
|
|
284
|
+
if (hasAfter === hasAt) {
|
|
285
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
286
|
+
}
|
|
287
|
+
if (hasAfter) {
|
|
288
|
+
const after = (opts.after as string).trim();
|
|
289
|
+
assertTimerDuration(`timer("${name}") after`, after);
|
|
290
|
+
out.push({ kind: "timer", name, after });
|
|
291
|
+
} else {
|
|
292
|
+
const at = (opts.at as string).trim();
|
|
293
|
+
assertTimerDate(`timer("${name}") at`, at);
|
|
294
|
+
out.push({ kind: "timer", name, at });
|
|
295
|
+
}
|
|
296
|
+
return b as unknown as FlowBuilder<C>;
|
|
297
|
+
},
|
|
298
|
+
startOn(spec: { cycle?: string; after?: string; at?: string }): FlowBuilder<C> {
|
|
299
|
+
if (!isRoot) {
|
|
300
|
+
throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
|
|
301
|
+
}
|
|
302
|
+
if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
303
|
+
if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
304
|
+
const set = (["cycle", "after", "at"] as const).filter((k) => typeof spec?.[k] === "string");
|
|
305
|
+
if (set.length !== 1) {
|
|
306
|
+
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
307
|
+
}
|
|
308
|
+
if (spec.cycle !== undefined) {
|
|
309
|
+
const cycle = spec.cycle.trim();
|
|
310
|
+
assertTimerCycle(`startOn cycle`, cycle);
|
|
311
|
+
ctx.startTimer = { cycle };
|
|
312
|
+
} else if (spec.after !== undefined) {
|
|
313
|
+
const after = spec.after.trim();
|
|
314
|
+
assertTimerDuration(`startOn after`, after);
|
|
315
|
+
ctx.startTimer = { after };
|
|
316
|
+
} else {
|
|
317
|
+
const at = (spec.at as string).trim();
|
|
318
|
+
assertTimerDate(`startOn at`, at);
|
|
319
|
+
ctx.startTimer = { at };
|
|
320
|
+
}
|
|
321
|
+
return b as unknown as FlowBuilder<C>;
|
|
322
|
+
},
|
|
195
323
|
switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
|
|
196
324
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
197
325
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
@@ -221,6 +349,81 @@ function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx:
|
|
|
221
349
|
out.push({ kind: "loop", body: child(body, true) });
|
|
222
350
|
return b as unknown as FlowBuilder<C>;
|
|
223
351
|
},
|
|
352
|
+
parallel(branches: Block<C>[]): FlowBuilder<C> {
|
|
353
|
+
if (!Array.isArray(branches) || branches.length < 2) {
|
|
354
|
+
throw new Error(`parallel() needs at least two branch blocks`);
|
|
355
|
+
}
|
|
356
|
+
for (const fn of branches) {
|
|
357
|
+
if (typeof fn !== "function") throw new Error(`parallel() branches must all be blocks (b) => {…}`);
|
|
358
|
+
}
|
|
359
|
+
// Each branch runs in its own token scope up to the AND-join, so a
|
|
360
|
+
// `break`/`continue` must not escape the fork/join into an enclosing
|
|
361
|
+
// loop. Scope branches (loopDepth resets to 0) so cross-boundary
|
|
362
|
+
// break/continue is rejected at build time, matching forEach.
|
|
363
|
+
out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
|
|
364
|
+
return b as unknown as FlowBuilder<C>;
|
|
365
|
+
},
|
|
366
|
+
forEach(
|
|
367
|
+
collection: string,
|
|
368
|
+
itemVar: string,
|
|
369
|
+
body: Block<C>,
|
|
370
|
+
opts?: {
|
|
371
|
+
sequential?: boolean;
|
|
372
|
+
outputCollection?: string;
|
|
373
|
+
outputElement?: string;
|
|
374
|
+
completionCondition?: string;
|
|
375
|
+
},
|
|
376
|
+
): FlowBuilder<C> {
|
|
377
|
+
if (typeof collection !== "string" || collection.trim() === "") {
|
|
378
|
+
throw new Error(`forEach() needs a non-empty FEEL collection expression`);
|
|
379
|
+
}
|
|
380
|
+
// Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip
|
|
381
|
+
// it so the emitter's own "=" prefix never produces an invalid "==...".
|
|
382
|
+
const collExpr = collection.trim().replace(/^=/, "").trim();
|
|
383
|
+
if (collExpr === "") {
|
|
384
|
+
throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
|
|
385
|
+
}
|
|
386
|
+
assertIdent("forEach itemVar", itemVar);
|
|
387
|
+
if (typeof body !== "function") throw new Error(`forEach("${itemVar}") needs a body function`);
|
|
388
|
+
const nodes = childScoped(body);
|
|
389
|
+
if (nodes.length === 0) throw new Error(`forEach("${itemVar}") body declared no steps`);
|
|
390
|
+
const node: Extract<FlowNode, { kind: "forEach" }> = {
|
|
391
|
+
kind: "forEach",
|
|
392
|
+
collection: collExpr,
|
|
393
|
+
itemVar,
|
|
394
|
+
body: nodes,
|
|
395
|
+
};
|
|
396
|
+
if (opts?.sequential) node.sequential = true;
|
|
397
|
+
if (opts?.outputCollection !== undefined) {
|
|
398
|
+
assertIdent("forEach outputCollection", opts.outputCollection);
|
|
399
|
+
node.outputCollection = opts.outputCollection;
|
|
400
|
+
}
|
|
401
|
+
if (opts?.outputElement !== undefined) {
|
|
402
|
+
if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
|
|
403
|
+
throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
|
|
404
|
+
}
|
|
405
|
+
const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
|
|
406
|
+
if (outEl === "") {
|
|
407
|
+
throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
|
|
408
|
+
}
|
|
409
|
+
node.outputElement = outEl;
|
|
410
|
+
}
|
|
411
|
+
if (opts?.completionCondition !== undefined) {
|
|
412
|
+
if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
|
|
413
|
+
throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
|
|
414
|
+
}
|
|
415
|
+
const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
|
|
416
|
+
if (compCond === "") {
|
|
417
|
+
throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
|
|
418
|
+
}
|
|
419
|
+
node.completionCondition = compCond;
|
|
420
|
+
}
|
|
421
|
+
if (node.outputElement && !node.outputCollection) {
|
|
422
|
+
throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
|
|
423
|
+
}
|
|
424
|
+
out.push(node);
|
|
425
|
+
return b as unknown as FlowBuilder<C>;
|
|
426
|
+
},
|
|
224
427
|
break(): FlowBuilder<C> {
|
|
225
428
|
if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
|
|
226
429
|
out.push({ kind: "break" });
|
|
@@ -263,9 +466,11 @@ export function defineFlow(
|
|
|
263
466
|
const steps: FlowNode[] = [];
|
|
264
467
|
const handlers: Record<string, StepHandler> = {};
|
|
265
468
|
const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
266
|
-
build(makeBuilder<FlowContracts>(id, steps, ctx));
|
|
469
|
+
build(makeBuilder<FlowContracts>(id, steps, ctx, true));
|
|
267
470
|
if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
|
|
268
|
-
|
|
471
|
+
const flow: DeclarativeFlow = { kind: "declarative", id, steps, handlers };
|
|
472
|
+
if (ctx.startTimer) flow.startTimer = ctx.startTimer;
|
|
473
|
+
return flow;
|
|
269
474
|
}
|
|
270
475
|
|
|
271
476
|
// --- Tree walkers ------------------------------------------------------------
|
|
@@ -287,6 +492,12 @@ export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void
|
|
|
287
492
|
case "loop":
|
|
288
493
|
walkNodes(n.body, visit);
|
|
289
494
|
break;
|
|
495
|
+
case "parallel":
|
|
496
|
+
for (const branch of n.branches) walkNodes(branch, visit);
|
|
497
|
+
break;
|
|
498
|
+
case "forEach":
|
|
499
|
+
walkNodes(n.body, visit);
|
|
500
|
+
break;
|
|
290
501
|
default:
|
|
291
502
|
break;
|
|
292
503
|
}
|
|
@@ -318,6 +529,9 @@ interface RenderNode {
|
|
|
318
529
|
render(incoming: string[], outgoing: string[]): string;
|
|
319
530
|
/** For exclusive gateways: the flow id of the unconditional default edge. */
|
|
320
531
|
defaultFlow?: string;
|
|
532
|
+
/** Id of the embedded sub-process this node lives in, or undefined at the
|
|
533
|
+
* top level (the root process). */
|
|
534
|
+
scope?: string;
|
|
321
535
|
}
|
|
322
536
|
|
|
323
537
|
interface Edge {
|
|
@@ -326,6 +540,9 @@ interface Edge {
|
|
|
326
540
|
to?: string;
|
|
327
541
|
condition?: string;
|
|
328
542
|
name?: string;
|
|
543
|
+
/** The scope (sub-process id, or undefined = root process) this flow is
|
|
544
|
+
* nested in — so it renders inside the right container. */
|
|
545
|
+
scope?: string;
|
|
329
546
|
}
|
|
330
547
|
|
|
331
548
|
interface LoopCtx {
|
|
@@ -338,13 +555,29 @@ class Compiler {
|
|
|
338
555
|
private readonly edges: Edge[] = [];
|
|
339
556
|
/** envelope name → its fields, deduped for lifting to a single nano:shape. */
|
|
340
557
|
private readonly envelopes = new Map<string, EnvelopeField[]>();
|
|
558
|
+
/** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
|
|
559
|
+
* node/edge created now nests inside it). Empty at the top level. */
|
|
560
|
+
private readonly scopeStack: string[] = [];
|
|
561
|
+
/** Edges with a resolved target, indexed in `compile()` before rendering so a
|
|
562
|
+
* sub-process can render its own nested scope. */
|
|
563
|
+
private live: Edge[] = [];
|
|
341
564
|
private seq = 0;
|
|
342
565
|
private gw = 0;
|
|
343
566
|
|
|
344
567
|
constructor(private readonly flow: DeclarativeFlow) {}
|
|
345
568
|
|
|
569
|
+
private currentScope(): string | undefined {
|
|
570
|
+
return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
346
573
|
private newEdge(from: string, opts: { condition?: string; name?: string } = {}): Edge {
|
|
347
|
-
const e: Edge = {
|
|
574
|
+
const e: Edge = {
|
|
575
|
+
id: `f_${this.seq++}`,
|
|
576
|
+
from,
|
|
577
|
+
condition: opts.condition,
|
|
578
|
+
name: opts.name,
|
|
579
|
+
scope: this.currentScope(),
|
|
580
|
+
};
|
|
348
581
|
this.edges.push(e);
|
|
349
582
|
return e;
|
|
350
583
|
}
|
|
@@ -367,7 +600,7 @@ class Compiler {
|
|
|
367
600
|
this.envelopes.set(env.name, env.fields);
|
|
368
601
|
}
|
|
369
602
|
|
|
370
|
-
private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }): void {
|
|
603
|
+
private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi = ""): void {
|
|
371
604
|
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
372
605
|
this.recordEnvelope(node.envelopes?.in);
|
|
373
606
|
this.recordEnvelope(node.envelopes?.out);
|
|
@@ -382,11 +615,13 @@ class Compiler {
|
|
|
382
615
|
const id = node.name;
|
|
383
616
|
this.nodes.push({
|
|
384
617
|
id,
|
|
618
|
+
scope: this.currentScope(),
|
|
385
619
|
render: (inc, outg) =>
|
|
386
620
|
` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
387
621
|
ext +
|
|
388
622
|
"\n" +
|
|
389
623
|
incomingOutgoing(inc, outg) +
|
|
624
|
+
mi +
|
|
390
625
|
` </bpmn:serviceTask>`,
|
|
391
626
|
});
|
|
392
627
|
}
|
|
@@ -396,6 +631,7 @@ class Compiler {
|
|
|
396
631
|
const msgId = `Msg_${id}`;
|
|
397
632
|
this.nodes.push({
|
|
398
633
|
id,
|
|
634
|
+
scope: this.currentScope(),
|
|
399
635
|
render: (inc, outg) =>
|
|
400
636
|
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
401
637
|
incomingOutgoing(inc, outg) +
|
|
@@ -404,9 +640,29 @@ class Compiler {
|
|
|
404
640
|
});
|
|
405
641
|
}
|
|
406
642
|
|
|
643
|
+
private addTimerCatchEvent(node: { name: string; after?: string; at?: string }): void {
|
|
644
|
+
const id = node.name;
|
|
645
|
+
const body =
|
|
646
|
+
node.after !== undefined
|
|
647
|
+
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
648
|
+
: ` <bpmn:timeDate>${escapeXml(node.at as string)}</bpmn:timeDate>\n`;
|
|
649
|
+
this.nodes.push({
|
|
650
|
+
id,
|
|
651
|
+
scope: this.currentScope(),
|
|
652
|
+
render: (inc, outg) =>
|
|
653
|
+
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
654
|
+
incomingOutgoing(inc, outg) +
|
|
655
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
656
|
+
body +
|
|
657
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
658
|
+
` </bpmn:intermediateCatchEvent>`,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
407
662
|
private addGateway(id: string, name?: string): RenderNode {
|
|
408
663
|
const gwNode: RenderNode = {
|
|
409
664
|
id,
|
|
665
|
+
scope: this.currentScope(),
|
|
410
666
|
render: (inc, outg) => {
|
|
411
667
|
const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
|
|
412
668
|
const nm = name ? ` name="${escapeXml(name)}"` : "";
|
|
@@ -421,6 +677,73 @@ class Compiler {
|
|
|
421
677
|
return gwNode;
|
|
422
678
|
}
|
|
423
679
|
|
|
680
|
+
private addParallelGateway(id: string): RenderNode {
|
|
681
|
+
const node: RenderNode = {
|
|
682
|
+
id,
|
|
683
|
+
scope: this.currentScope(),
|
|
684
|
+
render: (inc, outg) =>
|
|
685
|
+
` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
|
|
686
|
+
};
|
|
687
|
+
this.nodes.push(node);
|
|
688
|
+
return node;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private addPlainStart(id: string): void {
|
|
692
|
+
this.nodes.push({
|
|
693
|
+
id,
|
|
694
|
+
scope: this.currentScope(),
|
|
695
|
+
render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private addPlainEnd(id: string): void {
|
|
700
|
+
this.nodes.push({
|
|
701
|
+
id,
|
|
702
|
+
scope: this.currentScope(),
|
|
703
|
+
render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** An embedded sub-process whose body renders nested in its own scope, with the
|
|
708
|
+
* given multi-instance characteristics lifted onto it. */
|
|
709
|
+
private addSubProcess(id: string, mi: string): RenderNode {
|
|
710
|
+
const node: RenderNode = {
|
|
711
|
+
id,
|
|
712
|
+
scope: this.currentScope(),
|
|
713
|
+
render: (inc, outg) =>
|
|
714
|
+
` <bpmn:subProcess id="${escapeXml(id)}">\n` +
|
|
715
|
+
incomingOutgoing(inc, outg) +
|
|
716
|
+
mi +
|
|
717
|
+
this.renderScope(id) +
|
|
718
|
+
`\n </bpmn:subProcess>`,
|
|
719
|
+
};
|
|
720
|
+
this.nodes.push(node);
|
|
721
|
+
return node;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node —
|
|
725
|
+
* the same shape whether lifted onto a leaf service task or a sub-process. */
|
|
726
|
+
private miCharacteristics(node: Extract<FlowNode, { kind: "forEach" }>): string {
|
|
727
|
+
const seq = node.sequential ? "true" : "false";
|
|
728
|
+
const attrs = [
|
|
729
|
+
`inputCollection="${escapeXml(`=${node.collection}`)}"`,
|
|
730
|
+
`inputElement="${escapeXml(node.itemVar)}"`,
|
|
731
|
+
];
|
|
732
|
+
if (node.outputCollection) attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
|
|
733
|
+
if (node.outputElement) attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
|
|
734
|
+
const completion = node.completionCondition
|
|
735
|
+
? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
|
|
736
|
+
: "";
|
|
737
|
+
return (
|
|
738
|
+
` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
|
|
739
|
+
` <bpmn:extensionElements>\n` +
|
|
740
|
+
` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
|
|
741
|
+
` </bpmn:extensionElements>\n` +
|
|
742
|
+
completion +
|
|
743
|
+
` </bpmn:multiInstanceLoopCharacteristics>\n`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
424
747
|
private emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[] {
|
|
425
748
|
let cur = incoming;
|
|
426
749
|
for (const node of list) cur = this.emitNode(node, cur, loop);
|
|
@@ -441,6 +764,11 @@ class Compiler {
|
|
|
441
764
|
this.recordEnvelope(node.payload);
|
|
442
765
|
return [this.newEdge(node.name)];
|
|
443
766
|
}
|
|
767
|
+
case "timer": {
|
|
768
|
+
this.addTimerCatchEvent(node);
|
|
769
|
+
this.connect(incoming, node.name);
|
|
770
|
+
return [this.newEdge(node.name)];
|
|
771
|
+
}
|
|
444
772
|
case "switch": {
|
|
445
773
|
const id = `Gw_${this.gw++}`;
|
|
446
774
|
const gw = this.addGateway(id, node.subject);
|
|
@@ -491,14 +819,91 @@ class Compiler {
|
|
|
491
819
|
this.connect(incoming, loop.headId);
|
|
492
820
|
return [];
|
|
493
821
|
}
|
|
822
|
+
case "parallel": {
|
|
823
|
+
// A diverging parallel gateway forks a token onto every branch; a
|
|
824
|
+
// converging one joins them (AND-join) into a single continuation.
|
|
825
|
+
const splitId = `Gw_${this.gw++}`;
|
|
826
|
+
this.addParallelGateway(splitId);
|
|
827
|
+
this.connect(incoming, splitId);
|
|
828
|
+
const joinId = `Gw_${this.gw++}`;
|
|
829
|
+
this.addParallelGateway(joinId);
|
|
830
|
+
for (const branch of node.branches) {
|
|
831
|
+
const e = this.newEdge(splitId);
|
|
832
|
+
const branchOut = this.emitList(branch, [e], loop);
|
|
833
|
+
this.connect(branchOut, joinId);
|
|
834
|
+
}
|
|
835
|
+
return [this.newEdge(joinId)];
|
|
836
|
+
}
|
|
837
|
+
case "forEach": {
|
|
838
|
+
const mi = this.miCharacteristics(node);
|
|
839
|
+
const only = node.body.length === 1 ? node.body[0] : undefined;
|
|
840
|
+
// A single service-task body carries the MI characteristics directly; a
|
|
841
|
+
// multi-step body is wrapped in an embedded MI sub-process (its own token
|
|
842
|
+
// scope, with its own start/end).
|
|
843
|
+
if (only && (only.kind === "run" || only.kind === "task")) {
|
|
844
|
+
this.addServiceTask(only, mi);
|
|
845
|
+
this.connect(incoming, only.name);
|
|
846
|
+
return [this.newEdge(only.name)];
|
|
847
|
+
}
|
|
848
|
+
const subId = `Sub_${this.gw++}`;
|
|
849
|
+
this.addSubProcess(subId, mi);
|
|
850
|
+
this.connect(incoming, subId);
|
|
851
|
+
this.scopeStack.push(subId);
|
|
852
|
+
const startId = `${subId}_start`;
|
|
853
|
+
this.addPlainStart(startId);
|
|
854
|
+
const innerOut = this.emitList(node.body, [this.newEdge(startId)], null);
|
|
855
|
+
const endId = `${subId}_end`;
|
|
856
|
+
this.addPlainEnd(endId);
|
|
857
|
+
this.connect(innerOut, endId);
|
|
858
|
+
this.scopeStack.pop();
|
|
859
|
+
return [this.newEdge(subId)];
|
|
860
|
+
}
|
|
494
861
|
}
|
|
495
862
|
}
|
|
496
863
|
|
|
864
|
+
/** Render every node + sequence flow that belongs to `scope` (undefined = the
|
|
865
|
+
* root process, a sub-process id otherwise). Called for the process body and,
|
|
866
|
+
* recursively, from each sub-process's render closure. */
|
|
867
|
+
private renderScope(scope: string | undefined): string {
|
|
868
|
+
const incomingOf = (id: string) => this.live.filter((e) => e.to === id).map((e) => e.id);
|
|
869
|
+
const outgoingOf = (id: string) => this.live.filter((e) => e.from === id).map((e) => e.id);
|
|
870
|
+
const nodeXml = this.nodes
|
|
871
|
+
.filter((n) => n.scope === scope)
|
|
872
|
+
.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
|
|
873
|
+
.join("\n");
|
|
874
|
+
const flowXml = this.live
|
|
875
|
+
.filter((e) => e.scope === scope)
|
|
876
|
+
.map((e) => sequenceFlow(e))
|
|
877
|
+
.join("\n");
|
|
878
|
+
return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Render the Start event — a plain none start, or a timer start when the
|
|
882
|
+
* flow declared `startOn(...)`. */
|
|
883
|
+
private renderStart(outg: string[]): string {
|
|
884
|
+
const t = this.flow.startTimer;
|
|
885
|
+
if (!t) return ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`;
|
|
886
|
+
const body =
|
|
887
|
+
t.cycle !== undefined
|
|
888
|
+
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
889
|
+
: t.after !== undefined
|
|
890
|
+
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
891
|
+
: `<bpmn:timeDate>${escapeXml(t.at as string)}</bpmn:timeDate>`;
|
|
892
|
+
return (
|
|
893
|
+
` <bpmn:startEvent id="Start">\n` +
|
|
894
|
+
` ${outgoingOnly(outg)}\n` +
|
|
895
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
896
|
+
` ${body}\n` +
|
|
897
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
898
|
+
` </bpmn:startEvent>`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
497
902
|
compile(): string {
|
|
498
903
|
// Start → top-level sequence → End.
|
|
499
904
|
this.nodes.push({
|
|
500
905
|
id: "Start",
|
|
501
|
-
render: (_inc, outg) =>
|
|
906
|
+
render: (_inc, outg) => this.renderStart(outg),
|
|
502
907
|
});
|
|
503
908
|
const s0 = this.newEdge("Start");
|
|
504
909
|
const finalDanglers = this.emitList(this.flow.steps, [s0], null);
|
|
@@ -509,12 +914,9 @@ class Compiler {
|
|
|
509
914
|
this.connect(finalDanglers, "End");
|
|
510
915
|
|
|
511
916
|
// A sequence flow needs both ends; drop any edge that never got a target.
|
|
512
|
-
|
|
513
|
-
const incomingOf = (id: string) => live.filter((e) => e.to === id).map((e) => e.id);
|
|
514
|
-
const outgoingOf = (id: string) => live.filter((e) => e.from === id).map((e) => e.id);
|
|
917
|
+
this.live = this.edges.filter((e) => e.to !== undefined);
|
|
515
918
|
|
|
516
|
-
const
|
|
517
|
-
const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
|
|
919
|
+
const bodyXml = this.renderScope(undefined);
|
|
518
920
|
const messageXml = this.emitMessages();
|
|
519
921
|
const shapeXml = this.emitShapes();
|
|
520
922
|
|
|
@@ -526,9 +928,7 @@ class Compiler {
|
|
|
526
928
|
`id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
527
929
|
` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
|
|
528
930
|
(shapeXml ? shapeXml + "\n" : "") +
|
|
529
|
-
|
|
530
|
-
"\n" +
|
|
531
|
-
flowXml +
|
|
931
|
+
bodyXml +
|
|
532
932
|
"\n" +
|
|
533
933
|
` </bpmn:process>\n` +
|
|
534
934
|
messageXml +
|