@nanobpm/urban 0.28.0 → 0.30.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 (84) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/adapters/deno.js +5 -0
  4. package/dist/runtime/adapters/node.js +5 -0
  5. package/dist/runtime/core/execContext.d.ts +28 -0
  6. package/dist/runtime/core/execContext.js +39 -0
  7. package/dist/runtime/core/host.d.ts +41 -0
  8. package/dist/runtime/core/manifest.d.ts +1 -1
  9. package/dist/runtime/core/modules/api.d.ts +61 -0
  10. package/dist/runtime/core/modules/api.js +359 -0
  11. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  12. package/dist/runtime/core/modules/cancel.js +90 -0
  13. package/dist/runtime/core/modules/connectors.js +3 -1
  14. package/dist/runtime/core/modules/datasource.d.ts +23 -3
  15. package/dist/runtime/core/modules/datasource.js +61 -6
  16. package/dist/runtime/core/modules/gateway.d.ts +10 -3
  17. package/dist/runtime/core/modules/gateway.js +23 -6
  18. package/dist/runtime/core/modules/instance-tracking.d.ts +22 -0
  19. package/dist/runtime/core/modules/instance-tracking.js +158 -0
  20. package/dist/runtime/core/modules/pages.d.ts +8 -0
  21. package/dist/runtime/core/modules/pages.js +17 -1
  22. package/dist/runtime/core/modules/scheduler.d.ts +11 -0
  23. package/dist/runtime/core/modules/scheduler.js +21 -0
  24. package/dist/runtime/core/modules/surfaces.js +9 -0
  25. package/dist/runtime/core/modules/triggers.d.ts +2 -8
  26. package/dist/runtime/core/modules/triggers.js +1 -16
  27. package/dist/runtime/core/modules/workers.js +3 -2
  28. package/dist/runtime/core/runtime.d.ts +3 -0
  29. package/dist/runtime/core/runtime.js +12 -0
  30. package/dist/runtime/core/validate.js +38 -0
  31. package/dist/runtime/engine/nanosdk.d.ts +14 -1
  32. package/dist/runtime/engine/nanosdk.js +90 -7
  33. package/dist/runtime/index.d.ts +4 -0
  34. package/dist/runtime/index.js +2 -0
  35. package/dist/toolkit/artifact.d.ts +5 -0
  36. package/dist/toolkit/artifact.js +7 -0
  37. package/dist/toolkit/derivers/api.d.ts +19 -0
  38. package/dist/toolkit/derivers/api.js +224 -0
  39. package/dist/toolkit/gen.js +23 -4
  40. package/dist/toolkit/index.d.ts +5 -0
  41. package/dist/toolkit/index.js +12 -1
  42. package/package.json +2 -2
  43. package/src/openapi/spec.test.ts +292 -0
  44. package/src/openapi/spec.ts +540 -0
  45. package/src/runtime/adapters/deno.ts +6 -0
  46. package/src/runtime/adapters/node.ts +6 -0
  47. package/src/runtime/core/execContext.ts +56 -0
  48. package/src/runtime/core/host.ts +44 -0
  49. package/src/runtime/core/manifest.ts +1 -0
  50. package/src/runtime/core/modules/actions.test.ts +1 -0
  51. package/src/runtime/core/modules/api.test.ts +445 -0
  52. package/src/runtime/core/modules/api.ts +453 -0
  53. package/src/runtime/core/modules/cancel.test.ts +310 -0
  54. package/src/runtime/core/modules/cancel.ts +119 -0
  55. package/src/runtime/core/modules/connectors.test.ts +1 -0
  56. package/src/runtime/core/modules/connectors.ts +7 -1
  57. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  58. package/src/runtime/core/modules/datasource.ts +77 -6
  59. package/src/runtime/core/modules/gateway.ts +28 -6
  60. package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
  61. package/src/runtime/core/modules/instance-tracking.ts +180 -0
  62. package/src/runtime/core/modules/pages.test.ts +47 -2
  63. package/src/runtime/core/modules/pages.ts +23 -1
  64. package/src/runtime/core/modules/provenance.test.ts +181 -0
  65. package/src/runtime/core/modules/scheduler.ts +31 -0
  66. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  67. package/src/runtime/core/modules/surfaces.ts +10 -0
  68. package/src/runtime/core/modules/triggers.test.ts +1 -0
  69. package/src/runtime/core/modules/triggers.ts +3 -25
  70. package/src/runtime/core/modules/workers.test.ts +1 -0
  71. package/src/runtime/core/modules/workers.ts +11 -2
  72. package/src/runtime/core/runtime.test.ts +3 -0
  73. package/src/runtime/core/runtime.ts +16 -0
  74. package/src/runtime/core/validate.test.ts +96 -0
  75. package/src/runtime/core/validate.ts +39 -0
  76. package/src/runtime/engine/nanosdk.test.ts +118 -5
  77. package/src/runtime/engine/nanosdk.ts +102 -7
  78. package/src/runtime/index.ts +12 -0
  79. package/src/toolkit/artifact.ts +8 -0
  80. package/src/toolkit/derivers/api.test.ts +219 -0
  81. package/src/toolkit/derivers/api.ts +255 -0
  82. package/src/toolkit/gen.test.ts +39 -0
  83. package/src/toolkit/gen.ts +24 -4
  84. package/src/toolkit/index.ts +41 -1
