@nanobpm/urban 0.25.0 → 0.27.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,96 @@
1
+ import type { ColumnMeta } from "../../runtime/core/modules/gateway.ts";
2
+ import { type DomainFieldDef, type DomainTypeDef, type DomainTypeRegistry, type SourceSchema } from "./domain.ts";
3
+ /** One composition operation of a `nano:shape`, in author (XML) order. */
4
+ export type ShapeOp = {
5
+ op: "carry";
6
+ ref: string;
7
+ } | {
8
+ op: "project";
9
+ ref: string;
10
+ fields: string[];
11
+ via?: string;
12
+ } | {
13
+ op: "extend";
14
+ name: string;
15
+ type: string;
16
+ optional?: boolean;
17
+ list?: boolean;
18
+ } | {
19
+ op: "reference";
20
+ name: string;
21
+ ref: string;
22
+ spread?: boolean;
23
+ list?: boolean;
24
+ };
25
+ /** A composed motion-shape declaration lifted from the model (ADR 0040 §9). */
26
+ export interface ShapeDecl {
27
+ /** The fuse identity; also the `DomainTypes` key the resolved shape lands under. */
28
+ id: string;
29
+ /** Optional human label (`nano:shape name`). */
30
+ name?: string;
31
+ /** The defining process id, for the `model:<processId>` provenance tag. */
32
+ process?: string;
33
+ /** The ordered composition operations. */
34
+ ops: ShapeOp[];
35
+ /** Model-level metadata (`nano:meta`), free-form key/value (ADR 0040 §5). */
36
+ meta?: Record<string, string>;
37
+ }
38
+ /** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
39
+ * silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
40
+ * deliberate field shadow) still resolves. */
41
+ export interface ShapeDiagnostic {
42
+ /** The offending shape id. */
43
+ shape: string;
44
+ kind: "unresolved-reference" | "reference-cycle" | "field-conflict" | "unknown-field" | "duplicate-id" | "ambiguous-reference" | "nominal-table-ref" | "same-id-collision";
45
+ severity: "error" | "warning";
46
+ message: string;
47
+ }
48
+ /** The result of resolving the project's shapes against the leaf fuse. */
49
+ export interface ShapeResolution {
50
+ /** The resolved shapes as registry entries, keyed by shape id, ready to fold into the manifest
51
+ * `types` registry before `emitDomainModel`. */
52
+ types: DomainTypeRegistry;
53
+ diagnostics: ShapeDiagnostic[];
54
+ }
55
+ /** Map one datasource column to a domain field. A nullable column widens to `optional` (the
56
+ * composed shape is a motion snapshot: an absent value reads as undefined, not SQL NULL). The
57
+ * manifest keyword is inferred from the column's SQLite affinity so it flows through `fieldTsType`
58
+ * like any declared field; affinities with no keyword (BLOB/opaque) fall back to `json`. */
59
+ export declare function columnToField(col: ColumnMeta): DomainFieldDef;
60
+ /**
61
+ * Resolve the project's composed shapes into `DomainTypeDef`s and diagnostics (ADR 0040 §10).
62
+ * Leaves (DB tables, manifest types) fuse first; shapes resolve in dependency order so a shape can
63
+ * carry another shape regardless of declaration order. A shape in a reference cycle, or one that
64
+ * names an unresolvable id, is omitted (degrades to untyped) with an `error` diagnostic; a
65
+ * deliberate field shadow with a differing type resolves with a `field-conflict` warning.
66
+ */
67
+ export declare function resolveShapes(shapes: ShapeDecl[], types: DomainTypeRegistry, sources?: SourceSchema[]): ShapeResolution;
68
+ /** The basename of the generated structured fuse cache (ADR 0040 §1). */
69
+ export declare const DOMAIN_MODEL_JSON = "domain.json";
70
+ /** One model-level metadata entry (`nano:meta`) as `emitDomainModelJson` consumes it. Mirrors
71
+ * `meta.ts`'s `MetaDecl` (kept local so the fuse cache does not depend on the meta deriver). */
72
+ export interface FusedMetaDecl {
73
+ process?: string;
74
+ key: string;
75
+ value: string;
76
+ }
77
+ /**
78
+ * Emit `domain.json`: the structured Fused Domain Model (ADR 0040 §1). Assembles every fused
79
+ * entity — DB tables (`db:` provenance), manifest `types` (`manifest:`), and composed motion shapes
80
+ * (`model:<processId>`, or bare `model` for an unsaved editor shape with no process) — with its
81
+ * resolved fields, plus the model-level metadata and the shape diagnostics. An `inputsHash` —
82
+ * FNV-1a (hex) over the compact `JSON.stringify` of the model object with `inputsHash` omitted
83
+ * (property-insertion order, not the pretty-printed bytes) — tags the cache for staleness.
84
+ */
85
+ export declare function emitDomainModelJson(input: {
86
+ sources: SourceSchema[];
87
+ default?: string;
88
+ manifestTypes: DomainTypeRegistry;
89
+ /** Resolved shapes paired with their declaration (for provenance/label). */
90
+ shapes: {
91
+ decl: ShapeDecl;
92
+ def: DomainTypeDef;
93
+ }[];
94
+ meta: FusedMetaDecl[];
95
+ diagnostics: ShapeDiagnostic[];
96
+ }): string;
@@ -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;