@nanobpm/workflow 0.8.2 → 0.10.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 +73 -2
- package/dist/declarative.d.ts +178 -10
- package/dist/declarative.js +216 -356
- 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 +109 -0
- package/dist/nodes/index.d.ts +12 -0
- package/dist/nodes/index.js +19 -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/race.d.ts +54 -0
- package/dist/nodes/race.js +134 -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 +26 -0
- package/dist/nodes/switch.d.ts +11 -0
- package/dist/nodes/switch.js +51 -0
- package/dist/nodes/task.d.ts +47 -0
- package/dist/nodes/task.js +97 -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 +12 -4
- package/src/declarative.ts +339 -360
- 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 +142 -0
- package/src/nodes/index.ts +20 -0
- package/src/nodes/loop.ts +34 -0
- package/src/nodes/parallel.ts +48 -0
- package/src/nodes/race.ts +185 -0
- package/src/nodes/registry.ts +105 -0
- package/src/nodes/run.ts +29 -0
- package/src/nodes/signal.ts +35 -0
- package/src/nodes/switch.ts +56 -0
- package/src/nodes/task.ts +150 -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,245 @@
|
|
|
1
|
+
// Canonicalize a BPMN 2.0 document down to its SEMANTIC structure, so two models
|
|
2
|
+
// that mean the same thing compare equal regardless of element ids, child
|
|
3
|
+
// ordering, or diagram-interchange (DI) layout. This is the oracle S1–S6 assert
|
|
4
|
+
// their derived flows against.
|
|
5
|
+
//
|
|
6
|
+
// What "semantic structure" keeps (and what it deliberately drops):
|
|
7
|
+
// - DROPPED: the `bpmndi:BPMNDiagram` layout subtree (shapes, edges, bounds);
|
|
8
|
+
// every element `id` and every `sequenceFlow` id (canonicalized away — a
|
|
9
|
+
// flow node is identified by its TYPE + semantic definition, an edge by its
|
|
10
|
+
// endpoints); cosmetic `name` labels on flow nodes and gateways (the routing
|
|
11
|
+
// meaning lives in edge conditions and in derived task/message identities,
|
|
12
|
+
// not in a human label); child ORDER (everything is compared as a sorted
|
|
13
|
+
// multiset).
|
|
14
|
+
// - KEPT: flow nodes (tasks, gateways, events) with their semantic definition
|
|
15
|
+
// (task-definition job type, user-task / form / assignment / io-mapping /
|
|
16
|
+
// linked-resources extension elements, multi-instance characteristics, event
|
|
17
|
+
// definitions); sequence flows as endpoint pairs with their condition /
|
|
18
|
+
// default marker; message subscriptions (a catch event's `messageRef`
|
|
19
|
+
// resolved to the referenced message's full definition — name plus its
|
|
20
|
+
// `zeebe:subscription correlationKey` and `zeebe:properties` envelope);
|
|
21
|
+
// timer definitions; and boundary events (their `attachedToRef` modelled as
|
|
22
|
+
// a structural attach edge to the host, never as a raw-id attribute).
|
|
23
|
+
import { localName, parseXml } from "./xml.js";
|
|
24
|
+
const FLOW_NODES = new Set([
|
|
25
|
+
"startEvent",
|
|
26
|
+
"endEvent",
|
|
27
|
+
"intermediateCatchEvent",
|
|
28
|
+
"intermediateThrowEvent",
|
|
29
|
+
"boundaryEvent",
|
|
30
|
+
"task",
|
|
31
|
+
"serviceTask",
|
|
32
|
+
"userTask",
|
|
33
|
+
"scriptTask",
|
|
34
|
+
"sendTask",
|
|
35
|
+
"receiveTask",
|
|
36
|
+
"manualTask",
|
|
37
|
+
"businessRuleTask",
|
|
38
|
+
"callActivity",
|
|
39
|
+
"subProcess",
|
|
40
|
+
"adHocSubProcess",
|
|
41
|
+
"transaction",
|
|
42
|
+
"exclusiveGateway",
|
|
43
|
+
"parallelGateway",
|
|
44
|
+
"inclusiveGateway",
|
|
45
|
+
"eventBasedGateway",
|
|
46
|
+
"complexGateway",
|
|
47
|
+
]);
|
|
48
|
+
const REF_ATTRS = new Set(["messageRef", "errorRef", "signalRef", "escalationRef"]);
|
|
49
|
+
function isDI(el) {
|
|
50
|
+
const prefix = el.name.includes(":") ? el.name.slice(0, el.name.indexOf(":")) : "";
|
|
51
|
+
if (prefix === "bpmndi" || prefix === "di" || prefix === "dc" || prefix === "omgdi" || prefix === "omgdc") {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const l = localName(el.name);
|
|
55
|
+
return l === "BPMNDiagram" || l === "BPMNPlane" || l === "BPMNShape" || l === "BPMNEdge" || l === "BPMNLabel";
|
|
56
|
+
}
|
|
57
|
+
/** Accumulates the flat graph while walking every process / sub-process. */
|
|
58
|
+
class Collector {
|
|
59
|
+
refNames = new Map();
|
|
60
|
+
messageDefs = new Map();
|
|
61
|
+
nodes = new Map();
|
|
62
|
+
edges = [];
|
|
63
|
+
messages = new Set();
|
|
64
|
+
defaultFlows = new Set();
|
|
65
|
+
seqFlows = [];
|
|
66
|
+
/** Resolve a *Ref value to the referenced element's name (falling back to the
|
|
67
|
+
* raw ref only when the target is unknown), recording message subscriptions.
|
|
68
|
+
* A `messageRef` records the subscribed message's FULL semantic identity — the
|
|
69
|
+
* serialized `<bpmn:message>` definition (its name plus the `zeebe:subscription
|
|
70
|
+
* correlationKey` and any `zeebe:properties` envelope), not just its name — so
|
|
71
|
+
* two models whose catch events share a message name but differ in correlation
|
|
72
|
+
* key or envelope properties do NOT compare equal (the parity oracle would
|
|
73
|
+
* otherwise be blind to a wrong-correlationKey derivation). Falls back to the
|
|
74
|
+
* bare name only when the referenced message definition is unknown. */
|
|
75
|
+
resolveRef(attr, value) {
|
|
76
|
+
const name = this.refNames.get(value) ?? value;
|
|
77
|
+
if (attr === "messageRef") {
|
|
78
|
+
const def = this.messageDefs.get(value);
|
|
79
|
+
this.messages.add(def ? this.serialize(def) : name);
|
|
80
|
+
}
|
|
81
|
+
return `@${name}`;
|
|
82
|
+
}
|
|
83
|
+
/** Serialize a definition/extension descendant fully (all descendants), with
|
|
84
|
+
* sorted attributes (ids dropped, refs resolved) and sorted children, so the
|
|
85
|
+
* string is invariant to attribute/child ordering and to id renaming. */
|
|
86
|
+
serialize(el) {
|
|
87
|
+
const attrs = Object.entries(el.attrs)
|
|
88
|
+
.filter(([k]) => k !== "id")
|
|
89
|
+
.map(([k, v]) => `${k}=${REF_ATTRS.has(k) ? this.resolveRef(k, v) : v}`)
|
|
90
|
+
.sort();
|
|
91
|
+
const kids = el.children.filter((c) => !isDI(c)).map((c) => this.serialize(c)).sort();
|
|
92
|
+
const text = el.text ? `=${el.text}` : "";
|
|
93
|
+
return `${localName(el.name)}{${attrs.join(",")}}${text}[${kids.join(";")}]`;
|
|
94
|
+
}
|
|
95
|
+
/** The id-agnostic semantic descriptor of a flow node: its type, its own
|
|
96
|
+
* discriminating attributes, and its definition subtree — but NOT nested flow
|
|
97
|
+
* nodes / sequence flows (those are separate graph entities) and NOT cosmetic
|
|
98
|
+
* id / name / structural-ref attributes. */
|
|
99
|
+
nodeLabel(el) {
|
|
100
|
+
const excluded = new Set(["id", "name", "default", "attachedToRef", "sourceRef", "targetRef"]);
|
|
101
|
+
const attrs = Object.entries(el.attrs)
|
|
102
|
+
.filter(([k]) => !excluded.has(k))
|
|
103
|
+
.map(([k, v]) => `${k}=${REF_ATTRS.has(k) ? this.resolveRef(k, v) : v}`)
|
|
104
|
+
.sort();
|
|
105
|
+
const defs = el.children
|
|
106
|
+
.filter((c) => {
|
|
107
|
+
const cl = localName(c.name);
|
|
108
|
+
// Skip nested flow nodes / sequence flows (separate graph entities) and
|
|
109
|
+
// the `incoming`/`outgoing` flow-id back-references (fully redundant with
|
|
110
|
+
// the sequence-flow edges, and carrying raw ids that must not leak here).
|
|
111
|
+
return !isDI(c) && !FLOW_NODES.has(cl) && cl !== "sequenceFlow" && cl !== "incoming" && cl !== "outgoing";
|
|
112
|
+
})
|
|
113
|
+
.map((c) => this.serialize(c))
|
|
114
|
+
.sort();
|
|
115
|
+
return `${localName(el.name)}(${attrs.join(",")})[${defs.join(";")}]`;
|
|
116
|
+
}
|
|
117
|
+
registerNode(el) {
|
|
118
|
+
const id = el.attrs.id;
|
|
119
|
+
if (!id)
|
|
120
|
+
return;
|
|
121
|
+
this.nodes.set(id, { id, type: localName(el.name), label: this.nodeLabel(el) });
|
|
122
|
+
if (el.attrs.default)
|
|
123
|
+
this.defaultFlows.add(el.attrs.default);
|
|
124
|
+
}
|
|
125
|
+
/** Walk a container (process or sub-process), registering its flow nodes and
|
|
126
|
+
* sequence flows and recursing into nested sub-processes. `scope` is the
|
|
127
|
+
* enclosing sub-process id (containment edges point scope→child). */
|
|
128
|
+
collectContainer(container, scope) {
|
|
129
|
+
for (const child of container.children) {
|
|
130
|
+
if (isDI(child))
|
|
131
|
+
continue;
|
|
132
|
+
const l = localName(child.name);
|
|
133
|
+
if (FLOW_NODES.has(l)) {
|
|
134
|
+
this.registerNode(child);
|
|
135
|
+
const id = child.attrs.id;
|
|
136
|
+
if (id && scope)
|
|
137
|
+
this.edges.push({ kind: "contain", from: scope, to: id, tag: "" });
|
|
138
|
+
if (l === "boundaryEvent" && child.attrs.attachedToRef && id) {
|
|
139
|
+
this.edges.push({ kind: "attach", from: id, to: child.attrs.attachedToRef, tag: "" });
|
|
140
|
+
}
|
|
141
|
+
if (l === "subProcess" || l === "adHocSubProcess" || l === "transaction") {
|
|
142
|
+
this.collectContainer(child, id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else if (l === "sequenceFlow") {
|
|
146
|
+
this.seqFlows.push(child);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Turn collected sequence-flow elements into endpoint edges once every node
|
|
151
|
+
* and default marker is known. */
|
|
152
|
+
finalizeFlows() {
|
|
153
|
+
for (const sf of this.seqFlows) {
|
|
154
|
+
const from = sf.attrs.sourceRef;
|
|
155
|
+
const to = sf.attrs.targetRef;
|
|
156
|
+
if (!from || !to)
|
|
157
|
+
continue;
|
|
158
|
+
const cond = sf.children.find((c) => localName(c.name) === "conditionExpression");
|
|
159
|
+
let tag = "";
|
|
160
|
+
if (cond?.text)
|
|
161
|
+
tag = `if:${cond.text}`;
|
|
162
|
+
else if (sf.attrs.id && this.defaultFlows.has(sf.attrs.id))
|
|
163
|
+
tag = "default";
|
|
164
|
+
this.edges.push({ kind: "seq", from, to, tag });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Record every referenceable root definition (`message`/`error`/`signal`/
|
|
169
|
+
* `escalation`) so a `*Ref` can be resolved to a stable name. */
|
|
170
|
+
function collectRefs(el, c) {
|
|
171
|
+
const l = localName(el.name);
|
|
172
|
+
if ((l === "message" || l === "error" || l === "signal" || l === "escalation") && el.attrs.id) {
|
|
173
|
+
c.refNames.set(el.attrs.id, el.attrs.name ?? el.attrs.errorCode ?? el.attrs.id);
|
|
174
|
+
if (l === "message")
|
|
175
|
+
c.messageDefs.set(el.attrs.id, el);
|
|
176
|
+
}
|
|
177
|
+
for (const child of el.children)
|
|
178
|
+
if (!isDI(child))
|
|
179
|
+
collectRefs(child, c);
|
|
180
|
+
}
|
|
181
|
+
function findContainers(el, out) {
|
|
182
|
+
const l = localName(el.name);
|
|
183
|
+
if (l === "process")
|
|
184
|
+
out.push(el);
|
|
185
|
+
for (const child of el.children)
|
|
186
|
+
if (!isDI(child))
|
|
187
|
+
findContainers(child, out);
|
|
188
|
+
}
|
|
189
|
+
/** Normalize a BPMN XML document to its canonical semantic model. Strips DI and
|
|
190
|
+
* canonicalizes ids, child ordering, and sequence-flow ids so only semantic
|
|
191
|
+
* structure remains. */
|
|
192
|
+
export function normalize(bpmnXml) {
|
|
193
|
+
const root = parseXml(bpmnXml);
|
|
194
|
+
const c = new Collector();
|
|
195
|
+
collectRefs(root, c);
|
|
196
|
+
const processes = [];
|
|
197
|
+
findContainers(root, processes);
|
|
198
|
+
if (processes.length === 0)
|
|
199
|
+
throw new Error("normalize: no <process> element found");
|
|
200
|
+
for (const p of processes)
|
|
201
|
+
c.collectContainer(p, undefined);
|
|
202
|
+
c.finalizeFlows();
|
|
203
|
+
// Per-node edge-degree signature — captures wiring (how many sequence flows in
|
|
204
|
+
// / out, whether it hosts or is a boundary, whether it contains or is
|
|
205
|
+
// contained) so a rewiring that preserves node labels still shows up.
|
|
206
|
+
const deg = new Map();
|
|
207
|
+
const bump = (id) => {
|
|
208
|
+
let d = deg.get(id);
|
|
209
|
+
if (!d) {
|
|
210
|
+
d = { si: 0, so: 0, at: 0, ah: 0, ci: 0, co: 0 };
|
|
211
|
+
deg.set(id, d);
|
|
212
|
+
}
|
|
213
|
+
return d;
|
|
214
|
+
};
|
|
215
|
+
for (const e of c.edges) {
|
|
216
|
+
if (e.kind === "seq") {
|
|
217
|
+
bump(e.from).so += 1;
|
|
218
|
+
bump(e.to).si += 1;
|
|
219
|
+
}
|
|
220
|
+
else if (e.kind === "attach") {
|
|
221
|
+
bump(e.from).at += 1; // this node is a boundary attached to something
|
|
222
|
+
bump(e.to).ah += 1; // this node hosts a boundary
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
bump(e.from).co += 1; // this sub-process contains children
|
|
226
|
+
bump(e.to).ci += 1; // this node is contained
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const labelWithDegree = (id) => {
|
|
230
|
+
const node = c.nodes.get(id);
|
|
231
|
+
if (!node)
|
|
232
|
+
return `«unknown:${id}»`;
|
|
233
|
+
const d = deg.get(id) ?? { si: 0, so: 0, at: 0, ah: 0, ci: 0, co: 0 };
|
|
234
|
+
return `${node.label}<in=${d.si},out=${d.so},boundary=${d.at},hosts=${d.ah},contained=${d.ci},contains=${d.co}>`;
|
|
235
|
+
};
|
|
236
|
+
const nodes = [...c.nodes.keys()].map(labelWithDegree).sort();
|
|
237
|
+
const flows = c.edges
|
|
238
|
+
.map((e) => {
|
|
239
|
+
const arrow = e.kind === "attach" ? "--attach-->" : e.kind === "contain" ? "--contains-->" : `=[${e.tag}]=>`;
|
|
240
|
+
return `${labelWithDegree(e.from)} ${arrow} ${labelWithDegree(e.to)}`;
|
|
241
|
+
})
|
|
242
|
+
.sort();
|
|
243
|
+
const messages = [...c.messages].sort();
|
|
244
|
+
return { nodes, flows, messages };
|
|
245
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { WorkflowClient } from "../client.js";
|
|
2
|
+
import type { DeclarativeFlow, DeployResult, Workflow } from "../types.js";
|
|
3
|
+
/** A derived flow, or the derived BPMN XML directly. */
|
|
4
|
+
export type DerivedInput = DeclarativeFlow | string;
|
|
5
|
+
/** Derive `derived`'s BPMN, normalize it and the golden at `goldenBpmnPath`, and
|
|
6
|
+
* assert structural equality. Throws with a legible structural diff on
|
|
7
|
+
* mismatch. Returns silently on parity. */
|
|
8
|
+
export declare function assertDerivationParity(derived: DerivedInput, goldenBpmnPath: string): void;
|
|
9
|
+
/** Options for {@link deploySmoke}. Supply a pre-built `client`, a `baseUrl`
|
|
10
|
+
* (a client is built for you), or rely on the `WORKFLOW_GATEWAY_URL` env var. */
|
|
11
|
+
export interface DeploySmokeOptions {
|
|
12
|
+
client?: WorkflowClient;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
token?: string;
|
|
15
|
+
transport?: "auto" | "falcon" | "rest";
|
|
16
|
+
}
|
|
17
|
+
/** The outcome of a {@link deploySmoke}: either a completed deployment, or a
|
|
18
|
+
* skip because no engine was reachable. */
|
|
19
|
+
export interface DeploySmokeResult {
|
|
20
|
+
deployed: boolean;
|
|
21
|
+
skipped: boolean;
|
|
22
|
+
reason?: string;
|
|
23
|
+
result?: DeployResult;
|
|
24
|
+
}
|
|
25
|
+
/** Deploy `model` to the engine and assert the deployment is accepted. Resolves
|
|
26
|
+
* a skip (never throws) when no engine is reachable, so it is safe to call in
|
|
27
|
+
* unit-only CI; it throws only when a reachable engine REJECTS the model. */
|
|
28
|
+
export declare function deploySmoke(model: Workflow, opts?: DeploySmokeOptions): Promise<DeploySmokeResult>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The two assertions S1–S6 build their derivation-parity tests on:
|
|
2
|
+
//
|
|
3
|
+
// assertDerivationParity(derivedFlow, goldenBpmnPath)
|
|
4
|
+
// Derive the flow's BPMN, normalize it and the checked-in golden, and assert
|
|
5
|
+
// they are structurally equal — throwing a legible red/green diff otherwise.
|
|
6
|
+
//
|
|
7
|
+
// deploySmoke(derivedModel, opts?)
|
|
8
|
+
// Deploy the derived model to a live/wasm engine and assert acceptance. When
|
|
9
|
+
// no engine is reachable (no client, baseUrl, or WORKFLOW_GATEWAY_URL), it
|
|
10
|
+
// resolves `{ deployed: false, skipped: true }` so unit-only CI stays green —
|
|
11
|
+
// the caller decides whether to hard-require deployment.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { WorkflowClient } from "../client.js";
|
|
14
|
+
import { declarativeToBpmn } from "../declarative.js";
|
|
15
|
+
import { diffModels } from "./diff.js";
|
|
16
|
+
import { normalize } from "./normalize.js";
|
|
17
|
+
function toBpmnXml(input) {
|
|
18
|
+
if (typeof input === "string")
|
|
19
|
+
return input;
|
|
20
|
+
return declarativeToBpmn(input);
|
|
21
|
+
}
|
|
22
|
+
/** Derive `derived`'s BPMN, normalize it and the golden at `goldenBpmnPath`, and
|
|
23
|
+
* assert structural equality. Throws with a legible structural diff on
|
|
24
|
+
* mismatch. Returns silently on parity. */
|
|
25
|
+
export function assertDerivationParity(derived, goldenBpmnPath) {
|
|
26
|
+
const derivedXml = toBpmnXml(derived);
|
|
27
|
+
const goldenXml = readFileSync(goldenBpmnPath, "utf8");
|
|
28
|
+
const golden = normalize(goldenXml);
|
|
29
|
+
const actual = normalize(derivedXml);
|
|
30
|
+
const diff = diffModels(golden, actual);
|
|
31
|
+
if (diff) {
|
|
32
|
+
throw new Error(`derivation parity failed against ${goldenBpmnPath}:\n${diff}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function resolveClient(opts) {
|
|
36
|
+
if (opts.client)
|
|
37
|
+
return opts.client;
|
|
38
|
+
const baseUrl = opts.baseUrl ?? process.env.WORKFLOW_GATEWAY_URL;
|
|
39
|
+
if (!baseUrl)
|
|
40
|
+
return undefined;
|
|
41
|
+
const clientOpts = {
|
|
42
|
+
baseUrl,
|
|
43
|
+
...(opts.token ? { token: opts.token } : {}),
|
|
44
|
+
...(opts.transport ? { transport: opts.transport } : {}),
|
|
45
|
+
};
|
|
46
|
+
return new WorkflowClient(clientOpts);
|
|
47
|
+
}
|
|
48
|
+
/** Deploy `model` to the engine and assert the deployment is accepted. Resolves
|
|
49
|
+
* a skip (never throws) when no engine is reachable, so it is safe to call in
|
|
50
|
+
* unit-only CI; it throws only when a reachable engine REJECTS the model. */
|
|
51
|
+
export async function deploySmoke(model, opts = {}) {
|
|
52
|
+
const client = resolveClient(opts);
|
|
53
|
+
if (!client) {
|
|
54
|
+
return { deployed: false, skipped: true, reason: "no engine reachable (set baseUrl/client/WORKFLOW_GATEWAY_URL)" };
|
|
55
|
+
}
|
|
56
|
+
const result = await client.deploy(model);
|
|
57
|
+
if (!result || typeof result !== "object" || Object.keys(result).length === 0) {
|
|
58
|
+
throw new Error(`deploySmoke: engine did not accept "${model.id}" (empty deploy result)`);
|
|
59
|
+
}
|
|
60
|
+
return { deployed: true, skipped: false, result };
|
|
61
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A parsed XML element. `attrs` preserves declared attributes (entity-decoded);
|
|
2
|
+
* `children` are child elements in document order; `text` is the concatenated
|
|
3
|
+
* direct text content (trimmed), used for elements like `conditionExpression`. */
|
|
4
|
+
export interface XmlElement {
|
|
5
|
+
/** The qualified name as written, e.g. `bpmn:serviceTask` or `process`. */
|
|
6
|
+
name: string;
|
|
7
|
+
attrs: Record<string, string>;
|
|
8
|
+
children: XmlElement[];
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
/** The local (un-prefixed) name of an element or attribute. */
|
|
12
|
+
export declare function localName(qualified: string): string;
|
|
13
|
+
/** Parse an XML document into its root element. Throws on a malformed document
|
|
14
|
+
* (unbalanced tags, no root, or more than one top-level element). Comments,
|
|
15
|
+
* CDATA, the `<?xml?>` prolog and other processing instructions are handled;
|
|
16
|
+
* CDATA content contributes to `text`. */
|
|
17
|
+
export declare function parseXml(xml: string): XmlElement;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// A dependency-free XML reader, just enough to parse a BPMN 2.0 document into a
|
|
2
|
+
// tree the normalizer can canonicalize. BPMN files this package emits (and the
|
|
3
|
+
// hand-authored goldens) are plain XML — elements, attributes, text — with no
|
|
4
|
+
// DTDs, processing instructions beyond the `<?xml …?>` prolog, or mixed-content
|
|
5
|
+
// subtleties that would need a full parser. We keep prefixed names verbatim
|
|
6
|
+
// (`bpmn:serviceTask`, `zeebe:taskDefinition`) so the normalizer can reason about
|
|
7
|
+
// namespaces by local name.
|
|
8
|
+
/** The local (un-prefixed) name of an element or attribute. */
|
|
9
|
+
export function localName(qualified) {
|
|
10
|
+
const i = qualified.indexOf(":");
|
|
11
|
+
return i === -1 ? qualified : qualified.slice(i + 1);
|
|
12
|
+
}
|
|
13
|
+
function decodeEntities(s) {
|
|
14
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
|
|
15
|
+
switch (body) {
|
|
16
|
+
case "lt":
|
|
17
|
+
return "<";
|
|
18
|
+
case "gt":
|
|
19
|
+
return ">";
|
|
20
|
+
case "amp":
|
|
21
|
+
return "&";
|
|
22
|
+
case "quot":
|
|
23
|
+
return '"';
|
|
24
|
+
case "apos":
|
|
25
|
+
return "'";
|
|
26
|
+
}
|
|
27
|
+
if (body[0] === "#") {
|
|
28
|
+
const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
|
|
29
|
+
// Only decode a valid Unicode scalar value. `String.fromCodePoint` throws
|
|
30
|
+
// a `RangeError` for code points outside 0..0x10FFFF (e.g. `�`),
|
|
31
|
+
// and surrogate halves (0xD800..0xDFFF) are not valid characters — treat
|
|
32
|
+
// any such reference as malformed and leave it verbatim rather than
|
|
33
|
+
// throwing an opaque exception out of `parseXml`.
|
|
34
|
+
const isScalar = Number.isInteger(code) && code >= 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff);
|
|
35
|
+
return isScalar ? String.fromCodePoint(code) : m;
|
|
36
|
+
}
|
|
37
|
+
return m;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function parseAttrs(src) {
|
|
41
|
+
const attrs = {};
|
|
42
|
+
const re = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
43
|
+
let m = re.exec(src);
|
|
44
|
+
while (m !== null) {
|
|
45
|
+
const value = m[3] !== undefined ? m[3] : (m[4] ?? "");
|
|
46
|
+
attrs[m[1]] = decodeEntities(value);
|
|
47
|
+
m = re.exec(src);
|
|
48
|
+
}
|
|
49
|
+
return attrs;
|
|
50
|
+
}
|
|
51
|
+
function appendText(el, chunk) {
|
|
52
|
+
if (chunk)
|
|
53
|
+
el.text = el.text ? `${el.text} ${chunk}` : chunk;
|
|
54
|
+
}
|
|
55
|
+
/** Parse an XML document into its root element. Throws on a malformed document
|
|
56
|
+
* (unbalanced tags, no root, or more than one top-level element). Comments,
|
|
57
|
+
* CDATA, the `<?xml?>` prolog and other processing instructions are handled;
|
|
58
|
+
* CDATA content contributes to `text`. */
|
|
59
|
+
export function parseXml(xml) {
|
|
60
|
+
const root = { name: "#root", attrs: {}, children: [], text: "" };
|
|
61
|
+
const stack = [root];
|
|
62
|
+
let i = 0;
|
|
63
|
+
const n = xml.length;
|
|
64
|
+
while (i < n) {
|
|
65
|
+
const lt = xml.indexOf("<", i);
|
|
66
|
+
if (lt === -1)
|
|
67
|
+
break;
|
|
68
|
+
if (lt > i)
|
|
69
|
+
appendText(stack[stack.length - 1], decodeEntities(xml.slice(i, lt)).trim());
|
|
70
|
+
if (xml.startsWith("<!--", lt)) {
|
|
71
|
+
const end = xml.indexOf("-->", lt + 4);
|
|
72
|
+
if (end === -1)
|
|
73
|
+
throw new Error("parseXml: unterminated comment");
|
|
74
|
+
i = end + 3;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (xml.startsWith("<![CDATA[", lt)) {
|
|
78
|
+
const end = xml.indexOf("]]>", lt + 9);
|
|
79
|
+
if (end === -1)
|
|
80
|
+
throw new Error("parseXml: unterminated CDATA");
|
|
81
|
+
appendText(stack[stack.length - 1], xml.slice(lt + 9, end).trim());
|
|
82
|
+
i = end + 3;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (xml.startsWith("<?", lt) || xml.startsWith("<!", lt)) {
|
|
86
|
+
const end = xml.indexOf(">", lt + 2);
|
|
87
|
+
if (end === -1)
|
|
88
|
+
throw new Error("parseXml: unterminated processing instruction / declaration");
|
|
89
|
+
i = end + 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const gt = xml.indexOf(">", lt);
|
|
93
|
+
if (gt === -1)
|
|
94
|
+
throw new Error("parseXml: unterminated tag");
|
|
95
|
+
let inner = xml.slice(lt + 1, gt);
|
|
96
|
+
if (inner.startsWith("/")) {
|
|
97
|
+
const closeName = inner.slice(1).trim();
|
|
98
|
+
if (stack.length <= 1)
|
|
99
|
+
throw new Error(`parseXml: unbalanced end tag </${closeName}>`);
|
|
100
|
+
const open = stack.pop();
|
|
101
|
+
if (open === undefined || open.name !== closeName) {
|
|
102
|
+
throw new Error(`parseXml: mismatched end tag </${closeName}> (expected </${open?.name ?? "#root"}>)`);
|
|
103
|
+
}
|
|
104
|
+
i = gt + 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const selfClosing = inner.endsWith("/");
|
|
108
|
+
if (selfClosing)
|
|
109
|
+
inner = inner.slice(0, -1);
|
|
110
|
+
const nameMatch = /^\s*(\S+)/.exec(inner);
|
|
111
|
+
if (!nameMatch)
|
|
112
|
+
throw new Error("parseXml: malformed start tag");
|
|
113
|
+
const name = nameMatch[1];
|
|
114
|
+
const attrs = parseAttrs(inner.slice(nameMatch[0].length));
|
|
115
|
+
const el = { name, attrs, children: [], text: "" };
|
|
116
|
+
stack[stack.length - 1].children.push(el);
|
|
117
|
+
if (!selfClosing)
|
|
118
|
+
stack.push(el);
|
|
119
|
+
i = gt + 1;
|
|
120
|
+
}
|
|
121
|
+
if (stack.length !== 1) {
|
|
122
|
+
const unclosed = stack
|
|
123
|
+
.slice(1)
|
|
124
|
+
.map((el) => `<${el.name}>`)
|
|
125
|
+
.join(", ");
|
|
126
|
+
throw new Error(`parseXml: unclosed tag(s): ${unclosed}`);
|
|
127
|
+
}
|
|
128
|
+
if (root.children.length === 0)
|
|
129
|
+
throw new Error("parseXml: no root element");
|
|
130
|
+
if (root.children.length > 1) {
|
|
131
|
+
const roots = root.children.map((el) => `<${el.name}>`).join(", ");
|
|
132
|
+
throw new Error(`parseXml: multiple root elements (${roots}) — a well-formed document has exactly one`);
|
|
133
|
+
}
|
|
134
|
+
return root.children[0];
|
|
135
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -37,62 +37,26 @@ export type FlowContracts = Record<string, StepContract>;
|
|
|
37
37
|
export type StepHandler<V extends JsonObject = JsonObject, R extends JsonObject = JsonObject> = {
|
|
38
38
|
bivarianceHack(job: Job<V>): Promise<R | void> | R | void;
|
|
39
39
|
}["bivarianceHack"];
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
} & TimerAt) | {
|
|
61
|
-
kind: "switch";
|
|
62
|
-
subject: string;
|
|
63
|
-
cases: SwitchCase[];
|
|
64
|
-
default?: FlowNode[];
|
|
65
|
-
} | {
|
|
66
|
-
kind: "branch";
|
|
67
|
-
condition: string;
|
|
68
|
-
then: FlowNode[];
|
|
69
|
-
else?: FlowNode[];
|
|
70
|
-
} | {
|
|
71
|
-
kind: "loop";
|
|
72
|
-
body: FlowNode[];
|
|
73
|
-
} | {
|
|
74
|
-
kind: "break";
|
|
75
|
-
} | {
|
|
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;
|
|
95
|
-
};
|
|
40
|
+
/** The OPEN registry of flow-node kinds — the single extension seam for the
|
|
41
|
+
* `FlowNode` union (epic #314, S0/#315). Each kind module under `src/nodes/`
|
|
42
|
+
* declaration-merges ONE property into this interface (`kindName: NodeShape`),
|
|
43
|
+
* so `FlowNode` grows without editing a central union here. The built-in kinds
|
|
44
|
+
* register `run`/`task`/`signal`/`timer`/`switch`/`branch`/`loop`/`break`/
|
|
45
|
+
* `continue`/`parallel`/`forEach` from their own modules through this same
|
|
46
|
+
* mechanism — see `src/nodes/README.md`.
|
|
47
|
+
*
|
|
48
|
+
* Invariant: every value type MUST be an object with a `kind` discriminant
|
|
49
|
+
* equal to its key, so `FlowNode` stays a discriminated union.
|
|
50
|
+
*
|
|
51
|
+
* This interface is intentionally empty here: it is the augmentation target the
|
|
52
|
+
* per-kind modules declaration-merge their variant into. */
|
|
53
|
+
export interface FlowNodeRegistry {
|
|
54
|
+
}
|
|
55
|
+
/** A node in a declarative flow tree, derived from the {@link FlowNodeRegistry}.
|
|
56
|
+
* Leaf activities carry optional data envelopes (lifted to `nano:shape` +
|
|
57
|
+
* `dataEnvelope` in the model); structural combinators carry nested
|
|
58
|
+
* `FlowNode[]` bodies. */
|
|
59
|
+
export type FlowNode = FlowNodeRegistry[keyof FlowNodeRegistry];
|
|
96
60
|
/** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
|
|
97
61
|
* delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
|
|
98
62
|
* Encoded as an XOR so downstream consumers can rely on exactly-one. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Code-first durable orchestration for nanobpmn (ADR 0044/0045). Author a durable workflow as a declarative flow — `defineFlow(id, w => { w.run(...); w.signal(...); w.task(...) })` — and the SDK derives the executable BPMN model, the job types, and the message/correlation wiring, then hosts a generic worker. `w.run` is a local step, `w.signal` is a durable human-in-the-loop wait, `w.task` is a step serviced by an external worker. `defineFlow` is the single code-first surface; the imperative `defineWorkflow` replay function is experimental/internal, not a recommended API. No diagram, no task-type wiring, no correlation plumbing authored by hand — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway through @nanobpm/nano-sdk (the shared engine-transport spine — ADR 0055), which negotiates the Falcon protocol on a Nano server and REST elsewhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"types": "./dist/index.d.ts",
|
|
18
18
|
"default": "./dist/index.js"
|
|
19
19
|
},
|
|
20
|
+
"./test-support": {
|
|
21
|
+
"types": "./dist/test-support/index.d.ts",
|
|
22
|
+
"default": "./dist/test-support/index.js"
|
|
23
|
+
},
|
|
20
24
|
"./source": "./src/index.ts"
|
|
21
25
|
},
|
|
22
26
|
"files": [
|
|
@@ -26,13 +30,17 @@
|
|
|
26
30
|
"engines": {
|
|
27
31
|
"node": ">=22.6"
|
|
28
32
|
},
|
|
29
|
-
"sideEffects":
|
|
33
|
+
"sideEffects": [
|
|
34
|
+
"./dist/nodes/*.js",
|
|
35
|
+
"./src/nodes/*.ts"
|
|
36
|
+
],
|
|
30
37
|
"dependencies": {
|
|
31
38
|
"@nanobpm/nano-sdk": "^1.2.5"
|
|
32
39
|
},
|
|
33
40
|
"scripts": {
|
|
34
|
-
"
|
|
35
|
-
"
|
|
41
|
+
"gen:nodes": "node scripts/gen-node-registry.mjs",
|
|
42
|
+
"build": "npm run gen:nodes && tsc -p tsconfig.json",
|
|
43
|
+
"typecheck": "npm run gen:nodes && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.types.json",
|
|
36
44
|
"test": "npm run build && npm run test:ci",
|
|
37
45
|
"test:ci": "node --experimental-strip-types --test test/*.test.ts",
|
|
38
46
|
"test:integration": "node --experimental-strip-types --test test/integration.test.ts",
|