@@ -0,0 +1,255 @@
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
+
9
+ import type { DerivedArtifact, Deriver } from "../artifact.ts";
10
+ import { GENERATED_DIR } from "../artifact.ts";
11
+ import {
12
+ collectOperations,
13
+ isObjectSchema,
14
+ type OpenApiDoc,
15
+ type OpenApiSchema,
16
+ type OperationInfo,
17
+ refName,
18
+ } from "../../openapi/spec.ts";
19
+
20
+ /** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
21
+ export const API_BINDINGS_DTS = "api-io.d.ts";
22
+ export const API_BINDINGS_TS = "operations.ts";
23
+
24
+ /** A TS identifier form of an operationId (used for the per-op type names). */
25
+ function typeStem(operationId: string): string {
26
+ const cleaned = operationId.replace(/[^A-Za-z0-9]+/g, " ").trim();
27
+ const pascal = cleaned
28
+ .split(/\s+/)
29
+ .map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : ""))
30
+ .join("");
31
+ return /^[A-Za-z_]/.test(pascal) ? pascal : `Op${pascal}`;
32
+ }
33
+
34
+ /** Render a JSON-Schema (OpenAPI subset) as a TS type expression. Local component `$ref`s render as
35
+ * their component name (emitted as named interfaces); unknown shapes fall back to `unknown`. */
36
+ export function schemaToTs(schema: OpenApiSchema | undefined, depth = 0): string {
37
+ if (!schema || depth > 20) return "unknown";
38
+ if (schema.$ref) {
39
+ const name = refName(schema.$ref);
40
+ return name ? typeStem(name) : "unknown";
41
+ }
42
+ const nullable = schema.nullable === true;
43
+ const wrap = (t: string) => (nullable ? `${t} | null` : t);
44
+
45
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
46
+ return wrap(schema.enum.map((v) => JSON.stringify(v)).join(" | "));
47
+ }
48
+ if (schema.const !== undefined) return wrap(JSON.stringify(schema.const));
49
+
50
+ if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
51
+ return wrap(schema.oneOf.map((s) => schemaToTs(s, depth + 1)).join(" | "));
52
+ }
53
+ if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
54
+ return wrap(schema.anyOf.map((s) => schemaToTs(s, depth + 1)).join(" | "));
55
+ }
56
+ if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
57
+ return wrap(schema.allOf.map((s) => schemaToTs(s, depth + 1)).join(" & "));
58
+ }
59
+
60
+ switch (schema.type) {
61
+ case "string":
62
+ return wrap("string");
63
+ case "integer":
64
+ case "number":
65
+ return wrap("number");
66
+ case "boolean":
67
+ return wrap("boolean");
68
+ case "null":
69
+ return "null";
70
+ case "array":
71
+ return wrap(`Array<${schemaToTs(schema.items, depth + 1)}>`);
72
+ case "object":
73
+ return wrap(objectToTs(schema, depth));
74
+ default:
75
+ // No `type`: infer object-ness from object-only keywords (shared with the runtime
76
+ // validator via isObjectSchema so types and validation agree); otherwise unknown.
77
+ if (isObjectSchema(schema)) return wrap(objectToTs(schema, depth));
78
+ return "unknown";
79
+ }
80
+ }
81
+
82
+ function objectToTs(schema: OpenApiSchema, depth: number): string {
83
+ const props = schema.properties ?? {};
84
+ const required = new Set(schema.required ?? []);
85
+ const entries = Object.entries(props).map(([key, ps]) => {
86
+ const opt = required.has(key) ? "" : "?";
87
+ const doc = typeof ps.description === "string" ? ps.description.replace(/\*\//g, "*\\/") : undefined;
88
+ const jsdoc = doc ? `/** ${doc} */ ` : "";
89
+ return `${jsdoc}${JSON.stringify(key)}${opt}: ${schemaToTs(ps, depth + 1)}`;
90
+ });
91
+ const extra = schema.additionalProperties;
92
+ if (extra && typeof extra === "object") {
93
+ // TS requires every named property type to be assignable to the index signature. When the
94
+ // schema also declares properties, a narrow index type (e.g. `number`) would make the emitted
95
+ // type invalid (TS2411), so fall back to `unknown`; with no declared properties we can use the
96
+ // precise additionalProperties type (the common "map" case).
97
+ const hasProps = Object.keys(props).length > 0;
98
+ entries.push(`[key: string]: ${hasProps ? "unknown" : schemaToTs(extra, depth + 1)}`);
99
+ } else if (extra !== false) {
100
+ // OpenAPI/JSON-Schema default: additional properties are allowed. Emit an index
101
+ // signature so the emitted type isn't stricter than the runtime validator, which only
102
+ // rejects extras when additionalProperties === false (otherwise excess-property errors
103
+ // on object literals would reject bodies the runtime accepts).
104
+ entries.push("[key: string]: unknown");
105
+ }
106
+ if (entries.length > 0) return `{ ${entries.join("; ")} }`;
107
+ // No declared properties and additionalProperties: false → a closed, empty object.
108
+ return "Record<string, never>";
109
+ }
110
+
111
+ /** The `params`/`query` object type for an operation, from its parameters (strings on the wire). */
112
+ function paramsTs(op: OperationInfo, where: "path" | "query"): string {
113
+ const ps = op.parameters.filter((p) => p.in === where);
114
+ if (ps.length === 0) return "{}";
115
+ const fields = ps.map((p) => {
116
+ const opt = p.required ? "" : "?";
117
+ // Query values arrive as strings (or string[] for repeated keys); path params are single
118
+ // strings. Optionality is carried by the `?` modifier — don't fold `undefined` into the
119
+ // value type, or required query params would still accept undefined.
120
+ const t = where === "query" ? "string | string[]" : "string";
121
+ return `${JSON.stringify(p.name)}${opt}: ${t}`;
122
+ });
123
+ return `{ ${fields.join("; ")} }`;
124
+ }
125
+
126
+ /** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union. */
127
+ export function emitApiBindings(doc: OpenApiDoc): string {
128
+ const ops = collectOperations(doc);
129
+ const components = doc.components?.schemas ?? {};
130
+
131
+ // Fail-closed on TypeScript name collisions: typeStem is lossy (non-alphanumerics collapse), so
132
+ // distinct component/operation names ("get-invoice" vs "get_invoice") can map to the same TS
133
+ // identifier and emit duplicate declarations that break typechecking. Surface it here (where
134
+ // `urban gen`/`urban check` reports it) rather than shipping broken generated types.
135
+ const emittedTypeNames = new Map<string, string>();
136
+ const claimTypeName = (typeName: string, source: string): void => {
137
+ const prior = emittedTypeNames.get(typeName);
138
+ if (prior !== undefined) {
139
+ throw new Error(
140
+ `OpenAPI spec produces a duplicate TypeScript type "${typeName}" from ${prior} and ${source} ` +
141
+ `— their names collapse to the same identifier; rename one to disambiguate (ADR 0058).`,
142
+ );
143
+ }
144
+ emittedTypeNames.set(typeName, source);
145
+ };
146
+ for (const name of Object.keys(components)) {
147
+ claimTypeName(typeStem(name), `component schema "${name}"`);
148
+ }
149
+ for (const op of ops) {
150
+ claimTypeName(`${typeStem(op.operationId)}Request`, `operationId "${op.operationId}"`);
151
+ claimTypeName(`${typeStem(op.operationId)}Response`, `operationId "${op.operationId}"`);
152
+ }
153
+
154
+ const header =
155
+ "// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
156
+ "// The endpoint type map: each operationId maps to its typed request (path/query params +\n" +
157
+ "// JSON body) and success response, derived from the spec. Do not edit — regenerated from\n" +
158
+ "// the spec; erased to plain JS at compile.\n" +
159
+ "// eslint-disable\n\n";
160
+
161
+ const componentDecls = Object.entries(components)
162
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
163
+ .map(([name, schema]) => `export type ${typeStem(name)} = ${schemaToTs(schema)};`)
164
+ .join("\n");
165
+
166
+ const opDecls = ops
167
+ .map((op) => {
168
+ const stem = typeStem(op.operationId);
169
+ // With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
170
+ // required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
171
+ const bodyType = op.requestBodySchema
172
+ ? schemaToTs(op.requestBodySchema)
173
+ : op.requestBodyRequired
174
+ ? "unknown"
175
+ : "undefined";
176
+ // An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
177
+ // so mark it optional in the type rather than forcing handlers to treat it as always-present.
178
+ const bodyOpt = op.requestBodyRequired ? "" : "?";
179
+ const resp = op.responseSchema ? schemaToTs(op.responseSchema) : "unknown";
180
+ const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
181
+ return (
182
+ `${summary}export interface ${stem}Request {\n` +
183
+ ` params: ${paramsTs(op, "path")};\n` +
184
+ ` query: ${paramsTs(op, "query")};\n` +
185
+ ` body${bodyOpt}: ${bodyType};\n` +
186
+ `}\n` +
187
+ `export type ${stem}Response = ${resp};`
188
+ );
189
+ })
190
+ .join("\n\n");
191
+
192
+ const idUnion = ops.length > 0
193
+ ? ops.map((o) => JSON.stringify(o.operationId)).join(" | ")
194
+ : "never";
195
+
196
+ const mapEntries = ops
197
+ .map((op) => {
198
+ const stem = typeStem(op.operationId);
199
+ return ` ${JSON.stringify(op.operationId)}: { request: ${stem}Request; response: ${stem}Response };`;
200
+ })
201
+ .join("\n");
202
+
203
+ return (
204
+ header +
205
+ (componentDecls ? `${componentDecls}\n\n` : "") +
206
+ (opDecls ? `${opDecls}\n\n` : "") +
207
+ `/** Every declared operationId (ADR 0058): the delegate key set. */\n` +
208
+ `export type ApiOperationId = ${idUnion};\n\n` +
209
+ `/** Request/response contract per operationId. */\n` +
210
+ `export interface ApiOperations {\n${mapEntries}\n}\n`
211
+ );
212
+ }
213
+
214
+ /** Emit `operations.ts`: the typed `defineOperation` wrapper, keyed by operationId (mirrors the
215
+ * generated typed `defineWorker`). Written verbatim (it never changes with the spec) — it only
216
+ * re-types the runtime's `defineOperation`. */
217
+ export function emitApiBindingsRuntime(): string {
218
+ return (
219
+ "// AUTO-GENERATED by @nanobpm/urban (ADR 0058): the typed `defineOperation`.\n" +
220
+ "// Re-exports the operation SDK and overrides `defineOperation` with an operationId-keyed\n" +
221
+ "// signature (the delegate's validated request + result typed from the spec). Erased to a\n" +
222
+ "// pass-through at runtime. Do not edit.\n" +
223
+ "// eslint-disable\n\n" +
224
+ `import { defineOperation as defineOperationRaw } from "@nanobpm/urban";\n` +
225
+ `import type { OperationHandler } from "@nanobpm/urban";\n` +
226
+ `import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n\n` +
227
+ `type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
228
+ `type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
229
+ `/**\n` +
230
+ ` * Typed \`defineOperation\`: \`id\` is constrained to the spec's declared operationIds\n` +
231
+ ` * (ADR 0058) so it autocompletes and rejects unknown ops, and the handler's request\n` +
232
+ ` * (validated params/query/body) + result are typed from the operation's schemas.\n` +
233
+ ` */\n` +
234
+ `export function defineOperation<K extends ApiOperationId>(\n` +
235
+ ` id: K,\n` +
236
+ ` handler: OperationHandler<ReqFor<K>, ResFor<K>>,\n` +
237
+ `): OperationHandler<ReqFor<K>, ResFor<K>> {\n` +
238
+ ` return defineOperationRaw(id, handler);\n` +
239
+ `}\n`
240
+ );
241
+ }
242
+
243
+ /** Derive the api artifacts from a parsed OpenAPI document. */
244
+ export function deriveApi(doc: OpenApiDoc): DerivedArtifact[] {
245
+ return [
246
+ { path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
247
+ { path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
248
+ ];
249
+ }
250
+
251
+ export const apiDeriver: Deriver<{ doc: OpenApiDoc }> = {
252
+ id: "openapi->api-io",
253
+ describe: "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation.",
254
+ derive: ({ doc }) => deriveApi(doc),
255
+ };
@@ -165,3 +165,42 @@ test("the sweep never touches an authored (un-stamped) .bpmn", async () => {
165
165
  await runGen({ root: "/cf", io });
166
166
  assert.ok(io.files["/cf/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
167
167
  });
168
+
169
+ test("collectArtifacts trims api.spec whitespace so gen matches the runtime (no gen/runtime drift)", async () => {
170
+ const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: " openapi.json " } });
171
+ const openapi = JSON.stringify({
172
+ openapi: "3.0.0",
173
+ paths: { "/ping": { get: { operationId: "ping", responses: { "200": {} } } } },
174
+ });
175
+ const io = memIO({ "/app/nano.app.json": manifest, "/app/openapi.json": openapi });
176
+ const res = await collectArtifacts({ root: "/app", io });
177
+ const paths = res.map((a) => a.path);
178
+ // The whitespace-padded spec path still resolved and derived the endpoint contracts.
179
+ assert.ok(paths.includes("nano-generated/api-io.d.ts"));
180
+ });
181
+
182
+ test("collectArtifacts resolves an api.spec with Windows-style separators (no gen/runtime drift)", async () => {
183
+ const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: "specs\\openapi.json" } });
184
+ const openapi = JSON.stringify({
185
+ openapi: "3.0.0",
186
+ paths: { "/ping": { get: { operationId: "ping", responses: { "200": {} } } } },
187
+ });
188
+ const io = memIO({ "/app/nano.app.json": manifest, "/app/specs/openapi.json": openapi });
189
+ const res = await collectArtifacts({ root: "/app", io });
190
+ assert.ok(res.map((a) => a.path).includes("nano-generated/api-io.d.ts"));
191
+ });
192
+
193
+ test("collectArtifacts resolves an ABSOLUTE api.spec at its own path, not root-relative (no gen/runtime drift)", async () => {
194
+ // An absolute manifest spec path must resolve to itself — mirroring the runtime's resolveAppPath
195
+ // — not be stripped to a root-relative "/app/abs/openapi.json". Otherwise gen would read/derive a
196
+ // different file than the runtime dispatches against.
197
+ const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: "/abs/openapi.json" } });
198
+ const openapi = JSON.stringify({
199
+ openapi: "3.0.0",
200
+ paths: { "/ping": { get: { operationId: "ping", responses: { "200": {} } } } },
201
+ });
202
+ // Only the absolute location holds the spec; a root-relative read would ENOENT.
203
+ const io = memIO({ "/app/nano.app.json": manifest, "/abs/openapi.json": openapi });
204
+ const res = await collectArtifacts({ root: "/app", io });
205
+ assert.ok(res.map((a) => a.path).includes("nano-generated/api-io.d.ts"));
206
+ });
@@ -4,12 +4,14 @@
4
4
  // pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
