@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.
@@ -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
- const inputs: string[] = [];
141
- const outputs: string[] = [];
142
- const headers: string[] = [];
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
- const inRef = typeRefFor(w.inputType, declared);
146
- if (inRef) inputs.push(` ${propKey(w.taskType)}: ${inRef};`);
147
- const outRef = typeRefFor(w.outputType, declared);
148
- if (outRef) outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
149
- const hdrRef = headerRefFor(w.headerKeys);
150
- if (hdrRef) headers.push(` ${propKey(w.taskType)}: ${hdrRef};`);
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
@@ -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
+ });
@@ -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 { deriveDomain } from "./derivers/domain.ts";
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. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
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
- artifacts.push(...deriveDomain(manifest));
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
- // 2. code-first models → derive BPMN from `workflows/*.ts` (executes the app's TS). The derived
143
- // `.bpmn` are emitted only when `emitModels` (default), but always fed to the type-contract
144
- // derivers below so worker I/O works for a code-first app even on a `--no-models` run.
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(manifest.types ?? {});
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