@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.
- package/dist/runtime/core/modules/pages.js +111 -5
- 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 +70 -3
- package/src/runtime/core/modules/pages.ts +111 -5
- 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
|
@@ -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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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",
|