@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.
- package/dist/runtime/core/modules/pages.js +43 -3
- package/dist/toolkit/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/gen.js +49 -16
- package/package.json +1 -1
- package/src/runtime/core/modules/pages.test.ts +28 -3
- package/src/runtime/core/modules/pages.ts +43 -3
- package/src/toolkit/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/gen.test.ts +35 -0
- package/src/toolkit/gen.ts +50 -16
|
@@ -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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
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
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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(
|
|
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.
|
|
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, /
|
|
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*
|
|
345
|
-
assert.match(js, /"aria-label":\s*
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
if (
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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(
|
|
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
|
|