@nanobpm/urban 0.29.0 → 0.31.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.
Files changed (44) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/core/modules/api.d.ts +61 -0
  4. package/dist/runtime/core/modules/api.js +359 -0
  5. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  6. package/dist/runtime/core/modules/cancel.js +90 -0
  7. package/dist/runtime/core/modules/datasource.d.ts +5 -2
  8. package/dist/runtime/core/modules/datasource.js +5 -4
  9. package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
  10. package/dist/runtime/core/modules/instance-tracking.js +28 -11
  11. package/dist/runtime/core/modules/pages.d.ts +8 -0
  12. package/dist/runtime/core/modules/pages.js +43 -7
  13. package/dist/runtime/core/modules/surfaces.js +9 -0
  14. package/dist/runtime/engine/nanosdk.js +41 -7
  15. package/dist/runtime/index.d.ts +4 -0
  16. package/dist/runtime/index.js +2 -0
  17. package/dist/toolkit/artifact.d.ts +5 -0
  18. package/dist/toolkit/artifact.js +7 -0
  19. package/dist/toolkit/derivers/api.d.ts +19 -0
  20. package/dist/toolkit/derivers/api.js +224 -0
  21. package/dist/toolkit/gen.js +23 -4
  22. package/dist/toolkit/index.d.ts +5 -0
  23. package/dist/toolkit/index.js +12 -1
  24. package/package.json +1 -1
  25. package/src/openapi/spec.test.ts +292 -0
  26. package/src/openapi/spec.ts +540 -0
  27. package/src/runtime/core/modules/api.test.ts +445 -0
  28. package/src/runtime/core/modules/api.ts +453 -0
  29. package/src/runtime/core/modules/cancel.test.ts +310 -0
  30. package/src/runtime/core/modules/cancel.ts +119 -0
  31. package/src/runtime/core/modules/datasource.ts +5 -4
  32. package/src/runtime/core/modules/instance-tracking.ts +33 -11
  33. package/src/runtime/core/modules/pages.test.ts +63 -2
  34. package/src/runtime/core/modules/pages.ts +49 -7
  35. package/src/runtime/core/modules/surfaces.ts +10 -0
  36. package/src/runtime/engine/nanosdk.test.ts +45 -5
  37. package/src/runtime/engine/nanosdk.ts +39 -7
  38. package/src/runtime/index.ts +12 -0
  39. package/src/toolkit/artifact.ts +8 -0
  40. package/src/toolkit/derivers/api.test.ts +219 -0
  41. package/src/toolkit/derivers/api.ts +255 -0
  42. package/src/toolkit/gen.test.ts +39 -0
  43. package/src/toolkit/gen.ts +24 -4
  44. package/src/toolkit/index.ts +41 -1
@@ -16,6 +16,7 @@
16
16
  // POST /app/actions/message → engine.publishMessage
17
17
  import { errorMessage, isRecord } from "../guards.js";
18
18
  import { html, json } from "../router.js";
19
+ import { cancelInstanceReconciling } from "./cancel.js";
19
20
  import { quoteIdent } from "./gateway.js";
