@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
|
@@ -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
|
|