@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/dist/declarative.js
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",
|
|
@@ -38,14 +40,25 @@ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTi
|
|
|
38
40
|
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
39
41
|
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
40
42
|
* invalid model, so reject them at authoring time. */
|
|
41
|
-
const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
|
|
43
|
+
const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
|
|
44
|
+
const TIMER_START_KEYS = ["cycle", "after", "at"];
|
|
45
|
+
function timerStartValue(spec, key) {
|
|
46
|
+
switch (key) {
|
|
47
|
+
case "cycle":
|
|
48
|
+
return "cycle" in spec ? spec.cycle : undefined;
|
|
49
|
+
case "after":
|
|
50
|
+
return "after" in spec ? spec.after : undefined;
|
|
51
|
+
case "at":
|
|
52
|
+
return "at" in spec ? spec.at : undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
42
55
|
function claimName(ctx, id, name) {
|
|
43
56
|
assertIdent("step name", name);
|
|
44
57
|
if (name === "Start" || name === "End" || name === id) {
|
|
45
58
|
throw new Error(`step name "${name}" is reserved (collides with a generated BPMN id) in flow "${id}"`);
|
|
46
59
|
}
|
|
47
60
|
if (RESERVED_PREFIXES.test(name)) {
|
|
48
|
-
throw new Error(`step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`);
|
|
61
|
+
throw new Error(`step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`);
|
|
49
62
|
}
|
|
50
63
|
if (ctx.seen.has(name))
|
|
51
64
|
throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
@@ -68,6 +81,15 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
68
81
|
fn(makeBuilder(id, body, { ...ctx, loopDepth: depth }));
|
|
69
82
|
return body;
|
|
70
83
|
};
|
|
84
|
+
// A body that runs in its OWN token scope (an embedded sub-process): the
|
|
85
|
+
// enclosing loop is not reachable across the scope boundary, so `break`/
|
|
86
|
+
// `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
|
|
87
|
+
// INSIDE the body still works — it creates its own scope.
|
|
88
|
+
const childScoped = (fn) => {
|
|
89
|
+
const body = [];
|
|
90
|
+
fn(makeBuilder(id, body, { ...ctx, loopDepth: 0 }));
|
|
91
|
+
return body;
|
|
92
|
+
};
|
|
71
93
|
const b = {
|
|
72
94
|
run(name, handler) {
|
|
73
95
|
claimName(ctx, id, name);
|
|
@@ -95,12 +117,12 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
95
117
|
},
|
|
96
118
|
timer(name, opts) {
|
|
97
119
|
claimName(ctx, id, name);
|
|
98
|
-
const hasAfter = typeof opts
|
|
99
|
-
const hasAt = typeof opts
|
|
120
|
+
const hasAfter = "after" in opts && typeof opts.after === "string";
|
|
121
|
+
const hasAt = "at" in opts && typeof opts.at === "string";
|
|
100
122
|
if (hasAfter === hasAt) {
|
|
101
123
|
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
102
124
|
}
|
|
103
|
-
if (
|
|
125
|
+
if ("after" in opts) {
|
|
104
126
|
const after = opts.after.trim();
|
|
105
127
|
assertTimerDuration(`timer("${name}") after`, after);
|
|
106
128
|
out.push({ kind: "timer", name, after });
|
|
@@ -120,16 +142,16 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
120
142
|
throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
121
143
|
if (ctx.startTimer)
|
|
122
144
|
throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
145
|
+
const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
|
|
146
|
+
if (setCount !== 1) {
|
|
125
147
|
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
126
148
|
}
|
|
127
|
-
if (
|
|
149
|
+
if ("cycle" in spec) {
|
|
128
150
|
const cycle = spec.cycle.trim();
|
|
129
151
|
assertTimerCycle(`startOn cycle`, cycle);
|
|
130
152
|
ctx.startTimer = { cycle };
|
|
131
153
|
}
|
|
132
|
-
else if (
|
|
154
|
+
else if ("after" in spec) {
|
|
133
155
|
const after = spec.after.trim();
|
|
134
156
|
assertTimerDuration(`startOn after`, after);
|
|
135
157
|
ctx.startTimer = { after };
|
|
@@ -145,10 +167,16 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
145
167
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
146
168
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
147
169
|
}
|
|
148
|
-
const
|
|
149
|
-
|
|
170
|
+
const caseNodes = [];
|
|
171
|
+
for (const [value, fn] of Object.entries(cases)) {
|
|
172
|
+
if (value === "default")
|
|
173
|
+
continue;
|
|
174
|
+
if (typeof fn !== "function")
|
|
175
|
+
throw new Error(`switch("${subject}") cases must be blocks (b) => {…}`);
|
|
176
|
+
caseNodes.push({ value, body: child(fn, false) });
|
|
177
|
+
}
|
|
178
|
+
if (caseNodes.length === 0)
|
|
150
179
|
throw new Error(`switch("${subject}") needs at least one case`);
|
|
151
|
-
const caseNodes = entries.map(([value, fn]) => ({ value, body: child(fn, false) }));
|
|
152
180
|
const def = cases.default ? child(cases.default, false) : undefined;
|
|
153
181
|
out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
154
182
|
return b;
|
|
@@ -173,6 +201,75 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
173
201
|
out.push({ kind: "loop", body: child(body, true) });
|
|
174
202
|
return b;
|
|
175
203
|
},
|
|
204
|
+
parallel(branches) {
|
|
205
|
+
if (!Array.isArray(branches) || branches.length < 2) {
|
|
206
|
+
throw new Error(`parallel() needs at least two branch blocks`);
|
|
207
|
+
}
|
|
208
|
+
for (const fn of branches) {
|
|
209
|
+
if (typeof fn !== "function")
|
|
210
|
+
throw new Error(`parallel() branches must all be blocks (b) => {…}`);
|
|
211
|
+
}
|
|
212
|
+
// Each branch runs in its own token scope up to the AND-join, so a
|
|
213
|
+
// `break`/`continue` must not escape the fork/join into an enclosing
|
|
214
|
+
// loop. Scope branches (loopDepth resets to 0) so cross-boundary
|
|
215
|
+
// break/continue is rejected at build time, matching forEach.
|
|
216
|
+
out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
|
|
217
|
+
return b;
|
|
218
|
+
},
|
|
219
|
+
forEach(collection, itemVar, body, opts) {
|
|
220
|
+
if (typeof collection !== "string" || collection.trim() === "") {
|
|
221
|
+
throw new Error(`forEach() needs a non-empty FEEL collection expression`);
|
|
222
|
+
}
|
|
223
|
+
// Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip
|
|
224
|
+
// it so the emitter's own "=" prefix never produces an invalid "==...".
|
|
225
|
+
const collExpr = collection.trim().replace(/^=/, "").trim();
|
|
226
|
+
if (collExpr === "") {
|
|
227
|
+
throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
|
|
228
|
+
}
|
|
229
|
+
assertIdent("forEach itemVar", itemVar);
|
|
230
|
+
if (typeof body !== "function")
|
|
231
|
+
throw new Error(`forEach("${itemVar}") needs a body function`);
|
|
232
|
+
const nodes = childScoped(body);
|
|
233
|
+
if (nodes.length === 0)
|
|
234
|
+
throw new Error(`forEach("${itemVar}") body declared no steps`);
|
|
235
|
+
const node = {
|
|
236
|
+
kind: "forEach",
|
|
237
|
+
collection: collExpr,
|
|
238
|
+
itemVar,
|
|
239
|
+
body: nodes,
|
|
240
|
+
};
|
|
241
|
+
if (opts?.sequential)
|
|
242
|
+
node.sequential = true;
|
|
243
|
+
if (opts?.outputCollection !== undefined) {
|
|
244
|
+
assertIdent("forEach outputCollection", opts.outputCollection);
|
|
245
|
+
node.outputCollection = opts.outputCollection;
|
|
246
|
+
}
|
|
247
|
+
if (opts?.outputElement !== undefined) {
|
|
248
|
+
if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
|
|
249
|
+
throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
|
|
250
|
+
}
|
|
251
|
+
const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
|
|
252
|
+
if (outEl === "") {
|
|
253
|
+
throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
|
|
254
|
+
}
|
|
255
|
+
node.outputElement = outEl;
|
|
256
|
+
}
|
|
257
|
+
if (opts?.completionCondition !== undefined) {
|
|
258
|
+
if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
|
|
259
|
+
throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
|
|
260
|
+
}
|
|
261
|
+
const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
|
|
262
|
+
if (compCond === "") {
|
|
263
|
+
throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
|
|
264
|
+
}
|
|
265
|
+
node.completionCondition = compCond;
|
|
266
|
+
}
|
|
267
|
+
if (node.outputElement && !node.outputCollection) {
|
|
268
|
+
throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
|
|
269
|
+
}
|
|
270
|
+
out.push(node);
|
|
271
|
+
return b;
|
|
272
|
+
},
|
|
176
273
|
break() {
|
|
177
274
|
if (ctx.loopDepth === 0)
|
|
178
275
|
throw new Error(`break() is only valid inside a loop`);
|
|
@@ -194,14 +291,22 @@ export function defineFlow(id, second, third) {
|
|
|
194
291
|
throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
|
|
195
292
|
}
|
|
196
293
|
const contracts = typeof second === "function" ? {} : second;
|
|
197
|
-
|
|
198
|
-
if (typeof build !== "function") {
|
|
294
|
+
if (typeof second !== "function" && typeof third !== "function") {
|
|
199
295
|
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
200
296
|
}
|
|
201
297
|
const steps = [];
|
|
202
298
|
const handlers = {};
|
|
203
299
|
const ctx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
204
|
-
|
|
300
|
+
if (typeof second === "function") {
|
|
301
|
+
second(makeBuilder(id, steps, ctx, true));
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
const build = third;
|
|
305
|
+
if (typeof build !== "function") {
|
|
306
|
+
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
307
|
+
}
|
|
308
|
+
build(makeBuilder(id, steps, ctx, true));
|
|
309
|
+
}
|
|
205
310
|
if (steps.length === 0)
|
|
206
311
|
throw new Error(`flow "${id}" declared no steps`);
|
|
207
312
|
const flow = { kind: "declarative", id, steps, handlers };
|
|
@@ -230,6 +335,13 @@ export function walkNodes(nodes, visit) {
|
|
|
230
335
|
case "loop":
|
|
231
336
|
walkNodes(n.body, visit);
|
|
232
337
|
break;
|
|
338
|
+
case "parallel":
|
|
339
|
+
for (const branch of n.branches)
|
|
340
|
+
walkNodes(branch, visit);
|
|
341
|
+
break;
|
|
342
|
+
case "forEach":
|
|
343
|
+
walkNodes(n.body, visit);
|
|
344
|
+
break;
|
|
233
345
|
default:
|
|
234
346
|
break;
|
|
235
347
|
}
|
|
@@ -254,19 +366,44 @@ export function externalJobTypes(flow) {
|
|
|
254
366
|
});
|
|
255
367
|
return types;
|
|
256
368
|
}
|
|
369
|
+
function requireTimerAt(node) {
|
|
370
|
+
if (node.at === undefined)
|
|
371
|
+
throw new Error(`timer event is missing its { at } value`);
|
|
372
|
+
return node.at;
|
|
373
|
+
}
|
|
374
|
+
function requireStartAt(timer) {
|
|
375
|
+
if (timer.at === undefined)
|
|
376
|
+
throw new Error(`timer start is missing its { at } value`);
|
|
377
|
+
return timer.at;
|
|
378
|
+
}
|
|
257
379
|
class Compiler {
|
|
258
380
|
flow;
|
|
259
381
|
nodes = [];
|
|
260
382
|
edges = [];
|
|
261
383
|
/** envelope name → its fields, deduped for lifting to a single nano:shape. */
|
|
262
384
|
envelopes = new Map();
|
|
385
|
+
/** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
|
|
386
|
+
* node/edge created now nests inside it). Empty at the top level. */
|
|
387
|
+
scopeStack = [];
|
|
388
|
+
/** Edges with a resolved target, indexed in `compile()` before rendering so a
|
|
389
|
+
* sub-process can render its own nested scope. */
|
|
390
|
+
live = [];
|
|
263
391
|
seq = 0;
|
|
264
392
|
gw = 0;
|
|
265
393
|
constructor(flow) {
|
|
266
394
|
this.flow = flow;
|
|
267
395
|
}
|
|
396
|
+
currentScope() {
|
|
397
|
+
return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
|
|
398
|
+
}
|
|
268
399
|
newEdge(from, opts = {}) {
|
|
269
|
-
const e = {
|
|
400
|
+
const e = {
|
|
401
|
+
id: `f_${this.seq++}`,
|
|
402
|
+
from,
|
|
403
|
+
condition: opts.condition,
|
|
404
|
+
name: opts.name,
|
|
405
|
+
scope: this.currentScope(),
|
|
406
|
+
};
|
|
270
407
|
this.edges.push(e);
|
|
271
408
|
return e;
|
|
272
409
|
}
|
|
@@ -286,7 +423,7 @@ class Compiler {
|
|
|
286
423
|
}
|
|
287
424
|
this.envelopes.set(env.name, env.fields);
|
|
288
425
|
}
|
|
289
|
-
addServiceTask(node) {
|
|
426
|
+
addServiceTask(node, mi = "") {
|
|
290
427
|
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
291
428
|
this.recordEnvelope(node.envelopes?.in);
|
|
292
429
|
this.recordEnvelope(node.envelopes?.out);
|
|
@@ -302,10 +439,12 @@ class Compiler {
|
|
|
302
439
|
const id = node.name;
|
|
303
440
|
this.nodes.push({
|
|
304
441
|
id,
|
|
442
|
+
scope: this.currentScope(),
|
|
305
443
|
render: (inc, outg) => ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
306
444
|
ext +
|
|
307
445
|
"\n" +
|
|
308
446
|
incomingOutgoing(inc, outg) +
|
|
447
|
+
mi +
|
|
309
448
|
` </bpmn:serviceTask>`,
|
|
310
449
|
});
|
|
311
450
|
}
|
|
@@ -314,6 +453,7 @@ class Compiler {
|
|
|
314
453
|
const msgId = `Msg_${id}`;
|
|
315
454
|
this.nodes.push({
|
|
316
455
|
id,
|
|
456
|
+
scope: this.currentScope(),
|
|
317
457
|
render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
318
458
|
incomingOutgoing(inc, outg) +
|
|
319
459
|
` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
|
|
@@ -324,9 +464,10 @@ class Compiler {
|
|
|
324
464
|
const id = node.name;
|
|
325
465
|
const body = node.after !== undefined
|
|
326
466
|
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
327
|
-
: ` <bpmn:timeDate>${escapeXml(node
|
|
467
|
+
: ` <bpmn:timeDate>${escapeXml(requireTimerAt(node))}</bpmn:timeDate>\n`;
|
|
328
468
|
this.nodes.push({
|
|
329
469
|
id,
|
|
470
|
+
scope: this.currentScope(),
|
|
330
471
|
render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
331
472
|
incomingOutgoing(inc, outg) +
|
|
332
473
|
` <bpmn:timerEventDefinition>\n` +
|
|
@@ -338,6 +479,7 @@ class Compiler {
|
|
|
338
479
|
addGateway(id, name) {
|
|
339
480
|
const gwNode = {
|
|
340
481
|
id,
|
|
482
|
+
scope: this.currentScope(),
|
|
341
483
|
render: (inc, outg) => {
|
|
342
484
|
const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
|
|
343
485
|
const nm = name ? ` name="${escapeXml(name)}"` : "";
|
|
@@ -349,6 +491,66 @@ class Compiler {
|
|
|
349
491
|
this.nodes.push(gwNode);
|
|
350
492
|
return gwNode;
|
|
351
493
|
}
|
|
494
|
+
addParallelGateway(id) {
|
|
495
|
+
const node = {
|
|
496
|
+
id,
|
|
497
|
+
scope: this.currentScope(),
|
|
498
|
+
render: (inc, outg) => ` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
|
|
499
|
+
};
|
|
500
|
+
this.nodes.push(node);
|
|
501
|
+
return node;
|
|
502
|
+
}
|
|
503
|
+
addPlainStart(id) {
|
|
504
|
+
this.nodes.push({
|
|
505
|
+
id,
|
|
506
|
+
scope: this.currentScope(),
|
|
507
|
+
render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
addPlainEnd(id) {
|
|
511
|
+
this.nodes.push({
|
|
512
|
+
id,
|
|
513
|
+
scope: this.currentScope(),
|
|
514
|
+
render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/** An embedded sub-process whose body renders nested in its own scope, with the
|
|
518
|
+
* given multi-instance characteristics lifted onto it. */
|
|
519
|
+
addSubProcess(id, mi) {
|
|
520
|
+
const node = {
|
|
521
|
+
id,
|
|
522
|
+
scope: this.currentScope(),
|
|
523
|
+
render: (inc, outg) => ` <bpmn:subProcess id="${escapeXml(id)}">\n` +
|
|
524
|
+
incomingOutgoing(inc, outg) +
|
|
525
|
+
mi +
|
|
526
|
+
this.renderScope(id) +
|
|
527
|
+
`\n </bpmn:subProcess>`,
|
|
528
|
+
};
|
|
529
|
+
this.nodes.push(node);
|
|
530
|
+
return node;
|
|
531
|
+
}
|
|
532
|
+
/** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node —
|
|
533
|
+
* the same shape whether lifted onto a leaf service task or a sub-process. */
|
|
534
|
+
miCharacteristics(node) {
|
|
535
|
+
const seq = node.sequential ? "true" : "false";
|
|
536
|
+
const attrs = [
|
|
537
|
+
`inputCollection="${escapeXml(`=${node.collection}`)}"`,
|
|
538
|
+
`inputElement="${escapeXml(node.itemVar)}"`,
|
|
539
|
+
];
|
|
540
|
+
if (node.outputCollection)
|
|
541
|
+
attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
|
|
542
|
+
if (node.outputElement)
|
|
543
|
+
attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
|
|
544
|
+
const completion = node.completionCondition
|
|
545
|
+
? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
|
|
546
|
+
: "";
|
|
547
|
+
return (` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
|
|
548
|
+
` <bpmn:extensionElements>\n` +
|
|
549
|
+
` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
|
|
550
|
+
` </bpmn:extensionElements>\n` +
|
|
551
|
+
completion +
|
|
552
|
+
` </bpmn:multiInstanceLoopCharacteristics>\n`);
|
|
553
|
+
}
|
|
352
554
|
emitList(list, incoming, loop) {
|
|
353
555
|
let cur = incoming;
|
|
354
556
|
for (const node of list)
|
|
@@ -426,8 +628,63 @@ class Compiler {
|
|
|
426
628
|
this.connect(incoming, loop.headId);
|
|
427
629
|
return [];
|
|
428
630
|
}
|
|
631
|
+
case "parallel": {
|
|
632
|
+
// A diverging parallel gateway forks a token onto every branch; a
|
|
633
|
+
// converging one joins them (AND-join) into a single continuation.
|
|
634
|
+
const splitId = `Gw_${this.gw++}`;
|
|
635
|
+
this.addParallelGateway(splitId);
|
|
636
|
+
this.connect(incoming, splitId);
|
|
637
|
+
const joinId = `Gw_${this.gw++}`;
|
|
638
|
+
this.addParallelGateway(joinId);
|
|
639
|
+
for (const branch of node.branches) {
|
|
640
|
+
const e = this.newEdge(splitId);
|
|
641
|
+
const branchOut = this.emitList(branch, [e], loop);
|
|
642
|
+
this.connect(branchOut, joinId);
|
|
643
|
+
}
|
|
644
|
+
return [this.newEdge(joinId)];
|
|
645
|
+
}
|
|
646
|
+
case "forEach": {
|
|
647
|
+
const mi = this.miCharacteristics(node);
|
|
648
|
+
const only = node.body.length === 1 ? node.body[0] : undefined;
|
|
649
|
+
// A single service-task body carries the MI characteristics directly; a
|
|
650
|
+
// multi-step body is wrapped in an embedded MI sub-process (its own token
|
|
651
|
+
// scope, with its own start/end).
|
|
652
|
+
if (only && (only.kind === "run" || only.kind === "task")) {
|
|
653
|
+
this.addServiceTask(only, mi);
|
|
654
|
+
this.connect(incoming, only.name);
|
|
655
|
+
return [this.newEdge(only.name)];
|
|
656
|
+
}
|
|
657
|
+
const subId = `Sub_${this.gw++}`;
|
|
658
|
+
this.addSubProcess(subId, mi);
|
|
659
|
+
this.connect(incoming, subId);
|
|
660
|
+
this.scopeStack.push(subId);
|
|
661
|
+
const startId = `${subId}_start`;
|
|
662
|
+
this.addPlainStart(startId);
|
|
663
|
+
const innerOut = this.emitList(node.body, [this.newEdge(startId)], null);
|
|
664
|
+
const endId = `${subId}_end`;
|
|
665
|
+
this.addPlainEnd(endId);
|
|
666
|
+
this.connect(innerOut, endId);
|
|
667
|
+
this.scopeStack.pop();
|
|
668
|
+
return [this.newEdge(subId)];
|
|
669
|
+
}
|
|
429
670
|
}
|
|
430
671
|
}
|
|
672
|
+
/** Render every node + sequence flow that belongs to `scope` (undefined = the
|
|
673
|
+
* root process, a sub-process id otherwise). Called for the process body and,
|
|
674
|
+
* recursively, from each sub-process's render closure. */
|
|
675
|
+
renderScope(scope) {
|
|
676
|
+
const incomingOf = (id) => this.live.filter((e) => e.to === id).map((e) => e.id);
|
|
677
|
+
const outgoingOf = (id) => this.live.filter((e) => e.from === id).map((e) => e.id);
|
|
678
|
+
const nodeXml = this.nodes
|
|
679
|
+
.filter((n) => n.scope === scope)
|
|
680
|
+
.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
|
|
681
|
+
.join("\n");
|
|
682
|
+
const flowXml = this.live
|
|
683
|
+
.filter((e) => e.scope === scope)
|
|
684
|
+
.map((e) => sequenceFlow(e))
|
|
685
|
+
.join("\n");
|
|
686
|
+
return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
|
|
687
|
+
}
|
|
431
688
|
/** Render the Start event — a plain none start, or a timer start when the
|
|
432
689
|
* flow declared `startOn(...)`. */
|
|
433
690
|
renderStart(outg) {
|
|
@@ -438,7 +695,7 @@ class Compiler {
|
|
|
438
695
|
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
439
696
|
: t.after !== undefined
|
|
440
697
|
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
441
|
-
: `<bpmn:timeDate>${escapeXml(t
|
|
698
|
+
: `<bpmn:timeDate>${escapeXml(requireStartAt(t))}</bpmn:timeDate>`;
|
|
442
699
|
return (` <bpmn:startEvent id="Start">\n` +
|
|
443
700
|
` ${outgoingOnly(outg)}\n` +
|
|
444
701
|
` <bpmn:timerEventDefinition>\n` +
|
|
@@ -460,11 +717,8 @@ class Compiler {
|
|
|
460
717
|
});
|
|
461
718
|
this.connect(finalDanglers, "End");
|
|
462
719
|
// A sequence flow needs both ends; drop any edge that never got a target.
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
const outgoingOf = (id) => live.filter((e) => e.from === id).map((e) => e.id);
|
|
466
|
-
const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
|
|
467
|
-
const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
|
|
720
|
+
this.live = this.edges.filter((e) => e.to !== undefined);
|
|
721
|
+
const bodyXml = this.renderScope(undefined);
|
|
468
722
|
const messageXml = this.emitMessages();
|
|
469
723
|
const shapeXml = this.emitShapes();
|
|
470
724
|
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
@@ -474,9 +728,7 @@ class Compiler {
|
|
|
474
728
|
`id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
475
729
|
` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
|
|
476
730
|
(shapeXml ? shapeXml + "\n" : "") +
|
|
477
|
-
|
|
478
|
-
"\n" +
|
|
479
|
-
flowXml +
|
|
731
|
+
bodyXml +
|
|
480
732
|
"\n" +
|
|
481
733
|
` </bpmn:process>\n` +
|
|
482
734
|
messageXml +
|
package/dist/envelope.js
CHANGED
|
@@ -29,6 +29,15 @@ const SCALARS = new Set([
|
|
|
29
29
|
"boolean",
|
|
30
30
|
"datetime",
|
|
31
31
|
]);
|
|
32
|
+
class EnvelopeDef {
|
|
33
|
+
name;
|
|
34
|
+
fields;
|
|
35
|
+
type;
|
|
36
|
+
constructor(name, fields) {
|
|
37
|
+
this.name = name;
|
|
38
|
+
this.fields = fields;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
32
41
|
function normaliseField(name, spec) {
|
|
33
42
|
if (spec === null || (typeof spec !== "string" && typeof spec !== "object")) {
|
|
34
43
|
throw new Error(`envelope field "${name}": must be a scalar type or a { type, optional?, list? } object`);
|
|
@@ -56,7 +65,5 @@ export function envelope(name, fields) {
|
|
|
56
65
|
throw new Error(`envelope "${name}" declares no fields`);
|
|
57
66
|
for (const f of list)
|
|
58
67
|
assertIdent("envelope field name", f.name);
|
|
59
|
-
|
|
60
|
-
// minimal while the declared type carries the inference.
|
|
61
|
-
return { name, fields: list, type: undefined };
|
|
68
|
+
return new EnvelopeDef(name, list);
|
|
62
69
|
}
|
package/dist/imperative.js
CHANGED
|
@@ -55,6 +55,17 @@ export function imperativeToBpmn(wf) {
|
|
|
55
55
|
`</bpmn:definitions>\n`);
|
|
56
56
|
}
|
|
57
57
|
const SUSPEND = Symbol("nanobpm.workflow.suspend");
|
|
58
|
+
class SuspendError extends Error {
|
|
59
|
+
[SUSPEND];
|
|
60
|
+
constructor(key, result) {
|
|
61
|
+
super("nanobpm.workflow suspend");
|
|
62
|
+
this[SUSPEND] = { key, result };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function assertReplayValue(_value, _name) {
|
|
66
|
+
// The journal value was recorded from the same ordinal/name run call on an
|
|
67
|
+
// earlier pass; TypeScript cannot express that cross-replay invariant.
|
|
68
|
+
}
|
|
58
69
|
/**
|
|
59
70
|
* Replay the orchestration function against a journal. Returns `{ done: true }`
|
|
60
71
|
* if the function ran to completion, or the frontier step (the one un-recorded
|
|
@@ -70,12 +81,13 @@ export async function replayOnce(wf, input, journal) {
|
|
|
70
81
|
async run(name, fn) {
|
|
71
82
|
const key = `${++ordinal}:${name}`;
|
|
72
83
|
if (Object.prototype.hasOwnProperty.call(journal, key)) {
|
|
73
|
-
|
|
84
|
+
const value = journal[key];
|
|
85
|
+
assertReplayValue(value, name);
|
|
86
|
+
return value; // replay: return recorded value, no side effect
|
|
74
87
|
}
|
|
75
|
-
const result = (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
throw carrier;
|
|
88
|
+
const result = (await fn()) ?? null; // frontier: the ONE real side effect this turn
|
|
89
|
+
assertReplayValue(result, name);
|
|
90
|
+
throw new SuspendError(key, result);
|
|
79
91
|
},
|
|
80
92
|
};
|
|
81
93
|
try {
|
|
@@ -83,9 +95,8 @@ export async function replayOnce(wf, input, journal) {
|
|
|
83
95
|
return { done: true };
|
|
84
96
|
}
|
|
85
97
|
catch (e) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return { done: false, frontier: s };
|
|
98
|
+
if (e instanceof SuspendError)
|
|
99
|
+
return { done: false, frontier: e[SUSPEND] };
|
|
89
100
|
throw e; // a genuine error in the orchestration or a handler
|
|
90
101
|
}
|
|
91
102
|
}
|
package/dist/layout.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// stays authoritative. Re-run layout whenever the flow changes rather than
|
|
17
17
|
// hand-editing the generated diagram.
|
|
18
18
|
import { declarativeToBpmn } from "./declarative.js";
|
|
19
|
+
function isRecord(v) {
|
|
20
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
21
|
+
}
|
|
19
22
|
/**
|
|
20
23
|
* Generate BPMN diagram interchange (DI) for a BPMN XML string using bpmn-io's
|
|
21
24
|
* `bpmn-auto-layout`, returning the same model with an auto-laid-out diagram
|
|
@@ -36,7 +39,7 @@ export async function layoutBpmn(bpmnXml) {
|
|
|
36
39
|
// genuinely cannot be resolved. A different failure (the package is present
|
|
37
40
|
// but fails to load — syntax, a broken transitive dep, a runtime throw at
|
|
38
41
|
// import time) must surface as-is so it isn't masked as a missing dependency.
|
|
39
|
-
if (cause
|
|
42
|
+
if (!isRecord(cause) || cause.code !== "ERR_MODULE_NOT_FOUND") {
|
|
40
43
|
throw cause;
|
|
41
44
|
}
|
|
42
45
|
throw new Error('layoutBpmn requires the optional peer dependency "bpmn-auto-layout". ' +
|
package/dist/types.d.ts
CHANGED
|
@@ -34,7 +34,9 @@ export interface StepContract {
|
|
|
34
34
|
* untyped `JsonObject`. */
|
|
35
35
|
export type FlowContracts = Record<string, StepContract>;
|
|
36
36
|
/** Handler for a declarative `run` step: does real work, returns variables. */
|
|
37
|
-
export type StepHandler =
|
|
37
|
+
export type StepHandler<V extends JsonObject = JsonObject, R extends JsonObject = JsonObject> = {
|
|
38
|
+
bivarianceHack(job: Job<V>): Promise<R | void> | R | void;
|
|
39
|
+
}["bivarianceHack"];
|
|
38
40
|
/** A node in a declarative flow tree. Leaf activities carry optional data
|
|
39
41
|
* envelopes (lifted to `nano:shape` + `dataEnvelope` in the model); structural
|
|
40
42
|
* combinators carry nested `FlowNode[]` bodies. */
|
|
@@ -72,6 +74,24 @@ export type FlowNode = {
|
|
|
72
74
|
kind: "break";
|
|
73
75
|
} | {
|
|
74
76
|
kind: "continue";
|
|
77
|
+
} | {
|
|
78
|
+
kind: "parallel";
|
|
79
|
+
branches: FlowNode[][];
|
|
80
|
+
} | {
|
|
81
|
+
kind: "forEach";
|
|
82
|
+
/** FEEL expression evaluating to the list to fan out over. */
|
|
83
|
+
collection: string;
|
|
84
|
+
/** Variable each item is bound to in the child's scope (`inputElement`). */
|
|
85
|
+
itemVar: string;
|
|
86
|
+
body: FlowNode[];
|
|
87
|
+
/** Run children one at a time (sequential MI) instead of all at once. */
|
|
88
|
+
sequential?: boolean;
|
|
89
|
+
/** List variable that each child's `outputElement` is collected into. */
|
|
90
|
+
outputCollection?: string;
|
|
91
|
+
/** FEEL expression producing each child's contribution to `outputCollection`. */
|
|
92
|
+
outputElement?: string;
|
|
93
|
+
/** FEEL boolean that, once true, completes the body early. */
|
|
94
|
+
completionCondition?: string;
|
|
75
95
|
};
|
|
76
96
|
/** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
|
|
77
97
|
* delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
|
package/dist/worker.js
CHANGED
|
@@ -17,6 +17,9 @@ import { WorkflowClient } from "./client.js";
|
|
|
17
17
|
import { replayOnce } from "./imperative.js";
|
|
18
18
|
import { walkNodes } from "./declarative.js";
|
|
19
19
|
import { jobType } from "./xml.js";
|
|
20
|
+
function isJsonObject(v) {
|
|
21
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
}
|
|
20
23
|
export class Worker {
|
|
21
24
|
client;
|
|
22
25
|
name;
|
|
@@ -47,21 +50,25 @@ export class Worker {
|
|
|
47
50
|
this.addRoute(wf.orchestrateType, {
|
|
48
51
|
workflowId: wf.id,
|
|
49
52
|
handle: async (job) => {
|
|
50
|
-
const input = job.variables.input
|
|
51
|
-
const journal = job.variables.journal
|
|
53
|
+
const input = isJsonObject(job.variables.input) ? job.variables.input : {};
|
|
54
|
+
const journal = isJsonObject(job.variables.journal) ? job.variables.journal : {};
|
|
52
55
|
const step = await replayOnce(wf, input, journal);
|
|
53
|
-
if (step.done)
|
|
54
|
-
|
|
56
|
+
if (step.done) {
|
|
57
|
+
const variables = { wfDone: true };
|
|
58
|
+
return { variables, step: "__done" };
|
|
59
|
+
}
|
|
55
60
|
const next = { ...journal, [step.frontier.key]: step.frontier.result };
|
|
56
|
-
|
|
61
|
+
const variables = { journal: next, wfDone: false };
|
|
62
|
+
return { variables, step: step.frontier.key };
|
|
57
63
|
},
|
|
58
64
|
});
|
|
59
65
|
}
|
|
60
66
|
else {
|
|
61
67
|
// Walk the flow tree; only `run` steps are hosted locally. `signal`
|
|
62
68
|
// (message catch) and `task` (external worker) contribute no in-process
|
|
63
|
-
// route, and structural combinators (`switch`/`branch`/`loop
|
|
64
|
-
// routing with no job of their own
|
|
69
|
+
// route, and structural combinators (`switch`/`branch`/`loop`/`parallel`/
|
|
70
|
+
// `forEach`) are pure routing with no job of their own — but the `run`
|
|
71
|
+
// steps NESTED inside them are hosted, since `walkNodes` recurses.
|
|
65
72
|
walkNodes(wf.steps, (s) => {
|
|
66
73
|
if (s.kind !== "run")
|
|
67
74
|
return;
|
|
@@ -75,7 +82,10 @@ export class Worker {
|
|
|
75
82
|
}
|
|
76
83
|
this.addRoute(jobType(wf.id, s.name), {
|
|
77
84
|
workflowId: wf.id,
|
|
78
|
-
handle: async (job) =>
|
|
85
|
+
handle: async (job) => {
|
|
86
|
+
const variables = (await handler(job)) ?? {};
|
|
87
|
+
return { variables };
|
|
88
|
+
},
|
|
79
89
|
});
|
|
80
90
|
});
|
|
81
91
|
}
|