20
21
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
21
22
  function javascript(body, status = 200) {
@@ -34,7 +35,7 @@ export function createPagesRoutes(opts, deps) {
34
35
  const rowLimitRaw = opts.rowLimit ?? 200;
35
36
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
36
37
  const sourceName = opts.sourceName ?? "app";
37
- const { db, engine, readPage, listPages } = deps;
38
+ const { db, engine, readPage, listPages, cancel } = deps;
38
39
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
39
40
  // (before serving), so the schema is stable for the process lifetime, and the renderer
40
41
  // refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
@@ -257,6 +258,19 @@ export function createPagesRoutes(opts, deps) {
257
258
  }
258
259
  if (key === undefined || key === "")
259
260
  return json({ error: "processInstanceKey is required" }, 400);
261
+ // The reconcile-aware primitive verifies the real post-cancel state and flips the tracked
262
+ // row immediately; a !ok result means the engine did NOT stop the instance, so surface 502.
263
+ if (cancel) {
264
+ try {
265
+ const result = await cancel(String(key));
266
+ return json(result, result.ok ? 200 : 502);
267
+ }
268
+ catch (e) {
269
+ // Defensive: the primitive is designed not to throw, but if it ever does, mirror the
270
+ // fallback path's honest 502 rather than letting the request reject as a 500.
271
+ return json({ ok: false, processInstanceKey: String(key), error: errorMessage(e) }, 502);
272
+ }
273
+ }
260
274
  try {
261
275
  await engine.cancelInstance({ processInstanceKey: String(key) });
262
276
  return json({ ok: true });
@@ -323,9 +337,11 @@ export function mountPages(ctx, app) {
323
337
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
324
338
  };
325
339
  const sourceName = opts.sourceName ?? "app";
340
+ const bindings = ctx.manifest.instanceTracking ?? [];
326
341
  const routes = createPagesRoutes(opts, {
327
342
  db: app.data.open(sourceName),
328
343
  engine: app.engine,
344
+ cancel: (key) => cancelInstanceReconciling(app, bindings, key),
329
345
  readPage: (p) => ctx.host.readTextFile(p),
330
346
  listPages: async () => {
331
347
  const dir = opts.pagesDir ?? "pages";
@@ -564,12 +580,20 @@ function renderText(node) {
564
580
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
565
581
  }
566
582
 
567
- // A grid td cell. When the column declares linkField, the cell text (the
568
- // column's own value) becomes a link to the URL held in that other field,
569
- // opened in a new tab. Only http(s) hrefs are linked — anything else (e.g. a
570
- // javascript: URL smuggled through row data) falls back to plain text — and
571
- // external links get rel=noopener noreferrer so the opened page can't reach
572
- // window.opener. Shared by the top-level grid and child grids.
583
+ // A grid td cell. Two column-declared linking modes, checked in order:
584
+ // 1. linkField — the cell text becomes a link to the URL held in that other
585
+ // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
586
+ // smuggled through row data falls back to plain text).
587
+ // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
588
+ // link: the cell text links to the Nano console's explorer view for the
589
+ // process instance whose key is held in the row's keyField. The console
590
+ // path is constructed HERE (never taken from row data) and the key is
591
+ // URL-encoded, so row data can't smuggle a path or scheme; only a
592
+ // non-empty key (after trimming surrounding whitespace) produces a link.
593
+ // Unknown link kinds fall back to plain text so an unrecognised schema
594
+ // can't render a broken anchor.
595
+ // Both open in a new tab with rel=noopener noreferrer so the opened page can't
596
+ // reach window.opener. Shared by the top-level grid and child grids.
573
597
  function gridCell(col, row) {
574
598
  const text = row[col.field] == null ? "" : String(row[col.field]);
575
599
  if (col.linkField) {
@@ -582,6 +606,18 @@ function gridCell(col, row) {
582
606
  );
583
607
  }
584
608
  }
609
+ if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
610
+ const key = row[col.link.keyField];
611
+ const keyStr = key == null ? "" : String(key).trim();
612
+ if (text !== "" && keyStr !== "") {
613
+ const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
614
+ return el(
615
+ "td",
616
+ {},
617
+ el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
618
+ );
619
+ }
620
+ }
585
621
  return el("td", {}, text);
586
622
  }
587
623
 
@@ -4,6 +4,7 @@
4
4
  // (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
5
5
  import { html, json, normalizeRoutePath } from "../router.js";
6
6
  import { mountActions } from "./actions.js";
7
+ import { mountApi } from "./api.js";
7
8
  import { mountPages } from "./pages.js";
8
9
  /** Escape HTML-significant characters before embedding a value in markup. */
9
10
  function escapeHtml(s) {
@@ -93,6 +94,14 @@ export function mountSurfaces(ctx, app) {
93
94
  routes.push(...actions.routes);
94
95
  enabled.push(`actions(${actions.routes.length})`);
95
96
  }
97
+ // The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
98
+ // still shadows an operation) and BEFORE the generic pages routes. It owns the `api.base`
99
+ // namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
100
+ const api = mountApi(ctx, app);
101
+ if (api.routes.length > 0) {
102
+ routes.push(...api.routes);
103
+ enabled.push("api");
104
+ }
96
105
  // The pages surface (the schema-driven page runtime) mounts its own routes.
97
106
  const pages = mountPages(ctx, app);
98
107
  if (pages.routes.length > 0) {
@@ -169,9 +169,9 @@ export class SdkEngineClient {
169
169
  elementId: job.elementId,
170
170
  variables: job.variables ?? {},
171
171
  };
172
+ let out;
172
173
  try {
173
- const out = await handler(engineJob);
174
- return await job.complete(out ?? {});
174
+ out = await handler(engineJob);
175
175
  }
176
176
  catch (err) {
177
177
  // A handler-raised BPMN error is a modelled, non-retryable outcome:
@@ -201,17 +201,51 @@ export class SdkEngineClient {
201
201
  }
202
202
  }
203
203
  const message = err instanceof Error ? err.message : String(err);
204
- this.log("error", `handler ${jobType} threw`, { err: message });
204
+ this.log("error", `handler ${jobType} threw`, {
205
+ err: message,
206
+ jobKey: engineJob.jobKey,
207
+ processInstanceKey: engineJob.processInstanceKey,
208
+ elementId: engineJob.elementId,
209
+ });
205
210
  try {
206
- // Best-effort failure report; the engine redelivers after the lock
207
- // times out (at-least-once → handlers must be idempotent). Await +
208
- // swallow so a rejected `fail` cannot escape as an unhandled rejection.
209
- return await job.fail({ errorMessage: message.slice(0, 500), retries: 0 });
211
+ // A generic handler failure omits the retry count so the SDK decrements the job's
212
+ // remaining retries (`job.retries - 1`): a transient failure (e.g. a fleeting store
213
+ // error) self-heals on redelivery and only parks as an incident once the budget is
214
+ // exhausted. A `BpmnError` reaching here (no `job.error()` transport, or the error
215
+ // report itself threw) is the exception: it is a modelled, DETERMINISTIC outcome, so
216
+ // retrying would re-throw the same error and burn the budget for nothing — pin
217
+ // `retries: 0` to keep it non-retryable. Await + swallow so a rejected `fail` cannot
218
+ // escape as an unhandled rejection.
219
+ const body = {
220
+ errorMessage: message.slice(0, 500),
221
+ };
222
+ if (isBpmnError(err))
223
+ body.retries = 0;
224
+ return await job.fail(body);
210
225
  }
211
226
  catch {
212
227
  return undefined;
213
228
  }
214
229
  }
230
+ // The handler resolved: its work is done. Reporting that result to the engine
231
+ // (`complete`) is a SEPARATE concern — a failure here is a transport/engine problem
232
+ // (e.g. a transient store error), NOT a handler bug. Failing it (even with a decrementing
233
+ // retry) would burn the job's retry budget for a completion we can't confirm didn't land.
234
+ // Instead, log and leave the job locked so the engine redelivers it on lock timeout;
235
+ // handlers are idempotent (at-least-once), so a redelivery re-completes cleanly.
236
+ try {
237
+ return await job.complete(out ?? {});
238
+ }
239
+ catch (completeErr) {
240
+ const message = completeErr instanceof Error ? completeErr.message : String(completeErr);
241
+ this.log("error", `complete ${jobType} failed; leaving job for engine redelivery`, {
242
+ err: message,
243
+ jobKey: engineJob.jobKey,
244
+ processInstanceKey: engineJob.processInstanceKey,
245
+ elementId: engineJob.elementId,
246
+ });
247
+ return undefined;
248
+ }
215
249
  },
216
250
  });
217
251
  // The nano-sdk job worker owns its own start lifecycle: `createJobWorker` runs
@@ -17,8 +17,12 @@ export { makeGateway, Table } from "./core/modules/gateway.ts";
17
17
  export type { ColumnMeta, DataSource as GatewayDataSource, ExecResult, ForeignKeyMeta, Row, TableMeta, } from "./core/modules/gateway.ts";
18
18
  export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
19
19
  export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./core/modules/pages.ts";
20
+ export { cancelInstanceReconciling } from "./core/modules/cancel.ts";
21
+ export type { CancelInstanceResult, CancelInstanceState } from "./core/modules/cancel.ts";
20
22
  export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
21
23
  export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
24
+ export { defineOperation, mountApi, resolveOperationHandler } from "./core/modules/api.ts";
25
+ export type { ApiBinding, ApiHandle, DefaultContract, OperationContract, OperationHandler, OperationInput, OperationResult, } from "./core/modules/api.ts";
22
26
  export type { AppJobHandler } from "./core/modules/workers.ts";
23
27
  export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts";
24
28
  export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
@@ -15,7 +15,9 @@ export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
15
15
  export { runDataOp } from "./core/modules/dataops.js";
16
16
  export { makeGateway, Table } from "./core/modules/gateway.js";
17
17
  export { createPagesRoutes, mountPages } from "./core/modules/pages.js";
18
+ export { cancelInstanceReconciling } from "./core/modules/cancel.js";
18
19
  export { mountActions, resolveActionHandler } from "./core/modules/actions.js";
20
+ export { defineOperation, mountApi, resolveOperationHandler } from "./core/modules/api.js";
19
21
  export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
20
22
  export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
21
23
  export { evalCorrelation } from "./core/modules/triggers.js";
@@ -17,3 +17,8 @@ export interface Deriver<I> {
17
17
  export declare const GENERATED_DIR = "nano-generated";
18
18
  /** Stable ordering so artifact lists compare deterministically. */
19
19
  export declare function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[];
20
+ /** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
21
+ * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
22
+ * shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
23
+ * whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
24
+ export declare function isAbsolutePath(p: string): boolean;
@@ -9,3 +9,10 @@ export const GENERATED_DIR = "nano-generated";
9
9
  export function sortArtifacts(a) {
10
10
  return [...a].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
11
11
  }
12
+ /** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
13
+ * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
14
+ * shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
15
+ * whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
16
+ export function isAbsolutePath(p) {
17
+ return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
18
+ }
@@ -0,0 +1,19 @@
1
+ import type { DerivedArtifact, Deriver } from "../artifact.ts";
2
+ import { type OpenApiDoc, type OpenApiSchema } from "../../openapi/spec.ts";
3
+ /** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
4
+ export declare const API_BINDINGS_DTS = "api-io.d.ts";
5
+ export declare const API_BINDINGS_TS = "operations.ts";
6
+ /** Render a JSON-Schema (OpenAPI subset) as a TS type expression. Local component `$ref`s render as
7
+ * their component name (emitted as named interfaces); unknown shapes fall back to `unknown`. */
8
+ export declare function schemaToTs(schema: OpenApiSchema | undefined, depth?: number): string;
9
+ /** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union. */
10
+ export declare function emitApiBindings(doc: OpenApiDoc): string;
11
+ /** Emit `operations.ts`: the typed `defineOperation` wrapper, keyed by operationId (mirrors the
12
+ * generated typed `defineWorker`). Written verbatim (it never changes with the spec) — it only
13
+ * re-types the runtime's `defineOperation`. */
14
+ export declare function emitApiBindingsRuntime(): string;
15
+ /** Derive the api artifacts from a parsed OpenAPI document. */
16
+ export declare function deriveApi(doc: OpenApiDoc): DerivedArtifact[];
17
+ export declare const apiDeriver: Deriver<{
18
+ doc: OpenApiDoc;
19
+ }>;
@@ -0,0 +1,224 @@
1
+ // Deriver: an OpenAPI document → `api-io.d.ts` (the endpoint type map) + `operations.ts` (the typed
2
+ // `defineOperation` wrapper). ADR 0058: the contract-first endpoint surface. This is the authoring
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
5
+ // `worker-io.d.ts` types `defineWorker` from the model (ADR 0033 §3). The runtime
6
+ // (`runtime/core/modules/api.ts`) is the matching half: it validates + routes from the SAME spec via
7
+ // the shared `openapi/spec.ts`, so types and runtime never drift.
8
+ import { GENERATED_DIR } from "../artifact.js";
9
+ import { collectOperations, isObjectSchema, refName, } from "../../openapi/spec.js";
10
+ /** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
11
+ export const API_BINDINGS_DTS = "api-io.d.ts";
12
+ export const API_BINDINGS_TS = "operations.ts";
13
+ /** A TS identifier form of an operationId (used for the per-op type names). */
14
+ function typeStem(operationId) {
15
+ const cleaned = operationId.replace(/[^A-Za-z0-9]+/g, " ").trim();
16
+ const pascal = cleaned
17
+ .split(/\s+/)
18
+ .map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : ""))
19
+ .join("");
20
+ return /^[A-Za-z_]/.test(pascal) ? pascal : `Op${pascal}`;
21
+ }
22
+ /** Render a JSON-Schema (OpenAPI subset) as a TS type expression. Local component `$ref`s render as
23
+ * their component name (emitted as named interfaces); unknown shapes fall back to `unknown`. */
24
+ export function schemaToTs(schema, depth = 0) {
25
+ if (!schema || depth > 20)
26
+ return "unknown";
27
+ if (schema.$ref) {
28
+ const name = refName(schema.$ref);
29
+ return name ? typeStem(name) : "unknown";
30
+ }
31
+ const nullable = schema.nullable === true;
32
+ const wrap = (t) => (nullable ? `${t} | null` : t);
33
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
34
+ return wrap(schema.enum.map((v) => JSON.stringify(v)).join(" | "));
35
+ }
36
+ if (schema.const !== undefined)
37
+ return wrap(JSON.stringify(schema.const));
38
+ if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
39
+ return wrap(schema.oneOf.map((s) => schemaToTs(s, depth + 1)).join(" | "));
40
+ }
41
+ if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
42
+ return wrap(schema.anyOf.map((s) => schemaToTs(s, depth + 1)).join(" | "));
43
+ }
44
+ if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
45
+ return wrap(schema.allOf.map((s) => schemaToTs(s, depth + 1)).join(" & "));
46
+ }
47
+ switch (schema.type) {
48
+ case "string":
49
+ return wrap("string");
50
+ case "integer":
51
+ case "number":
52
+ return wrap("number");
53
+ case "boolean":
54
+ return wrap("boolean");
55
+ case "null":
56
+ return "null";
57
+ case "array":
58
+ return wrap(`Array<${schemaToTs(schema.items, depth + 1)}>`);
59
+ case "object":
60
+ return wrap(objectToTs(schema, depth));
61
+ default:
62
+ // No `type`: infer object-ness from object-only keywords (shared with the runtime
63
+ // validator via isObjectSchema so types and validation agree); otherwise unknown.
64
+ if (isObjectSchema(schema))
65
+ return wrap(objectToTs(schema, depth));
66
+ return "unknown";
67
+ }
68
+ }
69
+ function objectToTs(schema, depth) {
70
+ const props = schema.properties ?? {};
71
+ const required = new Set(schema.required ?? []);
72
+ const entries = Object.entries(props).map(([key, ps]) => {
73
+ const opt = required.has(key) ? "" : "?";
74
+ const doc = typeof ps.description === "string" ? ps.description.replace(/\*\//g, "*\\/") : undefined;
75
+ const jsdoc = doc ? `/** ${doc} */ ` : "";
76
+ return `${jsdoc}${JSON.stringify(key)}${opt}: ${schemaToTs(ps, depth + 1)}`;
77
+ });
78
+ const extra = schema.additionalProperties;
79
+ if (extra && typeof extra === "object") {
80
+ // TS requires every named property type to be assignable to the index signature. When the
81
+ // schema also declares properties, a narrow index type (e.g. `number`) would make the emitted
82
+ // type invalid (TS2411), so fall back to `unknown`; with no declared properties we can use the
83
+ // precise additionalProperties type (the common "map" case).
84
+ const hasProps = Object.keys(props).length > 0;
85
+ entries.push(`[key: string]: ${hasProps ? "unknown" : schemaToTs(extra, depth + 1)}`);
86
+ }
87
+ else if (extra !== false) {
88
+ // OpenAPI/JSON-Schema default: additional properties are allowed. Emit an index
89
+ // signature so the emitted type isn't stricter than the runtime validator, which only
90
+ // rejects extras when additionalProperties === false (otherwise excess-property errors
91
+ // on object literals would reject bodies the runtime accepts).
92
+ entries.push("[key: string]: unknown");
93
+ }
94
+ if (entries.length > 0)
95
+ return `{ ${entries.join("; ")} }`;
96
+ // No declared properties and additionalProperties: false → a closed, empty object.
97
+ return "Record<string, never>";
98
+ }
99
+ /** The `params`/`query` object type for an operation, from its parameters (strings on the wire). */
100
+ function paramsTs(op, where) {
101
+ const ps = op.parameters.filter((p) => p.in === where);
102
+ if (ps.length === 0)
103
+ return "{}";
104
+ const fields = ps.map((p) => {
105
+ const opt = p.required ? "" : "?";
106
+ // Query values arrive as strings (or string[] for repeated keys); path params are single
107
+ // strings. Optionality is carried by the `?` modifier — don't fold `undefined` into the
108
+ // value type, or required query params would still accept undefined.
109
+ const t = where === "query" ? "string | string[]" : "string";
110
+ return `${JSON.stringify(p.name)}${opt}: ${t}`;
111
+ });
112
+ return `{ ${fields.join("; ")} }`;
113
+ }
114
+ /** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union. */
115
+ export function emitApiBindings(doc) {
116
+ const ops = collectOperations(doc);
117
+ const components = doc.components?.schemas ?? {};
118
+ // Fail-closed on TypeScript name collisions: typeStem is lossy (non-alphanumerics collapse), so
119
+ // distinct component/operation names ("get-invoice" vs "get_invoice") can map to the same TS
120
+ // identifier and emit duplicate declarations that break typechecking. Surface it here (where
121
+ // `urban gen`/`urban check` reports it) rather than shipping broken generated types.
122
+ const emittedTypeNames = new Map();
123
+ const claimTypeName = (typeName, source) => {
124
+ const prior = emittedTypeNames.get(typeName);
125
+ if (prior !== undefined) {
126
+ throw new Error(`OpenAPI spec produces a duplicate TypeScript type "${typeName}" from ${prior} and ${source} ` +
127
+ `— their names collapse to the same identifier; rename one to disambiguate (ADR 0058).`);
128
+ }
129
+ emittedTypeNames.set(typeName, source);
130
+ };
131
+ for (const name of Object.keys(components)) {
132
+ claimTypeName(typeStem(name), `component schema "${name}"`);
133
+ }
134
+ for (const op of ops) {
135
+ claimTypeName(`${typeStem(op.operationId)}Request`, `operationId "${op.operationId}"`);
136
+ claimTypeName(`${typeStem(op.operationId)}Response`, `operationId "${op.operationId}"`);
137
+ }
138
+ const header = "// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
139
+ "// The endpoint type map: each operationId maps to its typed request (path/query params +\n" +
140
+ "// JSON body) and success response, derived from the spec. Do not edit — regenerated from\n" +
141
+ "// the spec; erased to plain JS at compile.\n" +
142
+ "// eslint-disable\n\n";
143
+ const componentDecls = Object.entries(components)
144
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
145
+ .map(([name, schema]) => `export type ${typeStem(name)} = ${schemaToTs(schema)};`)
146
+ .join("\n");
147
+ const opDecls = ops
148
+ .map((op) => {
149
+ const stem = typeStem(op.operationId);
150
+ // With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
151
+ // required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
152
+ const bodyType = op.requestBodySchema
153
+ ? schemaToTs(op.requestBodySchema)
154
+ : op.requestBodyRequired
155
+ ? "unknown"
156
+ : "undefined";
157
+ // An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
158
+ // so mark it optional in the type rather than forcing handlers to treat it as always-present.
159
+ const bodyOpt = op.requestBodyRequired ? "" : "?";
160
+ const resp = op.responseSchema ? schemaToTs(op.responseSchema) : "unknown";
161
+ const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
162
+ return (`${summary}export interface ${stem}Request {\n` +
163
+ ` params: ${paramsTs(op, "path")};\n` +
164
+ ` query: ${paramsTs(op, "query")};\n` +
165
+ ` body${bodyOpt}: ${bodyType};\n` +
166
+ `}\n` +
167
+ `export type ${stem}Response = ${resp};`);
168
+ })
169
+ .join("\n\n");
170
+ const idUnion = ops.length > 0
171
+ ? ops.map((o) => JSON.stringify(o.operationId)).join(" | ")
172
+ : "never";
173
+ const mapEntries = ops
174
+ .map((op) => {
175
+ const stem = typeStem(op.operationId);
176
+ return ` ${JSON.stringify(op.operationId)}: { request: ${stem}Request; response: ${stem}Response };`;
177
+ })
178
+ .join("\n");
179
+ return (header +
180
+ (componentDecls ? `${componentDecls}\n\n` : "") +
181
+ (opDecls ? `${opDecls}\n\n` : "") +
182
+ `/** Every declared operationId (ADR 0058): the delegate key set. */\n` +
183
+ `export type ApiOperationId = ${idUnion};\n\n` +
184
+ `/** Request/response contract per operationId. */\n` +
185
+ `export interface ApiOperations {\n${mapEntries}\n}\n`);
186
+ }
187
+ /** Emit `operations.ts`: the typed `defineOperation` wrapper, keyed by operationId (mirrors the
188
+ * generated typed `defineWorker`). Written verbatim (it never changes with the spec) — it only
189
+ * re-types the runtime's `defineOperation`. */
190
+ export function emitApiBindingsRuntime() {
191
+ return ("// AUTO-GENERATED by @nanobpm/urban (ADR 0058): the typed `defineOperation`.\n" +
192
+ "// Re-exports the operation SDK and overrides `defineOperation` with an operationId-keyed\n" +
193
+ "// signature (the delegate's validated request + result typed from the spec). Erased to a\n" +
194
+ "// pass-through at runtime. Do not edit.\n" +
195
+ "// eslint-disable\n\n" +
196
+ `import { defineOperation as defineOperationRaw } from "@nanobpm/urban";\n` +
197
+ `import type { OperationHandler } from "@nanobpm/urban";\n` +
198
+ `import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n\n` +
199
+ `type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
200
+ `type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
201
+ `/**\n` +
202
+ ` * Typed \`defineOperation\`: \`id\` is constrained to the spec's declared operationIds\n` +
203
+ ` * (ADR 0058) so it autocompletes and rejects unknown ops, and the handler's request\n` +
204
+ ` * (validated params/query/body) + result are typed from the operation's schemas.\n` +
205
+ ` */\n` +
206
+ `export function defineOperation<K extends ApiOperationId>(\n` +
207
+ ` id: K,\n` +
208
+ ` handler: OperationHandler<ReqFor<K>, ResFor<K>>,\n` +
209
+ `): OperationHandler<ReqFor<K>, ResFor<K>> {\n` +
210
+ ` return defineOperationRaw(id, handler);\n` +
211
+ `}\n`);
212
+ }
213
+ /** Derive the api artifacts from a parsed OpenAPI document. */
214
+ export function deriveApi(doc) {
215
+ return [
216
+ { path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
217
+ { path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
218
+ ];
219
+ }
220
+ export const apiDeriver = {
221
+ id: "openapi->api-io",
222
+ describe: "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation.",
223
+ derive: ({ doc }) => deriveApi(doc),
224
+ };
@@ -2,17 +2,28 @@
2
2
  // models, runs the pure derivers, and either writes the artifacts to disk (`urban gen`) or
3
3
  // compares them against what's on disk and reports drift (`urban gen --check`). The derivers stay
4
4
  // pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
5
- import { sortArtifacts } from "./artifact.js";
5
+ import { isAbsolutePath, sortArtifacts } from "./artifact.js";
6
6
  import { deriveMigrations } from "./derivers/migrations.js";
7
7
  import { deriveDomain } from "./derivers/domain.js";
8
8
  import { deriveWorkerBindings } from "./derivers/worker-io.js";
9
9
  import { deriveMeta } from "./derivers/meta.js";
10
10
  import { deriveMessageBindings } from "./derivers/messages.js";
11
+ import { deriveApi } from "./derivers/api.js";
12
+ import { parseSpec } from "../openapi/spec.js";
11
13
  import { deriveModels, MODEL_PROVENANCE } from "./models.js";
12
14
  function join(root, rel) {
13
- // Trim either separator so callers may pass Windows-style paths; GenIO
14
- // implementations accept forward slashes on all platforms.
15
- return `${root.replace(/[/\\]+$/, "")}/${rel.replace(/^[/\\]+/, "")}`;
15
+ // Normalize Windows-style separators to forward slashes (GenIO uses forward slashes on all
16
+ // platforms — matching the runtime's resolveAppPath) and trim edge separators, so callers may
17
+ // pass either style without gen/runtime drift over where a file resolves.
18
+ const norm = (s) => s.replace(/\\/g, "/");
19
+ // An absolute `rel` resolves to itself — never prefixed with `root` — mirroring the runtime's
20
+ // resolveAppPath (isAbsolutePath is the shared SoT in artifact.ts). Without this an absolute
21
+ // manifest path (e.g. "/abs/openapi.json") would be stripped to root-relative and gen would
22
+ // read/derive a different file than the runtime resolves. Trailing edge separators are still
23
+ // trimmed for stable, comparable artifact keys.
24
+ if (isAbsolutePath(rel))
25
+ return norm(rel).replace(/\/+$/, "");
26
+ return `${norm(root).replace(/\/+$/, "")}/${norm(rel).replace(/^\/+/, "")}`;
16
27
  }
17
28
  function dirOf(p) {
18
29
  const i = p.lastIndexOf("/");
@@ -81,6 +92,14 @@ async function collectAll(opts) {
81
92
  artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
82
93
  }
83
94
  }
95
+ // 4. OpenAPI `api` binding → typed endpoint contracts (ADR 0058). Fail-closed: a declared spec
96
+ // that is missing or malformed throws here so `urban gen`/`urban check` surfaces it. Trim the
97
+ // spec path so benign whitespace matches the runtime's readApiBinding (no gen/runtime drift).
98
+ const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
99
+ if (!opts.modelsOnly && specRef.length > 0) {
100
+ const specText = await io.readText(join(root, specRef));
101
+ artifacts.push(...deriveApi(parseSpec(specText)));
102
+ }
84
103
  return { artifacts: sortArtifacts(artifacts), derived };
85
104
  }
86
105
  /** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
@@ -13,6 +13,9 @@ export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, }
13
13
  export type { MetaDecl } from "./derivers/meta.ts";
14
14
  export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.ts";
15
15
  export type { MessageIo, MessageBindingDecl } from "./derivers/messages.ts";
16
+ export { apiDeriver, deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, } from "./derivers/api.ts";
17
+ export { collectOperations, HTTP_METHODS, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, refName, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../openapi/spec.ts";
18
+ export type { HttpMethodLower, OpenApiDoc, OpenApiParameter, OpenApiSchema, OperationInfo, } from "../openapi/spec.ts";
16
19
  export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<{
17
20
  models: import("./derivers/worker-io.ts").ModelSource[];
18
21
  declaredTypeIds: Iterable<string>;
@@ -21,6 +24,8 @@ export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import(
21
24
  }>, import("./artifact.ts").Deriver<{
22
25
  models: import("./derivers/worker-io.ts").ModelSource[];
23
26
  declaredTypeIds: Iterable<string>;
27
+ }>, import("./artifact.ts").Deriver<{
28
+ doc: import("../openapi/spec.ts").OpenApiDoc;
24
29
  }>];
25
30
  export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
26
31
  export type { GenIO, GenOptions, GenResult } from "./gen.ts";
@@ -11,13 +11,24 @@ export { deriveWorkerBindings, emitWorkerBindings, emitWorkerBindingsRuntime, ov
11
11
  export { byModelPath, typeRefFor } from "./derivers/worker-io.js";
12
12
  export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, } from "./derivers/meta.js";
13
13
  export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.js";
14
+ export { apiDeriver, deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, } from "./derivers/api.js";
15
+ // The OpenAPI machinery the deriver + runtime share (ADR 0058).
16
+ export { collectOperations, HTTP_METHODS, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, refName, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../openapi/spec.js";
14
17
  // The registry of all derivers (for discovery / IDE migration).
15
18
  import { migrationsDeriver } from "./derivers/migrations.js";
16
19
  import { domainDeriver } from "./derivers/domain.js";
17
20
  import { workerIoDeriver } from "./derivers/worker-io.js";
18
21
  import { metaDeriver } from "./derivers/meta.js";
19
22
  import { messagesDeriver } from "./derivers/messages.js";
20
- export const DERIVERS = [migrationsDeriver, domainDeriver, workerIoDeriver, metaDeriver, messagesDeriver];
23
+ import { apiDeriver } from "./derivers/api.js";
24
+ export const DERIVERS = [
25
+ migrationsDeriver,
26
+ domainDeriver,
27
+ workerIoDeriver,
28
+ metaDeriver,
29
+ messagesDeriver,
30
+ apiDeriver,
31
+ ];
21
32
  // Gen orchestrator + IO
22
33
  export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.js";
23
34
  export { createNodeGenIO } from "./fsio.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.29.0",
3
+ "version": "0.31.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",