@nanobpm/urban 0.47.0 → 0.48.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.
@@ -786,6 +786,27 @@ function el(tag, attrs = {}, ...kids) {
786
786
  function interpParam(s) {
787
787
  return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
788
788
  }
789
+
790
+ // Interpolate {{field}} tokens in a column's per-cell template with values from
791
+ // the row, coercing each to a string (null/undefined → ""). Like interpParam the
792
+ // result is only ever set as DOM text (textContent via el()), so a substituted
793
+ // row value cannot inject markup or attributes. A function replacement inserts
794
+ // each value literally, so a "$"-bearing value can't be reinterpreted as a
795
+ // replacement pattern. Unknown tokens (no such field) render empty rather than
796
+ // throwing, matching interpParam. This is a dumb text formatter: no expressions,
797
+ // arithmetic, or conditionals — just field splicing. Only the row's own
798
+ // fields resolve (own-property gate, enumerable or not): an inherited
799
+ // Object.prototype key (toString, constructor, …) counts as unknown and
800
+ // renders "", so a token can't pick up prototype cruft instead of blank.
801
+ function interpTemplate(tpl, row) {
802
+ return typeof tpl === "string"
803
+ ? tpl.replace(/\{\{([^{}]+)\}\}/g, (_m, name) => {
804
+ const key = name.trim();
805
+ const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;
806
+ return v == null ? "" : String(v);
807
+ })
808
+ : tpl;
809
+ }
789
810
  function renderText(node) {
790
811
  const v = node.props.variant;
791
812
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
@@ -950,8 +971,27 @@ function renderButton(node) {
950
971
  // host's own explorer view in place via the nano-navigate postMessage bridge
951
972
  // (a modified/middle click keeps the new-tab behavior). Shared by the top-level
952
973
  // grid and child grids.
974
+ //
975
+ // Cell TEXT (the 'text' local below): the visible string a cell renders. By
976
+ // default it is the column's single 'field' value, but an optional
977
+ // 'col.template' string wins: its {{field}} tokens are spliced from the row
978
+ // (interpTemplate) into DOM text. A template lets one cell combine several
979
+ // fields / add surrounding text (e.g. "{{current_wave}}/{{wave_count}}")
980
+ // without denormalising a display column into the datasource. The template
981
+ // drives the rendered text for the plain, linkField and link
982
+ // (page/processExplorer) paths — a link's href still comes from its own
983
+ // keyField/linkField, never the template. The 'badge' presence gate and its
984
+ // tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
985
+ // the field text), so a template does not change badge behaviour. Sorting,
986
+ // groupBy and orderBy also key off real fields, never the rendered template.
953
987
  function gridCell(col, row) {
954
- const text = row[col.field] == null ? "" : String(row[col.field]);
988
+ // Raw single-field value: drives the badge presence gate + tooltip and any
989
+ // field-derived defaults. Kept separate from the (possibly templated) display
990
+ // text so a template never re-lights or blanks a badge.
991
+ const rawText = row[col.field] == null ? "" : String(row[col.field]);
992
+ // Visible text: a col.template (interpolated from the row) wins over the
993
+ // raw field value, else the field value is shown verbatim.
994
+ const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
955
995
  // 1. badge — a compact status indicator. When the row's field value is
956
996
  // non-empty (truthy after trimming) render a small circular badge (e.g. a
957
997
  // red "1" dot flagging an incident) whose tooltip is the full field text;
@@ -963,14 +1003,14 @@ function gridCell(col, row) {
963
1003
  // field text is also mirrored into aria-label so assistive tech announces
964
1004
  // the incident/status text (the visible "1" glyph alone is not meaningful).
965
1005
  if (col.badge) {
966
- if (text.trim() === "") return el("td", {});
1006
+ if (rawText.trim() === "") return el("td", {});
967
1007
  const t = col.badge.tone;
968
1008
  const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
969
1009
  const label = col.badge.label == null ? "1" : String(col.badge.label);
970
1010
  return el(
971
1011
  "td",
972
1012
  {},
973
- el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
1013
+ el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
974
1014
  );
975
1015
  }
976
1016
  if (col.linkField) {
@@ -35,6 +35,11 @@ export interface ShapeDecl {
35
35
  /** Model-level metadata (`nano:meta`), free-form key/value (ADR 0040 §5). */
36
36
  meta?: Record<string, string>;
37
37
  }
38
+ /** Scan one BPMN document for its `nano:shape` declarations (ADR 0040 §9), in document order, each
39
+ * tagged with the enclosing process for provenance. A shape with no `id` is skipped; op children
40
+ * are folded in author (XML) order. This is the standalone-`urban-gen` port of the shape-parsing
41
+ * half of the console's `envelope_scan.rs`. */
42
+ export declare function scanModelShapes(xml: string): ShapeDecl[];
38
43
  /** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
39
44
  * silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
40
45
  * deliberate field shadow) still resolves. */
@@ -19,6 +19,96 @@
19
19
  // `domain_types.ts`), so the toolkit is the single source of truth for the IDE's shape fuse
20
20
  // (host dry-out nano-bpm#576).
21
21
  import { isPrimitiveKeyword, sqliteAffinityToTs, } from "./domain.js";
22
+ // --- model scan (`nano:shape` XML → `ShapeDecl[]`) -------------------------------------------
23
+ // The regex-based BPMN scan mirrors the Rust `envelope_scan.rs` shape parser (and the sibling
24
+ // `meta.ts`/`worker-io.ts` scanners): the toolkit is the standalone `urban gen` path, so it must
25
+ // lift model-authored shapes itself rather than receive them from the host reifier.
26
+ /** Read a `name="…"` attribute off an element's opening tag (any namespace prefix on the name). */
27
+ function shapeAttr(tag, name) {
28
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
29
+ const v = m?.[1]?.trim();
30
+ return v != null && v.length > 0 ? v : undefined;
31
+ }
32
+ /** A boolean attribute is true only for the exact literal `"true"` (mirrors `bool_attr`). */
33
+ function shapeBool(tag, name) {
34
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
35
+ return m?.[1]?.trim() === "true";
36
+ }
37
+ /** Split a comma-delimited `fields="a, b"` list, trimming and dropping empties (`split_fields`). */
38
+ function splitFields(raw) {
39
+ return (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
40
+ }
41
+ /** The enclosing `bpmn:process` id (provenance for the `model:<processId>` tag), best-effort. */
42
+ function processId(xml) {
43
+ const m = xml.match(/<[\w.-]*:?process\b[^>]*>/);
44
+ return m ? shapeAttr(m[0], "id") : undefined;
45
+ }
46
+ // One `nano:shape` element: capture its open-tag attrs (2) and inner body (3, empty when self-closing).
47
+ const SHAPE_RE = /<([\w.-]*:)?shape\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1?shape\s*>)/g;
48
+ // One composition op child (`nano:carry|project|extend|reference`): the local name (2) + its attrs (3).
49
+ const SHAPE_OP_RE = /<([\w.-]*:)?(carry|project|extend|reference)\b([^>]*?)\/?>/g;
50
+ /** Parse one composition-op element into a `ShapeOp`. An op missing a required attribute is dropped
51
+ * (returns `undefined`) so a malformed model degrades rather than emitting a spurious binding —
52
+ * mirrors `parse_shape_op` in `envelope_scan.rs`. */
53
+ function parseShapeOp(local, attrs) {
54
+ const tag = `<x ${attrs}>`;
55
+ switch (local) {
56
+ case "carry": {
57
+ const ref = shapeAttr(tag, "ref");
58
+ return ref ? { op: "carry", ref } : undefined;
59
+ }
60
+ case "project": {
61
+ const ref = shapeAttr(tag, "ref");
62
+ if (!ref)
63
+ return undefined;
64
+ return { op: "project", ref, fields: splitFields(shapeAttr(tag, "fields")), via: shapeAttr(tag, "via") };
65
+ }
66
+ case "extend": {
67
+ const name = shapeAttr(tag, "name");
68
+ const type = shapeAttr(tag, "type");
69
+ if (!name || !type)
70
+ return undefined;
71
+ return { op: "extend", name, type, optional: shapeBool(tag, "optional"), list: shapeBool(tag, "list") };
72
+ }
73
+ case "reference": {
74
+ const name = shapeAttr(tag, "name");
75
+ const ref = shapeAttr(tag, "ref");
76
+ if (!name || !ref)
77
+ return undefined;
78
+ return { op: "reference", name, ref, spread: shapeBool(tag, "spread"), list: shapeBool(tag, "list") };
79
+ }
80
+ default:
81
+ return undefined;
82
+ }
83
+ }
84
+ /** Scan one BPMN document for its `nano:shape` declarations (ADR 0040 §9), in document order, each
85
+ * tagged with the enclosing process for provenance. A shape with no `id` is skipped; op children
86
+ * are folded in author (XML) order. This is the standalone-`urban-gen` port of the shape-parsing
87
+ * half of the console's `envelope_scan.rs`. */
88
+ export function scanModelShapes(xml) {
89
+ const proc = processId(xml);
90
+ const out = [];
91
+ let sm;
92
+ SHAPE_RE.lastIndex = 0;
93
+ while ((sm = SHAPE_RE.exec(xml)) !== null) {
94
+ const open = `<x ${sm[2]}>`;
95
+ const id = shapeAttr(open, "id");
96
+ if (!id)
97
+ continue;
98
+ const name = shapeAttr(open, "name");
99
+ const body = sm[3] ?? "";
100
+ const ops = [];
101
+ let om;
102
+ SHAPE_OP_RE.lastIndex = 0;
103
+ while ((om = SHAPE_OP_RE.exec(body)) !== null) {
104
+ const op = parseShapeOp(om[2], om[3] ?? "");
105
+ if (op)
106
+ ops.push(op);
107
+ }
108
+ out.push({ id, name, process: proc, ops });
109
+ }
110
+ return out;
111
+ }
22
112
  /** Map one datasource column to a domain field. A nullable column widens to `optional` (the
23
113
  * composed shape is a motion snapshot: an absent value reads as undefined, not SQL NULL). The
24
114
  * manifest keyword is inferred from the column's SQLite affinity so it flows through `fieldTsType`
@@ -1,4 +1,5 @@
1
1
  import type { DerivedArtifact, Deriver } from "../artifact.ts";
2
+ import type { DomainTypeRegistry } from "./domain.ts";
2
3
  export interface ModelSource {
3
4
  /** Path (used only for diagnostics/ordering). */
4
5
  path: string;
@@ -57,6 +58,30 @@ export declare const workerIoDeriver: Deriver<{
57
58
  models: ModelSource[];
58
59
  declaredTypeIds: Iterable<string>;
59
60
  }>;
61
+ /** A data-envelope conflict: one `taskType` bound to variants that disagree on a field's *type*. */
62
+ export interface EnvelopeConflict {
63
+ taskType: string;
64
+ /** `"in"` (job.variables) or `"out"` (result). */
65
+ slot: "in" | "out";
66
+ field: string;
67
+ /** The distinct resolved type expressions seen for `field`, one per conflicting variant. */
68
+ types: string[];
69
+ /** The envelope ids that carry the conflicting field, in scan order. */
70
+ envelopes: string[];
71
+ }
72
+ /**
73
+ * Report data-envelope conflicts: a `taskType` serviced by more than one *distinct* envelope whose
74
+ * variants disagree on the resolved TYPE of a shared field. A single handler is keyed by `taskType`,
75
+ * so its input/output is the UNION of every variant (see `emitWorkerBindings`). A union is sound as
76
+ * long as a shared field means the same thing everywhere; differing presence or optionality is fine
77
+ * (that is the normal subset/superset relationship the union already narrows). But a field declared
78
+ * `string` in one variant and `number` in another is a genuine contract conflict the author must
79
+ * resolve (split the job type or unify the envelope) — this surfaces it instead of silently
80
+ * emitting `string | number`. Compatible variants (nwf's escalation subset) produce no diagnostic.
81
+ */
82
+ export declare function detectEnvelopeConflicts(workers: WorkerBindingDecl[], types: DomainTypeRegistry): EnvelopeConflict[];
83
+ /** Human-readable warning for one envelope conflict, for the gen warning stream. */
84
+ export declare function formatEnvelopeConflict(c: EnvelopeConflict): string;
60
85
  /**
61
86
  * Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
62
87
  * for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
@@ -96,21 +96,48 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
96
96
  const taskTypeUnion = taskTypes.length > 0
97
97
  ? taskTypes.map((t) => JSON.stringify(t)).join(" | ")
98
98
  : "string";
99
- const inputs = [];
100
- const outputs = [];
101
- const headers = [];
99
+ // A `taskType` may be serviced by more than one model element (a worker reused across processes,
100
+ // or the same job on several boundaries), each declaring its own data envelope. The interface is
101
+ // keyed by `taskType`, so a single member must cover every caller. We UNION the distinct declared
102
+ // envelope ids: `DomainTypes["A"] | DomainTypes["B"]`. A union is exactly the right contract for a
103
+ // shared handler — it reads the safe common surface (a field is accessible only where it is
104
+ // present-and-compatibly-typed in *every* variant, so an optional/absent-in-one field is not
105
+ // guaranteed), which is the "required iff required in all callers" rule. A single variant collapses
106
+ // to a plain `DomainTypes["A"]`. Genuine field-type conflicts across variants are reported out of
107
+ // band (see `detectEnvelopeConflicts`), where the resolved field types are in hand.
108
+ const inIds = new Map();
109
+ const outIds = new Map();
110
+ const hdrKeys = new Map();
111
+ const pushDistinct = (m, key, val) => {
112
+ const list = m.get(key) ?? [];
113
+ if (!list.includes(val))
114
+ list.push(val);
115
+ m.set(key, list);
116
+ };
102
117
  for (const w of workers) {
103
118
  if (typeof w?.taskType !== "string" || w.taskType.length === 0)
104
119
  continue;
105
- const inRef = typeRefFor(w.inputType, declared);
106
- if (inRef)
107
- inputs.push(` ${propKey(w.taskType)}: ${inRef};`);
108
- const outRef = typeRefFor(w.outputType, declared);
109
- if (outRef)
110
- outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
111
- const hdrRef = headerRefFor(w.headerKeys);
120
+ if (w.inputType && declared.has(w.inputType))
121
+ pushDistinct(inIds, w.taskType, w.inputType);
122
+ if (w.outputType && declared.has(w.outputType))
123
+ pushDistinct(outIds, w.taskType, w.outputType);
124
+ for (const k of w.headerKeys ?? []) {
125
+ if (typeof k === "string" && k.trim().length > 0)
126
+ pushDistinct(hdrKeys, w.taskType, k.trim());
127
+ }
128
+ }
129
+ const unionRef = (ids) => ids.map((id) => typeRefFor(id, declared)).filter((r) => !!r).join(" | ");
130
+ const inputs = [...inIds.entries()]
131
+ .filter(([, ids]) => ids.length > 0)
132
+ .map(([t, ids]) => ` ${propKey(t)}: ${unionRef(ids)};`);
133
+ const outputs = [...outIds.entries()]
134
+ .filter(([, ids]) => ids.length > 0)
135
+ .map(([t, ids]) => ` ${propKey(t)}: ${unionRef(ids)};`);
136
+ const headers = [];
137
+ for (const [t, keys] of hdrKeys.entries()) {
138
+ const hdrRef = headerRefFor(keys);
112
139
  if (hdrRef)
113
- headers.push(` ${propKey(w.taskType)}: ${hdrRef};`);
140
+ headers.push(` ${propKey(t)}: ${hdrRef};`);
114
141
  }
115
142
  const header = "// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\n" +
116
143
  "// The bridge from the process model to the worker type system: each declared\n" +
@@ -169,6 +196,72 @@ export const workerIoDeriver = {
169
196
  describe: "Derive worker-io.d.ts (task type + data-envelope in/out) from BPMN models.",
170
197
  derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
171
198
  };
199
+ function fieldSig(f) {
200
+ return `${f.type}${f.list ? "[]" : ""}`;
201
+ }
202
+ /**
203
+ * Report data-envelope conflicts: a `taskType` serviced by more than one *distinct* envelope whose
204
+ * variants disagree on the resolved TYPE of a shared field. A single handler is keyed by `taskType`,
205
+ * so its input/output is the UNION of every variant (see `emitWorkerBindings`). A union is sound as
206
+ * long as a shared field means the same thing everywhere; differing presence or optionality is fine
207
+ * (that is the normal subset/superset relationship the union already narrows). But a field declared
208
+ * `string` in one variant and `number` in another is a genuine contract conflict the author must
209
+ * resolve (split the job type or unify the envelope) — this surfaces it instead of silently
210
+ * emitting `string | number`. Compatible variants (nwf's escalation subset) produce no diagnostic.
211
+ */
212
+ export function detectEnvelopeConflicts(workers, types) {
213
+ const collect = (pick, slot) => {
214
+ const byType = new Map();
215
+ for (const w of workers) {
216
+ const id = pick(w);
217
+ if (typeof w?.taskType !== "string" || !w.taskType || !id || !Object.hasOwn(types, id))
218
+ continue;
219
+ const list = byType.get(w.taskType) ?? [];
220
+ if (!list.includes(id))
221
+ list.push(id);
222
+ byType.set(w.taskType, list);
223
+ }
224
+ const conflicts = [];
225
+ for (const [taskType, ids] of byType.entries()) {
226
+ if (ids.length < 2)
227
+ continue;
228
+ const fieldNames = new Set();
229
+ for (const id of ids)
230
+ for (const name of Object.keys(types[id].fields))
231
+ fieldNames.add(name);
232
+ for (const field of fieldNames) {
233
+ const seen = new Map(); // signature -> envelope ids carrying it
234
+ for (const id of ids) {
235
+ if (!Object.hasOwn(types[id].fields, field))
236
+ continue; // absent in this variant — a presence difference, not a type conflict
237
+ const f = types[id].fields[field];
238
+ const sig = fieldSig(f);
239
+ const carriers = seen.get(sig) ?? [];
240
+ carriers.push(id);
241
+ seen.set(sig, carriers);
242
+ }
243
+ if (seen.size > 1) {
244
+ conflicts.push({
245
+ taskType,
246
+ slot,
247
+ field,
248
+ types: [...seen.keys()],
249
+ envelopes: ids.filter((id) => Object.hasOwn(types[id].fields, field)),
250
+ });
251
+ }
252
+ }
253
+ }
254
+ return conflicts;
255
+ };
256
+ return [...collect((w) => w.inputType, "in"), ...collect((w) => w.outputType, "out")];
257
+ }
258
+ /** Human-readable warning for one envelope conflict, for the gen warning stream. */
259
+ export function formatEnvelopeConflict(c) {
260
+ return (`data-envelope conflict on worker "${c.taskType}" (${c.slot}): field "${c.field}" is ` +
261
+ `${c.types.join(" vs ")} across envelopes ${c.envelopes.map((e) => `"${e}"`).join(", ")}. ` +
262
+ `The taskType's ${c.slot === "in" ? "input" : "output"} is the union of its envelopes; a ` +
263
+ `field must have one type across all of them. Unify the envelope or split the job type.`);
264
+ }
172
265
  /**
173
266
  * Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
174
267
  * for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
@@ -4,10 +4,11 @@
4
4
  // pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
5
5
  import { GENERATED_DIR, isAbsolutePath, RUNTIME_MATERIALIZED_ARTIFACTS, sortArtifacts } from "./artifact.js";
6
6
  import { deriveMigrations } from "./derivers/migrations.js";
7
- import { deriveDomain } from "./derivers/domain.js";
8
- import { deriveWorkerBindings } from "./derivers/worker-io.js";
7
+ import { emitDomainModel, registryFromManifest, sourcesFromManifest } from "./derivers/domain.js";
8
+ import { byModelPath, deriveWorkerBindings, detectEnvelopeConflicts, DOMAIN_DTS, formatEnvelopeConflict, scanModelWorkers } from "./derivers/worker-io.js";
9
9
  import { deriveMeta } from "./derivers/meta.js";
10
10
  import { deriveMessageBindings } from "./derivers/messages.js";
11
+ import { resolveShapes, scanModelShapes } from "./derivers/shapes.js";
11
12
  import { deriveApi } from "./derivers/api.js";
12
13
  import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.js";
13
14
  import { deriveModels, MODEL_PROVENANCE } from "./models.js";
@@ -69,28 +70,60 @@ async function collectAll(opts) {
69
70
  const manifest = JSON.parse(await io.readText(manifestPath));
70
71
  const artifacts = [];
71
72
  const warnings = [];
72
- // 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
73
- if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
74
- artifacts.push(...deriveMigrations(manifest));
75
- artifacts.push(...deriveDomain(manifest));
76
- }
77
- // 2. code-first models → derive BPMN from `workflows/*.ts` (executes the app's TS). The derived
78
- // `.bpmn` are emitted only when `emitModels` (default), but always fed to the type-contract
79
- // derivers below so worker I/O works for a code-first app even on a `--no-models` run.
73
+ // 1. Resolve the app's models first (code-first `workflows/*.ts` → BPMN, plus authored on-disk
74
+ // `.bpmn`): the fused domain model (below) lifts `nano:shape` envelopes *from* the models, so
75
+ // they must be in hand before the domain + worker/message type-contracts are derived.
80
76
  const derived = await deriveModels(root, io, manifest);
77
+ // The derived `.bpmn` are emitted only when `emitModels` (default), but always fed to the
78
+ // type-contract derivers so worker I/O works for a code-first app even on a `--no-models` run.
81
79
  if (emitModels)
82
80
  artifacts.push(...derived.artifacts);
83
- // 3. models (authored on-disk + derived) → worker I/O index + meta accessor + message map.
84
- // Drop any on-disk model whose path a derived model already covers, to avoid double-counting.
81
+ const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
82
+ const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
83
+ const models = [...diskModels, ...derived.models];
84
+ // 2. Fused domain model (ADR 0040): the `DomainTypes` registry is the union of the manifest
85
+ // `types` registry and the model-authored `nano:shape` envelopes, resolved through the same
86
+ // fuse the console reifier (`envelope_scan.rs`) uses. Without this the standalone `urban gen`
87
+ // would honour only `manifest.types` and silently ignore every model-authored data envelope,
88
+ // so worker/message I/O against those envelopes would fall back to the untyped `WorkerVars`.
89
+ const manifestRegistry = registryFromManifest(manifest);
90
+ const sources = sourcesFromManifest(manifest);
91
+ const shapeDecls = models.flatMap((m) => scanModelShapes(m.xml));
92
+ const shapeResolution = resolveShapes(shapeDecls, manifestRegistry, sources);
93
+ const fusedTypes = { ...manifestRegistry, ...shapeResolution.types };
94
+ for (const d of shapeResolution.diagnostics) {
95
+ warnings.push(`data-envelope shape "${d.shape}" (${d.kind}): ${d.message}`);
96
+ }
97
+ // 3. types → migrations (persisted `manifest.types` only — shapes are transient wire envelopes,
98
+ // never tables) + the fused domain row types (`DomainTables` spine + `DomainTypes` registry).
99
+ if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
100
+ artifacts.push(...deriveMigrations(manifest));
101
+ }
102
+ if (!opts.modelsOnly && Object.keys(fusedTypes).length > 0) {
103
+ artifacts.push({
104
+ path: `${GENERATED_DIR}/${DOMAIN_DTS}`,
105
+ content: emitDomainModel(sources, manifest.data?.default ?? "app", fusedTypes),
106
+ });
107
+ }
108
+ // 4. models (authored on-disk + derived) → worker I/O index + meta accessor + message map. The
109
+ // declared type ids include the fused shapes, so an envelope ref to a model-authored shape
110
+ // resolves to its `DomainTypes[...]` entry instead of degrading to the untyped fallback.
85
111
  if (!opts.modelsOnly) {
86
- const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
87
- const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
88
- const models = [...diskModels, ...derived.models];
89
112
  if (models.length > 0) {
90
- const declaredTypeIds = Object.keys(manifest.types ?? {});
113
+ const declaredTypeIds = Object.keys(fusedTypes);
91
114
  artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
92
115
  artifacts.push(...deriveMeta(models));
93
116
  artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
117
+ // Surface genuine data-envelope conflicts: a taskType whose variants disagree on a field's
118
+ // TYPE. The emitted contract is the union of the variants (sound for a shared handler), so a
119
+ // type clash would silently widen to `A | B` — warn instead so the author unifies the envelope.
120
+ const scannedWorkers = [...models]
121
+ .sort(byModelPath)
122
+ .flatMap((m) => scanModelWorkers(m.xml))
123
+ .map((w) => ({ taskType: w.taskType, inputType: w.in, outputType: w.out }));
124
+ for (const c of detectEnvelopeConflicts(scannedWorkers, fusedTypes)) {
125
+ warnings.push(formatEnvelopeConflict(c));
126
+ }
94
127
  }
95
128
  }
96
129
  // 4. OpenAPI `api` binding → typed endpoint contracts (ADR 0058). Fail-closed: a declared spec
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -337,12 +337,37 @@ test("renderer wires a column's badge to a tone-classed pill shown only when pre
337
337
  // (default danger), the label defaults to "1", and the full field text becomes
338
338
  // the tooltip.
339
339
  assert.match(js, /if\s*\(col\.badge\)/);
340
- assert.match(js, /text\.trim\(\)\s*===\s*""/);
340
+ assert.match(js, /rawText\.trim\(\)\s*===\s*""/);
341
341
  assert.match(js, /t\s*===\s*"warn"\s*\|\|\s*t\s*===\s*"ok"\s*\|\|\s*t\s*===\s*"info"\s*\?\s*t\s*:\s*"danger"/);
342
342
  assert.match(js, /col\.badge\.label\s*==\s*null\s*\?\s*"1"/);
343
343
  assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
344
- assert.match(js, /title:\s*text/);
345
- assert.match(js, /"aria-label":\s*text/);
344
+ assert.match(js, /title:\s*rawText/);
345
+ assert.match(js, /"aria-label":\s*rawText/);
346
+ });
347
+
348
+ test("renderer interpolates a column's per-cell template into DOM text (#214)", async () => {
349
+ const res = await dispatch("GET", "/app/runtime.js");
350
+ const js = res.body ?? "";
351
+ // A column declaring `template: "{{a}}/{{b}}"` renders one cell by splicing the
352
+ // named row fields into a string, coerced to text and set only as DOM text
353
+ // (never markup). Guard the whole shape so the primitive can't silently
354
+ // regress: interpTemplate exists, replaces {{field}} tokens via a function
355
+ // replacement (so a "$"-bearing value can't be reinterpreted as a replacement
356
+ // pattern), coerces null/undefined to "" (unknown tokens render blank, no
357
+ // throw), and gridCell prefers a string col.template over the raw field value
358
+ // for the visible text while the badge stays keyed to the raw field.
359
+ assert.match(js, /function interpTemplate\(tpl, row\)/);
360
+ assert.match(js, /replace\(\/\\\{\\\{\(\[\^\{\}\]\+\)\\\}\\\}\/g,/);
361
+ assert.match(js, /const key = name\.trim\(\);/);
362
+ // Unknown tokens (no such own field) render blank and, crucially, an inherited
363
+ // Object.prototype key (toString, constructor, …) counts as unknown rather than
364
+ // resolving to prototype cruft — guard the own-property gate explicitly.
365
+ assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, key\) \? row\[key\] : null/);
366
+ assert.match(js, /return v == null \? "" : String\(v\);/);
367
+ // gridCell keeps the raw single-field value separate (drives the badge gate)
368
+ // and lets a string template win for the rendered text.
369
+ assert.match(js, /const rawText = row\[col\.field\] == null \? "" : String\(row\[col\.field\]\);/);
370
+ assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
346
371
  });
347
372
 
348
373
  test("renderer wires a column's processExplorer link to the console explorer", async () => {
@@ -838,6 +838,27 @@ function el(tag, attrs = {}, ...kids) {
838
838
  function interpParam(s) {
839
839
  return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
840
840
  }
841
+
842
+ // Interpolate {{field}} tokens in a column's per-cell template with values from
843
+ // the row, coercing each to a string (null/undefined → ""). Like interpParam the
844
+ // result is only ever set as DOM text (textContent via el()), so a substituted
845
+ // row value cannot inject markup or attributes. A function replacement inserts
846
+ // each value literally, so a "$"-bearing value can't be reinterpreted as a
847
+ // replacement pattern. Unknown tokens (no such field) render empty rather than
848
+ // throwing, matching interpParam. This is a dumb text formatter: no expressions,
849
+ // arithmetic, or conditionals — just field splicing. Only the row's own
850
+ // fields resolve (own-property gate, enumerable or not): an inherited
851
+ // Object.prototype key (toString, constructor, …) counts as unknown and
852
+ // renders "", so a token can't pick up prototype cruft instead of blank.
853
+ function interpTemplate(tpl, row) {
854
+ return typeof tpl === "string"
855
+ ? tpl.replace(/\{\{([^{}]+)\}\}/g, (_m, name) => {
856
+ const key = name.trim();
857
+ const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;
858
+ return v == null ? "" : String(v);
859
+ })
860
+ : tpl;
861
+ }
841
862
  function renderText(node) {
842
863
  const v = node.props.variant;
843
864
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
@@ -1002,8 +1023,27 @@ function renderButton(node) {
1002
1023
  // host's own explorer view in place via the nano-navigate postMessage bridge
1003
1024
  // (a modified/middle click keeps the new-tab behavior). Shared by the top-level
1004
1025
  // grid and child grids.
1026
+ //
1027
+ // Cell TEXT (the 'text' local below): the visible string a cell renders. By
1028
+ // default it is the column's single 'field' value, but an optional
1029
+ // 'col.template' string wins: its {{field}} tokens are spliced from the row
1030
+ // (interpTemplate) into DOM text. A template lets one cell combine several
1031
+ // fields / add surrounding text (e.g. "{{current_wave}}/{{wave_count}}")
1032
+ // without denormalising a display column into the datasource. The template
1033
+ // drives the rendered text for the plain, linkField and link
1034
+ // (page/processExplorer) paths — a link's href still comes from its own
1035
+ // keyField/linkField, never the template. The 'badge' presence gate and its
1036
+ // tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
1037
+ // the field text), so a template does not change badge behaviour. Sorting,
1038
+ // groupBy and orderBy also key off real fields, never the rendered template.
1005
1039
  function gridCell(col, row) {
1006
- const text = row[col.field] == null ? "" : String(row[col.field]);
1040
+ // Raw single-field value: drives the badge presence gate + tooltip and any
1041
+ // field-derived defaults. Kept separate from the (possibly templated) display
1042
+ // text so a template never re-lights or blanks a badge.
1043
+ const rawText = row[col.field] == null ? "" : String(row[col.field]);
1044
+ // Visible text: a col.template (interpolated from the row) wins over the
1045
+ // raw field value, else the field value is shown verbatim.
1046
+ const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
1007
1047
  // 1. badge — a compact status indicator. When the row's field value is
1008
1048
  // non-empty (truthy after trimming) render a small circular badge (e.g. a
1009
1049
  // red "1" dot flagging an incident) whose tooltip is the full field text;
@@ -1015,14 +1055,14 @@ function gridCell(col, row) {
1015
1055
  // field text is also mirrored into aria-label so assistive tech announces
1016
1056
  // the incident/status text (the visible "1" glyph alone is not meaningful).
1017
1057
  if (col.badge) {
1018
- if (text.trim() === "") return el("td", {});
1058
+ if (rawText.trim() === "") return el("td", {});
1019
1059
  const t = col.badge.tone;
1020
1060
  const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
1021
1061
  const label = col.badge.label == null ? "1" : String(col.badge.label);
1022
1062
  return el(
1023
1063
  "td",
1024
1064
  {},
1025
- el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
1065
+ el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
1026
1066
  );
1027
1067
  }
1028
1068
  if (col.linkField) {
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  emitDomainModelJson,
5
5
  resolveShapes,
6
+ scanModelShapes,
6
7
  type ShapeDecl,
7
8
  } from "./shapes.ts";
8
9
  import type { DomainTypeRegistry, SourceSchema } from "./domain.ts";
@@ -169,3 +170,68 @@ test("resolveShapes returns an empty resolution for no shapes", () => {
169
170
  assert.deepEqual(types, {});
170
171
  assert.deepEqual(diagnostics, []);
171
172
  });
173
+
174
+ // --- scanModelShapes: the standalone-`urban gen` port of the `nano:shape` XML scan --------------
175
+
176
+ const SHAPES_BPMN = `<?xml version="1.0"?>
177
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
178
+ xmlns:nano="http://nanobpm.io/schema/nano/1.0">
179
+ <bpmn:process id="p1">
180
+ <bpmn:extensionElements>
181
+ <nano:shapes>
182
+ <nano:shape id="ReviewIn" name="Review — input">
183
+ <nano:extend name="prUrl" type="string" />
184
+ <nano:extend name="round" type="integer" />
185
+ <nano:extend name="answer" type="string" optional="true" />
186
+ </nano:shape>
187
+ <nano:shape id="Composed">
188
+ <nano:carry ref="ReviewIn" />
189
+ <nano:project ref="Customer" fields="tier, region" via="Order.customerId" />
190
+ <nano:reference name="lines" ref="OrderLine" spread="false" list="true" />
191
+ </nano:shape>
192
+ <nano:shape name="no-id-dropped"><nano:carry ref="ReviewIn" /></nano:shape>
193
+ </nano:shapes>
194
+ </bpmn:extensionElements>
195
+ </bpmn:process>
196
+ </bpmn:definitions>`;
197
+
198
+ test("scanModelShapes lifts nano:shape declarations with ops in author order", () => {
199
+ const shapes = scanModelShapes(SHAPES_BPMN);
200
+ // The id-less shape is dropped; the two identified shapes survive, process-tagged.
201
+ assert.deepEqual(shapes.map((s) => s.id), ["ReviewIn", "Composed"]);
202
+ assert.equal(shapes[0].process, "p1");
203
+ assert.equal(shapes[0].name, "Review — input");
204
+ assert.deepEqual(shapes[0].ops, [
205
+ { op: "extend", name: "prUrl", type: "string", optional: false, list: false },
206
+ { op: "extend", name: "round", type: "integer", optional: false, list: false },
207
+ { op: "extend", name: "answer", type: "string", optional: true, list: false },
208
+ ]);
209
+ assert.deepEqual(shapes[1].ops, [
210
+ { op: "carry", ref: "ReviewIn" },
211
+ { op: "project", ref: "Customer", fields: ["tier", "region"], via: "Order.customerId" },
212
+ { op: "reference", name: "lines", ref: "OrderLine", spread: false, list: true },
213
+ ]);
214
+ });
215
+
216
+ test("scanModelShapes → resolveShapes round-trips an all-extend envelope into DomainTypes", () => {
217
+ const shapes = scanModelShapes(SHAPES_BPMN).filter((s) => s.id === "ReviewIn");
218
+ const { types, diagnostics } = resolveShapes(shapes, {}, []);
219
+ assert.deepEqual(diagnostics, []);
220
+ assert.deepEqual(types.ReviewIn.fields, {
221
+ prUrl: { type: "string" },
222
+ round: { type: "integer" },
223
+ answer: { type: "string", optional: true },
224
+ });
225
+ });
226
+
227
+ test("scanModelShapes drops an op missing a required attribute", () => {
228
+ const xml = `<bpmn:process id="p"><bpmn:extensionElements><nano:shapes>
229
+ <nano:shape id="S">
230
+ <nano:extend name="ok" type="string" />
231
+ <nano:extend name="noType" />
232
+ <nano:carry />
233
+ </nano:shape>
234
+ </nano:shapes></bpmn:extensionElements></bpmn:process>`;
235
+ const [s] = scanModelShapes(xml);
236
+ assert.deepEqual(s.ops, [{ op: "extend", name: "ok", type: "string", optional: false, list: false }]);
237
+ });
@@ -50,6 +50,99 @@ export interface ShapeDecl {
50
50
  meta?: Record<string, string>;
51
51
  }
52
52
 
53
+ // --- model scan (`nano:shape` XML → `ShapeDecl[]`) -------------------------------------------
54
+ // The regex-based BPMN scan mirrors the Rust `envelope_scan.rs` shape parser (and the sibling
55
+ // `meta.ts`/`worker-io.ts` scanners): the toolkit is the standalone `urban gen` path, so it must
56
+ // lift model-authored shapes itself rather than receive them from the host reifier.
57
+
58
+ /** Read a `name="…"` attribute off an element's opening tag (any namespace prefix on the name). */
59
+ function shapeAttr(tag: string, name: string): string | undefined {
60
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
61
+ const v = m?.[1]?.trim();
62
+ return v != null && v.length > 0 ? v : undefined;
63
+ }
64
+
65
+ /** A boolean attribute is true only for the exact literal `"true"` (mirrors `bool_attr`). */
66
+ function shapeBool(tag: string, name: string): boolean {
67
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
68
+ return m?.[1]?.trim() === "true";
69
+ }
70
+
71
+ /** Split a comma-delimited `fields="a, b"` list, trimming and dropping empties (`split_fields`). */
72
+ function splitFields(raw: string | undefined): string[] {
73
+ return (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
74
+ }
75
+
76
+ /** The enclosing `bpmn:process` id (provenance for the `model:<processId>` tag), best-effort. */
77
+ function processId(xml: string): string | undefined {
78
+ const m = xml.match(/<[\w.-]*:?process\b[^>]*>/);
79
+ return m ? shapeAttr(m[0], "id") : undefined;
80
+ }
81
+
82
+ // One `nano:shape` element: capture its open-tag attrs (2) and inner body (3, empty when self-closing).
83
+ const SHAPE_RE = /<([\w.-]*:)?shape\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1?shape\s*>)/g;
84
+ // One composition op child (`nano:carry|project|extend|reference`): the local name (2) + its attrs (3).
85
+ const SHAPE_OP_RE = /<([\w.-]*:)?(carry|project|extend|reference)\b([^>]*?)\/?>/g;
86
+
87
+ /** Parse one composition-op element into a `ShapeOp`. An op missing a required attribute is dropped
88
+ * (returns `undefined`) so a malformed model degrades rather than emitting a spurious binding —
89
+ * mirrors `parse_shape_op` in `envelope_scan.rs`. */
90
+ function parseShapeOp(local: string, attrs: string): ShapeOp | undefined {
91
+ const tag = `<x ${attrs}>`;
92
+ switch (local) {
93
+ case "carry": {
94
+ const ref = shapeAttr(tag, "ref");
95
+ return ref ? { op: "carry", ref } : undefined;
96
+ }
97
+ case "project": {
98
+ const ref = shapeAttr(tag, "ref");
99
+ if (!ref) return undefined;
100
+ return { op: "project", ref, fields: splitFields(shapeAttr(tag, "fields")), via: shapeAttr(tag, "via") };
101
+ }
102
+ case "extend": {
103
+ const name = shapeAttr(tag, "name");
104
+ const type = shapeAttr(tag, "type");
105
+ if (!name || !type) return undefined;
106
+ return { op: "extend", name, type, optional: shapeBool(tag, "optional"), list: shapeBool(tag, "list") };
107
+ }
108
+ case "reference": {
109
+ const name = shapeAttr(tag, "name");
110
+ const ref = shapeAttr(tag, "ref");
111
+ if (!name || !ref) return undefined;
112
+ return { op: "reference", name, ref, spread: shapeBool(tag, "spread"), list: shapeBool(tag, "list") };
113
+ }
114
+ default:
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ /** Scan one BPMN document for its `nano:shape` declarations (ADR 0040 §9), in document order, each
120
+ * tagged with the enclosing process for provenance. A shape with no `id` is skipped; op children
121
+ * are folded in author (XML) order. This is the standalone-`urban-gen` port of the shape-parsing
122
+ * half of the console's `envelope_scan.rs`. */
123
+ export function scanModelShapes(xml: string): ShapeDecl[] {
124
+ const proc = processId(xml);
125
+ const out: ShapeDecl[] = [];
126
+ let sm: RegExpExecArray | null;
127
+ SHAPE_RE.lastIndex = 0;
128
+ while ((sm = SHAPE_RE.exec(xml)) !== null) {
129
+ const open = `<x ${sm[2]}>`;
130
+ const id = shapeAttr(open, "id");
131
+ if (!id) continue;
132
+ const name = shapeAttr(open, "name");
133
+ const body = sm[3] ?? "";
134
+ const ops: ShapeOp[] = [];
135
+ let om: RegExpExecArray | null;
136
+ SHAPE_OP_RE.lastIndex = 0;
137
+ while ((om = SHAPE_OP_RE.exec(body)) !== null) {
138
+ const op = parseShapeOp(om[2], om[3] ?? "");
139
+ if (op) ops.push(op);
140
+ }
141
+ out.push({ id, name, process: proc, ops });
142
+ }
143
+ return out;
144
+ }
145
+
53
146
  /** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
54
147
  * silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
55
148
  * deliberate field shadow) still resolves. */
@@ -2,11 +2,14 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  deriveWorkerBindings,
5
+ detectEnvelopeConflicts,
5
6
  emitWorkerBindings,
7
+ formatEnvelopeConflict,
6
8
  scanModelWorkers,
7
9
  byModelPath,
8
10
  type WorkerIo,
9
11
  } from "./worker-io.ts";
12
+ import type { DomainFieldDef, DomainTypeRegistry } from "./domain.ts";
10
13
 
11
14
  test("byModelPath is a spec-compliant comparator: returns 0 for equal paths", () => {
12
15
  assert.equal(byModelPath({ path: "a" }, { path: "a" }), 0);
@@ -72,6 +75,134 @@ test("emitWorkerBindings falls back to string union + no import when nothing is
72
75
  assert.match(out, /export interface WorkerInputs \{\}/);
73
76
  });
74
77
 
78
+ test("emitWorkerBindings unions distinct envelopes bound to one taskType (shared handler)", () => {
79
+ // The same worker services two processes, each declaring its own envelope. Keyed by taskType,
80
+ // the member is the UNION — a shared handler reads only what every caller guarantees.
81
+ const out = emitWorkerBindings(
82
+ [
83
+ { taskType: "pr.persist-escalation", inputType: "PrPersistEscalationIn" },
84
+ { taskType: "pr.persist-escalation", inputType: "MergeEscalationIn" },
85
+ ],
86
+ ["PrPersistEscalationIn", "MergeEscalationIn"],
87
+ );
88
+ assert.match(
89
+ out,
90
+ /"pr.persist-escalation": DomainTypes\["PrPersistEscalationIn"\] \| DomainTypes\["MergeEscalationIn"\];/,
91
+ );
92
+ });
93
+
94
+ test("emitWorkerBindings collapses a repeated identical envelope to a single ref (no dup keys)", () => {
95
+ const out = emitWorkerBindings(
96
+ [
97
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
98
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
99
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
100
+ ],
101
+ ["EscalationIn"],
102
+ );
103
+ // Exactly one member, no union, and the interface still parses (no duplicate identifier).
104
+ assert.match(out, /export interface WorkerInputs \{\n {2}"pr.persist-escalation": DomainTypes\["EscalationIn"\];\n\}/);
105
+ });
106
+
107
+ test("detectEnvelopeConflicts stays silent for a compatible subset (nwf escalation shape)", () => {
108
+ const types: DomainTypeRegistry = {
109
+ PrPersistEscalationIn: {
110
+ fields: {
111
+ prKey: { type: "string" },
112
+ round: { type: "integer" },
113
+ recordRound: { type: "boolean", optional: true },
114
+ },
115
+ },
116
+ MergeEscalationIn: {
117
+ fields: { prKey: { type: "string" }, round: { type: "integer" } },
118
+ },
119
+ };
120
+ const conflicts = detectEnvelopeConflicts(
121
+ [
122
+ { taskType: "pr.persist-escalation", inputType: "PrPersistEscalationIn" },
123
+ { taskType: "pr.persist-escalation", inputType: "MergeEscalationIn" },
124
+ ],
125
+ types,
126
+ );
127
+ // A field present in one and absent in the other, or optional in one, is NOT a conflict.
128
+ assert.deepEqual(conflicts, []);
129
+ });
130
+
131
+ test("detectEnvelopeConflicts flags a genuine field-type clash across envelopes", () => {
132
+ const types: DomainTypeRegistry = {
133
+ A: { fields: { round: { type: "integer" } } },
134
+ B: { fields: { round: { type: "string" } } },
135
+ };
136
+ const conflicts = detectEnvelopeConflicts(
137
+ [
138
+ { taskType: "pr.review", inputType: "A" },
139
+ { taskType: "pr.review", inputType: "B" },
140
+ ],
141
+ types,
142
+ );
143
+ assert.equal(conflicts.length, 1);
144
+ assert.equal(conflicts[0].taskType, "pr.review");
145
+ assert.equal(conflicts[0].slot, "in");
146
+ assert.equal(conflicts[0].field, "round");
147
+ assert.deepEqual(conflicts[0].types.sort(), ["integer", "string"]);
148
+ assert.match(formatEnvelopeConflict(conflicts[0]), /field "round" is integer vs string/);
149
+ });
150
+
151
+ test("detectEnvelopeConflicts treats a list mismatch as a conflict", () => {
152
+ const types: DomainTypeRegistry = {
153
+ A: { fields: { tags: { type: "string" } } },
154
+ B: { fields: { tags: { type: "string", list: true } } },
155
+ };
156
+ const conflicts = detectEnvelopeConflicts(
157
+ [
158
+ { taskType: "t", outputType: "A" },
159
+ { taskType: "t", outputType: "B" },
160
+ ],
161
+ types,
162
+ );
163
+ assert.equal(conflicts.length, 1);
164
+ assert.equal(conflicts[0].slot, "out");
165
+ assert.deepEqual(conflicts[0].types.sort(), ["string", "string[]"]);
166
+ });
167
+
168
+ test("detectEnvelopeConflicts uses own-property checks for envelope ids (no prototype-chain false positives)", () => {
169
+ const types: DomainTypeRegistry = {
170
+ Real: { fields: { prKey: { type: "string" } } },
171
+ };
172
+ // An author-named envelope id that only exists on Object.prototype ("toString", "constructor")
173
+ // must be treated as undeclared and skipped — not read through the prototype chain, which would
174
+ // yield a non-registry value and (previously) crash on `.fields`.
175
+ const conflicts = detectEnvelopeConflicts(
176
+ [
177
+ { taskType: "t", inputType: "Real" },
178
+ { taskType: "t", inputType: "toString" },
179
+ { taskType: "t", inputType: "constructor" },
180
+ ],
181
+ types,
182
+ );
183
+ assert.deepEqual(conflicts, []);
184
+ });
185
+
186
+ test("detectEnvelopeConflicts uses own-property checks for field names (no prototype-chain false positives)", () => {
187
+ // Envelope A carries an own field literally named "toString"; B does not carry it at all.
188
+ // A prototype-chain read (`B.fields["toString"]`) would resolve to Object.prototype.toString and
189
+ // fabricate a spurious type conflict; an own-property check correctly sees it as a presence
190
+ // difference, which is not a conflict.
191
+ const aFields: Record<string, DomainFieldDef> = { toString: { type: "string" } };
192
+ const types: DomainTypeRegistry = {
193
+ A: { fields: aFields },
194
+ B: { fields: { prKey: { type: "string" } } },
195
+ };
196
+ const conflicts = detectEnvelopeConflicts(
197
+ [
198
+ { taskType: "t", inputType: "A" },
199
+ { taskType: "t", inputType: "B" },
200
+ ],
201
+ types,
202
+ );
203
+ assert.deepEqual(conflicts, []);
204
+ });
205
+
75
206
  test("deriveWorkerBindings is deterministic and emits worker-io.d.ts", () => {
76
207
  const models = [{ path: "processes/orders.bpmn", xml: XML }];
77
208
  const a1 = deriveWorkerBindings(models, ["Payment", "Receipt"]);
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { DerivedArtifact, Deriver } from "../artifact.ts";
8
8
  import { GENERATED_DIR } from "../artifact.ts";
9
+ import type { DomainFieldDef, DomainTypeRegistry } from "./domain.ts";
9
10
 
10
11
  export interface ModelSource {
11
12
  /** Path (used only for diagnostics/ordering). */
@@ -137,17 +138,43 @@ export function emitWorkerBindings(
137
138
  const taskTypeUnion = taskTypes.length > 0
138
139
  ? taskTypes.map((t) => JSON.stringify(t)).join(" | ")
139
140
  : "string";
140
- const inputs: string[] = [];
141
- const outputs: string[] = [];
142
- const headers: string[] = [];
141
+ // A `taskType` may be serviced by more than one model element (a worker reused across processes,
142
+ // or the same job on several boundaries), each declaring its own data envelope. The interface is
143
+ // keyed by `taskType`, so a single member must cover every caller. We UNION the distinct declared
144
+ // envelope ids: `DomainTypes["A"] | DomainTypes["B"]`. A union is exactly the right contract for a
145
+ // shared handler — it reads the safe common surface (a field is accessible only where it is
146
+ // present-and-compatibly-typed in *every* variant, so an optional/absent-in-one field is not
147
+ // guaranteed), which is the "required iff required in all callers" rule. A single variant collapses
148
+ // to a plain `DomainTypes["A"]`. Genuine field-type conflicts across variants are reported out of
149
+ // band (see `detectEnvelopeConflicts`), where the resolved field types are in hand.
150
+ const inIds = new Map<string, string[]>();
151
+ const outIds = new Map<string, string[]>();
152
+ const hdrKeys = new Map<string, string[]>();
153
+ const pushDistinct = (m: Map<string, string[]>, key: string, val: string) => {
154
+ const list = m.get(key) ?? [];
155
+ if (!list.includes(val)) list.push(val);
156
+ m.set(key, list);
157
+ };
143
158
  for (const w of workers) {
144
159
  if (typeof w?.taskType !== "string" || w.taskType.length === 0) continue;
145
- const inRef = typeRefFor(w.inputType, declared);
146
- if (inRef) inputs.push(` ${propKey(w.taskType)}: ${inRef};`);
147
- const outRef = typeRefFor(w.outputType, declared);
148
- if (outRef) outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
149
- const hdrRef = headerRefFor(w.headerKeys);
150
- if (hdrRef) headers.push(` ${propKey(w.taskType)}: ${hdrRef};`);
160
+ if (w.inputType && declared.has(w.inputType)) pushDistinct(inIds, w.taskType, w.inputType);
161
+ if (w.outputType && declared.has(w.outputType)) pushDistinct(outIds, w.taskType, w.outputType);
162
+ for (const k of w.headerKeys ?? []) {
163
+ if (typeof k === "string" && k.trim().length > 0) pushDistinct(hdrKeys, w.taskType, k.trim());
164
+ }
165
+ }
166
+ const unionRef = (ids: string[]): string =>
167
+ ids.map((id) => typeRefFor(id, declared)).filter((r): r is string => !!r).join(" | ");
168
+ const inputs = [...inIds.entries()]
169
+ .filter(([, ids]) => ids.length > 0)
170
+ .map(([t, ids]) => ` ${propKey(t)}: ${unionRef(ids)};`);
171
+ const outputs = [...outIds.entries()]
172
+ .filter(([, ids]) => ids.length > 0)
173
+ .map(([t, ids]) => ` ${propKey(t)}: ${unionRef(ids)};`);
174
+ const headers: string[] = [];
175
+ for (const [t, keys] of hdrKeys.entries()) {
176
+ const hdrRef = headerRefFor(keys);
177
+ if (hdrRef) headers.push(` ${propKey(t)}: ${hdrRef};`);
151
178
  }
152
179
 
153
180
  const header =
@@ -217,6 +244,86 @@ export const workerIoDeriver: Deriver<{ models: ModelSource[]; declaredTypeIds:
217
244
  derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
218
245
  };
219
246
 
247
+ /** A data-envelope conflict: one `taskType` bound to variants that disagree on a field's *type*. */
248
+ export interface EnvelopeConflict {
249
+ taskType: string;
250
+ /** `"in"` (job.variables) or `"out"` (result). */
251
+ slot: "in" | "out";
252
+ field: string;
253
+ /** The distinct resolved type expressions seen for `field`, one per conflicting variant. */
254
+ types: string[];
255
+ /** The envelope ids that carry the conflicting field, in scan order. */
256
+ envelopes: string[];
257
+ }
258
+
259
+ function fieldSig(f: DomainFieldDef): string {
260
+ return `${f.type}${f.list ? "[]" : ""}`;
261
+ }
262
+
263
+ /**
264
+ * Report data-envelope conflicts: a `taskType` serviced by more than one *distinct* envelope whose
265
+ * variants disagree on the resolved TYPE of a shared field. A single handler is keyed by `taskType`,
266
+ * so its input/output is the UNION of every variant (see `emitWorkerBindings`). A union is sound as
267
+ * long as a shared field means the same thing everywhere; differing presence or optionality is fine
268
+ * (that is the normal subset/superset relationship the union already narrows). But a field declared
269
+ * `string` in one variant and `number` in another is a genuine contract conflict the author must
270
+ * resolve (split the job type or unify the envelope) — this surfaces it instead of silently
271
+ * emitting `string | number`. Compatible variants (nwf's escalation subset) produce no diagnostic.
272
+ */
273
+ export function detectEnvelopeConflicts(
274
+ workers: WorkerBindingDecl[],
275
+ types: DomainTypeRegistry,
276
+ ): EnvelopeConflict[] {
277
+ const collect = (pick: (w: WorkerBindingDecl) => string | undefined, slot: "in" | "out") => {
278
+ const byType = new Map<string, string[]>();
279
+ for (const w of workers) {
280
+ const id = pick(w);
281
+ if (typeof w?.taskType !== "string" || !w.taskType || !id || !Object.hasOwn(types, id)) continue;
282
+ const list = byType.get(w.taskType) ?? [];
283
+ if (!list.includes(id)) list.push(id);
284
+ byType.set(w.taskType, list);
285
+ }
286
+ const conflicts: EnvelopeConflict[] = [];
287
+ for (const [taskType, ids] of byType.entries()) {
288
+ if (ids.length < 2) continue;
289
+ const fieldNames = new Set<string>();
290
+ for (const id of ids) for (const name of Object.keys(types[id].fields)) fieldNames.add(name);
291
+ for (const field of fieldNames) {
292
+ const seen = new Map<string, string[]>(); // signature -> envelope ids carrying it
293
+ for (const id of ids) {
294
+ if (!Object.hasOwn(types[id].fields, field)) continue; // absent in this variant — a presence difference, not a type conflict
295
+ const f = types[id].fields[field];
296
+ const sig = fieldSig(f);
297
+ const carriers = seen.get(sig) ?? [];
298
+ carriers.push(id);
299
+ seen.set(sig, carriers);
300
+ }
301
+ if (seen.size > 1) {
302
+ conflicts.push({
303
+ taskType,
304
+ slot,
305
+ field,
306
+ types: [...seen.keys()],
307
+ envelopes: ids.filter((id) => Object.hasOwn(types[id].fields, field)),
308
+ });
309
+ }
310
+ }
311
+ }
312
+ return conflicts;
313
+ };
314
+ return [...collect((w) => w.inputType, "in"), ...collect((w) => w.outputType, "out")];
315
+ }
316
+
317
+ /** Human-readable warning for one envelope conflict, for the gen warning stream. */
318
+ export function formatEnvelopeConflict(c: EnvelopeConflict): string {
319
+ return (
320
+ `data-envelope conflict on worker "${c.taskType}" (${c.slot}): field "${c.field}" is ` +
321
+ `${c.types.join(" vs ")} across envelopes ${c.envelopes.map((e) => `"${e}"`).join(", ")}. ` +
322
+ `The taskType's ${c.slot === "in" ? "input" : "output"} is the union of its envelopes; a ` +
323
+ `field must have one type across all of them. Unify the envelope or split the job type.`
324
+ );
325
+ }
326
+
220
327
  /**
221
328
  * Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
222
329
  * for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
@@ -289,3 +289,38 @@ test("runGen warns when a requestBody schema is shared across operations, but ne
289
289
  });
290
290
  assert.deepEqual((await runGen({ root: "/app", io: clean })).warnings, []);
291
291
  });
292
+
293
+ test("gen fuses a model-authored nano:shape envelope into the worker-io contract", async () => {
294
+ // No manifest `types` at all: the envelope's shape lives ENTIRELY in the model as a `nano:shape`,
295
+ // linked to the worker by `io.nanobpm.dataEnvelope.in`. Before the shape fuse was wired into gen
296
+ // this produced an empty `WorkerInputs {}`; now it resolves to `DomainTypes["ReviewIn"]`.
297
+ const manifest = JSON.stringify({
298
+ id: "demo",
299
+ data: { default: "app" },
300
+ models: { processes: ["processes/p.bpmn"] },
301
+ });
302
+ const bpmn = `<bpmn:process id="p"
303
+ xmlns:bpmn="x" xmlns:zeebe="y" xmlns:nano="z">
304
+ <bpmn:extensionElements><nano:shapes>
305
+ <nano:shape id="ReviewIn" name="Review — input">
306
+ <nano:extend name="prUrl" type="string" />
307
+ <nano:extend name="round" type="integer" />
308
+ </nano:shape>
309
+ </nano:shapes></bpmn:extensionElements>
310
+ <bpmn:serviceTask id="T"><bpmn:extensionElements>
311
+ <zeebe:taskDefinition type="demo.review" />
312
+ <zeebe:properties>
313
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReviewIn" />
314
+ </zeebe:properties>
315
+ </bpmn:extensionElements></bpmn:serviceTask>
316
+ </bpmn:process>`;
317
+ const io = memIO({ "/app/nano.app.json": manifest, "/app/processes/p.bpmn": bpmn });
318
+ const res = await runGen({ root: "/app", io });
319
+ const workerIo = res.artifacts.find((a) => a.path.endsWith("worker-io.d.ts"));
320
+ assert.ok(workerIo, "worker-io.d.ts is emitted");
321
+ assert.match(workerIo!.content, /"demo\.review": DomainTypes\["ReviewIn"\];/);
322
+ // The fused shape also lands in the domain registry the worker index imports from.
323
+ const domain = res.artifacts.find((a) => a.path.endsWith("domain-rows.d.ts"));
324
+ assert.ok(domain, "domain-rows.d.ts is emitted from the fused registry");
325
+ assert.match(domain!.content, /"ReviewIn":/);
326
+ });
@@ -6,10 +6,11 @@
6
6
  import type { DerivedArtifact } from "./artifact.ts";
7
7
  import { GENERATED_DIR, isAbsolutePath, RUNTIME_MATERIALIZED_ARTIFACTS, sortArtifacts } from "./artifact.ts";
8
8
  import { deriveMigrations, type ToolkitManifest } from "./derivers/migrations.ts";
9
- import { deriveDomain } from "./derivers/domain.ts";
10
- import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
9
+ import { emitDomainModel, registryFromManifest, sourcesFromManifest } from "./derivers/domain.ts";
10
+ import { byModelPath, deriveWorkerBindings, detectEnvelopeConflicts, DOMAIN_DTS, formatEnvelopeConflict, type ModelSource, scanModelWorkers } from "./derivers/worker-io.ts";
11
11
  import { deriveMeta } from "./derivers/meta.ts";
12
12
  import { deriveMessageBindings } from "./derivers/messages.ts";
13
+ import { resolveShapes, scanModelShapes } from "./derivers/shapes.ts";
13
14
  import { deriveApi } from "./derivers/api.ts";
14
15
  import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.ts";
15
16
  import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
@@ -133,29 +134,62 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
133
134
  const artifacts: DerivedArtifact[] = [];
134
135
  const warnings: string[] = [];
135
136
 
136
- // 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
137
+ // 1. Resolve the app's models first (code-first `workflows/*.ts` → BPMN, plus authored on-disk
138
+ // `.bpmn`): the fused domain model (below) lifts `nano:shape` envelopes *from* the models, so
139
+ // they must be in hand before the domain + worker/message type-contracts are derived.
140
+ const derived = await deriveModels(root, io, manifest);
141
+ // The derived `.bpmn` are emitted only when `emitModels` (default), but always fed to the
142
+ // type-contract derivers so worker I/O works for a code-first app even on a `--no-models` run.
143
+ if (emitModels) artifacts.push(...derived.artifacts);
144
+ const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
145
+ const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
146
+ const models = [...diskModels, ...derived.models];
147
+
148
+ // 2. Fused domain model (ADR 0040): the `DomainTypes` registry is the union of the manifest
149
+ // `types` registry and the model-authored `nano:shape` envelopes, resolved through the same
150
+ // fuse the console reifier (`envelope_scan.rs`) uses. Without this the standalone `urban gen`
151
+ // would honour only `manifest.types` and silently ignore every model-authored data envelope,
152
+ // so worker/message I/O against those envelopes would fall back to the untyped `WorkerVars`.
153
+ const manifestRegistry = registryFromManifest(manifest);
154
+ const sources = sourcesFromManifest(manifest);
155
+ const shapeDecls = models.flatMap((m) => scanModelShapes(m.xml));
156
+ const shapeResolution = resolveShapes(shapeDecls, manifestRegistry, sources);
157
+ const fusedTypes = { ...manifestRegistry, ...shapeResolution.types };
158
+ for (const d of shapeResolution.diagnostics) {
159
+ warnings.push(`data-envelope shape "${d.shape}" (${d.kind}): ${d.message}`);
160
+ }
161
+
162
+ // 3. types → migrations (persisted `manifest.types` only — shapes are transient wire envelopes,
163
+ // never tables) + the fused domain row types (`DomainTables` spine + `DomainTypes` registry).
137
164
  if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
138
165
  artifacts.push(...deriveMigrations(manifest));
139
- artifacts.push(...deriveDomain(manifest));
166
+ }
167
+ if (!opts.modelsOnly && Object.keys(fusedTypes).length > 0) {
168
+ artifacts.push({
169
+ path: `${GENERATED_DIR}/${DOMAIN_DTS}`,
170
+ content: emitDomainModel(sources, manifest.data?.default ?? "app", fusedTypes),
171
+ });
140
172
  }
141
173
 
142
- // 2. code-first models → derive BPMN from `workflows/*.ts` (executes the app's TS). The derived
143
- // `.bpmn` are emitted only when `emitModels` (default), but always fed to the type-contract
144
- // derivers below so worker I/O works for a code-first app even on a `--no-models` run.
145
- const derived = await deriveModels(root, io, manifest);
146
- if (emitModels) artifacts.push(...derived.artifacts);
147
-
148
- // 3. models (authored on-disk + derived) → worker I/O index + meta accessor + message map.
149
- // Drop any on-disk model whose path a derived model already covers, to avoid double-counting.
174
+ // 4. models (authored on-disk + derived) → worker I/O index + meta accessor + message map. The
175
+ // declared type ids include the fused shapes, so an envelope ref to a model-authored shape
176
+ // resolves to its `DomainTypes[...]` entry instead of degrading to the untyped fallback.
150
177
  if (!opts.modelsOnly) {
151
- const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
152
- const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
153
- const models = [...diskModels, ...derived.models];
154
178
  if (models.length > 0) {
155
- const declaredTypeIds = Object.keys(manifest.types ?? {});
179
+ const declaredTypeIds = Object.keys(fusedTypes);
156
180
  artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
157
181
  artifacts.push(...deriveMeta(models));
158
182
  artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
183
+ // Surface genuine data-envelope conflicts: a taskType whose variants disagree on a field's
184
+ // TYPE. The emitted contract is the union of the variants (sound for a shared handler), so a
185
+ // type clash would silently widen to `A | B` — warn instead so the author unifies the envelope.
186
+ const scannedWorkers = [...models]
187
+ .sort(byModelPath)
188
+ .flatMap((m) => scanModelWorkers(m.xml))
189
+ .map((w) => ({ taskType: w.taskType, inputType: w.in, outputType: w.out }));
190
+ for (const c of detectEnvelopeConflicts(scannedWorkers, fusedTypes)) {
191
+ warnings.push(formatEnvelopeConflict(c));
192
+ }
159
193
  }
160
194
  }
161
195