@nanobpm/urban 0.47.0 → 0.49.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/README.md +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +71 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +77 -5
- package/dist/runtime/core/modules/surfaces.js +192 -18
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- 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/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +103 -22
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +74 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +71 -3
- package/src/runtime/core/modules/pages.ts +77 -5
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- 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/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +81 -10
- package/src/toolkit/gen.ts +109 -22
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
|
@@ -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/fsio.ts
CHANGED
|
@@ -25,6 +25,14 @@ export function createNodeGenIO(): GenIO {
|
|
|
25
25
|
return [];
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
|
+
async listSubdirs(path: string): Promise<string[]> {
|
|
29
|
+
try {
|
|
30
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
31
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
},
|
|
28
36
|
async exists(path: string): Promise<boolean> {
|
|
29
37
|
try {
|
|
30
38
|
await stat(path);
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { runGen, collectArtifacts, type GenIO } from "./gen.ts";
|
|
3
|
+
import { runGen, collectArtifacts, readModels, type GenIO } from "./gen.ts";
|
|
4
4
|
import { defineFlow, envelope } from "@nanobpm/workflow";
|
|
5
5
|
import { MODEL_PROVENANCE } from "./models.ts";
|
|
6
6
|
|
|
@@ -29,6 +29,18 @@ function memIO(
|
|
|
29
29
|
}
|
|
30
30
|
return [...names];
|
|
31
31
|
},
|
|
32
|
+
async listSubdirs(p) {
|
|
33
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
34
|
+
const names = new Set<string>();
|
|
35
|
+
for (const f of Object.keys(files)) {
|
|
36
|
+
if (f.startsWith(prefix)) {
|
|
37
|
+
const rest = f.slice(prefix.length);
|
|
38
|
+
const slash = rest.indexOf("/");
|
|
39
|
+
if (slash > 0) names.add(rest.slice(0, slash));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [...names];
|
|
43
|
+
},
|
|
32
44
|
async exists(p) {
|
|
33
45
|
return p in files;
|
|
34
46
|
},
|
|
@@ -181,11 +193,11 @@ function codeFirstIO() {
|
|
|
181
193
|
);
|
|
182
194
|
}
|
|
183
195
|
|
|
184
|
-
test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
|
|
196
|
+
test("runGen derives resources/processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
|
|
185
197
|
const io = codeFirstIO();
|
|
186
198
|
const res = await runGen({ root: "/cf", io });
|
|
187
|
-
const bpmn = io.files["/cf/processes/greet.bpmn"];
|
|
188
|
-
assert.ok(bpmn, "expected a derived processes/greet.bpmn");
|
|
199
|
+
const bpmn = io.files["/cf/resources/processes/greet.bpmn"];
|
|
200
|
+
assert.ok(bpmn, "expected a derived resources/processes/greet.bpmn");
|
|
189
201
|
assert.ok(bpmn.includes(MODEL_PROVENANCE), "derived .bpmn must be provenance-stamped");
|
|
190
202
|
assert.ok(bpmn.includes('type="greet:hello"'), "derived .bpmn carries the task type");
|
|
191
203
|
// worker-io is derived FROM the in-memory model, not from any on-disk file.
|
|
@@ -197,7 +209,7 @@ test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io
|
|
|
197
209
|
test("gen --no-models derives in-memory for worker-io but writes no .bpmn", async () => {
|
|
198
210
|
const io = codeFirstIO();
|
|
199
211
|
const res = await runGen({ root: "/cf", io, emitModels: false });
|
|
200
|
-
assert.equal(io.files["/cf/processes/greet.bpmn"], undefined, "must not write .bpmn");
|
|
212
|
+
assert.equal(io.files["/cf/resources/processes/greet.bpmn"], undefined, "must not write .bpmn");
|
|
201
213
|
// still derives the model in-memory so worker-io is populated.
|
|
202
214
|
assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
|
|
203
215
|
assert.equal(res.incomplete, false);
|
|
@@ -206,17 +218,17 @@ test("gen --no-models derives in-memory for worker-io but writes no .bpmn", asyn
|
|
|
206
218
|
test("the provenance sweep removes a stale derived .bpmn no longer backed by a workflow", async () => {
|
|
207
219
|
const io = codeFirstIO();
|
|
208
220
|
// a leftover derived model from a since-deleted flow, provenance-stamped.
|
|
209
|
-
io.files["/cf/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
|
|
221
|
+
io.files["/cf/resources/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
|
|
210
222
|
await runGen({ root: "/cf", io });
|
|
211
|
-
assert.ok(io.files["/cf/processes/greet.bpmn"], "keeps the live derived model");
|
|
212
|
-
assert.equal(io.files["/cf/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
|
|
223
|
+
assert.ok(io.files["/cf/resources/processes/greet.bpmn"], "keeps the live derived model");
|
|
224
|
+
assert.equal(io.files["/cf/resources/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
|
|
213
225
|
});
|
|
214
226
|
|
|
215
227
|
test("the sweep never touches an authored (un-stamped) .bpmn", async () => {
|
|
216
228
|
const io = codeFirstIO();
|
|
217
|
-
io.files["/cf/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
|
|
229
|
+
io.files["/cf/resources/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
|
|
218
230
|
await runGen({ root: "/cf", io });
|
|
219
|
-
assert.ok(io.files["/cf/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
|
|
231
|
+
assert.ok(io.files["/cf/resources/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
|
|
220
232
|
});
|
|
221
233
|
|
|
222
234
|
test("collectArtifacts trims api.spec whitespace so gen matches the runtime (no gen/runtime drift)", async () => {
|
|
@@ -289,3 +301,62 @@ test("runGen warns when a requestBody schema is shared across operations, but ne
|
|
|
289
301
|
});
|
|
290
302
|
assert.deepEqual((await runGen({ root: "/app", io: clean })).warnings, []);
|
|
291
303
|
});
|
|
304
|
+
|
|
305
|
+
test("gen fuses a model-authored nano:shape envelope into the worker-io contract", async () => {
|
|
306
|
+
// No manifest `types` at all: the envelope's shape lives ENTIRELY in the model as a `nano:shape`,
|
|
307
|
+
// linked to the worker by `io.nanobpm.dataEnvelope.in`. Before the shape fuse was wired into gen
|
|
308
|
+
// this produced an empty `WorkerInputs {}`; now it resolves to `DomainTypes["ReviewIn"]`.
|
|
309
|
+
const manifest = JSON.stringify({
|
|
310
|
+
id: "demo",
|
|
311
|
+
data: { default: "app" },
|
|
312
|
+
models: { processes: ["processes/p.bpmn"] },
|
|
313
|
+
});
|
|
314
|
+
const bpmn = `<bpmn:process id="p"
|
|
315
|
+
xmlns:bpmn="x" xmlns:zeebe="y" xmlns:nano="z">
|
|
316
|
+
<bpmn:extensionElements><nano:shapes>
|
|
317
|
+
<nano:shape id="ReviewIn" name="Review — input">
|
|
318
|
+
<nano:extend name="prUrl" type="string" />
|
|
319
|
+
<nano:extend name="round" type="integer" />
|
|
320
|
+
</nano:shape>
|
|
321
|
+
</nano:shapes></bpmn:extensionElements>
|
|
322
|
+
<bpmn:serviceTask id="T"><bpmn:extensionElements>
|
|
323
|
+
<zeebe:taskDefinition type="demo.review" />
|
|
324
|
+
<zeebe:properties>
|
|
325
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReviewIn" />
|
|
326
|
+
</zeebe:properties>
|
|
327
|
+
</bpmn:extensionElements></bpmn:serviceTask>
|
|
328
|
+
</bpmn:process>`;
|
|
329
|
+
const io = memIO({ "/app/nano.app.json": manifest, "/app/processes/p.bpmn": bpmn });
|
|
330
|
+
const res = await runGen({ root: "/app", io });
|
|
331
|
+
const workerIo = res.artifacts.find((a) => a.path.endsWith("worker-io.d.ts"));
|
|
332
|
+
assert.ok(workerIo, "worker-io.d.ts is emitted");
|
|
333
|
+
assert.match(workerIo!.content, /"demo\.review": DomainTypes\["ReviewIn"\];/);
|
|
334
|
+
// The fused shape also lands in the domain registry the worker index imports from.
|
|
335
|
+
const domain = res.artifacts.find((a) => a.path.endsWith("domain-rows.d.ts"));
|
|
336
|
+
assert.ok(domain, "domain-rows.d.ts is emitted from the fused registry");
|
|
337
|
+
assert.match(domain!.content, /"ReviewIn":/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Guards the gen/runtime drift class: gen's readModels must key the `resources/` convention off the
|
|
341
|
+
// *absence* of the whole `models` block (mirroring the runtime deploy, ADR 0062), NOT off a missing
|
|
342
|
+
// `models.processes`. A `models` block present for other overrides (e.g. forms) must suppress the
|
|
343
|
+
// convention scan, so gen never derives process models the runtime deploy would never deploy.
|
|
344
|
+
test("readModels: a models block without processes suppresses the resources/ convention (no gen/runtime drift)", async () => {
|
|
345
|
+
const io = memIO({
|
|
346
|
+
"/app/resources/a.bpmn": "<bpmn:definitions/>",
|
|
347
|
+
"/app/resources/sub/b.bpmn": "<bpmn:definitions/>",
|
|
348
|
+
});
|
|
349
|
+
// No models block at all → convention scans resources/ (shallow, one level deep).
|
|
350
|
+
const byConvention = await readModels("/app", io, {});
|
|
351
|
+
assert.deepEqual(
|
|
352
|
+
byConvention.map((m) => m.path).sort(),
|
|
353
|
+
["resources/a.bpmn", "resources/sub/b.bpmn"],
|
|
354
|
+
"with no models block, gen discovers under resources/ by convention",
|
|
355
|
+
);
|
|
356
|
+
// A declared models block (even one that resolves to zero process files) is an explicit override:
|
|
357
|
+
// the convention scan must be skipped entirely, matching runtime deployModels.
|
|
358
|
+
const withEmptyModels = await readModels("/app", io, { models: {} });
|
|
359
|
+
assert.deepEqual(withEmptyModels, [], "a declared models block suppresses the resources/ convention");
|
|
360
|
+
const withOnlyProcesses = await readModels("/app", io, { models: { processes: [] } });
|
|
361
|
+
assert.deepEqual(withOnlyProcesses, [], "an explicitly empty models.processes yields no models, not a convention fallback");
|
|
362
|
+
});
|