@nanobpm/urban 0.36.0 → 0.38.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.
@@ -1,7 +1,8 @@
1
1
  // Deriver: an OpenAPI document → `api-io.d.ts` (the endpoint type map) + `operations.ts` (the typed
2
2
  // `defineOperation` wrapper). ADR 0058: the contract-first endpoint surface. This is the authoring
3
3
  // half — per `operationId` it emits a `Request`/`Response` type from the operation's parameters,
4
- // requestBody, and first success response, plus an `ApiOperationId` union — mirroring how
4
+ // requestBody, and response (a union of every documented JSON response body), plus an
5
+ // `ApiOperationId` union — mirroring how
5
6
  // `worker-io.d.ts` types `defineWorker` from the model (ADR 0033 §3). The runtime
6
7
  // (`runtime/core/modules/api.ts`) is the matching half: it validates + routes from the SAME spec via
7
8
  // the shared `openapi/spec.ts`, so types and runtime never drift.
@@ -117,23 +118,34 @@ function objectToTs(schema: OpenApiSchema, depth: number): string {
117
118
  return "Record<string, never>";
118
119
  }
119
120
 
120
- /** The `params`/`query` object type for an operation, from its parameters (strings on the wire). */
121
- function paramsTs(op: OperationInfo, where: "path" | "query"): string {
121
+ /** The `params`/`query` object type for an operation, from its parameters. Values are coerced to
122
+ * their declared schema type before the delegate receives them (the runtime coerces + validates,
123
+ * then forwards the coerced value), so a param's type is its schema type — not the raw wire string.
124
+ * A parameter with no schema, or ANY parameter of an ejected operation (whose runtime skips
125
+ * coercion/validation and hands the delegate the raw request), keeps the raw wire type (`string`,
126
+ * or `string | string[]` for query). Ejected query params are also made optional: the runtime does
127
+ * not enforce presence for ejected ops, so a "required" query param may in fact be absent. */
128
+ function paramsTs(op: OperationInfo, where: "path" | "query", ejected: boolean): string {
122
129
  const ps = op.parameters.filter((p) => p.in === where);
123
130
  if (ps.length === 0) return "{}";
131
+ const rawWire = where === "query" ? "string | string[]" : "string";
124
132
  const fields = ps.map((p) => {
125
- const opt = p.required ? "" : "?";
126
- // Query values arrive as strings (or string[] for repeated keys); path params are single
127
- // strings. Optionality is carried by the `?` modifier — don't fold `undefined` into the
128
- // value type, or required query params would still accept undefined.
129
- const t = where === "query" ? "string | string[]" : "string";
133
+ // Coerced to the schema type (number/boolean/array/enum/…); optionality is carried by the `?`
134
+ // modifier — don't fold `undefined` into the value type, or required params would still accept
135
+ // undefined. Schemaless params, and every param of an ejected op, fall back to the raw wire type.
136
+ // A required query param of an ejected op is nonetheless optional here (runtime skips presence);
137
+ // path params stay as declared (the route only matches when every path segment is present).
138
+ const opt = ejected && where === "query" ? "?" : p.required ? "" : "?";
139
+ const t = ejected || !p.schema ? rawWire : schemaToTs(p.schema);
130
140
  return `${JSON.stringify(p.name)}${opt}: ${t}`;
131
141
  });
132
142
  return `{ ${fields.join("; ")} }`;
133
143
  }
134
144
 
135
- /** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union. */
136
- export function emitApiBindings(doc: OpenApiDoc): string {
145
+ /** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
146
+ * `surfaceEject` reflects the manifest's whole-surface `api.eject` (which the deriver otherwise
147
+ * can't see): when set, EVERY operation is treated as ejected (raw params/query, `unknown` body). */
148
+ export function emitApiBindings(doc: OpenApiDoc, surfaceEject = false): string {
137
149
  const ops = collectOperations(doc);
138
150
  const components = doc.components?.schemas ?? {};
139
151
 
@@ -152,6 +164,11 @@ export function emitApiBindings(doc: OpenApiDoc): string {
152
164
  }
153
165
  emittedTypeNames.set(typeName, source);
154
166
  };
167
+ // Reserve the fixed generated declarations (the operation-id union + operations map) up front, so
168
+ // a component or operationId whose stem collapses onto one of them fails the drift gate with a
169
+ // clear rename message instead of silently emitting a duplicate, uncompilable declaration.
170
+ claimTypeName("ApiOperationId", "the generated operation-id union");
171
+ claimTypeName("ApiOperations", "the generated operations map");
155
172
  for (const name of Object.keys(components)) {
156
173
  claimTypeName(typeStem(name), `component schema "${name}"`);
157
174
  }
@@ -162,8 +179,9 @@ export function emitApiBindings(doc: OpenApiDoc): string {
162
179
 
163
180
  const header =
164
181
  "// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
165
- "// The endpoint type map: each operationId maps to its typed request (path/query params +\n" +
166
- "// JSON body) and success response, derived from the spec. Do not edit — regenerated from\n" +
182
+ "// The endpoint type map: each operationId maps to its typed request (coerced path/query params\n" +
183
+ "// + JSON body) and response (a union of every documented JSON response body), derived from the\n" +
184
+ "// spec. Do not edit — regenerated from\n" +
167
185
  "// the spec; erased to plain JS at compile.\n" +
168
186
  "// eslint-disable\n\n";
169
187
 
@@ -175,22 +193,38 @@ export function emitApiBindings(doc: OpenApiDoc): string {
175
193
  const opDecls = ops
176
194
  .map((op) => {
177
195
  const stem = typeStem(op.operationId);
196
+ const ejected = surfaceEject || op.eject;
197
+ // An ejected operation (`x-urban-eject`, or the whole surface via manifest `api.eject`)
198
+ // bypasses the generated coercion/validation — its delegate reads the raw request — so its
199
+ // input is NOT schema-shaped at runtime: params/query stay raw wire types and the body is
200
+ // unvalidated (`unknown`). Typing it by the schema would claim guarantees the runtime doesn't
201
+ // make. (ADR 0059 documents the ejection exception.)
178
202
  // With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
179
203
  // required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
180
- const bodyType = op.requestBodySchema
181
- ? schemaToTs(op.requestBodySchema)
182
- : op.requestBodyRequired
183
- ? "unknown"
184
- : "undefined";
204
+ const bodyType = ejected
205
+ ? "unknown"
206
+ : op.requestBodySchema
207
+ ? schemaToTs(op.requestBodySchema)
208
+ : op.requestBodyRequired
209
+ ? "unknown"
210
+ : "undefined";
185
211
  // An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
186
212
  // so mark it optional in the type rather than forcing handlers to treat it as always-present.
187
213
  const bodyOpt = op.requestBodyRequired ? "" : "?";
188
- const resp = op.responseSchema ? schemaToTs(op.responseSchema) : "unknown";
214
+ // Response type: a union of every documented JSON response body (success + errors), so a
215
+ // handler that returns a documented error body typechecks against its operation's response
216
+ // type. Bodyless documented statuses carry no schema and are skipped here. Deduped (identical
217
+ // schemas collapse), stable order. No documented JSON body → unknown.
218
+ const respSeen = new Set<string>();
219
+ const respParts = op.responseSchemas
220
+ .flatMap((e) => (e.schema ? [schemaToTs(e.schema)] : []))
221
+ .filter((t) => (respSeen.has(t) ? false : (respSeen.add(t), true)));
222
+ const resp = respParts.length > 0 ? respParts.join(" | ") : "unknown";
189
223
  const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
190
224
  return (
191
225
  `${summary}export interface ${stem}Request {\n` +
192
- ` params: ${paramsTs(op, "path")};\n` +
193
- ` query: ${paramsTs(op, "query")};\n` +
226
+ ` params: ${paramsTs(op, "path", ejected)};\n` +
227
+ ` query: ${paramsTs(op, "query", ejected)};\n` +
194
228
  ` body${bodyOpt}: ${bodyType};\n` +
195
229
  `}\n` +
196
230
  `export type ${stem}Response = ${resp};`
@@ -308,18 +342,24 @@ export function emitApiController(doc: OpenApiDoc, operationsDir: string = DEFAU
308
342
  }
309
343
 
310
344
  /** Derive the api artifacts from a parsed OpenAPI document. `operationsDir` is the manifest's
311
- * `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it. */
312
- export function deriveApi(doc: OpenApiDoc, operationsDir: string = DEFAULT_OPERATIONS_DIR): DerivedArtifact[] {
345
+ * `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
346
+ * `surfaceEject` is the manifest's whole-surface `api.eject`: when set, every operation is typed as
347
+ * ejected (raw params/query, `unknown` body) so the generated types match the runtime. */
348
+ export function deriveApi(
349
+ doc: OpenApiDoc,
350
+ operationsDir: string = DEFAULT_OPERATIONS_DIR,
351
+ surfaceEject = false,
352
+ ): DerivedArtifact[] {
313
353
  return [
314
- { path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
354
+ { path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc, surfaceEject) },
315
355
  { path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
316
356
  { path: `${GENERATED_DIR}/${API_CONTROLLER_TS}`, content: emitApiController(doc, operationsDir) },
317
357
  ];
318
358
  }
319
359
 
320
- export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string }> = {
360
+ export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string; surfaceEject?: boolean }> = {
321
361
  id: "openapi->api-io",
322
362
  describe:
323
363
  "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation + the delegate registry.",
324
- derive: ({ doc, operationsDir }) => deriveApi(doc, operationsDir),
364
+ derive: ({ doc, operationsDir, surfaceEject }) => deriveApi(doc, operationsDir, surfaceEject),
325
365
  };
@@ -122,7 +122,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
122
122
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
123
123
  const manifest: ToolkitManifest & {
124
124
  models?: { processes?: string[]; workflows?: string[] };
125
- api?: { spec?: string; dir?: string };
125
+ api?: { spec?: string; dir?: string; eject?: boolean };
126
126
  } = JSON.parse(await io.readText(manifestPath));
127
127
 
128
128
  const artifacts: DerivedArtifact[] = [];
@@ -162,7 +162,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
162
162
  const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
163
163
  ? manifest.api.dir.trim()
164
164
  : undefined;
165
- artifacts.push(...deriveApi(parseSpec(specText), opsDir));
165
+ artifacts.push(...deriveApi(parseSpec(specText), opsDir, manifest.api?.eject === true));
166
166
  }
167
167
 
168
168
  return { artifacts: sortArtifacts(artifacts), derived };
@@ -99,6 +99,16 @@ test("planWorkerScaffold: skips already-wired, orchestrator, and duplicates", ()
99
99
  assert.equal(reasons["t.new"], "duplicate");
100
100
  });
101
101
 
102
+ test("planWorkerScaffold: skips a task type declared external (out-of-process worker)", () => {
103
+ const models = [
104
+ model("a.bpmn", "p1", serviceTask("A", "senior:review"), serviceTask("B", "t.new")),
105
+ ];
106
+ const { plans, skipped } = planWorkerScaffold(models, [], [], ["senior:review"]);
107
+ assert.deepEqual(plans.map((p) => p.taskType), ["t.new"]);
108
+ const reasons = Object.fromEntries(skipped.map((s) => [s.taskType, s.reason]));
109
+ assert.equal(reasons["senior:review"], "external");
110
+ });
111
+
102
112
  test("planWorkerScaffold: colliding slugs get a numeric suffix", () => {
103
113
  const m = model("a.bpmn", "p", serviceTask("A", "orders.charge"), serviceTask("B", "orders-charge"));
104
114
  const { plans } = planWorkerScaffold([m], [], []);
@@ -195,3 +205,28 @@ test("scaffoldWorkers is idempotent: a second --write run is a no-op", async ()
195
205
  assert.equal(run2.manifestPatched, false, "no new wiring on the second run");
196
206
  assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
197
207
  });
208
+
209
+ test("scaffoldWorkers reads manifest.externalTaskTypes and skips those (never scaffolds/wires)", async () => {
210
+ const files = {
211
+ "/app/nano.app.json": JSON.stringify({
212
+ id: "demo",
213
+ models: { processes: ["processes/*.bpmn"] },
214
+ externalTaskTypes: ["senior:review"],
215
+ }),
216
+ "/app/processes/p.bpmn": model("p.bpmn", "p",
217
+ serviceTask("A", "senior:review"),
218
+ serviceTask("B", "t.new"),
219
+ ).xml,
220
+ };
221
+ const io = memIO(files);
222
+ const run = await scaffoldWorkers({ root: "/app", io, write: true });
223
+ // Only the app-hosted task is scaffolded; the external one is skipped.
224
+ assert.deepEqual(run.outcomes.map((o) => o.taskType), ["t.new"]);
225
+ assert.ok(run.skipped.some((s) => s.taskType === "senior:review" && s.reason === "external"));
226
+ assert.equal(io.files["/app/workers/senior-review/worker.ts"], undefined, "external task gets no stub");
227
+ const manifest = JSON.parse(io.files["/app/nano.app.json"]);
228
+ assert.ok(
229
+ !manifest.workers?.some((w: { taskType: string }) => w.taskType === "senior:review"),
230
+ "external task is never wired into workers[]",
231
+ );
232
+ });
@@ -39,7 +39,7 @@ export interface WorkerStubPlan {
39
39
  manifestEntry: StubManifestEntry;
40
40
  }
41
41
 
42
- export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
42
+ export type SkipReason = "already-wired" | "orchestrator" | "duplicate" | "external";
43
43
 
44
44
  export interface SkippedWorker {
45
45
  taskType: string;
@@ -110,14 +110,17 @@ export function renderWorkerStub(
110
110
 
111
111
  /**
112
112
  * Plan write-once handler stubs from the models. A stub is planned for each service-task
113
- * `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
114
- * not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
115
- * envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
113
+ * `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, NOT
114
+ * declared external (serviced by an out-of-process worker), and not a duplicate of one already
115
+ * planned. `declaredTypeIds` are the manifest `types` ids — an envelope `in`/`out` is typed only
116
+ * when it names one (matching the worker-io deriver). `externalTaskTypes` are the manifest's
117
+ * declared external task types, which the app owns the model for but deliberately does not host.
116
118
  */
117
119
  export function planWorkerScaffold(
118
120
  models: ModelSource[],
119
121
  workers: ScaffoldWorker[] = [],
120
122
  declaredTypeIds: Iterable<string> = [],
123
+ externalTaskTypes: Iterable<string> = [],
121
124
  ): WorkerScaffoldPlan {
122
125
  const wired = new Set(
123
126
  workers
@@ -125,6 +128,7 @@ export function planWorkerScaffold(
125
128
  .filter((t): t is string => typeof t === "string" && t.length > 0),
126
129
  );
127
130
  const declared = new Set(declaredTypeIds);
131
+ const external = new Set(externalTaskTypes);
128
132
 
129
133
  const plans: WorkerStubPlan[] = [];
130
134
  const skipped: SkippedWorker[] = [];
@@ -138,6 +142,10 @@ export function planWorkerScaffold(
138
142
  skipped.push({ taskType, reason: "orchestrator" });
139
143
  continue;
140
144
  }
145
+ if (external.has(taskType)) {
146
+ skipped.push({ taskType, reason: "external" });
147
+ continue;
148
+ }
141
149
  if (wired.has(taskType)) {
142
150
  skipped.push({ taskType, reason: "already-wired" });
143
151
  continue;
@@ -19,6 +19,7 @@ interface ScaffoldManifest {
19
19
  types?: Record<string, unknown>;
20
20
  models?: { processes?: string[] };
21
21
  workers?: ScaffoldWorker[];
22
+ externalTaskTypes?: string[];
22
23
  api?: { spec?: string; dir?: string };
23
24
  }
24
25
 
@@ -64,7 +65,12 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
64
65
  const models = await readModels(root, io, manifest);
65
66
  const declaredTypeIds = Object.keys(manifest.types ?? {});
66
67
 
67
- const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
68
+ const { plans, skipped } = planWorkerScaffold(
69
+ models,
70
+ manifest.workers ?? [],
71
+ declaredTypeIds,
72
+ manifest.externalTaskTypes ?? [],
73
+ );
68
74
 
69
75
  const outcomes: StubOutcome[] = [];
70
76
  const wired: StubManifestEntry[] = [];