@nanobpm/workflow 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/dist/client.d.ts +4 -4
- package/dist/client.js +85 -11
- package/dist/declarative.d.ts +46 -5
- package/dist/declarative.js +280 -28
- package/dist/envelope.js +10 -3
- package/dist/imperative.js +19 -8
- package/dist/layout.js +4 -1
- package/dist/types.d.ts +21 -1
- package/dist/worker.js +18 -8
- package/package.json +3 -3
- package/src/client.ts +102 -20
- package/src/declarative.ts +374 -52
- package/src/envelope.ts +10 -3
- package/src/imperative.ts +20 -9
- package/src/layout.ts +5 -1
- package/src/types.ts +27 -4
- package/src/worker.ts +19 -7
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",
|
|
@@ -52,9 +54,9 @@ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTi
|
|
|
52
54
|
// --- Authoring surface -------------------------------------------------------
|
|
53
55
|
|
|
54
56
|
/** The TS payload type of a contract's input envelope (untyped fallback). */
|
|
55
|
-
type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] : JsonObject;
|
|
57
|
+
type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] & JsonObject : JsonObject;
|
|
56
58
|
/** The TS payload type of a contract's output envelope (untyped fallback). */
|
|
57
|
-
type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] : JsonObject;
|
|
59
|
+
type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] & JsonObject : JsonObject;
|
|
58
60
|
/** The input payload type of step `K` under contracts `C`. */
|
|
59
61
|
type VarsOf<C, K extends string> = K extends keyof C ? InPayload<C[K]> : JsonObject;
|
|
60
62
|
/** The output payload type of step `K` under contracts `C`. */
|
|
@@ -62,7 +64,7 @@ type ResultOf<C, K extends string> = K extends keyof C ? OutPayload<C[K]> : Json
|
|
|
62
64
|
|
|
63
65
|
/** A typed handler for a `run` step: its job variables and result are resolved
|
|
64
66
|
* from the flow contracts by the step name. */
|
|
65
|
-
type TypedHandler<V, R> = (job: {
|
|
67
|
+
type TypedHandler<V extends JsonObject, R extends JsonObject> = (job: {
|
|
66
68
|
jobKey: string;
|
|
67
69
|
processInstanceKey: string;
|
|
68
70
|
elementId: string;
|
|
@@ -71,9 +73,20 @@ type TypedHandler<V, R> = (job: {
|
|
|
71
73
|
}) => Promise<R | void> | R | void;
|
|
72
74
|
|
|
73
75
|
/** A block: the callback that populates a nested body (a case, arm, or loop). */
|
|
74
|
-
type Block<C extends
|
|
76
|
+
type Block<C extends object> = (b: FlowBuilder<C>) => void;
|
|
75
77
|
|
|
76
|
-
|
|
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> {
|
|
77
90
|
/**
|
|
78
91
|
* A durable activity served by a worker THIS program hosts (a BPMN service
|
|
79
92
|
* task; the handler runs in the in-process `Worker`). If `name` is a key in
|
|
@@ -140,6 +153,41 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
140
153
|
* the loop run once `break()` is reached.
|
|
141
154
|
*/
|
|
142
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>;
|
|
143
191
|
/** Exit the enclosing loop (routes to whatever follows it). Only valid inside
|
|
144
192
|
* a `loop`. */
|
|
145
193
|
break(): FlowBuilder<C>;
|
|
@@ -160,7 +208,22 @@ interface BuilderCtx {
|
|
|
160
208
|
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
161
209
|
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
162
210
|
* invalid model, so reject them at authoring time. */
|
|
163
|
-
const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
|
|
211
|
+
const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
|
|
212
|
+
const TIMER_START_KEYS = ["cycle", "after", "at"] as const;
|
|
213
|
+
|
|
214
|
+
function timerStartValue(
|
|
215
|
+
spec: { cycle: string } | { after: string } | { at: string },
|
|
216
|
+
key: (typeof TIMER_START_KEYS)[number],
|
|
217
|
+
): string | undefined {
|
|
218
|
+
switch (key) {
|
|
219
|
+
case "cycle":
|
|
220
|
+
return "cycle" in spec ? spec.cycle : undefined;
|
|
221
|
+
case "after":
|
|
222
|
+
return "after" in spec ? spec.after : undefined;
|
|
223
|
+
case "at":
|
|
224
|
+
return "at" in spec ? spec.at : undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
164
227
|
|
|
165
228
|
function claimName(ctx: BuilderCtx, id: string, name: string): void {
|
|
166
229
|
assertIdent("step name", name);
|
|
@@ -169,7 +232,7 @@ function claimName(ctx: BuilderCtx, id: string, name: string): void {
|
|
|
169
232
|
}
|
|
170
233
|
if (RESERVED_PREFIXES.test(name)) {
|
|
171
234
|
throw new Error(
|
|
172
|
-
`step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`,
|
|
235
|
+
`step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`,
|
|
173
236
|
);
|
|
174
237
|
}
|
|
175
238
|
if (ctx.seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
@@ -186,7 +249,7 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
|
|
|
186
249
|
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
187
250
|
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
188
251
|
* combinators recurse with a fresh `out` array for each nested body. */
|
|
189
|
-
function makeBuilder<C extends
|
|
252
|
+
function makeBuilder<C extends object>(
|
|
190
253
|
id: string,
|
|
191
254
|
out: FlowNode[],
|
|
192
255
|
ctx: BuilderCtx,
|
|
@@ -198,81 +261,97 @@ function makeBuilder<C extends FlowContracts>(
|
|
|
198
261
|
fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: depth }));
|
|
199
262
|
return body;
|
|
200
263
|
};
|
|
201
|
-
|
|
202
|
-
|
|
264
|
+
// A body that runs in its OWN token scope (an embedded sub-process): the
|
|
265
|
+
// enclosing loop is not reachable across the scope boundary, so `break`/
|
|
266
|
+
// `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
|
|
267
|
+
// INSIDE the body still works — it creates its own scope.
|
|
268
|
+
const childScoped = (fn: Block<C>): FlowNode[] => {
|
|
269
|
+
const body: FlowNode[] = [];
|
|
270
|
+
fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: 0 }));
|
|
271
|
+
return body;
|
|
272
|
+
};
|
|
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> {
|
|
203
278
|
claimName(ctx, id, name);
|
|
204
279
|
if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
|
|
205
280
|
ctx.handlers[name] = handler;
|
|
206
281
|
out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
|
|
207
|
-
return b
|
|
282
|
+
return b;
|
|
208
283
|
},
|
|
209
284
|
task(name: string, opts?: { jobType?: string }): FlowBuilder<C> {
|
|
210
285
|
claimName(ctx, id, name);
|
|
211
286
|
const override = opts?.jobType;
|
|
212
287
|
if (override !== undefined) assertJobType("task jobType", override);
|
|
213
288
|
out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
|
|
214
|
-
return b
|
|
289
|
+
return b;
|
|
215
290
|
},
|
|
216
291
|
signal(name: string, opts: { correlationKey: string }): FlowBuilder<C> {
|
|
217
292
|
claimName(ctx, id, name);
|
|
218
293
|
if (!opts || !opts.correlationKey) throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
219
294
|
assertIdent("correlationKey", opts.correlationKey);
|
|
220
295
|
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
221
|
-
return b
|
|
296
|
+
return b;
|
|
222
297
|
},
|
|
223
|
-
timer(name: string, opts: { after
|
|
298
|
+
timer(name: string, opts: { after: string } | { at: string }): FlowBuilder<C> {
|
|
224
299
|
claimName(ctx, id, name);
|
|
225
|
-
const hasAfter = typeof opts
|
|
226
|
-
const hasAt = typeof opts
|
|
300
|
+
const hasAfter = "after" in opts && typeof opts.after === "string";
|
|
301
|
+
const hasAt = "at" in opts && typeof opts.at === "string";
|
|
227
302
|
if (hasAfter === hasAt) {
|
|
228
303
|
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
229
304
|
}
|
|
230
|
-
if (
|
|
231
|
-
const after =
|
|
305
|
+
if ("after" in opts) {
|
|
306
|
+
const after = opts.after.trim();
|
|
232
307
|
assertTimerDuration(`timer("${name}") after`, after);
|
|
233
308
|
out.push({ kind: "timer", name, after });
|
|
234
309
|
} else {
|
|
235
|
-
const at =
|
|
310
|
+
const at = opts.at.trim();
|
|
236
311
|
assertTimerDate(`timer("${name}") at`, at);
|
|
237
312
|
out.push({ kind: "timer", name, at });
|
|
238
313
|
}
|
|
239
|
-
return b
|
|
314
|
+
return b;
|
|
240
315
|
},
|
|
241
|
-
startOn(spec: { cycle
|
|
316
|
+
startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C> {
|
|
242
317
|
if (!isRoot) {
|
|
243
318
|
throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
|
|
244
319
|
}
|
|
245
320
|
if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
246
321
|
if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
322
|
+
const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
|
|
323
|
+
if (setCount !== 1) {
|
|
249
324
|
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
250
325
|
}
|
|
251
|
-
if (
|
|
326
|
+
if ("cycle" in spec) {
|
|
252
327
|
const cycle = spec.cycle.trim();
|
|
253
328
|
assertTimerCycle(`startOn cycle`, cycle);
|
|
254
329
|
ctx.startTimer = { cycle };
|
|
255
|
-
} else if (
|
|
330
|
+
} else if ("after" in spec) {
|
|
256
331
|
const after = spec.after.trim();
|
|
257
332
|
assertTimerDuration(`startOn after`, after);
|
|
258
333
|
ctx.startTimer = { after };
|
|
259
334
|
} else {
|
|
260
|
-
const at =
|
|
335
|
+
const at = spec.at.trim();
|
|
261
336
|
assertTimerDate(`startOn at`, at);
|
|
262
337
|
ctx.startTimer = { at };
|
|
263
338
|
}
|
|
264
|
-
return b
|
|
339
|
+
return b;
|
|
265
340
|
},
|
|
266
341
|
switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
|
|
267
342
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
268
343
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
269
344
|
}
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
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`);
|
|
273
352
|
const def = cases.default ? child(cases.default, false) : undefined;
|
|
274
353
|
out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
275
|
-
return b
|
|
354
|
+
return b;
|
|
276
355
|
},
|
|
277
356
|
branch(condition: string, arms: { then: Block<C>; else?: Block<C> }): FlowBuilder<C> {
|
|
278
357
|
if (typeof condition !== "string" || condition.trim() === "") {
|
|
@@ -285,25 +364,100 @@ function makeBuilder<C extends FlowContracts>(
|
|
|
285
364
|
then: child(arms.then, false),
|
|
286
365
|
else: arms.else ? child(arms.else, false) : undefined,
|
|
287
366
|
});
|
|
288
|
-
return b
|
|
367
|
+
return b;
|
|
289
368
|
},
|
|
290
369
|
loop(body: Block<C>): FlowBuilder<C> {
|
|
291
370
|
if (typeof body !== "function") throw new Error(`loop() needs a body function`);
|
|
292
371
|
out.push({ kind: "loop", body: child(body, true) });
|
|
293
|
-
return b
|
|
372
|
+
return b;
|
|
373
|
+
},
|
|
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;
|
|
294
448
|
},
|
|
295
449
|
break(): FlowBuilder<C> {
|
|
296
450
|
if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
|
|
297
451
|
out.push({ kind: "break" });
|
|
298
|
-
return b
|
|
452
|
+
return b;
|
|
299
453
|
},
|
|
300
454
|
continue(): FlowBuilder<C> {
|
|
301
455
|
if (ctx.loopDepth === 0) throw new Error(`continue() is only valid inside a loop`);
|
|
302
456
|
out.push({ kind: "continue" });
|
|
303
|
-
return b
|
|
457
|
+
return b;
|
|
304
458
|
},
|
|
305
459
|
};
|
|
306
|
-
return b
|
|
460
|
+
return b;
|
|
307
461
|
}
|
|
308
462
|
|
|
309
463
|
/**
|
|
@@ -327,14 +481,21 @@ export function defineFlow(
|
|
|
327
481
|
throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
|
|
328
482
|
}
|
|
329
483
|
const contracts: FlowContracts = typeof second === "function" ? {} : second;
|
|
330
|
-
|
|
331
|
-
if (typeof build !== "function") {
|
|
484
|
+
if (typeof second !== "function" && typeof third !== "function") {
|
|
332
485
|
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
333
486
|
}
|
|
334
487
|
const steps: FlowNode[] = [];
|
|
335
488
|
const handlers: Record<string, StepHandler> = {};
|
|
336
489
|
const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
337
|
-
|
|
490
|
+
if (typeof second === "function") {
|
|
491
|
+
second(makeBuilder(id, steps, ctx, true));
|
|
492
|
+
} else {
|
|
493
|
+
const build = third;
|
|
494
|
+
if (typeof build !== "function") {
|
|
495
|
+
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
496
|
+
}
|
|
497
|
+
build(makeBuilder<FlowContracts>(id, steps, ctx, true));
|
|
498
|
+
}
|
|
338
499
|
if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
|
|
339
500
|
const flow: DeclarativeFlow = { kind: "declarative", id, steps, handlers };
|
|
340
501
|
if (ctx.startTimer) flow.startTimer = ctx.startTimer;
|
|
@@ -360,6 +521,12 @@ export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void
|
|
|
360
521
|
case "loop":
|
|
361
522
|
walkNodes(n.body, visit);
|
|
362
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;
|
|
363
530
|
default:
|
|
364
531
|
break;
|
|
365
532
|
}
|
|
@@ -384,6 +551,16 @@ export function externalJobTypes(flow: DeclarativeFlow): string[] {
|
|
|
384
551
|
return types;
|
|
385
552
|
}
|
|
386
553
|
|
|
554
|
+
function requireTimerAt(node: { at?: string }): string {
|
|
555
|
+
if (node.at === undefined) throw new Error(`timer event is missing its { at } value`);
|
|
556
|
+
return node.at;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function requireStartAt(timer: TimerStart): string {
|
|
560
|
+
if (timer.at === undefined) throw new Error(`timer start is missing its { at } value`);
|
|
561
|
+
return timer.at;
|
|
562
|
+
}
|
|
563
|
+
|
|
387
564
|
// --- Model emitter (two-phase graph compiler) --------------------------------
|
|
388
565
|
|
|
389
566
|
interface RenderNode {
|
|
@@ -391,6 +568,9 @@ interface RenderNode {
|
|
|
391
568
|
render(incoming: string[], outgoing: string[]): string;
|
|
392
569
|
/** For exclusive gateways: the flow id of the unconditional default edge. */
|
|
393
570
|
defaultFlow?: string;
|
|
571
|
+
/** Id of the embedded sub-process this node lives in, or undefined at the
|
|
572
|
+
* top level (the root process). */
|
|
573
|
+
scope?: string;
|
|
394
574
|
}
|
|
395
575
|
|
|
396
576
|
interface Edge {
|
|
@@ -399,6 +579,9 @@ interface Edge {
|
|
|
399
579
|
to?: string;
|
|
400
580
|
condition?: string;
|
|
401
581
|
name?: string;
|
|
582
|
+
/** The scope (sub-process id, or undefined = root process) this flow is
|
|
583
|
+
* nested in — so it renders inside the right container. */
|
|
584
|
+
scope?: string;
|
|
402
585
|
}
|
|
403
586
|
|
|
404
587
|
interface LoopCtx {
|
|
@@ -411,13 +594,29 @@ class Compiler {
|
|
|
411
594
|
private readonly edges: Edge[] = [];
|
|
412
595
|
/** envelope name → its fields, deduped for lifting to a single nano:shape. */
|
|
413
596
|
private readonly envelopes = new Map<string, EnvelopeField[]>();
|
|
597
|
+
/** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
|
|
598
|
+
* node/edge created now nests inside it). Empty at the top level. */
|
|
599
|
+
private readonly scopeStack: string[] = [];
|
|
600
|
+
/** Edges with a resolved target, indexed in `compile()` before rendering so a
|
|
601
|
+
* sub-process can render its own nested scope. */
|
|
602
|
+
private live: Edge[] = [];
|
|
414
603
|
private seq = 0;
|
|
415
604
|
private gw = 0;
|
|
416
605
|
|
|
417
606
|
constructor(private readonly flow: DeclarativeFlow) {}
|
|
418
607
|
|
|
608
|
+
private currentScope(): string | undefined {
|
|
609
|
+
return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
|
|
610
|
+
}
|
|
611
|
+
|
|
419
612
|
private newEdge(from: string, opts: { condition?: string; name?: string } = {}): Edge {
|
|
420
|
-
const e: Edge = {
|
|
613
|
+
const e: Edge = {
|
|
614
|
+
id: `f_${this.seq++}`,
|
|
615
|
+
from,
|
|
616
|
+
condition: opts.condition,
|
|
617
|
+
name: opts.name,
|
|
618
|
+
scope: this.currentScope(),
|
|
619
|
+
};
|
|
421
620
|
this.edges.push(e);
|
|
422
621
|
return e;
|
|
423
622
|
}
|
|
@@ -440,7 +639,7 @@ class Compiler {
|
|
|
440
639
|
this.envelopes.set(env.name, env.fields);
|
|
441
640
|
}
|
|
442
641
|
|
|
443
|
-
private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }): void {
|
|
642
|
+
private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi = ""): void {
|
|
444
643
|
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
445
644
|
this.recordEnvelope(node.envelopes?.in);
|
|
446
645
|
this.recordEnvelope(node.envelopes?.out);
|
|
@@ -455,11 +654,13 @@ class Compiler {
|
|
|
455
654
|
const id = node.name;
|
|
456
655
|
this.nodes.push({
|
|
457
656
|
id,
|
|
657
|
+
scope: this.currentScope(),
|
|
458
658
|
render: (inc, outg) =>
|
|
459
659
|
` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
460
660
|
ext +
|
|
461
661
|
"\n" +
|
|
462
662
|
incomingOutgoing(inc, outg) +
|
|
663
|
+
mi +
|
|
463
664
|
` </bpmn:serviceTask>`,
|
|
464
665
|
});
|
|
465
666
|
}
|
|
@@ -469,6 +670,7 @@ class Compiler {
|
|
|
469
670
|
const msgId = `Msg_${id}`;
|
|
470
671
|
this.nodes.push({
|
|
471
672
|
id,
|
|
673
|
+
scope: this.currentScope(),
|
|
472
674
|
render: (inc, outg) =>
|
|
473
675
|
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
474
676
|
incomingOutgoing(inc, outg) +
|
|
@@ -482,9 +684,10 @@ class Compiler {
|
|
|
482
684
|
const body =
|
|
483
685
|
node.after !== undefined
|
|
484
686
|
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
485
|
-
: ` <bpmn:timeDate>${escapeXml(node
|
|
687
|
+
: ` <bpmn:timeDate>${escapeXml(requireTimerAt(node))}</bpmn:timeDate>\n`;
|
|
486
688
|
this.nodes.push({
|
|
487
689
|
id,
|
|
690
|
+
scope: this.currentScope(),
|
|
488
691
|
render: (inc, outg) =>
|
|
489
692
|
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
490
693
|
incomingOutgoing(inc, outg) +
|
|
@@ -498,6 +701,7 @@ class Compiler {
|
|
|
498
701
|
private addGateway(id: string, name?: string): RenderNode {
|
|
499
702
|
const gwNode: RenderNode = {
|
|
500
703
|
id,
|
|
704
|
+
scope: this.currentScope(),
|
|
501
705
|
render: (inc, outg) => {
|
|
502
706
|
const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
|
|
503
707
|
const nm = name ? ` name="${escapeXml(name)}"` : "";
|
|
@@ -512,6 +716,73 @@ class Compiler {
|
|
|
512
716
|
return gwNode;
|
|
513
717
|
}
|
|
514
718
|
|
|
719
|
+
private addParallelGateway(id: string): RenderNode {
|
|
720
|
+
const node: RenderNode = {
|
|
721
|
+
id,
|
|
722
|
+
scope: this.currentScope(),
|
|
723
|
+
render: (inc, outg) =>
|
|
724
|
+
` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
|
|
725
|
+
};
|
|
726
|
+
this.nodes.push(node);
|
|
727
|
+
return node;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private addPlainStart(id: string): void {
|
|
731
|
+
this.nodes.push({
|
|
732
|
+
id,
|
|
733
|
+
scope: this.currentScope(),
|
|
734
|
+
render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private addPlainEnd(id: string): void {
|
|
739
|
+
this.nodes.push({
|
|
740
|
+
id,
|
|
741
|
+
scope: this.currentScope(),
|
|
742
|
+
render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** An embedded sub-process whose body renders nested in its own scope, with the
|
|
747
|
+
* given multi-instance characteristics lifted onto it. */
|
|
748
|
+
private addSubProcess(id: string, mi: string): RenderNode {
|
|
749
|
+
const node: RenderNode = {
|
|
750
|
+
id,
|
|
751
|
+
scope: this.currentScope(),
|
|
752
|
+
render: (inc, outg) =>
|
|
753
|
+
` <bpmn:subProcess id="${escapeXml(id)}">\n` +
|
|
754
|
+
incomingOutgoing(inc, outg) +
|
|
755
|
+
mi +
|
|
756
|
+
this.renderScope(id) +
|
|
757
|
+
`\n </bpmn:subProcess>`,
|
|
758
|
+
};
|
|
759
|
+
this.nodes.push(node);
|
|
760
|
+
return node;
|
|
761
|
+
}
|
|
762
|
+
|
|
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
|
+
|
|
515
786
|
private emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[] {
|
|
516
787
|
let cur = incoming;
|
|
517
788
|
for (const node of list) cur = this.emitNode(node, cur, loop);
|
|
@@ -587,9 +858,65 @@ class Compiler {
|
|
|
587
858
|
this.connect(incoming, loop.headId);
|
|
588
859
|
return [];
|
|
589
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
|
+
}
|
|
590
900
|
}
|
|
591
901
|
}
|
|
592
902
|
|
|
903
|
+
/** Render every node + sequence flow that belongs to `scope` (undefined = the
|
|
904
|
+
* root process, a sub-process id otherwise). Called for the process body and,
|
|
905
|
+
* recursively, from each sub-process's render closure. */
|
|
906
|
+
private renderScope(scope: string | undefined): string {
|
|
907
|
+
const incomingOf = (id: string) => this.live.filter((e) => e.to === id).map((e) => e.id);
|
|
908
|
+
const outgoingOf = (id: string) => this.live.filter((e) => e.from === id).map((e) => e.id);
|
|
909
|
+
const nodeXml = this.nodes
|
|
910
|
+
.filter((n) => n.scope === scope)
|
|
911
|
+
.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
|
|
912
|
+
.join("\n");
|
|
913
|
+
const flowXml = this.live
|
|
914
|
+
.filter((e) => e.scope === scope)
|
|
915
|
+
.map((e) => sequenceFlow(e))
|
|
916
|
+
.join("\n");
|
|
917
|
+
return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
|
|
918
|
+
}
|
|
919
|
+
|
|
593
920
|
/** Render the Start event — a plain none start, or a timer start when the
|
|
594
921
|
* flow declared `startOn(...)`. */
|
|
595
922
|
private renderStart(outg: string[]): string {
|
|
@@ -600,7 +927,7 @@ class Compiler {
|
|
|
600
927
|
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
601
928
|
: t.after !== undefined
|
|
602
929
|
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
603
|
-
: `<bpmn:timeDate>${escapeXml(t
|
|
930
|
+
: `<bpmn:timeDate>${escapeXml(requireStartAt(t))}</bpmn:timeDate>`;
|
|
604
931
|
return (
|
|
605
932
|
` <bpmn:startEvent id="Start">\n` +
|
|
606
933
|
` ${outgoingOnly(outg)}\n` +
|
|
@@ -626,12 +953,9 @@ class Compiler {
|
|
|
626
953
|
this.connect(finalDanglers, "End");
|
|
627
954
|
|
|
628
955
|
// A sequence flow needs both ends; drop any edge that never got a target.
|
|
629
|
-
|
|
630
|
-
const incomingOf = (id: string) => live.filter((e) => e.to === id).map((e) => e.id);
|
|
631
|
-
const outgoingOf = (id: string) => live.filter((e) => e.from === id).map((e) => e.id);
|
|
956
|
+
this.live = this.edges.filter((e) => e.to !== undefined);
|
|
632
957
|
|
|
633
|
-
const
|
|
634
|
-
const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
|
|
958
|
+
const bodyXml = this.renderScope(undefined);
|
|
635
959
|
const messageXml = this.emitMessages();
|
|
636
960
|
const shapeXml = this.emitShapes();
|
|
637
961
|
|
|
@@ -643,9 +967,7 @@ class Compiler {
|
|
|
643
967
|
`id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
644
968
|
` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
|
|
645
969
|
(shapeXml ? shapeXml + "\n" : "") +
|
|
646
|
-
|
|
647
|
-
"\n" +
|
|
648
|
-
flowXml +
|
|
970
|
+
bodyXml +
|
|
649
971
|
"\n" +
|
|
650
972
|
` </bpmn:process>\n` +
|
|
651
973
|
messageXml +
|