@nanobpm/workflow 0.2.0 → 0.3.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 +96 -28
- package/dist/client.js +8 -2
- package/dist/declarative.d.ts +84 -17
- package/dist/declarative.js +423 -83
- package/dist/envelope.d.ts +57 -0
- package/dist/envelope.js +62 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -1
- package/dist/layout.d.ts +22 -0
- package/dist/layout.js +61 -0
- package/dist/types.d.ts +57 -5
- package/dist/worker.js +8 -5
- package/dist/xml.js +29 -2
- package/package.json +11 -2
- package/src/bpmn-auto-layout.d.ts +10 -0
- package/src/client.ts +7 -2
- package/src/declarative.ts +569 -101
- package/src/envelope.ts +130 -0
- package/src/index.ts +17 -1
- package/src/layout.ts +67 -0
- package/src/types.ts +56 -9
- package/src/worker.ts +8 -5
- package/src/xml.ts +28 -2
package/dist/declarative.js
CHANGED
|
@@ -1,108 +1,448 @@
|
|
|
1
|
-
// Strategy A — the declarative surface: a
|
|
1
|
+
// Strategy A — the declarative surface: a TREE of nodes compiled to a real BPMN
|
|
2
|
+
// model. Leaf activities (`run`/`task`/`signal`) plus structural combinators
|
|
3
|
+
// (`switch`/`branch`/`loop`) that compile to exclusive gateways + back-edges the
|
|
4
|
+
// engine already runs (engine-core supports XOR gateways with in-order condition
|
|
5
|
+
// evaluation and a default flow, and tasks with multiple incoming flows act as
|
|
6
|
+
// an implicit XOR merge).
|
|
2
7
|
//
|
|
3
|
-
// const
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
8
|
+
// const convergence = defineFlow(
|
|
9
|
+
// "convergence-loop",
|
|
10
|
+
// { // contracts keyed by step name
|
|
11
|
+
// "review-round": { in: PrReviewRoundIn, out: PrReviewRoundOut },
|
|
12
|
+
// "persist-round": { out: RoundState },
|
|
13
|
+
// "wait-review": { in: ReviewReady },
|
|
14
|
+
// "wait-answer": { in: EscalationAnswered },
|
|
15
|
+
// },
|
|
16
|
+
// (w) => {
|
|
17
|
+
// w.loop((b) => {
|
|
18
|
+
// b.run("review-round", reviewRound); // job.variables typed from the contract
|
|
19
|
+
// b.switch("status", {
|
|
20
|
+
// converged: (c) => { c.run("persist-converged", finalize); c.break(); },
|
|
21
|
+
// addressed: (c) => c.branch("round >= maxRounds", {
|
|
22
|
+
// then: (g) => { g.run("persist-escalation-maxrounds", persistEsc);
|
|
23
|
+
// g.signal("wait-answer", { correlationKey: "prKey" }); },
|
|
24
|
+
// else: (g) => { g.run("persist-round", persistRound); // returns { round: round + 1 }
|
|
25
|
+
// g.signal("wait-review", { correlationKey: "prKey" }); },
|
|
26
|
+
// }),
|
|
27
|
+
// default: (c) => { c.run("persist-escalation", persistEsc);
|
|
28
|
+
// c.signal("wait-answer", { correlationKey: "prKey" }); },
|
|
29
|
+
// });
|
|
30
|
+
// });
|
|
31
|
+
// },
|
|
32
|
+
// );
|
|
9
33
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
34
|
+
// Typed data envelopes are LIFTED into the model (nano:shape + dataEnvelope
|
|
35
|
+
// zeebe:property), so the generated .bpmn is ejectable to model-first with its
|
|
36
|
+
// typed contracts intact.
|
|
13
37
|
import { assertIdent, escapeXml, jobType, messageName } from "./xml.js";
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
38
|
+
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
39
|
+
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
40
|
+
* invalid model, so reject them at authoring time. */
|
|
41
|
+
const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
|
|
42
|
+
function claimName(ctx, id, name) {
|
|
43
|
+
assertIdent("step name", name);
|
|
44
|
+
if (name === "Start" || name === "End" || name === id) {
|
|
45
|
+
throw new Error(`step name "${name}" is reserved (collides with a generated BPMN id) in flow "${id}"`);
|
|
46
|
+
}
|
|
47
|
+
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}"`);
|
|
49
|
+
}
|
|
50
|
+
if (ctx.seen.has(name))
|
|
51
|
+
throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
52
|
+
ctx.seen.add(name);
|
|
53
|
+
}
|
|
54
|
+
/** Resolve a step's declared envelopes from the flow contracts. */
|
|
55
|
+
function contractEnvelopes(ctx, name) {
|
|
56
|
+
const c = ctx.contracts[name];
|
|
57
|
+
if (!c || (!c.in && !c.out))
|
|
58
|
+
return undefined;
|
|
59
|
+
return { in: c.in, out: c.out };
|
|
60
|
+
}
|
|
61
|
+
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
62
|
+
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
63
|
+
* combinators recurse with a fresh `out` array for each nested body. */
|
|
64
|
+
function makeBuilder(id, out, ctx) {
|
|
65
|
+
const child = (fn, inLoop) => {
|
|
66
|
+
const body = [];
|
|
67
|
+
const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
|
|
68
|
+
fn(makeBuilder(id, body, { ...ctx, loopDepth: depth }));
|
|
69
|
+
return body;
|
|
70
|
+
};
|
|
71
|
+
const b = {
|
|
21
72
|
run(name, handler) {
|
|
22
|
-
|
|
23
|
-
if (seen.has(name))
|
|
24
|
-
throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
73
|
+
claimName(ctx, id, name);
|
|
25
74
|
if (typeof handler !== "function")
|
|
26
75
|
throw new Error(`run("${name}") needs a handler function`);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return w;
|
|
76
|
+
ctx.handlers[name] = handler;
|
|
77
|
+
out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
|
|
78
|
+
return b;
|
|
31
79
|
},
|
|
32
80
|
task(name) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
seen.add(name);
|
|
37
|
-
steps.push({ kind: "task", name });
|
|
38
|
-
return w;
|
|
81
|
+
claimName(ctx, id, name);
|
|
82
|
+
out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name) });
|
|
83
|
+
return b;
|
|
39
84
|
},
|
|
40
85
|
signal(name, opts) {
|
|
41
|
-
|
|
42
|
-
if (seen.has(name))
|
|
43
|
-
throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
86
|
+
claimName(ctx, id, name);
|
|
44
87
|
if (!opts || !opts.correlationKey)
|
|
45
88
|
throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
46
89
|
assertIdent("correlationKey", opts.correlationKey);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
90
|
+
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
91
|
+
return b;
|
|
92
|
+
},
|
|
93
|
+
switch(subject, cases) {
|
|
94
|
+
if (typeof subject !== "string" || subject.trim() === "") {
|
|
95
|
+
throw new Error(`switch() needs a non-empty subject expression`);
|
|
96
|
+
}
|
|
97
|
+
const entries = Object.entries(cases).filter(([k]) => k !== "default");
|
|
98
|
+
if (entries.length === 0)
|
|
99
|
+
throw new Error(`switch("${subject}") needs at least one case`);
|
|
100
|
+
const caseNodes = entries.map(([value, fn]) => ({ value, body: child(fn, false) }));
|
|
101
|
+
const def = cases.default ? child(cases.default, false) : undefined;
|
|
102
|
+
out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
103
|
+
return b;
|
|
104
|
+
},
|
|
105
|
+
branch(condition, arms) {
|
|
106
|
+
if (typeof condition !== "string" || condition.trim() === "") {
|
|
107
|
+
throw new Error(`branch() needs a non-empty FEEL condition`);
|
|
108
|
+
}
|
|
109
|
+
if (!arms || typeof arms.then !== "function")
|
|
110
|
+
throw new Error(`branch("${condition}") needs a then arm`);
|
|
111
|
+
out.push({
|
|
112
|
+
kind: "branch",
|
|
113
|
+
condition,
|
|
114
|
+
then: child(arms.then, false),
|
|
115
|
+
else: arms.else ? child(arms.else, false) : undefined,
|
|
116
|
+
});
|
|
117
|
+
return b;
|
|
118
|
+
},
|
|
119
|
+
loop(body) {
|
|
120
|
+
if (typeof body !== "function")
|
|
121
|
+
throw new Error(`loop() needs a body function`);
|
|
122
|
+
out.push({ kind: "loop", body: child(body, true) });
|
|
123
|
+
return b;
|
|
124
|
+
},
|
|
125
|
+
break() {
|
|
126
|
+
if (ctx.loopDepth === 0)
|
|
127
|
+
throw new Error(`break() is only valid inside a loop`);
|
|
128
|
+
out.push({ kind: "break" });
|
|
129
|
+
return b;
|
|
130
|
+
},
|
|
131
|
+
continue() {
|
|
132
|
+
if (ctx.loopDepth === 0)
|
|
133
|
+
throw new Error(`continue() is only valid inside a loop`);
|
|
134
|
+
out.push({ kind: "continue" });
|
|
135
|
+
return b;
|
|
50
136
|
},
|
|
51
137
|
};
|
|
52
|
-
|
|
138
|
+
return b;
|
|
139
|
+
}
|
|
140
|
+
export function defineFlow(id, second, third) {
|
|
141
|
+
assertIdent("workflow id", id);
|
|
142
|
+
if (typeof second !== "function" && (second === null || typeof second !== "object")) {
|
|
143
|
+
throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
|
|
144
|
+
}
|
|
145
|
+
const contracts = typeof second === "function" ? {} : second;
|
|
146
|
+
const build = (typeof second === "function" ? second : third);
|
|
147
|
+
if (typeof build !== "function") {
|
|
148
|
+
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
149
|
+
}
|
|
150
|
+
const steps = [];
|
|
151
|
+
const handlers = {};
|
|
152
|
+
const ctx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
153
|
+
build(makeBuilder(id, steps, ctx));
|
|
53
154
|
if (steps.length === 0)
|
|
54
155
|
throw new Error(`flow "${id}" declared no steps`);
|
|
55
156
|
return { kind: "declarative", id, steps, handlers };
|
|
56
157
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
158
|
+
// --- Tree walkers ------------------------------------------------------------
|
|
159
|
+
/** Depth-first visit of every node in a flow tree (structural combinators
|
|
160
|
+
* recurse into their bodies). */
|
|
161
|
+
export function walkNodes(nodes, visit) {
|
|
162
|
+
for (const n of nodes) {
|
|
163
|
+
visit(n);
|
|
164
|
+
switch (n.kind) {
|
|
165
|
+
case "switch":
|
|
166
|
+
for (const c of n.cases)
|
|
167
|
+
walkNodes(c.body, visit);
|
|
168
|
+
if (n.default)
|
|
169
|
+
walkNodes(n.default, visit);
|
|
170
|
+
break;
|
|
171
|
+
case "branch":
|
|
172
|
+
walkNodes(n.then, visit);
|
|
173
|
+
if (n.else)
|
|
174
|
+
walkNodes(n.else, visit);
|
|
175
|
+
break;
|
|
176
|
+
case "loop":
|
|
177
|
+
walkNodes(n.body, visit);
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** The derived job types of a flow's external `task` steps (anywhere in the
|
|
185
|
+
* tree) — the contract workers outside this program must subscribe to. */
|
|
60
186
|
export function externalJobTypes(flow) {
|
|
61
|
-
|
|
187
|
+
const types = [];
|
|
188
|
+
walkNodes(flow.steps, (n) => {
|
|
189
|
+
if (n.kind === "task")
|
|
190
|
+
types.push(jobType(flow.id, n.name));
|
|
191
|
+
});
|
|
192
|
+
return types;
|
|
62
193
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
194
|
+
class Compiler {
|
|
195
|
+
flow;
|
|
196
|
+
nodes = [];
|
|
197
|
+
edges = [];
|
|
198
|
+
/** envelope name → its fields, deduped for lifting to a single nano:shape. */
|
|
199
|
+
envelopes = new Map();
|
|
200
|
+
seq = 0;
|
|
201
|
+
gw = 0;
|
|
202
|
+
constructor(flow) {
|
|
203
|
+
this.flow = flow;
|
|
204
|
+
}
|
|
205
|
+
newEdge(from, opts = {}) {
|
|
206
|
+
const e = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
|
|
207
|
+
this.edges.push(e);
|
|
208
|
+
return e;
|
|
209
|
+
}
|
|
210
|
+
connect(incoming, toId) {
|
|
211
|
+
for (const e of incoming)
|
|
212
|
+
e.to = toId;
|
|
213
|
+
}
|
|
214
|
+
recordEnvelope(env) {
|
|
215
|
+
if (!env)
|
|
216
|
+
return;
|
|
217
|
+
const prev = this.envelopes.get(env.name);
|
|
218
|
+
if (prev) {
|
|
219
|
+
if (JSON.stringify(prev) !== JSON.stringify(env.fields)) {
|
|
220
|
+
throw new Error(`envelope "${env.name}" is declared with two different field sets in flow "${this.flow.id}"`);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
81
223
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
224
|
+
this.envelopes.set(env.name, env.fields);
|
|
225
|
+
}
|
|
226
|
+
addServiceTask(node) {
|
|
227
|
+
const type = jobType(this.flow.id, node.name);
|
|
228
|
+
this.recordEnvelope(node.envelopes?.in);
|
|
229
|
+
this.recordEnvelope(node.envelopes?.out);
|
|
230
|
+
const props = [];
|
|
231
|
+
if (node.envelopes?.in)
|
|
232
|
+
props.push(envelopeProp("in", node.envelopes.in.name));
|
|
233
|
+
if (node.envelopes?.out)
|
|
234
|
+
props.push(envelopeProp("out", node.envelopes.out.name));
|
|
235
|
+
const ext = ` <bpmn:extensionElements>\n` +
|
|
236
|
+
` <zeebe:taskDefinition type="${escapeXml(type)}" />\n` +
|
|
237
|
+
(props.length ? ` <zeebe:properties>\n${props.join("\n")}\n </zeebe:properties>\n` : "") +
|
|
238
|
+
` </bpmn:extensionElements>`;
|
|
239
|
+
const id = node.name;
|
|
240
|
+
this.nodes.push({
|
|
241
|
+
id,
|
|
242
|
+
render: (inc, outg) => ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
243
|
+
ext +
|
|
244
|
+
"\n" +
|
|
245
|
+
incomingOutgoing(inc, outg) +
|
|
246
|
+
` </bpmn:serviceTask>`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
addCatchEvent(node) {
|
|
250
|
+
const id = node.name;
|
|
251
|
+
const msgId = `Msg_${id}`;
|
|
252
|
+
this.nodes.push({
|
|
253
|
+
id,
|
|
254
|
+
render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
255
|
+
incomingOutgoing(inc, outg) +
|
|
86
256
|
` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
|
|
87
|
-
` </bpmn:intermediateCatchEvent
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
257
|
+
` </bpmn:intermediateCatchEvent>`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
addGateway(id, name) {
|
|
261
|
+
const gwNode = {
|
|
262
|
+
id,
|
|
263
|
+
render: (inc, outg) => {
|
|
264
|
+
const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
|
|
265
|
+
const nm = name ? ` name="${escapeXml(name)}"` : "";
|
|
266
|
+
return (` <bpmn:exclusiveGateway id="${id}"${nm}${def}>\n` +
|
|
267
|
+
incomingOutgoing(inc, outg) +
|
|
268
|
+
` </bpmn:exclusiveGateway>`);
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
this.nodes.push(gwNode);
|
|
272
|
+
return gwNode;
|
|
273
|
+
}
|
|
274
|
+
emitList(list, incoming, loop) {
|
|
275
|
+
let cur = incoming;
|
|
276
|
+
for (const node of list)
|
|
277
|
+
cur = this.emitNode(node, cur, loop);
|
|
278
|
+
return cur;
|
|
279
|
+
}
|
|
280
|
+
emitNode(node, incoming, loop) {
|
|
281
|
+
switch (node.kind) {
|
|
282
|
+
case "run":
|
|
283
|
+
case "task": {
|
|
284
|
+
this.addServiceTask(node);
|
|
285
|
+
this.connect(incoming, node.name);
|
|
286
|
+
return [this.newEdge(node.name)];
|
|
287
|
+
}
|
|
288
|
+
case "signal": {
|
|
289
|
+
this.addCatchEvent(node);
|
|
290
|
+
this.connect(incoming, node.name);
|
|
291
|
+
this.recordEnvelope(node.payload);
|
|
292
|
+
return [this.newEdge(node.name)];
|
|
293
|
+
}
|
|
294
|
+
case "switch": {
|
|
295
|
+
const id = `Gw_${this.gw++}`;
|
|
296
|
+
const gw = this.addGateway(id, node.subject);
|
|
297
|
+
this.connect(incoming, id);
|
|
298
|
+
const out = [];
|
|
299
|
+
for (const c of node.cases) {
|
|
300
|
+
const e = this.newEdge(id, { condition: feelEquals(node.subject, c.value), name: c.value });
|
|
301
|
+
out.push(...this.emitList(c.body, [e], loop));
|
|
302
|
+
}
|
|
303
|
+
// The default (or a synthesised fall-through) is the unconditional edge.
|
|
304
|
+
const de = this.newEdge(id, { name: "default" });
|
|
305
|
+
gw.defaultFlow = de.id;
|
|
306
|
+
out.push(...this.emitList(node.default ?? [], [de], loop));
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
case "branch": {
|
|
310
|
+
const id = `Gw_${this.gw++}`;
|
|
311
|
+
const gw = this.addGateway(id);
|
|
312
|
+
this.connect(incoming, id);
|
|
313
|
+
const te = this.newEdge(id, { condition: feel(node.condition), name: "then" });
|
|
314
|
+
const out = this.emitList(node.then, [te], loop);
|
|
315
|
+
const ee = this.newEdge(id, { name: "else" });
|
|
316
|
+
gw.defaultFlow = ee.id;
|
|
317
|
+
out.push(...this.emitList(node.else ?? [], [ee], loop));
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
case "loop": {
|
|
321
|
+
const headId = `Loop_${this.gw++}`;
|
|
322
|
+
this.addGateway(headId);
|
|
323
|
+
this.connect(incoming, headId);
|
|
324
|
+
const ctx = { headId, breaks: [] };
|
|
325
|
+
const headOut = this.newEdge(headId);
|
|
326
|
+
const bodyOut = this.emitList(node.body, [headOut], ctx);
|
|
327
|
+
// Normal fall-through of the body loops back to the head (continue).
|
|
328
|
+
this.connect(bodyOut, headId);
|
|
329
|
+
// The loop exits only via break edges.
|
|
330
|
+
return ctx.breaks;
|
|
331
|
+
}
|
|
332
|
+
case "break": {
|
|
333
|
+
if (!loop)
|
|
334
|
+
throw new Error(`break outside a loop in flow "${this.flow.id}"`);
|
|
335
|
+
// The incoming edges (from the previous node) become the loop's exit
|
|
336
|
+
// danglers; this path does not fall through.
|
|
337
|
+
loop.breaks.push(...incoming);
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
case "continue": {
|
|
341
|
+
if (!loop)
|
|
342
|
+
throw new Error(`continue outside a loop in flow "${this.flow.id}"`);
|
|
343
|
+
this.connect(incoming, loop.headId);
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
91
346
|
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
347
|
+
}
|
|
348
|
+
compile() {
|
|
349
|
+
// Start → top-level sequence → End.
|
|
350
|
+
this.nodes.push({
|
|
351
|
+
id: "Start",
|
|
352
|
+
render: (_inc, outg) => ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`,
|
|
353
|
+
});
|
|
354
|
+
const s0 = this.newEdge("Start");
|
|
355
|
+
const finalDanglers = this.emitList(this.flow.steps, [s0], null);
|
|
356
|
+
this.nodes.push({
|
|
357
|
+
id: "End",
|
|
358
|
+
render: (inc) => ` <bpmn:endEvent id="End">${incomingOnly(inc)}</bpmn:endEvent>`,
|
|
359
|
+
});
|
|
360
|
+
this.connect(finalDanglers, "End");
|
|
361
|
+
// A sequence flow needs both ends; drop any edge that never got a target.
|
|
362
|
+
const live = this.edges.filter((e) => e.to !== undefined);
|
|
363
|
+
const incomingOf = (id) => live.filter((e) => e.to === id).map((e) => e.id);
|
|
364
|
+
const outgoingOf = (id) => live.filter((e) => e.from === id).map((e) => e.id);
|
|
365
|
+
const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
|
|
366
|
+
const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
|
|
367
|
+
const messageXml = this.emitMessages();
|
|
368
|
+
const shapeXml = this.emitShapes();
|
|
369
|
+
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
370
|
+
`<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ` +
|
|
371
|
+
`xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ` +
|
|
372
|
+
`xmlns:nano="https://nanobpm.io/schema/shapes/1.0" ` +
|
|
373
|
+
`id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
374
|
+
` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
|
|
375
|
+
(shapeXml ? shapeXml + "\n" : "") +
|
|
376
|
+
nodeXml +
|
|
377
|
+
"\n" +
|
|
378
|
+
flowXml +
|
|
379
|
+
"\n" +
|
|
380
|
+
` </bpmn:process>\n` +
|
|
381
|
+
messageXml +
|
|
382
|
+
(messageXml ? "\n" : "") +
|
|
383
|
+
`</bpmn:definitions>\n`);
|
|
384
|
+
}
|
|
385
|
+
/** Emit a `<bpmn:message>` per signal step, with its subscription and (when
|
|
386
|
+
* typed) its payload data envelope. */
|
|
387
|
+
emitMessages() {
|
|
388
|
+
const msgs = [];
|
|
389
|
+
walkNodes(this.flow.steps, (n) => {
|
|
390
|
+
if (n.kind !== "signal")
|
|
391
|
+
return;
|
|
392
|
+
const msgId = `Msg_${n.name}`;
|
|
393
|
+
const payloadProp = n.payload
|
|
394
|
+
? `\n <zeebe:properties>\n${envelopeProp("in", n.payload.name)}\n </zeebe:properties>`
|
|
395
|
+
: "";
|
|
396
|
+
msgs.push(` <bpmn:message id="${msgId}" name="${escapeXml(messageName(this.flow.id, n.name))}">\n` +
|
|
397
|
+
` <bpmn:extensionElements>\n` +
|
|
398
|
+
` <zeebe:subscription correlationKey="=${escapeXml(n.correlationKey)}" />` +
|
|
399
|
+
payloadProp +
|
|
400
|
+
`\n </bpmn:extensionElements>\n` +
|
|
401
|
+
` </bpmn:message>`);
|
|
402
|
+
});
|
|
403
|
+
return msgs.join("\n");
|
|
404
|
+
}
|
|
405
|
+
/** Lift the referenced data envelopes into a `<nano:shapes>` container on the
|
|
406
|
+
* process extension elements, so the model carries the typed contracts and is
|
|
407
|
+
* ejectable to model-first. */
|
|
408
|
+
emitShapes() {
|
|
409
|
+
if (this.envelopes.size === 0)
|
|
410
|
+
return "";
|
|
411
|
+
const shapes = [...this.envelopes.entries()].map(([name, fields]) => {
|
|
412
|
+
const exts = fields.map((f) => {
|
|
413
|
+
const opt = f.optional ? ` optional="true"` : "";
|
|
414
|
+
const list = f.list ? ` list="true"` : "";
|
|
415
|
+
return ` <nano:extend name="${escapeXml(f.name)}" type="${escapeXml(f.type)}"${opt}${list} />`;
|
|
416
|
+
});
|
|
417
|
+
return ` <nano:shape id="${escapeXml(name)}">\n${exts.join("\n")}\n </nano:shape>`;
|
|
418
|
+
});
|
|
419
|
+
return (` <bpmn:extensionElements>\n` +
|
|
420
|
+
` <nano:shapes>\n${shapes.join("\n")}\n </nano:shapes>\n` +
|
|
421
|
+
` </bpmn:extensionElements>`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** Derive an executable BPMN model from a declarative flow. */
|
|
425
|
+
export function declarativeToBpmn(flow) {
|
|
426
|
+
return new Compiler(flow).compile();
|
|
427
|
+
}
|
|
428
|
+
// --- small XML / FEEL helpers ------------------------------------------------
|
|
429
|
+
const envelopeProp = (dir, value) => ` <zeebe:property name="io.nanobpm.dataEnvelope.${dir}" value="${escapeXml(value)}" />`;
|
|
430
|
+
/** Wrap a raw FEEL expression as a Zeebe condition body (leading `=`). */
|
|
431
|
+
const feel = (expr) => `=${expr}`;
|
|
432
|
+
/** A FEEL equality test `subject = "value"`, with the value as a FEEL string. */
|
|
433
|
+
const feelEquals = (subject, value) => `=${subject} = "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
434
|
+
function incomingOutgoing(inc, outg) {
|
|
435
|
+
return (inc.map((f) => ` <bpmn:incoming>${f}</bpmn:incoming>\n`).join("") +
|
|
436
|
+
outg.map((f) => ` <bpmn:outgoing>${f}</bpmn:outgoing>\n`).join(""));
|
|
437
|
+
}
|
|
438
|
+
const incomingOnly = (inc) => inc.map((f) => `<bpmn:incoming>${f}</bpmn:incoming>`).join("");
|
|
439
|
+
const outgoingOnly = (outg) => outg.map((f) => `<bpmn:outgoing>${f}</bpmn:outgoing>`).join("");
|
|
440
|
+
function sequenceFlow(e) {
|
|
441
|
+
const nm = e.name ? ` name="${escapeXml(e.name)}"` : "";
|
|
442
|
+
if (e.condition) {
|
|
443
|
+
return (` <bpmn:sequenceFlow id="${e.id}" sourceRef="${e.from}" targetRef="${e.to}"${nm}>\n` +
|
|
444
|
+
` <bpmn:conditionExpression>${escapeXml(e.condition)}</bpmn:conditionExpression>\n` +
|
|
445
|
+
` </bpmn:sequenceFlow>`);
|
|
446
|
+
}
|
|
447
|
+
return ` <bpmn:sequenceFlow id="${e.id}" sourceRef="${e.from}" targetRef="${e.to}"${nm} />`;
|
|
108
448
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** The scalar types a `nano:extend` field may declare (mirrors the vocabulary
|
|
2
|
+
* the console's shape scan understands). `integer`/`number` are both `number`
|
|
3
|
+
* in TS; `datetime` is an ISO `string`. */
|
|
4
|
+
export type ScalarType = "string" | "integer" | "number" | "boolean" | "datetime";
|
|
5
|
+
/** Map a scalar envelope type to its TypeScript representation. */
|
|
6
|
+
export type ScalarTs<T extends ScalarType> = T extends "string" | "datetime" ? string : T extends "integer" | "number" ? number : T extends "boolean" ? boolean : never;
|
|
7
|
+
/** A field spec: either a bare scalar type, or an object with modifiers. */
|
|
8
|
+
export type FieldSpec = ScalarType | {
|
|
9
|
+
type: ScalarType;
|
|
10
|
+
optional?: boolean;
|
|
11
|
+
list?: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** The TS type of a single field spec (applies `list` before `optional`). */
|
|
14
|
+
export type FieldTs<F> = F extends ScalarType ? ScalarTs<F> : F extends {
|
|
15
|
+
type: infer T extends ScalarType;
|
|
16
|
+
list: true;
|
|
17
|
+
} ? ScalarTs<T>[] : F extends {
|
|
18
|
+
type: infer T extends ScalarType;
|
|
19
|
+
} ? ScalarTs<T> : never;
|
|
20
|
+
type OptionalKeys<S> = {
|
|
21
|
+
[K in keyof S]: S[K] extends {
|
|
22
|
+
optional: true;
|
|
23
|
+
} ? K : never;
|
|
24
|
+
}[keyof S];
|
|
25
|
+
type RequiredKeys<S> = Exclude<keyof S, OptionalKeys<S>>;
|
|
26
|
+
/** The inferred payload type of an envelope: required + optional fields. */
|
|
27
|
+
export type EnvelopeType<S extends Record<string, FieldSpec>> = {
|
|
28
|
+
[K in RequiredKeys<S>]: FieldTs<S[K]>;
|
|
29
|
+
} & {
|
|
30
|
+
[K in OptionalKeys<S>]?: FieldTs<S[K]>;
|
|
31
|
+
};
|
|
32
|
+
/** A normalised field, as stored on an `Envelope` and lifted to `nano:extend`. */
|
|
33
|
+
export interface EnvelopeField {
|
|
34
|
+
name: string;
|
|
35
|
+
type: ScalarType;
|
|
36
|
+
optional: boolean;
|
|
37
|
+
list: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A typed data envelope: a named schema (for the model) plus a phantom TS type
|
|
41
|
+
* (for the call site). Construct with {@link envelope}. `type` is a phantom
|
|
42
|
+
* property — it is `undefined` at runtime and exists only to carry the inferred
|
|
43
|
+
* payload type (use `typeof env.type` in type positions).
|
|
44
|
+
*/
|
|
45
|
+
export interface Envelope<S extends Record<string, FieldSpec> = Record<string, FieldSpec>> {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly fields: EnvelopeField[];
|
|
48
|
+
/** Phantom: the inferred payload type. Do not read at runtime. */
|
|
49
|
+
readonly type: EnvelopeType<S>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Declare a typed data envelope. `name` becomes the `nano:shape` id lifted into
|
|
53
|
+
* the model (must be a valid BPMN identifier); `fields` declares the payload.
|
|
54
|
+
* The returned envelope's `type` phantom carries the inferred TS payload type.
|
|
55
|
+
*/
|
|
56
|
+
export declare function envelope<const S extends Record<string, FieldSpec>>(name: string, fields: S): Envelope<S>;
|
|
57
|
+
export {};
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Typed data envelopes — the code-first expression of a nano:shape.
|
|
2
|
+
//
|
|
3
|
+
// An envelope declares a named, typed payload contract IN CODE. It carries two
|
|
4
|
+
// things at once:
|
|
5
|
+
// - a runtime schema (ordered fields + scalar types), which the model emitter
|
|
6
|
+
// LIFTS into the BPMN as a `nano:shape` (under `nano:shapes` on the process)
|
|
7
|
+
// plus the `io.nanobpm.dataEnvelope.in/out` `zeebe:property` on the service
|
|
8
|
+
// task / message — exactly the carrier the console derives worker I/O from
|
|
9
|
+
// (server/src/console/envelope_scan.rs); and
|
|
10
|
+
// - a phantom TypeScript type, inferred from the field spec, so `run`/`task`
|
|
11
|
+
// handlers and message payloads are statically typed at the call site.
|
|
12
|
+
//
|
|
13
|
+
// This is what makes code-first EJECTABLE to model-first: the generated `.bpmn`
|
|
14
|
+
// already carries the typed shapes + envelope wiring the modeller and the Fused
|
|
15
|
+
// Domain Model (ADR 0040) expect, so opening it in the Modeller loses nothing.
|
|
16
|
+
//
|
|
17
|
+
// const PrReviewRoundIn = envelope("PrReviewRoundIn", {
|
|
18
|
+
// prUrl: "string", repo: "string", prNumber: "integer",
|
|
19
|
+
// prompt: "string", round: "integer",
|
|
20
|
+
// answer: { type: "string", optional: true },
|
|
21
|
+
// });
|
|
22
|
+
// // PrReviewRoundIn.type ≅ { prUrl: string; repo: string; prNumber: number;
|
|
23
|
+
// // prompt: string; round: number; answer?: string }
|
|
24
|
+
import { assertIdent } from "./xml.js";
|
|
25
|
+
const SCALARS = new Set([
|
|
26
|
+
"string",
|
|
27
|
+
"integer",
|
|
28
|
+
"number",
|
|
29
|
+
"boolean",
|
|
30
|
+
"datetime",
|
|
31
|
+
]);
|
|
32
|
+
function normaliseField(name, spec) {
|
|
33
|
+
if (spec === null || (typeof spec !== "string" && typeof spec !== "object")) {
|
|
34
|
+
throw new Error(`envelope field "${name}": must be a scalar type or a { type, optional?, list? } object`);
|
|
35
|
+
}
|
|
36
|
+
const raw = typeof spec === "string" ? { type: spec } : spec;
|
|
37
|
+
if (!SCALARS.has(raw.type)) {
|
|
38
|
+
throw new Error(`envelope field "${name}": unknown type "${raw.type}" (expected one of ${[...SCALARS].join(", ")})`);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
type: raw.type,
|
|
43
|
+
optional: typeof spec === "object" && spec.optional === true,
|
|
44
|
+
list: typeof spec === "object" && spec.list === true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Declare a typed data envelope. `name` becomes the `nano:shape` id lifted into
|
|
49
|
+
* the model (must be a valid BPMN identifier); `fields` declares the payload.
|
|
50
|
+
* The returned envelope's `type` phantom carries the inferred TS payload type.
|
|
51
|
+
*/
|
|
52
|
+
export function envelope(name, fields) {
|
|
53
|
+
assertIdent("envelope name", name);
|
|
54
|
+
const list = Object.entries(fields).map(([k, v]) => normaliseField(k, v));
|
|
55
|
+
if (list.length === 0)
|
|
56
|
+
throw new Error(`envelope "${name}" declares no fields`);
|
|
57
|
+
for (const f of list)
|
|
58
|
+
assertIdent("envelope field name", f.name);
|
|
59
|
+
// `type` is phantom: never read at runtime; the cast keeps the value shape
|
|
60
|
+
// minimal while the declared type carries the inference.
|
|
61
|
+
return { name, fields: list, type: undefined };
|
|
62
|
+
}
|