@nanobpm/urban 0.25.0 → 0.26.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.
@@ -0,0 +1,498 @@
1
+ // Composed motion shapes: the fuse composition algebra (ADR 0040 §9/§10).
2
+ //
3
+ // A composed shape is a named, model-scoped declaration authored in the Modeller and carried in
4
+ // the `.bpmn` as a `nano:shape` (see the Rust scan `envelope_scan.rs`). It composes existing fused
5
+ // entities — DB tables, manifest `types`, and other shapes — via four ordered operations:
6
+ //
7
+ // carry(ref) spread every field of `ref`
8
+ // project(ref, fields, via) spread only the named fields of `ref`
9
+ // extend(name, type, ...) add a process-authored field
10
+ // reference(name, ref, ...) nest (or spread) another shape
11
+ //
12
+ // Resolution folds the ops, in author (XML) order, into a flat `DomainTypeDef`, so a composed
13
+ // shape enters the same `DomainTypes` registry a manifest `type` does — every downstream consumer
14
+ // (envelope pickers, `defineWorker`, `publishMessage`, FEEL scopes) types against
15
+ // `DomainTypes["ApprovedOrder"]` with no new codegen path. Broken shapes are omitted (degrade to
16
+ // untyped) and reported as diagnostics.
17
+ //
18
+ // Byte-faithful port of the console's `resolveShapes`/`emitDomainModelJson` (server
19
+ // `domain_types.ts`), so the toolkit is the single source of truth for the IDE's shape fuse
20
+ // (host dry-out nano-bpm#576).
21
+ import { isPrimitiveKeyword, sqliteAffinityToTs, } from "./domain.js";
22
+ /** Map one datasource column to a domain field. A nullable column widens to `optional` (the
23
+ * composed shape is a motion snapshot: an absent value reads as undefined, not SQL NULL). The
24
+ * manifest keyword is inferred from the column's SQLite affinity so it flows through `fieldTsType`
25
+ * like any declared field; affinities with no keyword (BLOB/opaque) fall back to `json`. */
26
+ export function columnToField(col) {
27
+ const ts = sqliteAffinityToTs(col.type);
28
+ const keyword = ts === "string" || ts === "number" || ts === "boolean" ? ts : "json";
29
+ const nullable = !(col.notNull || col.primaryKey);
30
+ return nullable ? { type: keyword, optional: true } : { type: keyword };
31
+ }
32
+ /** Build the leaf entity index the shape fold resolves references against: every datasource table
33
+ * (by raw wire name, and by `source.table` to disambiguate a name shared across sources) and every
34
+ * manifest `type` (by id). A raw table name shared across sources keeps the first
35
+ * (default-source-first) binding; the qualified `source.table` alias is always unambiguous.
36
+ *
37
+ * When a manifest `type` id collides with a bare table name, the **type wins** the unqualified id
38
+ * (it is the more first-class, `DomainTypes`-visible entity) and the collision is surfaced via
39
+ * `ambiguousIds` so resolution can warn; the table stays reachable through its `source.table`
40
+ * alias. `tableIds` records every id (bare + qualified) that resolves to a DB table, so a nominal
41
+ * reference (which the emitter can only express against `DomainTypes` keys) can be rejected. */
42
+ function leafEntityIndex(sources, types) {
43
+ const index = new Map();
44
+ const tableIds = new Set();
45
+ for (const s of sources) {
46
+ for (const t of s.tables) {
47
+ const fields = {};
48
+ const fks = {};
49
+ for (const c of t.columns)
50
+ fields[c.name] = columnToField(c);
51
+ // Store FK targets as the unambiguous `source.table` id (FKs are intra-source) so `via`-path
52
+ // following resolves the intended table, never a same-named table in another source or a
53
+ // type-preferred bare id.
54
+ for (const fk of t.foreignKeys ?? [])
55
+ fks[fk.column] = `${s.source}.${fk.refTable}`;
56
+ const entity = { fields, fks };
57
+ index.set(`${s.source}.${t.name}`, entity);
58
+ tableIds.add(`${s.source}.${t.name}`);
59
+ if (!index.has(t.name)) {
60
+ index.set(t.name, entity);
61
+ tableIds.add(t.name);
62
+ }
63
+ }
64
+ }
65
+ const ambiguousIds = new Set();
66
+ for (const [id, def] of Object.entries(types)) {
67
+ // The manifest type wins an unqualified id that also names a table; the table remains
68
+ // reachable by its `source.table` alias.
69
+ if (tableIds.has(id))
70
+ ambiguousIds.add(id);
71
+ index.set(id, { fields: { ...def.fields } });
72
+ }
73
+ return { index, tableIds, ambiguousIds };
74
+ }
75
+ /** The shape ids a shape references (carry/project/reference targets, and an `extend` whose type
76
+ * names a shape) — the edges of the shape dependency graph. */
77
+ function referencedShapeIds(shape, shapeIds) {
78
+ const refs = new Set();
79
+ for (const op of shape.ops) {
80
+ if (op.op === "carry" || op.op === "project" || op.op === "reference") {
81
+ if (shapeIds.has(op.ref))
82
+ refs.add(op.ref);
83
+ }
84
+ else if (op.op === "extend" && shapeIds.has(op.type)) {
85
+ refs.add(op.type);
86
+ }
87
+ }
88
+ return [...refs];
89
+ }
90
+ /** Structural equality of two domain field defs (`type` + normalized optional/list flags), so a
91
+ * differing property insertion order does not read as a conflict. */
92
+ function sameFieldDef(a, b) {
93
+ return a.type === b.type && !!a.optional === !!b.optional && !!a.list === !!b.list;
94
+ }
95
+ /**
96
+ * Resolve the project's composed shapes into `DomainTypeDef`s and diagnostics (ADR 0040 §10).
97
+ * Leaves (DB tables, manifest types) fuse first; shapes resolve in dependency order so a shape can
98
+ * carry another shape regardless of declaration order. A shape in a reference cycle, or one that
99
+ * names an unresolvable id, is omitted (degrades to untyped) with an `error` diagnostic; a
100
+ * deliberate field shadow with a differing type resolves with a `field-conflict` warning.
101
+ */
102
+ export function resolveShapes(shapes, types, sources = []) {
103
+ const diagnostics = [];
104
+ const resolved = {};
105
+ if (shapes.length === 0)
106
+ return { types: resolved, diagnostics };
107
+ const { index, tableIds, ambiguousIds } = leafEntityIndex(sources, types);
108
+ // A nominal reference (an `extend` type or a non-spread `reference`) must resolve to a
109
+ // `DomainTypes` key at emit time — a manifest type or a resolved shape id. DB tables are
110
+ // spread-only (their fields flatten via carry/project); nominally referencing one would degrade
111
+ // to `unknown` in the emitted `.d.ts`, so we reject.
112
+ const nominalIds = new Set(Object.keys(types));
113
+ // Duplicate shape ids are fuse-identity collisions: since resolution keys by id, a later
114
+ // declaration would silently shadow an earlier one. Report every id that appears more than once
115
+ // and omit all of its declarations from resolution.
116
+ const idCounts = new Map();
117
+ for (const s of shapes)
118
+ if (s.id)
119
+ idCounts.set(s.id, (idCounts.get(s.id) ?? 0) + 1);
120
+ const duplicated = new Set();
121
+ for (const [id, n] of idCounts) {
122
+ if (n > 1) {
123
+ duplicated.add(id);
124
+ diagnostics.push({
125
+ shape: id,
126
+ kind: "duplicate-id",
127
+ severity: "error",
128
+ message: `shape id "${id}" is declared ${n} times; ids are fuse identities and must be unique`,
129
+ });
130
+ }
131
+ }
132
+ const byId = new Map();
133
+ for (const s of shapes)
134
+ if (s.id && !duplicated.has(s.id))
135
+ byId.set(s.id, s);
136
+ const shapeIds = new Set(byId.keys());
137
+ // Cycle detection over the shape-only dependency graph (DFS three-colour). Every shape on a back
138
+ // edge is failed; the reported path aids the maker's fix.
139
+ const failed = new Set();
140
+ const colour = new Map(); // 0=unvisited 1=on-stack 2=done
141
+ const visit = (id, stack) => {
142
+ colour.set(id, 1);
143
+ stack.push(id);
144
+ for (const dep of referencedShapeIds(byId.get(id), shapeIds)) {
145
+ const c = colour.get(dep) ?? 0;
146
+ if (c === 1) {
147
+ const cycle = stack.slice(stack.indexOf(dep)).concat(dep);
148
+ for (const n of cycle) {
149
+ if (!failed.has(n)) {
150
+ failed.add(n);
151
+ diagnostics.push({
152
+ shape: n,
153
+ kind: "reference-cycle",
154
+ severity: "error",
155
+ message: `shape "${n}" is part of a reference cycle: ${cycle.join(" → ")}`,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ else if (c === 0) {
161
+ visit(dep, stack);
162
+ }
163
+ }
164
+ stack.pop();
165
+ colour.set(id, 2);
166
+ };
167
+ for (const id of byId.keys())
168
+ if ((colour.get(id) ?? 0) === 0)
169
+ visit(id, []);
170
+ // Topological resolution: resolve a shape only after its (non-failed) shape dependencies, adding
171
+ // each resolved shape to the index so later shapes see it.
172
+ const done = new Set();
173
+ const resolveOne = (shape) => {
174
+ if (done.has(shape.id) || failed.has(shape.id))
175
+ return;
176
+ // Resolve shape dependencies first (they may themselves be pending).
177
+ for (const dep of referencedShapeIds(shape, shapeIds)) {
178
+ const d = byId.get(dep);
179
+ if (d && !done.has(dep) && !failed.has(dep))
180
+ resolveOne(d);
181
+ }
182
+ const fields = {};
183
+ let broken = false;
184
+ const addField = (name, field) => {
185
+ const existing = fields[name];
186
+ if (existing && !sameFieldDef(existing, field)) {
187
+ diagnostics.push({
188
+ shape: shape.id,
189
+ kind: "field-conflict",
190
+ severity: "warning",
191
+ message: `field "${name}" is contributed twice with differing types; the later value wins (author-order fold)`,
192
+ });
193
+ }
194
+ fields[name] = field; // last (author-order) wins
195
+ };
196
+ const lookup = (ref) => index.get(ref);
197
+ const unresolved = (ref) => {
198
+ broken = true;
199
+ diagnostics.push({
200
+ shape: shape.id,
201
+ kind: "unresolved-reference",
202
+ severity: "error",
203
+ message: `shape "${shape.id}" references unknown entity "${ref}"`,
204
+ });
205
+ };
206
+ // A bare id that names both a manifest type and a table resolves to the type; warn so the
207
+ // (silent) precedence is visible and the maker can qualify.
208
+ const noteAmbiguity = (ref) => {
209
+ if (ambiguousIds.has(ref)) {
210
+ diagnostics.push({
211
+ shape: shape.id,
212
+ kind: "ambiguous-reference",
213
+ severity: "warning",
214
+ message: `"${ref}" names both a manifest type and a table; resolved to the type — qualify as "<source>.${ref}" to target the table`,
215
+ });
216
+ }
217
+ };
218
+ // Report a nominal reference (extend type / non-spread reference) that targets a DB table,
219
+ // which cannot be expressed as a `DomainTypes` ref (it would degrade to `unknown`); the maker
220
+ // should spread it instead (carry/project or spread=true).
221
+ const nominalTableRef = (kind, ref) => {
222
+ broken = true;
223
+ diagnostics.push({
224
+ shape: shape.id,
225
+ kind: "nominal-table-ref",
226
+ severity: "error",
227
+ message: `${kind} nominally references table "${ref}", which is not a DomainTypes entity; spread its fields (carry/project or reference spread="true") instead`,
228
+ });
229
+ };
230
+ for (const op of shape.ops) {
231
+ switch (op.op) {
232
+ case "carry": {
233
+ const e = lookup(op.ref);
234
+ if (!e) {
235
+ unresolved(op.ref);
236
+ break;
237
+ }
238
+ noteAmbiguity(op.ref);
239
+ for (const [k, f] of Object.entries(e.fields))
240
+ addField(k, f);
241
+ break;
242
+ }
243
+ case "project": {
244
+ const e = lookup(op.ref);
245
+ if (!e) {
246
+ unresolved(op.ref);
247
+ break;
248
+ }
249
+ noteAmbiguity(op.ref);
250
+ for (const fname of op.fields) {
251
+ const f = e.fields[fname];
252
+ if (!f) {
253
+ broken = true;
254
+ diagnostics.push({
255
+ shape: shape.id,
256
+ kind: "unknown-field",
257
+ severity: "error",
258
+ message: `projected field "${fname}" is not a field of "${op.ref}"`,
259
+ });
260
+ continue;
261
+ }
262
+ addField(fname, f);
263
+ }
264
+ if (op.via)
265
+ validateVia(shape.id, op.via, index, diagnostics);
266
+ break;
267
+ }
268
+ case "extend": {
269
+ if (!isPrimitiveKeyword(op.type)) {
270
+ if (nominalIds.has(op.type)) {
271
+ // ok — a manifest type or an already-resolved shape id
272
+ }
273
+ else if (tableIds.has(op.type)) {
274
+ nominalTableRef(`extend field "${op.name}"`, op.type);
275
+ break;
276
+ }
277
+ else {
278
+ broken = true;
279
+ diagnostics.push({
280
+ shape: shape.id,
281
+ kind: "unresolved-reference",
282
+ severity: "error",
283
+ message: `extend field "${op.name}" has type "${op.type}", which is neither a scalar keyword nor a fused entity`,
284
+ });
285
+ break;
286
+ }
287
+ }
288
+ const field = { type: op.type };
289
+ if (op.optional)
290
+ field.optional = true;
291
+ if (op.list)
292
+ field.list = true;
293
+ addField(op.name, field);
294
+ break;
295
+ }
296
+ case "reference": {
297
+ const e = lookup(op.ref);
298
+ if (!e) {
299
+ unresolved(op.ref);
300
+ break;
301
+ }
302
+ if (op.spread) {
303
+ noteAmbiguity(op.ref);
304
+ for (const [k, f] of Object.entries(e.fields))
305
+ addField(k, f);
306
+ }
307
+ else if (!nominalIds.has(op.ref)) {
308
+ // `e` exists but the id is not a DomainTypes key — it is a DB table.
309
+ nominalTableRef(`reference "${op.name}"`, op.ref);
310
+ }
311
+ else {
312
+ const field = { type: op.ref };
313
+ if (op.list)
314
+ field.list = true;
315
+ addField(op.name, field);
316
+ }
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ // same-id collision: the shape id shadows a leaf entity it does not source. The shape composes
322
+ // that entity when it references it under any id (bare or `source.table` alias) — since the
323
+ // index maps every alias to the same `FuseEntity` instance, compare by identity rather than by
324
+ // the bare id alone.
325
+ const collidingEntity = index.get(shape.id);
326
+ const composesColliding = collidingEntity !== undefined &&
327
+ shape.ops.some((o) => o.op !== "extend" && index.get(o.ref) === collidingEntity);
328
+ if (collidingEntity !== undefined && !composesColliding) {
329
+ diagnostics.push({
330
+ shape: shape.id,
331
+ kind: "same-id-collision",
332
+ severity: "error",
333
+ message: `shape id "${shape.id}" collides with an existing fused entity it does not compose`,
334
+ });
335
+ broken = true;
336
+ }
337
+ done.add(shape.id);
338
+ if (broken) {
339
+ failed.add(shape.id);
340
+ return;
341
+ }
342
+ const def = { name: shape.name, fields };
343
+ resolved[shape.id] = def;
344
+ // Later shapes may carry this one; expose its resolved fields to the index and mark it
345
+ // nominal-referenceable (it will be a `DomainTypes` key).
346
+ index.set(shape.id, { fields });
347
+ nominalIds.add(shape.id);
348
+ };
349
+ for (const s of shapes)
350
+ if (s.id && !duplicated.has(s.id))
351
+ resolveOne(s);
352
+ return { types: resolved, diagnostics };
353
+ }
354
+ /** Validate a `project via` FK path (`Entity.column[.column...]`): the leading entity must resolve
355
+ * and carry the named column (an FK column on a DB table, or any field on a manifest type/shape).
356
+ * Deeper hops are validated leniently — the FK target is followed when the leading entity is a DB
357
+ * table with that FK. */
358
+ function validateVia(shape, via, index, diagnostics) {
359
+ const parts = via.split(".");
360
+ if (parts.length < 2) {
361
+ diagnostics.push({
362
+ shape,
363
+ kind: "unknown-field",
364
+ severity: "warning",
365
+ message: `via path "${via}" is not of the form Entity.column`,
366
+ });
367
+ return;
368
+ }
369
+ // Resolve the starting entity as the *longest* dotted prefix present in the index (so a qualified
370
+ // `source.table` start works, not just a bare id), leaving at least one trailing segment as a hop
371
+ // column. The remaining segments are hop columns.
372
+ let entity;
373
+ let start = 0;
374
+ for (let p = 1; p < parts.length; p++) {
375
+ const candidate = parts.slice(0, p).join(".");
376
+ const hit = index.get(candidate);
377
+ if (hit) {
378
+ entity = hit;
379
+ start = p;
380
+ }
381
+ }
382
+ if (!entity) {
383
+ diagnostics.push({
384
+ shape,
385
+ kind: "unknown-field",
386
+ severity: "warning",
387
+ message: `via path "${via}" starts at unknown entity "${parts.slice(0, -1).join(".")}"`,
388
+ });
389
+ return;
390
+ }
391
+ let cursor = entity;
392
+ for (let i = start; i < parts.length; i++) {
393
+ const col = parts[i];
394
+ const hasField = Object.prototype.hasOwnProperty.call(cursor.fields, col);
395
+ const fkTarget = cursor.fks?.[col];
396
+ if (!hasField && !fkTarget) {
397
+ diagnostics.push({
398
+ shape,
399
+ kind: "unknown-field",
400
+ severity: "warning",
401
+ message: `via path "${via}" hops through unknown column "${col}"`,
402
+ });
403
+ return;
404
+ }
405
+ // Follow the FK to the next entity when we can; otherwise stop (lenient).
406
+ const next = fkTarget ? index.get(fkTarget) : undefined;
407
+ if (!next)
408
+ return;
409
+ cursor = next;
410
+ }
411
+ }
412
+ // --- the structured fused domain model: domain.json (ADR 0040 §1, OQ1) -----------------------
413
+ //
414
+ // The computed fuse is persisted as a generated, git-ignored `nano-generated/domain.json` — a
415
+ // fast-read structured index for the IDE/codegen so a reader need not re-scan every model +
416
+ // datasource. It is a *cache*, never a source: the `domaintypes` op regenerates it write-through on
417
+ // any source change, and an `inputsHash` over its own content lets a reader detect staleness
418
+ // cheaply.
419
+ /** The basename of the generated structured fuse cache (ADR 0040 §1). */
420
+ export const DOMAIN_MODEL_JSON = "domain.json";
421
+ /** FNV-1a (32-bit) hex of a string — a small, dependency-free content tag for the fuse cache's
422
+ * `inputsHash` (staleness detection, not security). */
423
+ function fnv1aHex(s) {
424
+ let h = 0x811c9dc5;
425
+ for (let i = 0; i < s.length; i++) {
426
+ h ^= s.charCodeAt(i);
427
+ h = Math.imul(h, 0x01000193);
428
+ }
429
+ return (h >>> 0).toString(16).padStart(8, "0");
430
+ }
431
+ /** Convert a resolved field map to the ordered `domain.json` field list. */
432
+ function fieldsJson(fields) {
433
+ return Object.entries(fields ?? {}).map(([name, f]) => {
434
+ const out = { name, type: f.type };
435
+ if (f.optional)
436
+ out.optional = true;
437
+ if (f.list)
438
+ out.list = true;
439
+ return out;
440
+ });
441
+ }
442
+ /**
443
+ * Emit `domain.json`: the structured Fused Domain Model (ADR 0040 §1). Assembles every fused
444
+ * entity — DB tables (`db:` provenance), manifest `types` (`manifest:`), and composed motion shapes
445
+ * (`model:<processId>`, or bare `model` for an unsaved editor shape with no process) — with its
446
+ * resolved fields, plus the model-level metadata and the shape diagnostics. An `inputsHash` —
447
+ * FNV-1a (hex) over the compact `JSON.stringify` of the model object with `inputsHash` omitted
448
+ * (property-insertion order, not the pretty-printed bytes) — tags the cache for staleness.
449
+ */
450
+ export function emitDomainModelJson(input) {
451
+ const entities = [];
452
+ for (const s of input.sources) {
453
+ for (const t of s.tables) {
454
+ const fields = {};
455
+ for (const c of t.columns)
456
+ fields[c.name] = columnToField(c);
457
+ entities.push({
458
+ id: `${s.source}.${t.name}`,
459
+ kind: "table",
460
+ provenance: `db:${s.source}.${t.name}`,
461
+ fields: fieldsJson(fields),
462
+ });
463
+ }
464
+ }
465
+ for (const [id, def] of Object.entries(input.manifestTypes)) {
466
+ entities.push({
467
+ id,
468
+ kind: "type",
469
+ provenance: `manifest:${id}`,
470
+ ...(def.name ? { name: def.name } : {}),
471
+ fields: fieldsJson(def.fields),
472
+ });
473
+ }
474
+ for (const { decl, def } of input.shapes) {
475
+ entities.push({
476
+ id: decl.id,
477
+ kind: "shape",
478
+ provenance: decl.process ? `model:${decl.process}` : "model",
479
+ ...(def.name ?? decl.name ? { name: def.name ?? decl.name } : {}),
480
+ fields: fieldsJson(def.fields),
481
+ });
482
+ }
483
+ const meta = input.meta
484
+ .filter((m) => (m?.key ?? "").trim().length > 0)
485
+ .map((m) => ({ ...(m.process ? { process: m.process } : {}), key: m.key, value: m.value ?? "" }));
486
+ const model = {
487
+ $generated: "nanobpmn ADR 0040 §1 Fused Domain Model — do not edit; regenerated by the domaintypes op",
488
+ version: 1,
489
+ ...(input.default ? { default: input.default } : {}),
490
+ sources: input.sources.map((s) => s.source),
491
+ entities,
492
+ meta,
493
+ diagnostics: input.diagnostics,
494
+ inputsHash: "",
495
+ };
496
+ model.inputsHash = fnv1aHex(JSON.stringify({ ...model, inputsHash: undefined }));
497
+ return `${JSON.stringify(model, null, 2)}\n`;
498
+ }
@@ -25,6 +25,9 @@ export interface WorkerIo {
25
25
  }
26
26
  /** Filename the console uses; kept identical so the artifact is a drop-in. */
27
27
  export declare const WORKER_BINDINGS_DTS = "worker-io.d.ts";
28
+ /** The generated typed-`defineWorker` wrapper's basename — re-exports the worker SDK and overrides
29
+ * `defineWorker` with a taskType-keyed typed signature. */
30
+ export declare const WORKER_BINDINGS_TS = "workers.ts";
28
31
  /** The domain type declarations `worker-io.d.ts` imports from (console: domain-rows.d.ts). */
29
32
  export declare const DOMAIN_DTS = "domain-rows.d.ts";
30
33
  /** Scan one BPMN document for service-task worker I/O. */
@@ -37,6 +40,10 @@ export interface WorkerBindingDecl {
37
40
  taskType?: string;
38
41
  inputType?: string;
39
42
  outputType?: string;
43
+ /** The `zeebe:header` keys declared on the task; reified into a typed `job.customHeaders` shape
44
+ * (known keys, `string` values — headers are strings on the wire). Empty/absent leaves
45
+ * `customHeaders` the untyped fallback. */
46
+ headerKeys?: string[];
40
47
  }
41
48
  /** Emit `worker-io.d.ts` from worker bindings + the set of declared domain type ids. */
42
49
  export declare function emitWorkerBindings(workers: WorkerBindingDecl[], declaredTypeIds: Iterable<string>): string;
@@ -50,3 +57,22 @@ export declare const workerIoDeriver: Deriver<{
50
57
  models: ModelSource[];
51
58
  declaredTypeIds: Iterable<string>;
52
59
  }>;
60
+ /**
61
+ * Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
62
+ * for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
63
+ * `derived` (clearing a stale manifest value when the model carries none); manifest entries the scan
64
+ * did not cover (e.g. a worker declared only for an `llm` binding, with no service task) are
65
+ * preserved. New `taskType`s seen only in the model are appended. A byte-faithful port of the
66
+ * console's `overlayDerivedWorkerIo` (server `data_cli.ts`).
67
+ */
68
+ export declare function overlayDerivedWorkerIo(manifest: WorkerBindingDecl[], derived: WorkerBindingDecl[]): WorkerBindingDecl[];
69
+ /**
70
+ * The static typed-`defineWorker` wrapper (`workers.ts`). It re-exports the whole worker SDK and
71
+ * overrides `defineWorker` with a signature that keys off the `type:` string literal: when a job
72
+ * type is present in the generated `WorkerInputs`/`WorkerOutputs` (ADR 0033 §3), the handler's
73
+ * `job.variables` and result are typed from the declared domain type; otherwise they fall back to
74
+ * `WorkerVars`. The body is a pass-through — the wire stays untyped JSON (ADR 0029 §3). This file
75
+ * never changes with the schema, so it is written verbatim (unlike the regenerated
76
+ * `worker-io.d.ts`). A byte-faithful port of the console's `emitWorkerBindingsRuntime`.
77
+ */
78
+ export declare function emitWorkerBindingsRuntime(): string;
@@ -14,6 +14,9 @@ export function byModelPath(a, b) {
14
14
  }
15
15
  /** Filename the console uses; kept identical so the artifact is a drop-in. */
16
16
  export const WORKER_BINDINGS_DTS = "worker-io.d.ts";
17
+ /** The generated typed-`defineWorker` wrapper's basename — re-exports the worker SDK and overrides
18
+ * `defineWorker` with a taskType-keyed typed signature. */
19
+ export const WORKER_BINDINGS_TS = "workers.ts";
17
20
  /** The domain type declarations `worker-io.d.ts` imports from (console: domain-rows.d.ts). */
18
21
  export const DOMAIN_DTS = "domain-rows.d.ts";
19
22
  const ENVELOPE_IN = "io.nanobpm.dataEnvelope.in";
@@ -65,6 +68,22 @@ export function scanModelWorkers(xml) {
65
68
  export function typeRefFor(id, declared) {
66
69
  return id != null && declared.has(id) ? `DomainTypes[${JSON.stringify(id)}]` : undefined;
67
70
  }
71
+ /** The TS type expression for a worker's declared custom-header keys: an object type mapping each
72
+ * declared key to `string` (Zeebe headers are strings on the wire) plus a `string` index signature
73
+ * so undeclared headers stay accessible with the same honest wire type. Returns `undefined` when no
74
+ * keys are declared (the caller omits the entry so the taskType falls back to `WorkerHdrs`). */
75
+ function headerRefFor(keys) {
76
+ const clean = [
77
+ ...new Set((keys ?? [])
78
+ .filter((k) => typeof k === "string")
79
+ .map((k) => k.trim())
80
+ .filter((k) => k.length > 0)),
81
+ ];
82
+ if (clean.length === 0)
83
+ return undefined;
84
+ const fields = clean.map((k) => `${JSON.stringify(k)}: string`).join("; ");
85
+ return `{ ${fields}; [key: string]: string }`;
86
+ }
68
87
  /** Emit `worker-io.d.ts` from worker bindings + the set of declared domain type ids. */
69
88
  export function emitWorkerBindings(workers, declaredTypeIds) {
70
89
  const declared = new Set(declaredTypeIds);
@@ -79,6 +98,7 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
79
98
  : "string";
80
99
  const inputs = [];
81
100
  const outputs = [];
101
+ const headers = [];
82
102
  for (const w of workers) {
83
103
  if (typeof w?.taskType !== "string" || w.taskType.length === 0)
84
104
  continue;
@@ -88,6 +108,9 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
88
108
  const outRef = typeRefFor(w.outputType, declared);
89
109
  if (outRef)
90
110
  outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
111
+ const hdrRef = headerRefFor(w.headerKeys);
112
+ if (hdrRef)
113
+ headers.push(` ${propKey(w.taskType)}: ${hdrRef};`);
91
114
  }
92
115
  const header = "// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\n" +
93
116
  "// The bridge from the process model to the worker type system: each declared\n" +
@@ -105,7 +128,9 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
105
128
  const outputsIface = outputs.length > 0
106
129
  ? `export interface WorkerOutputs {\n${outputs.join("\n")}\n}\n`
107
130
  : `export interface WorkerOutputs {}\n`;
108
- const headersIface = `export interface WorkerHeaders {}\n`;
131
+ const headersIface = headers.length > 0
132
+ ? `export interface WorkerHeaders {\n${headers.join("\n")}\n}\n`
133
+ : `export interface WorkerHeaders {}\n`;
109
134
  return `${header}\n` +
110
135
  importTypes +
111
136
  `\n/** Untyped fallback for a job whose worker declares no input/output type. */\n` +
@@ -144,3 +169,78 @@ export const workerIoDeriver = {
144
169
  describe: "Derive worker-io.d.ts (task type + data-envelope in/out) from BPMN models.",
145
170
  derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
146
171
  };
172
+ /**
173
+ * Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
174
+ * for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
175
+ * `derived` (clearing a stale manifest value when the model carries none); manifest entries the scan
176
+ * did not cover (e.g. a worker declared only for an `llm` binding, with no service task) are
177
+ * preserved. New `taskType`s seen only in the model are appended. A byte-faithful port of the
178
+ * console's `overlayDerivedWorkerIo` (server `data_cli.ts`).
179
+ */
180
+ export function overlayDerivedWorkerIo(manifest, derived) {
181
+ const byType = new Map();
182
+ for (const w of manifest) {
183
+ if (typeof w.taskType === "string")
184
+ byType.set(w.taskType, { ...w });
185
+ }
186
+ for (const d of derived) {
187
+ if (typeof d.taskType !== "string")
188
+ continue;
189
+ const existing = byType.get(d.taskType);
190
+ const merged = existing
191
+ ? { ...existing, taskType: d.taskType }
192
+ : { taskType: d.taskType };
193
+ if (d.inputType)
194
+ merged.inputType = d.inputType;
195
+ else
196
+ delete merged.inputType;
197
+ if (d.outputType)
198
+ merged.outputType = d.outputType;
199
+ else
200
+ delete merged.outputType;
201
+ // Header keys are authored on the model, so the derived set is authoritative: replace (not
202
+ // union) the manifest's, and clear when the model declares none.
203
+ if (d.headerKeys && d.headerKeys.length > 0)
204
+ merged.headerKeys = [...d.headerKeys];
205
+ else
206
+ delete merged.headerKeys;
207
+ byType.set(d.taskType, merged);
208
+ }
209
+ return [...byType.values()];
210
+ }
211
+ /**
212
+ * The static typed-`defineWorker` wrapper (`workers.ts`). It re-exports the whole worker SDK and
213
+ * overrides `defineWorker` with a signature that keys off the `type:` string literal: when a job
214
+ * type is present in the generated `WorkerInputs`/`WorkerOutputs` (ADR 0033 §3), the handler's
215
+ * `job.variables` and result are typed from the declared domain type; otherwise they fall back to
216
+ * `WorkerVars`. The body is a pass-through — the wire stays untyped JSON (ADR 0029 §3). This file
217
+ * never changes with the schema, so it is written verbatim (unlike the regenerated
218
+ * `worker-io.d.ts`). A byte-faithful port of the console's `emitWorkerBindingsRuntime`.
219
+ */
220
+ export function emitWorkerBindingsRuntime() {
221
+ return "// AUTO-GENERATED by nanobpmn (ADR 0033 §3): the typed `defineWorker`.\n" +
222
+ "// Re-exports the worker SDK and overrides `defineWorker` with a taskType-keyed\n" +
223
+ "// typed signature (job.variables, job.customHeaders + result typed from the\n" +
224
+ "// worker's declared input/output/header contract). Erased to a pass-through at\n" +
225
+ "// runtime. Do not edit.\n" +
226
+ "// eslint-disable\n\n" +
227
+ `import { defineWorker as defineWorkerRaw } from "./worker-sdk.ts";\n` +
228
+ `import type { WorkerOptions } from "./worker-sdk.ts";\n` +
229
+ `import type { WorkerInputs, WorkerOutputs, WorkerHeaders, WorkerTaskType, WorkerVars, WorkerHdrs } from "./${WORKER_BINDINGS_DTS}";\n\n` +
230
+ `export * from "./worker-sdk.ts";\n\n` +
231
+ `type InFor<K extends WorkerTaskType> = K extends keyof WorkerInputs ? WorkerInputs[K] : WorkerVars;\n` +
232
+ `type OutFor<K extends WorkerTaskType> = K extends keyof WorkerOutputs ? WorkerOutputs[K] : WorkerVars;\n` +
233
+ `type HdrFor<K extends WorkerTaskType> = K extends keyof WorkerHeaders ? WorkerHeaders[K] : WorkerHdrs;\n\n` +
234
+ `/**\n` +
235
+ ` * Typed \`defineWorker\`: \`type\` is constrained to the model's declared job types\n` +
236
+ ` * (\`WorkerTaskType\`, ADR 0033 §3) so it autocompletes and rejects unknown jobs,\n` +
237
+ ` * and the handler's \`job.variables\`, \`job.customHeaders\` + result are typed from\n` +
238
+ ` * the worker's declared \`inputType\`/\`outputType\`/header keys. A declared job type\n` +
239
+ ` * with no declared type falls back to WorkerVars / WorkerHdrs.\n` +
240
+ ` */\n` +
241
+ `export function defineWorker<K extends WorkerTaskType>(\n` +
242
+ ` opts: { type: K } & WorkerOptions<InFor<K> & object, OutFor<K> & object, HdrFor<K> & object>,\n` +
243
+ `): void {\n` +
244
+ ` defineWorkerRaw(opts as unknown as WorkerOptions);\n` +
245
+ `}\n`;
246
+ }