@nanobpm/workflow 0.2.0 → 0.4.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.
@@ -1,108 +1,460 @@
1
- // Strategy A — the declarative surface: a step list compiled to a linear model.
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 flow = defineFlow("pr-review", (w) => {
4
- // w.run("fetchDiff", async (job) => ({ diff: await gh.diff(job.variables.prId) }));
5
- // w.run("autoReview", async (job) => ({ findings: await llm.review(job.variables.diff) }));
6
- // w.signal("humanApproval", { correlationKey: "prId" }); // durable human wait
7
- // w.run("merge", async (job) => ({ merged: await gh.merge(job.variables.prId) }));
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
- // The façade derives the BPMN model, the job types (`<id>:<step>`), the message
11
- // name + `zeebe:subscription correlationKey`, and (via Worker) a generic worker.
12
- // This surface supports human-in-the-loop signals today.
13
- import { assertIdent, escapeXml, jobType, messageName } from "./xml.js";
14
- /** Define a declarative flow. `build(w)` declares an ordered list of steps. */
15
- export function defineFlow(id, build) {
16
- assertIdent("workflow id", id);
17
- const steps = [];
18
- const handlers = {};
19
- const seen = new Set();
20
- const w = {
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.
37
+ import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
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
- assertIdent("step name", name);
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
- seen.add(name);
28
- steps.push({ kind: "run", name });
29
- handlers[name] = handler;
30
- return w;
76
+ ctx.handlers[name] = handler;
77
+ out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
78
+ return b;
31
79
  },
32
- task(name) {
33
- assertIdent("step name", name);
34
- if (seen.has(name))
35
- throw new Error(`duplicate step name "${name}" in flow "${id}"`);
36
- seen.add(name);
37
- steps.push({ kind: "task", name });
38
- return w;
80
+ task(name, opts) {
81
+ claimName(ctx, id, name);
82
+ const override = opts?.jobType;
83
+ if (override !== undefined)
84
+ assertJobType("task jobType", override);
85
+ out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
86
+ return b;
39
87
  },
40
88
  signal(name, opts) {
41
- assertIdent("step name", name);
42
- if (seen.has(name))
43
- throw new Error(`duplicate step name "${name}" in flow "${id}"`);
89
+ claimName(ctx, id, name);
44
90
  if (!opts || !opts.correlationKey)
45
91
  throw new Error(`signal("${name}") needs { correlationKey }`);
46
92
  assertIdent("correlationKey", opts.correlationKey);
47
- seen.add(name);
48
- steps.push({ kind: "signal", name, correlationKey: opts.correlationKey });
49
- return w;
93
+ out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
94
+ return b;
95
+ },
96
+ switch(subject, cases) {
97
+ if (typeof subject !== "string" || subject.trim() === "") {
98
+ throw new Error(`switch() needs a non-empty subject expression`);
99
+ }
100
+ const entries = Object.entries(cases).filter(([k]) => k !== "default");
101
+ if (entries.length === 0)
102
+ throw new Error(`switch("${subject}") needs at least one case`);
103
+ const caseNodes = entries.map(([value, fn]) => ({ value, body: child(fn, false) }));
104
+ const def = cases.default ? child(cases.default, false) : undefined;
105
+ out.push({ kind: "switch", subject, cases: caseNodes, default: def });
106
+ return b;
107
+ },
108
+ branch(condition, arms) {
109
+ if (typeof condition !== "string" || condition.trim() === "") {
110
+ throw new Error(`branch() needs a non-empty FEEL condition`);
111
+ }
112
+ if (!arms || typeof arms.then !== "function")
113
+ throw new Error(`branch("${condition}") needs a then arm`);
114
+ out.push({
115
+ kind: "branch",
116
+ condition,
117
+ then: child(arms.then, false),
118
+ else: arms.else ? child(arms.else, false) : undefined,
119
+ });
120
+ return b;
121
+ },
122
+ loop(body) {
123
+ if (typeof body !== "function")
124
+ throw new Error(`loop() needs a body function`);
125
+ out.push({ kind: "loop", body: child(body, true) });
126
+ return b;
127
+ },
128
+ break() {
129
+ if (ctx.loopDepth === 0)
130
+ throw new Error(`break() is only valid inside a loop`);
131
+ out.push({ kind: "break" });
132
+ return b;
133
+ },
134
+ continue() {
135
+ if (ctx.loopDepth === 0)
136
+ throw new Error(`continue() is only valid inside a loop`);
137
+ out.push({ kind: "continue" });
138
+ return b;
50
139
  },
51
140
  };
52
- build(w);
141
+ return b;
142
+ }
143
+ export function defineFlow(id, second, third) {
144
+ assertIdent("workflow id", id);
145
+ if (typeof second !== "function" && (second === null || typeof second !== "object")) {
146
+ throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
147
+ }
148
+ const contracts = typeof second === "function" ? {} : second;
149
+ const build = (typeof second === "function" ? second : third);
150
+ if (typeof build !== "function") {
151
+ throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
152
+ }
153
+ const steps = [];
154
+ const handlers = {};
155
+ const ctx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
156
+ build(makeBuilder(id, steps, ctx));
53
157
  if (steps.length === 0)
54
158
  throw new Error(`flow "${id}" declared no steps`);
55
159
  return { kind: "declarative", id, steps, handlers };
56
160
  }
57
- /** The derived job types of a flow's external `task` steps — the contract that
58
- * workers outside this program must subscribe to (the engine offers those jobs
59
- * to whoever polls the type). Empty when the flow hosts all its own steps. */
161
+ // --- Tree walkers ------------------------------------------------------------
162
+ /** Depth-first visit of every node in a flow tree (structural combinators
163
+ * recurse into their bodies). */
164
+ export function walkNodes(nodes, visit) {
165
+ for (const n of nodes) {
166
+ visit(n);
167
+ switch (n.kind) {
168
+ case "switch":
169
+ for (const c of n.cases)
170
+ walkNodes(c.body, visit);
171
+ if (n.default)
172
+ walkNodes(n.default, visit);
173
+ break;
174
+ case "branch":
175
+ walkNodes(n.then, visit);
176
+ if (n.else)
177
+ walkNodes(n.else, visit);
178
+ break;
179
+ case "loop":
180
+ walkNodes(n.body, visit);
181
+ break;
182
+ default:
183
+ break;
184
+ }
185
+ }
186
+ }
187
+ /** The job types of a flow's external `task` steps (anywhere in the tree) — the
188
+ * contract workers outside this program must subscribe to. Each is the derived
189
+ * `<flowId>:<stepName>` unless the step overrode it via `w.task(name,
190
+ * { jobType })`. Deduplicated (preserving first-seen order) since several steps
191
+ * may intentionally share one override token. */
60
192
  export function externalJobTypes(flow) {
61
- return flow.steps.filter((s) => s.kind === "task").map((s) => jobType(flow.id, s.name));
193
+ const seen = new Set();
194
+ const types = [];
195
+ walkNodes(flow.steps, (n) => {
196
+ if (n.kind !== "task")
197
+ return;
198
+ const type = n.jobType ?? jobType(flow.id, n.name);
199
+ if (seen.has(type))
200
+ return;
201
+ seen.add(type);
202
+ types.push(type);
203
+ });
204
+ return types;
62
205
  }
63
- /** Derive an executable BPMN model from a declarative flow. */
64
- export function declarativeToBpmn(flow) {
65
- const nodes = [];
66
- const flows = [];
67
- const messages = [];
68
- const ids = ["Start", ...flow.steps.map((s) => s.name), "End"];
69
- nodes.push(` <bpmn:startEvent id="Start"><bpmn:outgoing>flow_0</bpmn:outgoing></bpmn:startEvent>`);
70
- flow.steps.forEach((step, i) => {
71
- const incoming = `flow_${i}`;
72
- const outgoing = `flow_${i + 1}`;
73
- if (step.kind === "run" || step.kind === "task") {
74
- // Both hosted (`run`) and external (`task`) steps are BPMN service tasks
75
- // with the same derived job type; they differ only in whether the local
76
- // Worker registers a handler for that type.
77
- nodes.push(` <bpmn:serviceTask id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
78
- ` <bpmn:extensionElements><zeebe:taskDefinition type="${escapeXml(jobType(flow.id, step.name))}" /></bpmn:extensionElements>\n` +
79
- ` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
80
- ` </bpmn:serviceTask>`);
206
+ class Compiler {
207
+ flow;
208
+ nodes = [];
209
+ edges = [];
210
+ /** envelope name → its fields, deduped for lifting to a single nano:shape. */
211
+ envelopes = new Map();
212
+ seq = 0;
213
+ gw = 0;
214
+ constructor(flow) {
215
+ this.flow = flow;
216
+ }
217
+ newEdge(from, opts = {}) {
218
+ const e = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
219
+ this.edges.push(e);
220
+ return e;
221
+ }
222
+ connect(incoming, toId) {
223
+ for (const e of incoming)
224
+ e.to = toId;
225
+ }
226
+ recordEnvelope(env) {
227
+ if (!env)
228
+ return;
229
+ const prev = this.envelopes.get(env.name);
230
+ if (prev) {
231
+ if (JSON.stringify(prev) !== JSON.stringify(env.fields)) {
232
+ throw new Error(`envelope "${env.name}" is declared with two different field sets in flow "${this.flow.id}"`);
233
+ }
234
+ return;
81
235
  }
82
- else {
83
- const msgId = `Msg_${escapeXml(step.name)}`;
84
- nodes.push(` <bpmn:intermediateCatchEvent id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
85
- ` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
236
+ this.envelopes.set(env.name, env.fields);
237
+ }
238
+ addServiceTask(node) {
239
+ const type = node.jobType ?? jobType(this.flow.id, node.name);
240
+ this.recordEnvelope(node.envelopes?.in);
241
+ this.recordEnvelope(node.envelopes?.out);
242
+ const props = [];
243
+ if (node.envelopes?.in)
244
+ props.push(envelopeProp("in", node.envelopes.in.name));
245
+ if (node.envelopes?.out)
246
+ props.push(envelopeProp("out", node.envelopes.out.name));
247
+ const ext = ` <bpmn:extensionElements>\n` +
248
+ ` <zeebe:taskDefinition type="${escapeXml(type)}" />\n` +
249
+ (props.length ? ` <zeebe:properties>\n${props.join("\n")}\n </zeebe:properties>\n` : "") +
250
+ ` </bpmn:extensionElements>`;
251
+ const id = node.name;
252
+ this.nodes.push({
253
+ id,
254
+ render: (inc, outg) => ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
255
+ ext +
256
+ "\n" +
257
+ incomingOutgoing(inc, outg) +
258
+ ` </bpmn:serviceTask>`,
259
+ });
260
+ }
261
+ addCatchEvent(node) {
262
+ const id = node.name;
263
+ const msgId = `Msg_${id}`;
264
+ this.nodes.push({
265
+ id,
266
+ render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
267
+ incomingOutgoing(inc, outg) +
86
268
  ` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
87
- ` </bpmn:intermediateCatchEvent>`);
88
- messages.push(` <bpmn:message id="${msgId}" name="${escapeXml(messageName(flow.id, step.name))}">\n` +
89
- ` <bpmn:extensionElements><zeebe:subscription correlationKey="=${escapeXml(step.correlationKey)}" /></bpmn:extensionElements>\n` +
90
- ` </bpmn:message>`);
269
+ ` </bpmn:intermediateCatchEvent>`,
270
+ });
271
+ }
272
+ addGateway(id, name) {
273
+ const gwNode = {
274
+ id,
275
+ render: (inc, outg) => {
276
+ const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
277
+ const nm = name ? ` name="${escapeXml(name)}"` : "";
278
+ return (` <bpmn:exclusiveGateway id="${id}"${nm}${def}>\n` +
279
+ incomingOutgoing(inc, outg) +
280
+ ` </bpmn:exclusiveGateway>`);
281
+ },
282
+ };
283
+ this.nodes.push(gwNode);
284
+ return gwNode;
285
+ }
286
+ emitList(list, incoming, loop) {
287
+ let cur = incoming;
288
+ for (const node of list)
289
+ cur = this.emitNode(node, cur, loop);
290
+ return cur;
291
+ }
292
+ emitNode(node, incoming, loop) {
293
+ switch (node.kind) {
294
+ case "run":
295
+ case "task": {
296
+ this.addServiceTask(node);
297
+ this.connect(incoming, node.name);
298
+ return [this.newEdge(node.name)];
299
+ }
300
+ case "signal": {
301
+ this.addCatchEvent(node);
302
+ this.connect(incoming, node.name);
303
+ this.recordEnvelope(node.payload);
304
+ return [this.newEdge(node.name)];
305
+ }
306
+ case "switch": {
307
+ const id = `Gw_${this.gw++}`;
308
+ const gw = this.addGateway(id, node.subject);
309
+ this.connect(incoming, id);
310
+ const out = [];
311
+ for (const c of node.cases) {
312
+ const e = this.newEdge(id, { condition: feelEquals(node.subject, c.value), name: c.value });
313
+ out.push(...this.emitList(c.body, [e], loop));
314
+ }
315
+ // The default (or a synthesised fall-through) is the unconditional edge.
316
+ const de = this.newEdge(id, { name: "default" });
317
+ gw.defaultFlow = de.id;
318
+ out.push(...this.emitList(node.default ?? [], [de], loop));
319
+ return out;
320
+ }
321
+ case "branch": {
322
+ const id = `Gw_${this.gw++}`;
323
+ const gw = this.addGateway(id);
324
+ this.connect(incoming, id);
325
+ const te = this.newEdge(id, { condition: feel(node.condition), name: "then" });
326
+ const out = this.emitList(node.then, [te], loop);
327
+ const ee = this.newEdge(id, { name: "else" });
328
+ gw.defaultFlow = ee.id;
329
+ out.push(...this.emitList(node.else ?? [], [ee], loop));
330
+ return out;
331
+ }
332
+ case "loop": {
333
+ const headId = `Loop_${this.gw++}`;
334
+ this.addGateway(headId);
335
+ this.connect(incoming, headId);
336
+ const ctx = { headId, breaks: [] };
337
+ const headOut = this.newEdge(headId);
338
+ const bodyOut = this.emitList(node.body, [headOut], ctx);
339
+ // Normal fall-through of the body loops back to the head (continue).
340
+ this.connect(bodyOut, headId);
341
+ // The loop exits only via break edges.
342
+ return ctx.breaks;
343
+ }
344
+ case "break": {
345
+ if (!loop)
346
+ throw new Error(`break outside a loop in flow "${this.flow.id}"`);
347
+ // The incoming edges (from the previous node) become the loop's exit
348
+ // danglers; this path does not fall through.
349
+ loop.breaks.push(...incoming);
350
+ return [];
351
+ }
352
+ case "continue": {
353
+ if (!loop)
354
+ throw new Error(`continue outside a loop in flow "${this.flow.id}"`);
355
+ this.connect(incoming, loop.headId);
356
+ return [];
357
+ }
91
358
  }
92
- });
93
- nodes.push(` <bpmn:endEvent id="End"><bpmn:incoming>flow_${flow.steps.length}</bpmn:incoming></bpmn:endEvent>`);
94
- for (let i = 0; i < ids.length - 1; i++) {
95
- flows.push(` <bpmn:sequenceFlow id="flow_${i}" sourceRef="${ids[i]}" targetRef="${ids[i + 1]}" />`);
96
- }
97
- return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
98
- `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" id="Definitions_${escapeXml(flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
99
- ` <bpmn:process id="${escapeXml(flow.id)}" name="${escapeXml(flow.id)}" isExecutable="true">\n` +
100
- nodes.join("\n") +
101
- "\n" +
102
- flows.join("\n") +
103
- "\n" +
104
- ` </bpmn:process>\n` +
105
- messages.join("\n") +
106
- (messages.length ? "\n" : "") +
107
- `</bpmn:definitions>\n`);
359
+ }
360
+ compile() {
361
+ // Start → top-level sequence → End.
362
+ this.nodes.push({
363
+ id: "Start",
364
+ render: (_inc, outg) => ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`,
365
+ });
366
+ const s0 = this.newEdge("Start");
367
+ const finalDanglers = this.emitList(this.flow.steps, [s0], null);
368
+ this.nodes.push({
369
+ id: "End",
370
+ render: (inc) => ` <bpmn:endEvent id="End">${incomingOnly(inc)}</bpmn:endEvent>`,
371
+ });
372
+ this.connect(finalDanglers, "End");
373
+ // A sequence flow needs both ends; drop any edge that never got a target.
374
+ const live = this.edges.filter((e) => e.to !== undefined);
375
+ const incomingOf = (id) => live.filter((e) => e.to === id).map((e) => e.id);
376
+ const outgoingOf = (id) => live.filter((e) => e.from === id).map((e) => e.id);
377
+ const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
378
+ const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
379
+ const messageXml = this.emitMessages();
380
+ const shapeXml = this.emitShapes();
381
+ return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
382
+ `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ` +
383
+ `xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ` +
384
+ `xmlns:nano="https://nanobpm.io/schema/shapes/1.0" ` +
385
+ `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
386
+ ` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
387
+ (shapeXml ? shapeXml + "\n" : "") +
388
+ nodeXml +
389
+ "\n" +
390
+ flowXml +
391
+ "\n" +
392
+ ` </bpmn:process>\n` +
393
+ messageXml +
394
+ (messageXml ? "\n" : "") +
395
+ `</bpmn:definitions>\n`);
396
+ }
397
+ /** Emit a `<bpmn:message>` per signal step, with its subscription and (when
398
+ * typed) its payload data envelope. */
399
+ emitMessages() {
400
+ const msgs = [];
401
+ walkNodes(this.flow.steps, (n) => {
402
+ if (n.kind !== "signal")
403
+ return;
404
+ const msgId = `Msg_${n.name}`;
405
+ const payloadProp = n.payload
406
+ ? `\n <zeebe:properties>\n${envelopeProp("in", n.payload.name)}\n </zeebe:properties>`
407
+ : "";
408
+ msgs.push(` <bpmn:message id="${msgId}" name="${escapeXml(messageName(this.flow.id, n.name))}">\n` +
409
+ ` <bpmn:extensionElements>\n` +
410
+ ` <zeebe:subscription correlationKey="=${escapeXml(n.correlationKey)}" />` +
411
+ payloadProp +
412
+ `\n </bpmn:extensionElements>\n` +
413
+ ` </bpmn:message>`);
414
+ });
415
+ return msgs.join("\n");
416
+ }
417
+ /** Lift the referenced data envelopes into a `<nano:shapes>` container on the
418
+ * process extension elements, so the model carries the typed contracts and is
419
+ * ejectable to model-first. */
420
+ emitShapes() {
421
+ if (this.envelopes.size === 0)
422
+ return "";
423
+ const shapes = [...this.envelopes.entries()].map(([name, fields]) => {
424
+ const exts = fields.map((f) => {
425
+ const opt = f.optional ? ` optional="true"` : "";
426
+ const list = f.list ? ` list="true"` : "";
427
+ return ` <nano:extend name="${escapeXml(f.name)}" type="${escapeXml(f.type)}"${opt}${list} />`;
428
+ });
429
+ return ` <nano:shape id="${escapeXml(name)}">\n${exts.join("\n")}\n </nano:shape>`;
430
+ });
431
+ return (` <bpmn:extensionElements>\n` +
432
+ ` <nano:shapes>\n${shapes.join("\n")}\n </nano:shapes>\n` +
433
+ ` </bpmn:extensionElements>`);
434
+ }
435
+ }
436
+ /** Derive an executable BPMN model from a declarative flow. */
437
+ export function declarativeToBpmn(flow) {
438
+ return new Compiler(flow).compile();
439
+ }
440
+ // --- small XML / FEEL helpers ------------------------------------------------
441
+ const envelopeProp = (dir, value) => ` <zeebe:property name="io.nanobpm.dataEnvelope.${dir}" value="${escapeXml(value)}" />`;
442
+ /** Wrap a raw FEEL expression as a Zeebe condition body (leading `=`). */
443
+ const feel = (expr) => `=${expr}`;
444
+ /** A FEEL equality test `subject = "value"`, with the value as a FEEL string. */
445
+ const feelEquals = (subject, value) => `=${subject} = "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
446
+ function incomingOutgoing(inc, outg) {
447
+ return (inc.map((f) => ` <bpmn:incoming>${f}</bpmn:incoming>\n`).join("") +
448
+ outg.map((f) => ` <bpmn:outgoing>${f}</bpmn:outgoing>\n`).join(""));
449
+ }
450
+ const incomingOnly = (inc) => inc.map((f) => `<bpmn:incoming>${f}</bpmn:incoming>`).join("");
451
+ const outgoingOnly = (outg) => outg.map((f) => `<bpmn:outgoing>${f}</bpmn:outgoing>`).join("");
452
+ function sequenceFlow(e) {
453
+ const nm = e.name ? ` name="${escapeXml(e.name)}"` : "";
454
+ if (e.condition) {
455
+ return (` <bpmn:sequenceFlow id="${e.id}" sourceRef="${e.from}" targetRef="${e.to}"${nm}>\n` +
456
+ ` <bpmn:conditionExpression>${escapeXml(e.condition)}</bpmn:conditionExpression>\n` +
457
+ ` </bpmn:sequenceFlow>`);
458
+ }
459
+ return ` <bpmn:sequenceFlow id="${e.id}" sourceRef="${e.from}" targetRef="${e.to}"${nm} />`;
108
460
  }
@@ -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 {};