@nanobpm/workflow 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/declarative.d.ts +172 -10
- package/dist/declarative.js +213 -355
- package/dist/nodes/branch.d.ts +11 -0
- package/dist/nodes/branch.js +40 -0
- package/dist/nodes/break.d.ts +8 -0
- package/dist/nodes/break.js +19 -0
- package/dist/nodes/continue.d.ts +8 -0
- package/dist/nodes/continue.js +18 -0
- package/dist/nodes/forEach.d.ts +24 -0
- package/dist/nodes/forEach.js +104 -0
- package/dist/nodes/index.d.ts +11 -0
- package/dist/nodes/index.js +18 -0
- package/dist/nodes/loop.d.ts +9 -0
- package/dist/nodes/loop.js +25 -0
- package/dist/nodes/parallel.d.ts +9 -0
- package/dist/nodes/parallel.js +40 -0
- package/dist/nodes/registry.d.ts +33 -0
- package/dist/nodes/registry.js +71 -0
- package/dist/nodes/run.d.ts +10 -0
- package/dist/nodes/run.js +21 -0
- package/dist/nodes/signal.d.ts +11 -0
- package/dist/nodes/signal.js +21 -0
- package/dist/nodes/switch.d.ts +11 -0
- package/dist/nodes/switch.js +51 -0
- package/dist/nodes/task.d.ts +11 -0
- package/dist/nodes/task.js +21 -0
- package/dist/nodes/timer.d.ts +9 -0
- package/dist/nodes/timer.js +34 -0
- package/dist/test-support/diff.d.ts +8 -0
- package/dist/test-support/diff.js +70 -0
- package/dist/test-support/index.d.ts +8 -0
- package/dist/test-support/index.js +49 -0
- package/dist/test-support/normalize.d.ts +19 -0
- package/dist/test-support/normalize.js +245 -0
- package/dist/test-support/parity.d.ts +28 -0
- package/dist/test-support/parity.js +61 -0
- package/dist/test-support/xml.d.ts +17 -0
- package/dist/test-support/xml.js +135 -0
- package/dist/types.d.ts +20 -56
- package/package.json +14 -6
- package/src/declarative.ts +327 -359
- package/src/nodes/README.md +123 -0
- package/src/nodes/branch.ts +48 -0
- package/src/nodes/break.ts +25 -0
- package/src/nodes/continue.ts +24 -0
- package/src/nodes/forEach.ts +137 -0
- package/src/nodes/index.ts +19 -0
- package/src/nodes/loop.ts +34 -0
- package/src/nodes/parallel.ts +48 -0
- package/src/nodes/registry.ts +105 -0
- package/src/nodes/run.ts +29 -0
- package/src/nodes/signal.ts +29 -0
- package/src/nodes/switch.ts +56 -0
- package/src/nodes/task.ts +29 -0
- package/src/nodes/timer.ts +42 -0
- package/src/test-support/diff.ts +69 -0
- package/src/test-support/index.ts +53 -0
- package/src/test-support/normalize.ts +274 -0
- package/src/test-support/parity.ts +85 -0
- package/src/test-support/xml.ts +140 -0
- package/src/types.ts +20 -30
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Built-in flow-node kind: `branch` — a two-way exclusive choice on a FEEL
|
|
2
|
+
// boolean condition (a BPMN exclusive gateway); the `then` arm is guarded, the
|
|
3
|
+
// `else` arm is the gateway default. Contributed through the extension seam.
|
|
4
|
+
import { feel } from "../declarative.js";
|
|
5
|
+
import { registerNodeKind } from "./registry.js";
|
|
6
|
+
registerNodeKind("branch", {
|
|
7
|
+
build: (api) => (condition, arms) => {
|
|
8
|
+
if (typeof condition !== "string" || condition.trim() === "") {
|
|
9
|
+
throw new Error(`branch() needs a non-empty FEEL condition`);
|
|
10
|
+
}
|
|
11
|
+
if (!arms || typeof arms.then !== "function")
|
|
12
|
+
throw new Error(`branch("${condition}") needs a then arm`);
|
|
13
|
+
if (arms.else !== undefined && typeof arms.else !== "function") {
|
|
14
|
+
throw new Error(`branch("${condition}") else arm must be a block (b) => {…}`);
|
|
15
|
+
}
|
|
16
|
+
api.out.push({
|
|
17
|
+
kind: "branch",
|
|
18
|
+
condition,
|
|
19
|
+
then: api.child(arms.then, false),
|
|
20
|
+
else: arms.else ? api.child(arms.else, false) : undefined,
|
|
21
|
+
});
|
|
22
|
+
return api.self();
|
|
23
|
+
},
|
|
24
|
+
walk: (node, recurse) => {
|
|
25
|
+
recurse(node.then);
|
|
26
|
+
if (node.else)
|
|
27
|
+
recurse(node.else);
|
|
28
|
+
},
|
|
29
|
+
emit: (node, incoming, loop, api) => {
|
|
30
|
+
const id = `Gw_${api.nextGw()}`;
|
|
31
|
+
const gw = api.addGateway(id);
|
|
32
|
+
api.connect(incoming, id);
|
|
33
|
+
const te = api.newEdge(id, { condition: feel(node.condition), name: "then" });
|
|
34
|
+
const out = api.emitList(node.then, [te], loop);
|
|
35
|
+
const ee = api.newEdge(id, { name: "else" });
|
|
36
|
+
gw.defaultFlow = ee.id;
|
|
37
|
+
out.push(...api.emitList(node.else ?? [], [ee], loop));
|
|
38
|
+
return out;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Built-in flow-node kind: `break` — exit the enclosing loop (routes to whatever
|
|
2
|
+
// follows it). Only valid inside a `loop`. Contributed through the extension seam.
|
|
3
|
+
import { registerNodeKind } from "./registry.js";
|
|
4
|
+
registerNodeKind("break", {
|
|
5
|
+
build: (api) => () => {
|
|
6
|
+
if (api.loopDepth === 0)
|
|
7
|
+
throw new Error(`break() is only valid inside a loop`);
|
|
8
|
+
api.out.push({ kind: "break" });
|
|
9
|
+
return api.self();
|
|
10
|
+
},
|
|
11
|
+
emit: (_node, incoming, loop, api) => {
|
|
12
|
+
if (!loop)
|
|
13
|
+
throw new Error(`break outside a loop in flow "${api.flowId}"`);
|
|
14
|
+
// The incoming edges (from the previous node) become the loop's exit
|
|
15
|
+
// danglers; this path does not fall through.
|
|
16
|
+
loop.breaks.push(...incoming);
|
|
17
|
+
return [];
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Built-in flow-node kind: `continue` — jump straight back to the top of the
|
|
2
|
+
// enclosing loop, skipping the rest of the body. Only valid inside a `loop`.
|
|
3
|
+
// Contributed through the extension seam (epic #314, S0/#315).
|
|
4
|
+
import { registerNodeKind } from "./registry.js";
|
|
5
|
+
registerNodeKind("continue", {
|
|
6
|
+
build: (api) => () => {
|
|
7
|
+
if (api.loopDepth === 0)
|
|
8
|
+
throw new Error(`continue() is only valid inside a loop`);
|
|
9
|
+
api.out.push({ kind: "continue" });
|
|
10
|
+
return api.self();
|
|
11
|
+
},
|
|
12
|
+
emit: (_node, incoming, loop, api) => {
|
|
13
|
+
if (!loop)
|
|
14
|
+
throw new Error(`continue outside a loop in flow "${api.flowId}"`);
|
|
15
|
+
api.connect(incoming, loop.headId);
|
|
16
|
+
return [];
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FlowNode } from "../types.js";
|
|
2
|
+
/** The `forEach` node shape (its `FlowNode` variant). */
|
|
3
|
+
interface ForEachNode {
|
|
4
|
+
kind: "forEach";
|
|
5
|
+
/** FEEL expression evaluating to the list to fan out over. */
|
|
6
|
+
collection: string;
|
|
7
|
+
/** Variable each item is bound to in the child's scope (`inputElement`). */
|
|
8
|
+
itemVar: string;
|
|
9
|
+
body: FlowNode[];
|
|
10
|
+
/** Run children one at a time (sequential MI) instead of all at once. */
|
|
11
|
+
sequential?: boolean;
|
|
12
|
+
/** List variable that each child's `outputElement` is collected into. */
|
|
13
|
+
outputCollection?: string;
|
|
14
|
+
/** FEEL expression producing each child's contribution to `outputCollection`. */
|
|
15
|
+
outputElement?: string;
|
|
16
|
+
/** FEEL boolean that, once true, completes the body early. */
|
|
17
|
+
completionCondition?: string;
|
|
18
|
+
}
|
|
19
|
+
declare module "../types.js" {
|
|
20
|
+
interface FlowNodeRegistry {
|
|
21
|
+
forEach: ForEachNode;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Built-in flow-node kind: `forEach` — a data-driven fan-out over a runtime
|
|
2
|
+
// collection (a BPMN parallel/sequential multi-instance activity). A single
|
|
3
|
+
// service-task body lifts the MI characteristics directly; a multi-step body is
|
|
4
|
+
// wrapped in an embedded MI sub-process. Contributed through the extension seam.
|
|
5
|
+
import { assertIdent, escapeXml } from "../xml.js";
|
|
6
|
+
import { registerNodeKind } from "./registry.js";
|
|
7
|
+
/** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node — the
|
|
8
|
+
* same shape whether lifted onto a leaf service task or a sub-process. */
|
|
9
|
+
function miCharacteristics(node) {
|
|
10
|
+
const seq = node.sequential ? "true" : "false";
|
|
11
|
+
const attrs = [
|
|
12
|
+
`inputCollection="${escapeXml(`=${node.collection}`)}"`,
|
|
13
|
+
`inputElement="${escapeXml(node.itemVar)}"`,
|
|
14
|
+
];
|
|
15
|
+
if (node.outputCollection)
|
|
16
|
+
attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
|
|
17
|
+
if (node.outputElement)
|
|
18
|
+
attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
|
|
19
|
+
const completion = node.completionCondition
|
|
20
|
+
? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
|
|
21
|
+
: "";
|
|
22
|
+
return (` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
|
|
23
|
+
` <bpmn:extensionElements>\n` +
|
|
24
|
+
` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
|
|
25
|
+
` </bpmn:extensionElements>\n` +
|
|
26
|
+
completion +
|
|
27
|
+
` </bpmn:multiInstanceLoopCharacteristics>\n`);
|
|
28
|
+
}
|
|
29
|
+
registerNodeKind("forEach", {
|
|
30
|
+
build: (api) => (collection, itemVar, body, opts) => {
|
|
31
|
+
if (typeof collection !== "string" || collection.trim() === "") {
|
|
32
|
+
throw new Error(`forEach() needs a non-empty FEEL collection expression`);
|
|
33
|
+
}
|
|
34
|
+
// Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip it
|
|
35
|
+
// so the emitter's own "=" prefix never produces an invalid "==...".
|
|
36
|
+
const collExpr = collection.trim().replace(/^=/, "").trim();
|
|
37
|
+
if (collExpr === "") {
|
|
38
|
+
throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
|
|
39
|
+
}
|
|
40
|
+
assertIdent("forEach itemVar", itemVar);
|
|
41
|
+
if (typeof body !== "function")
|
|
42
|
+
throw new Error(`forEach("${itemVar}") needs a body function`);
|
|
43
|
+
const nodes = api.childScoped(body);
|
|
44
|
+
if (nodes.length === 0)
|
|
45
|
+
throw new Error(`forEach("${itemVar}") body declared no steps`);
|
|
46
|
+
const node = { kind: "forEach", collection: collExpr, itemVar, body: nodes };
|
|
47
|
+
if (opts?.sequential)
|
|
48
|
+
node.sequential = true;
|
|
49
|
+
if (opts?.outputCollection !== undefined) {
|
|
50
|
+
assertIdent("forEach outputCollection", opts.outputCollection);
|
|
51
|
+
node.outputCollection = opts.outputCollection;
|
|
52
|
+
}
|
|
53
|
+
if (opts?.outputElement !== undefined) {
|
|
54
|
+
if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
|
|
55
|
+
throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
|
|
56
|
+
}
|
|
57
|
+
const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
|
|
58
|
+
if (outEl === "") {
|
|
59
|
+
throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
|
|
60
|
+
}
|
|
61
|
+
node.outputElement = outEl;
|
|
62
|
+
}
|
|
63
|
+
if (opts?.completionCondition !== undefined) {
|
|
64
|
+
if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
|
|
65
|
+
throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
|
|
66
|
+
}
|
|
67
|
+
const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
|
|
68
|
+
if (compCond === "") {
|
|
69
|
+
throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
|
|
70
|
+
}
|
|
71
|
+
node.completionCondition = compCond;
|
|
72
|
+
}
|
|
73
|
+
if (node.outputElement && !node.outputCollection) {
|
|
74
|
+
throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
|
|
75
|
+
}
|
|
76
|
+
api.out.push(node);
|
|
77
|
+
return api.self();
|
|
78
|
+
},
|
|
79
|
+
walk: (node, recurse) => recurse(node.body),
|
|
80
|
+
emit: (node, incoming, _loop, api) => {
|
|
81
|
+
const mi = miCharacteristics(node);
|
|
82
|
+
const only = node.body.length === 1 ? node.body[0] : undefined;
|
|
83
|
+
// A single service-task body carries the MI characteristics directly; a
|
|
84
|
+
// multi-step body is wrapped in an embedded MI sub-process (its own token
|
|
85
|
+
// scope, with its own start/end).
|
|
86
|
+
if (only && (only.kind === "run" || only.kind === "task")) {
|
|
87
|
+
api.addServiceTask(only, mi);
|
|
88
|
+
api.connect(incoming, only.name);
|
|
89
|
+
return [api.newEdge(only.name)];
|
|
90
|
+
}
|
|
91
|
+
const subId = `Sub_${api.nextGw()}`;
|
|
92
|
+
api.addSubProcess(subId, mi);
|
|
93
|
+
api.connect(incoming, subId);
|
|
94
|
+
api.pushScope(subId);
|
|
95
|
+
const startId = `${subId}_start`;
|
|
96
|
+
api.addPlainStart(startId);
|
|
97
|
+
const innerOut = api.emitList(node.body, [api.newEdge(startId)], null);
|
|
98
|
+
const endId = `${subId}_end`;
|
|
99
|
+
api.addPlainEnd(endId);
|
|
100
|
+
api.connect(innerOut, endId);
|
|
101
|
+
api.popScope();
|
|
102
|
+
return [api.newEdge(subId)];
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import "./branch.js";
|
|
2
|
+
import "./break.js";
|
|
3
|
+
import "./continue.js";
|
|
4
|
+
import "./forEach.js";
|
|
5
|
+
import "./loop.js";
|
|
6
|
+
import "./parallel.js";
|
|
7
|
+
import "./run.js";
|
|
8
|
+
import "./signal.js";
|
|
9
|
+
import "./switch.js";
|
|
10
|
+
import "./task.js";
|
|
11
|
+
import "./timer.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-node-registry.mjs — do not edit by hand.
|
|
2
|
+
//
|
|
3
|
+
// The barrel of built-in flow-node kind modules. Importing this module runs
|
|
4
|
+
// every kind's `registerNodeKind(...)` side effect, populating the dispatch
|
|
5
|
+
// table `makeBuilder`/`walkNodes`/the emitter route through. A new kind is
|
|
6
|
+
// picked up automatically by re-running the generator (a prebuild step) — no
|
|
7
|
+
// hand-edit here.
|
|
8
|
+
import "./branch.js";
|
|
9
|
+
import "./break.js";
|
|
10
|
+
import "./continue.js";
|
|
11
|
+
import "./forEach.js";
|
|
12
|
+
import "./loop.js";
|
|
13
|
+
import "./parallel.js";
|
|
14
|
+
import "./run.js";
|
|
15
|
+
import "./signal.js";
|
|
16
|
+
import "./switch.js";
|
|
17
|
+
import "./task.js";
|
|
18
|
+
import "./timer.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Built-in flow-node kind: `loop` — a durable loop (a back-edge to the loop
|
|
2
|
+
// head): the body runs, then control returns to the top unless a branch calls
|
|
3
|
+
// `break()`. Contributed through the extension seam (epic #314, S0/#315).
|
|
4
|
+
import { registerNodeKind } from "./registry.js";
|
|
5
|
+
registerNodeKind("loop", {
|
|
6
|
+
build: (api) => (body) => {
|
|
7
|
+
if (typeof body !== "function")
|
|
8
|
+
throw new Error(`loop() needs a body function`);
|
|
9
|
+
api.out.push({ kind: "loop", body: api.child(body, true) });
|
|
10
|
+
return api.self();
|
|
11
|
+
},
|
|
12
|
+
walk: (node, recurse) => recurse(node.body),
|
|
13
|
+
emit: (node, incoming, _loop, api) => {
|
|
14
|
+
const headId = `Loop_${api.nextGw()}`;
|
|
15
|
+
api.addGateway(headId);
|
|
16
|
+
api.connect(incoming, headId);
|
|
17
|
+
const ctx = { headId, breaks: [] };
|
|
18
|
+
const headOut = api.newEdge(headId);
|
|
19
|
+
const bodyOut = api.emitList(node.body, [headOut], ctx);
|
|
20
|
+
// Normal fall-through of the body loops back to the head (continue).
|
|
21
|
+
api.connect(bodyOut, headId);
|
|
22
|
+
// The loop exits only via break edges.
|
|
23
|
+
return ctx.breaks;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Built-in flow-node kind: `parallel` — a static parallel fork/join (a pair of
|
|
2
|
+
// BPMN parallel gateways): every block runs concurrently on its own branch, and
|
|
3
|
+
// control continues only once ALL branches reach the AND-join. Contributed
|
|
4
|
+
// through the extension seam (epic #314, S0/#315).
|
|
5
|
+
import { registerNodeKind } from "./registry.js";
|
|
6
|
+
registerNodeKind("parallel", {
|
|
7
|
+
build: (api) => (branches) => {
|
|
8
|
+
if (!Array.isArray(branches) || branches.length < 2) {
|
|
9
|
+
throw new Error(`parallel() needs at least two branch blocks`);
|
|
10
|
+
}
|
|
11
|
+
for (const fn of branches) {
|
|
12
|
+
if (typeof fn !== "function")
|
|
13
|
+
throw new Error(`parallel() branches must all be blocks (b) => {…}`);
|
|
14
|
+
}
|
|
15
|
+
// Each branch runs in its own token scope up to the AND-join, so a
|
|
16
|
+
// `break`/`continue` must not escape into an enclosing loop. Scope branches
|
|
17
|
+
// (loopDepth resets to 0) so cross-boundary break/continue is rejected.
|
|
18
|
+
api.out.push({ kind: "parallel", branches: branches.map((fn) => api.childScoped(fn)) });
|
|
19
|
+
return api.self();
|
|
20
|
+
},
|
|
21
|
+
walk: (node, recurse) => {
|
|
22
|
+
for (const branch of node.branches)
|
|
23
|
+
recurse(branch);
|
|
24
|
+
},
|
|
25
|
+
emit: (node, incoming, loop, api) => {
|
|
26
|
+
// A diverging parallel gateway forks a token onto every branch; a converging
|
|
27
|
+
// one joins them (AND-join) into a single continuation.
|
|
28
|
+
const splitId = `Gw_${api.nextGw()}`;
|
|
29
|
+
api.addParallelGateway(splitId);
|
|
30
|
+
api.connect(incoming, splitId);
|
|
31
|
+
const joinId = `Gw_${api.nextGw()}`;
|
|
32
|
+
api.addParallelGateway(joinId);
|
|
33
|
+
for (const branch of node.branches) {
|
|
34
|
+
const e = api.newEdge(splitId);
|
|
35
|
+
const branchOut = api.emitList(branch, [e], loop);
|
|
36
|
+
api.connect(branchOut, joinId);
|
|
37
|
+
}
|
|
38
|
+
return [api.newEdge(joinId)];
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FlowNode } from "../types.js";
|
|
2
|
+
import type { BuildApi, BuilderMethod, Edge, EmitApi, LoopCtx } from "../declarative.js";
|
|
3
|
+
/** Recurse the tree walker into a node's nested bodies (see `walkNodes`). */
|
|
4
|
+
export type WalkRecurse = (body: FlowNode[]) => void;
|
|
5
|
+
/** The three handlers a flow-node kind contributes. `N` is the kind's own
|
|
6
|
+
* `FlowNode` variant (narrowed from the union by the registry key). */
|
|
7
|
+
export interface NodeKindHandlers<N extends FlowNode = FlowNode> {
|
|
8
|
+
/** Factory for the `FlowBuilder` method that appends this kind's node. Given
|
|
9
|
+
* the per-body {@link BuildApi}, returns the method installed on the builder
|
|
10
|
+
* as `w.<builderMethod ?? kind>`. Omit for a kind with no authoring method. */
|
|
11
|
+
build?: (api: BuildApi) => BuilderMethod;
|
|
12
|
+
/** The builder method name, when it differs from the kind (defaults to the
|
|
13
|
+
* kind). */
|
|
14
|
+
builderMethod?: string;
|
|
15
|
+
/** Depth-first recursion into the node's nested bodies. Omit for a leaf. */
|
|
16
|
+
walk?: (node: N, recurse: WalkRecurse) => void;
|
|
17
|
+
/** Emit this node into the BPMN graph, returning the outgoing (dangling)
|
|
18
|
+
* edges its continuation attaches to. */
|
|
19
|
+
emit: (node: N, incoming: Edge[], loop: LoopCtx | null, api: EmitApi) => Edge[];
|
|
20
|
+
}
|
|
21
|
+
/** Register the handlers for one flow-node kind. Called once, at module import
|
|
22
|
+
* time, from `src/nodes/<kind>.ts`. */
|
|
23
|
+
export declare function registerNodeKind<K extends FlowNode["kind"]>(kind: K, handlers: NodeKindHandlers<Extract<FlowNode, {
|
|
24
|
+
kind: K;
|
|
25
|
+
}>>): void;
|
|
26
|
+
/** The handlers registered for a kind, or `undefined` if none. */
|
|
27
|
+
export declare function nodeKind(kind: string): NodeKindHandlers | undefined;
|
|
28
|
+
/** The registered kind, or a thrown error naming the unknown kind — used by the
|
|
29
|
+
* emitter, which cannot proceed without a handler. */
|
|
30
|
+
export declare function requireNodeKind(kind: string): NodeKindHandlers;
|
|
31
|
+
/** Every registered kind + its handlers, in registration order. Used by
|
|
32
|
+
* `makeBuilder` to install each kind's builder method. */
|
|
33
|
+
export declare function eachNodeKind(): Iterable<[string, NodeKindHandlers]>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The builder-extension seam (epic #314, S0/#315).
|
|
2
|
+
//
|
|
3
|
+
// Every flow-node KIND (`run`/`task`/`signal`/`timer`/`switch`/… and any kind a
|
|
4
|
+
// feature slice adds) contributes THREE things from its own module under
|
|
5
|
+
// `src/nodes/`, and NOTHING is edited centrally:
|
|
6
|
+
//
|
|
7
|
+
// 1. a `FlowNode` variant — by declaration-merging its shape into the
|
|
8
|
+
// augmentable `FlowNodeRegistry` interface (see `types.ts`), so the
|
|
9
|
+
// `FlowNode` union grows without editing a central union;
|
|
10
|
+
// 2. a builder method — by declaration-merging its signature into the
|
|
11
|
+
// `FlowBuilder<C>` interface (see `declarative.ts`) and registering a
|
|
12
|
+
// `build` factory here, so `w.<method>(…)` is contributed from the module;
|
|
13
|
+
// 3. `walk` + `emit` handlers — registered here, so the tree walker and the
|
|
14
|
+
// BPMN emitter DISPATCH through this table instead of a central `switch`.
|
|
15
|
+
//
|
|
16
|
+
// A module registers all three with a single `registerNodeKind(kind, {…})` call
|
|
17
|
+
// at import time; the generated barrel `src/nodes/index.ts` imports every kind
|
|
18
|
+
// module for its registration side effect. Adding a kind is therefore: drop one
|
|
19
|
+
// `src/nodes/<kind>.ts` file — no edit to the union, the dispatch, or the barrel.
|
|
20
|
+
//
|
|
21
|
+
// See `src/nodes/README.md` for the exact pattern a slice follows.
|
|
22
|
+
// The dispatcher always looks a handler up by `node.kind` before calling it, so
|
|
23
|
+
// each stored handler only ever receives a node of its own kind. Storing the
|
|
24
|
+
// widened `NodeKindHandlers<FlowNode>` is therefore sound; a kind registers with
|
|
25
|
+
// its own narrowed variant type for authoring ergonomics.
|
|
26
|
+
const kinds = new Map();
|
|
27
|
+
/** Register the handlers for one flow-node kind. Called once, at module import
|
|
28
|
+
* time, from `src/nodes/<kind>.ts`. */
|
|
29
|
+
export function registerNodeKind(kind, handlers) {
|
|
30
|
+
if (kinds.has(kind)) {
|
|
31
|
+
throw new Error(`flow-node kind "${kind}" is already registered`);
|
|
32
|
+
}
|
|
33
|
+
const walk = handlers.walk;
|
|
34
|
+
const emit = handlers.emit;
|
|
35
|
+
kinds.set(kind, {
|
|
36
|
+
builderMethod: handlers.builderMethod,
|
|
37
|
+
build: handlers.build,
|
|
38
|
+
walk: walk ? (node, recurse) => walk(narrow(node, kind), recurse) : undefined,
|
|
39
|
+
emit: (node, incoming, loop, api) => emit(narrow(node, kind), incoming, loop, api),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Narrow a dispatched node back to a kind's own variant. The discriminant
|
|
43
|
+
* check is the proof — no `as` cast. The dispatcher guarantees `node.kind ===
|
|
44
|
+
* kind`, so the throw is unreachable in practice. */
|
|
45
|
+
function narrow(node, kind) {
|
|
46
|
+
if (isKind(node, kind))
|
|
47
|
+
return node;
|
|
48
|
+
throw new Error(`internal: node of kind "${node.kind}" dispatched to handler for "${kind}"`);
|
|
49
|
+
}
|
|
50
|
+
function isKind(node, kind) {
|
|
51
|
+
return node.kind === kind;
|
|
52
|
+
}
|
|
53
|
+
/** The handlers registered for a kind, or `undefined` if none. */
|
|
54
|
+
export function nodeKind(kind) {
|
|
55
|
+
return kinds.get(kind);
|
|
56
|
+
}
|
|
57
|
+
/** The registered kind, or a thrown error naming the unknown kind — used by the
|
|
58
|
+
* emitter, which cannot proceed without a handler. */
|
|
59
|
+
export function requireNodeKind(kind) {
|
|
60
|
+
const h = kinds.get(kind);
|
|
61
|
+
if (!h) {
|
|
62
|
+
throw new Error(`no handler registered for flow-node kind "${kind}" — ` +
|
|
63
|
+
`did its module under src/nodes/ import and call registerNodeKind()?`);
|
|
64
|
+
}
|
|
65
|
+
return h;
|
|
66
|
+
}
|
|
67
|
+
/** Every registered kind + its handlers, in registration order. Used by
|
|
68
|
+
* `makeBuilder` to install each kind's builder method. */
|
|
69
|
+
export function eachNodeKind() {
|
|
70
|
+
return kinds.entries();
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Built-in flow-node kind: `run` — a durable service task served by a worker
|
|
2
|
+
// THIS program hosts (its handler runs in the in-process `Worker`). Contributed
|
|
3
|
+
// entirely from this module through the extension seam (epic #314, S0/#315):
|
|
4
|
+
// its `FlowNode` variant, its `w.run(...)` builder method, and its walk/emit
|
|
5
|
+
// handlers — with NO edit to the shared union or a central dispatch switch.
|
|
6
|
+
import { registerNodeKind } from "./registry.js";
|
|
7
|
+
registerNodeKind("run", {
|
|
8
|
+
build: (api) => (name, handler) => {
|
|
9
|
+
api.claim(name);
|
|
10
|
+
if (typeof handler !== "function")
|
|
11
|
+
throw new Error(`run("${name}") needs a handler function`);
|
|
12
|
+
api.handlers[name] = handler;
|
|
13
|
+
api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name) });
|
|
14
|
+
return api.self();
|
|
15
|
+
},
|
|
16
|
+
emit: (node, incoming, _loop, api) => {
|
|
17
|
+
api.addServiceTask(node);
|
|
18
|
+
api.connect(incoming, node.name);
|
|
19
|
+
return [api.newEdge(node.name)];
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Built-in flow-node kind: `signal` — a durable wait for an external/human event
|
|
2
|
+
// correlated on a process variable (a BPMN message intermediate catch event).
|
|
3
|
+
// Contributed from this module through the extension seam (epic #314, S0/#315).
|
|
4
|
+
import { assertIdent } from "../xml.js";
|
|
5
|
+
import { registerNodeKind } from "./registry.js";
|
|
6
|
+
registerNodeKind("signal", {
|
|
7
|
+
build: (api) => (name, opts) => {
|
|
8
|
+
api.claim(name);
|
|
9
|
+
if (!opts || !opts.correlationKey)
|
|
10
|
+
throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
11
|
+
assertIdent("correlationKey", opts.correlationKey);
|
|
12
|
+
api.out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: api.contracts[name]?.in });
|
|
13
|
+
return api.self();
|
|
14
|
+
},
|
|
15
|
+
emit: (node, incoming, _loop, api) => {
|
|
16
|
+
api.addCatchEvent(node);
|
|
17
|
+
api.connect(incoming, node.name);
|
|
18
|
+
api.recordEnvelope(node.payload);
|
|
19
|
+
return [api.newEdge(node.name)];
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Built-in flow-node kind: `switch` — a multi-way exclusive choice (a BPMN
|
|
2
|
+
// exclusive gateway); each case routes when `subject` equals the case value, with
|
|
3
|
+
// an optional unconditional `default`. Contributed through the extension seam.
|
|
4
|
+
import { feelEquals } from "../declarative.js";
|
|
5
|
+
import { registerNodeKind } from "./registry.js";
|
|
6
|
+
registerNodeKind("switch", {
|
|
7
|
+
build: (api) => (subject, cases) => {
|
|
8
|
+
if (typeof subject !== "string" || subject.trim() === "") {
|
|
9
|
+
throw new Error(`switch() needs a non-empty subject expression`);
|
|
10
|
+
}
|
|
11
|
+
if (cases === null || typeof cases !== "object") {
|
|
12
|
+
throw new Error(`switch("${subject}") needs a cases object { value: (b) => {…} }`);
|
|
13
|
+
}
|
|
14
|
+
const caseNodes = [];
|
|
15
|
+
for (const [value, fn] of Object.entries(cases)) {
|
|
16
|
+
if (value === "default")
|
|
17
|
+
continue;
|
|
18
|
+
if (typeof fn !== "function")
|
|
19
|
+
throw new Error(`switch("${subject}") cases must be blocks (b) => {…}`);
|
|
20
|
+
caseNodes.push({ value, body: api.child(fn, false) });
|
|
21
|
+
}
|
|
22
|
+
if (caseNodes.length === 0)
|
|
23
|
+
throw new Error(`switch("${subject}") needs at least one case`);
|
|
24
|
+
if (cases.default !== undefined && typeof cases.default !== "function") {
|
|
25
|
+
throw new Error(`switch("${subject}") default must be a block (b) => {…}`);
|
|
26
|
+
}
|
|
27
|
+
const def = cases.default ? api.child(cases.default, false) : undefined;
|
|
28
|
+
api.out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
29
|
+
return api.self();
|
|
30
|
+
},
|
|
31
|
+
walk: (node, recurse) => {
|
|
32
|
+
for (const c of node.cases)
|
|
33
|
+
recurse(c.body);
|
|
34
|
+
if (node.default)
|
|
35
|
+
recurse(node.default);
|
|
36
|
+
},
|
|
37
|
+
emit: (node, incoming, loop, api) => {
|
|
38
|
+
const id = `Gw_${api.nextGw()}`;
|
|
39
|
+
const gw = api.addGateway(id, node.subject);
|
|
40
|
+
api.connect(incoming, id);
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const c of node.cases) {
|
|
43
|
+
const e = api.newEdge(id, { condition: feelEquals(node.subject, c.value), name: c.value });
|
|
44
|
+
out.push(...api.emitList(c.body, [e], loop));
|
|
45
|
+
}
|
|
46
|
+
const de = api.newEdge(id, { name: "default" });
|
|
47
|
+
gw.defaultFlow = de.id;
|
|
48
|
+
out.push(...api.emitList(node.default ?? [], [de], loop));
|
|
49
|
+
return out;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Built-in flow-node kind: `task` — a durable service task served by a worker
|
|
2
|
+
// OUTSIDE this program (no locally-hosted handler). Its job type defaults to the
|
|
3
|
+
// derived `${flowId}:${name}` unless overridden with `{ jobType }`. Contributed
|
|
4
|
+
// from this module through the extension seam (epic #314, S0/#315).
|
|
5
|
+
import { assertJobType } from "../xml.js";
|
|
6
|
+
import { registerNodeKind } from "./registry.js";
|
|
7
|
+
registerNodeKind("task", {
|
|
8
|
+
build: (api) => (name, opts) => {
|
|
9
|
+
api.claim(name);
|
|
10
|
+
const override = opts?.jobType;
|
|
11
|
+
if (override !== undefined)
|
|
12
|
+
assertJobType("task jobType", override);
|
|
13
|
+
api.out.push({ kind: "task", name, envelopes: api.contractEnvelopes(name), jobType: override });
|
|
14
|
+
return api.self();
|
|
15
|
+
},
|
|
16
|
+
emit: (node, incoming, _loop, api) => {
|
|
17
|
+
api.addServiceTask(node);
|
|
18
|
+
api.connect(incoming, node.name);
|
|
19
|
+
return [api.newEdge(node.name)];
|
|
20
|
+
},
|
|
21
|
+
});
|