5
5
 
6
6
  import type { DerivedArtifact } from "./artifact.ts";
7
- import { sortArtifacts } from "./artifact.ts";
7
+ import { isAbsolutePath, sortArtifacts } from "./artifact.ts";
8
8
  import { deriveMigrations, type ToolkitManifest } from "./derivers/migrations.ts";
9
9
  import { deriveDomain } from "./derivers/domain.ts";
10
10
  import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
11
11
  import { deriveMeta } from "./derivers/meta.ts";
12
12
  import { deriveMessageBindings } from "./derivers/messages.ts";
13
+ import { deriveApi } from "./derivers/api.ts";
14
+ import { parseSpec } from "../openapi/spec.ts";
13
15
  import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
14
16
 
15
17
  /** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
@@ -56,9 +58,17 @@ export interface GenResult {
56
58
  }
57
59
 
58
60
  function join(root: string, rel: string): string {
59
- // Trim either separator so callers may pass Windows-style paths; GenIO
60
- // implementations accept forward slashes on all platforms.
61
- return `${root.replace(/[/\\]+$/, "")}/${rel.replace(/^[/\\]+/, "")}`;
61
+ // Normalize Windows-style separators to forward slashes (GenIO uses forward slashes on all
62
+ // platforms — matching the runtime's resolveAppPath) and trim edge separators, so callers may
63
+ // pass either style without gen/runtime drift over where a file resolves.
64
+ const norm = (s: string): string => s.replace(/\\/g, "/");
65
+ // An absolute `rel` resolves to itself — never prefixed with `root` — mirroring the runtime's
66
+ // resolveAppPath (isAbsolutePath is the shared SoT in artifact.ts). Without this an absolute
67
+ // manifest path (e.g. "/abs/openapi.json") would be stripped to root-relative and gen would
68
+ // read/derive a different file than the runtime resolves. Trailing edge separators are still
69
+ // trimmed for stable, comparable artifact keys.
70
+ if (isAbsolutePath(rel)) return norm(rel).replace(/\/+$/, "");
71
+ return `${norm(root).replace(/\/+$/, "")}/${norm(rel).replace(/^\/+/, "")}`;
62
72
  }
63
73
 
64
74
  function dirOf(p: string): string {
@@ -112,6 +122,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
112
122
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
113
123
  const manifest: ToolkitManifest & {
114
124
  models?: { processes?: string[]; workflows?: string[] };
125
+ api?: { spec?: string };
115
126
  } = JSON.parse(await io.readText(manifestPath));
116
127
 
117
128
  const artifacts: DerivedArtifact[] = [];
@@ -142,6 +153,15 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
142
153
  }
143
154
  }
144
155
 
156
+ // 4. OpenAPI `api` binding → typed endpoint contracts (ADR 0058). Fail-closed: a declared spec
157
+ // that is missing or malformed throws here so `urban gen`/`urban check` surfaces it. Trim the
158
+ // spec path so benign whitespace matches the runtime's readApiBinding (no gen/runtime drift).
159
+ const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
160
+ if (!opts.modelsOnly && specRef.length > 0) {
161
+ const specText = await io.readText(join(root, specRef));
162
+ artifacts.push(...deriveApi(parseSpec(specText)));
163
+ }
164
+
145
165
  return { artifacts: sortArtifacts(artifacts), derived };
146
166
  }
147
167
 
@@ -98,13 +98,53 @@ export {
98
98
  } from "./derivers/messages.ts";
99
99
  export type { MessageIo, MessageBindingDecl } from "./derivers/messages.ts";
100
100
 
101
+ export {
102
+ apiDeriver,
103
+ deriveApi,
104
+ emitApiBindings,
105
+ emitApiBindingsRuntime,
106
+ schemaToTs,
107
+ API_BINDINGS_DTS,
108
+ API_BINDINGS_TS,
109
+ } from "./derivers/api.ts";
110
+
111
+ // The OpenAPI machinery the deriver + runtime share (ADR 0058).
112
+ export {
113
+ collectOperations,
114
+ HTTP_METHODS,
115
+ isSafeOperationId,
116
+ operationsWithoutId,
117
+ operationsWithUnsafeId,
118
+ parseSpec,
119
+ refName,
120
+ resolveSchema,
121
+ toRouteMatcher,
122
+ undeclaredPathParams,
123
+ validateValue,
124
+ } from "../openapi/spec.ts";
125
+ export type {
126
+ HttpMethodLower,
127
+ OpenApiDoc,
128
+ OpenApiParameter,
129
+ OpenApiSchema,
130
+ OperationInfo,
131
+ } from "../openapi/spec.ts";
132
+
101
133
  // The registry of all derivers (for discovery / IDE migration).
102
134
  import { migrationsDeriver } from "./derivers/migrations.ts";
103
135
  import { domainDeriver } from "./derivers/domain.ts";
104
136
  import { workerIoDeriver } from "./derivers/worker-io.ts";
105
137
  import { metaDeriver } from "./derivers/meta.ts";
106
138
  import { messagesDeriver } from "./derivers/messages.ts";
107
- export const DERIVERS = [migrationsDeriver, domainDeriver, workerIoDeriver, metaDeriver, messagesDeriver] as const;
139
+ import { apiDeriver } from "./derivers/api.ts";
140
+ export const DERIVERS = [
141
+ migrationsDeriver,
142
+ domainDeriver,
143
+ workerIoDeriver,
144
+ metaDeriver,
145
+ messagesDeriver,
146
+ apiDeriver,
147
+ ] as const;
108
148
 
109
149
  // Gen orchestrator + IO
110
150
  export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";