@nanobpm/urban 0.46.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.
@@ -482,6 +482,10 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
482
482
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
483
483
  .pc-msg.err { color:var(--nano-danger); }
484
484
  .pc-msg.ok { color:var(--nano-ok); }
485
+ .pc-req { color:var(--nano-danger); font-weight:600; }
486
+ .pc-field-err { font-size:.75rem; color:var(--nano-danger); margin:.1rem 0 0; }
487
+ .pc-field-err:empty { display:none; }
488
+ .pc-field input.pc-invalid, .pc-field-check input.pc-invalid { border-color:var(--nano-danger); outline-color:var(--nano-danger); }
485
489
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
486
490
  /* table-layout:fixed divides the available width across columns and wraps text
487
491
  within each, so a grid with several long free-text columns (e.g. the epic
@@ -782,6 +786,27 @@ function el(tag, attrs = {}, ...kids) {
782
786
  function interpParam(s) {
783
787
  return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
784
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
+ }
785
810
  function renderText(node) {
786
811
  const v = node.props.variant;
787
812
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
@@ -946,8 +971,27 @@ function renderButton(node) {
946
971
  // host's own explorer view in place via the nano-navigate postMessage bridge
947
972
  // (a modified/middle click keeps the new-tab behavior). Shared by the top-level
948
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.
949
987
  function gridCell(col, row) {
950
- 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;
951
995
  // 1. badge — a compact status indicator. When the row's field value is
952
996
  // non-empty (truthy after trimming) render a small circular badge (e.g. a
953
997
  // red "1" dot flagging an incident) whose tooltip is the full field text;
@@ -959,14 +1003,14 @@ function gridCell(col, row) {
959
1003
  // field text is also mirrored into aria-label so assistive tech announces
960
1004
  // the incident/status text (the visible "1" glyph alone is not meaningful).
961
1005
  if (col.badge) {
962
- if (text.trim() === "") return el("td", {});
1006
+ if (rawText.trim() === "") return el("td", {});
963
1007
  const t = col.badge.tone;
964
1008
  const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
965
1009
  const label = col.badge.label == null ? "1" : String(col.badge.label);
966
1010
  return el(
967
1011
  "td",
968
1012
  {},
969
- 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),
970
1014
  );
971
1015
  }
972
1016
  if (col.linkField) {
@@ -1031,9 +1075,38 @@ function renderActionForm(node) {
1031
1075
  // pollute Object.prototype or shadow inherited props (prototype-pollution class).
1032
1076
  const inputs = new Map();
1033
1077
  const fieldTypes = new Map();
1078
+ // Client-side required-field validation state: the set of required keys, each
1079
+ // field's custom message, and its inline error node (populated on a blocked
1080
+ // submit, cleared as soon as the user edits the field).
1081
+ const required = new Set();
1082
+ const reqMsg = new Map();
1083
+ const errs = new Map();
1084
+ // A required field's label carries a danger-toned "*" so the requirement is
1085
+ // visible BEFORE submit; spread into el(...) — an empty array adds no child.
1086
+ const reqMark = (f) => (f.required === true ? [el("span", { class: "pc-req", title: "Required" }, " *")] : []);
1087
+ // Register a field's inline error node and, when required, mark the input
1088
+ // (aria-required) and wire a clear-on-edit handler so a hint disappears the
1089
+ // moment the user starts fixing it. Editing a required field also clears the
1090
+ // blocked-submit summary ("Please fill in the required fields.") so it never
1091
+ // lingers as a stale/misleading banner while the user is correcting input.
1092
+ const wireField = (f, input, ferr, evt) => {
1093
+ errs.set(f.key, ferr);
1094
+ if (f.required !== true) return;
1095
+ required.add(f.key);
1096
+ if (typeof f.requiredMessage === "string" && f.requiredMessage !== "") reqMsg.set(f.key, f.requiredMessage);
1097
+ input.setAttribute("aria-required", "true");
1098
+ input.addEventListener(evt, () => {
1099
+ input.classList.remove("pc-invalid");
1100
+ input.removeAttribute("aria-invalid");
1101
+ ferr.textContent = "";
1102
+ msg.textContent = "";
1103
+ msg.className = "pc-msg";
1104
+ });
1105
+ };
1034
1106
  for (const f of p.fields || []) {
1035
1107
  const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
1036
1108
  fieldTypes.set(f.key, kind);
1109
+ const ferr = el("p", { class: "pc-field-err" });
1037
1110
  if (kind === "checkbox") {
1038
1111
  // A boolean field renders a real checkbox; its default checked state comes
1039
1112
  // from default/checked on the field (unset -> unchecked). We record that on
@@ -1044,9 +1117,10 @@ function renderActionForm(node) {
1044
1117
  input.defaultChecked = checked;
1045
1118
  input.checked = checked;
1046
1119
  inputs.set(f.key, input);
1120
+ wireField(f, input, ferr, "change");
1047
1121
  // Input-first, wrapped in the label so the whole row is a click target.
1048
1122
  card.append(
1049
- el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
1123
+ el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key), ...reqMark(f)), ferr),
1050
1124
  );
1051
1125
  continue;
1052
1126
  }
@@ -1059,11 +1133,43 @@ function renderActionForm(node) {
1059
1133
  }
1060
1134
  const input = el("input", attrs);
1061
1135
  inputs.set(f.key, input);
1062
- card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
1136
+ wireField(f, input, ferr, "input");
1137
+ card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key, ...reqMark(f)), input, ferr));
1063
1138
  }
1064
1139
  const msg = el("p", { class: "pc-msg" });
1065
1140
  const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
1066
1141
  btn.addEventListener("click", async () => {
1142
+ // Required-field gate: block the submit and surface an inline hint on each
1143
+ // empty required field (a text/number left blank, a required checkbox left
1144
+ // unchecked), focusing the first offender — so a missing value never
1145
+ // round-trips to the server only to bounce back as a generic error.
1146
+ let firstInvalid = null;
1147
+ for (const [k, input] of inputs) {
1148
+ const ferr = errs.get(k);
1149
+ if (ferr) ferr.textContent = "";
1150
+ input.classList.remove("pc-invalid");
1151
+ input.removeAttribute("aria-invalid");
1152
+ if (!required.has(k)) continue;
1153
+ const type = fieldTypes.get(k);
1154
+ const val = String(input.value).trim();
1155
+ const missing = type === "checkbox"
1156
+ ? input.checked !== true
1157
+ : type === "number"
1158
+ ? (val === "" || !Number.isFinite(Number(val)))
1159
+ : val === "";
1160
+ if (missing) {
1161
+ if (ferr) ferr.textContent = reqMsg.get(k) || "Required";
1162
+ input.classList.add("pc-invalid");
1163
+ input.setAttribute("aria-invalid", "true");
1164
+ if (!firstInvalid) firstInvalid = input;
1165
+ }
1166
+ }
1167
+ if (firstInvalid) {
1168
+ msg.className = "pc-msg err";
1169
+ msg.textContent = "Please fill in the required fields.";
1170
+ firstInvalid.focus();
1171
+ return;
1172
+ }
1067
1173
  const variables = Object.create(null);
1068
1174
  for (const [k, input] of inputs) {
1069
1175
  if (fieldTypes.get(k) === "checkbox") {
@@ -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.46.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",