@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.
@@ -1,108 +1,448 @@
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.
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
- /** 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 = {
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
80
  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;
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
- assertIdent("step name", name);
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
- seen.add(name);
48
- steps.push({ kind: "signal", name, correlationKey: opts.correlationKey });
49
- return w;
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
- build(w);
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
- /** 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. */
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
- return flow.steps.filter((s) => s.kind === "task").map((s) => jobType(flow.id, s.name));
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
- /** 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>`);
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
- 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` +
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
- 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>`);
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
- 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`);
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 {};
@@ -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
+ }