@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
@@ -13,6 +13,9 @@ export interface MountFlags {
13
13
  surfaces?: boolean;
14
14
  triggers?: boolean;
15
15
  security?: boolean;
16
+ /** The `instanceTracking` reconciler poll loop. Shares no HTTP surface; safe to disable
17
+ * in hosts that only render surfaces (e.g. the console preview). */
18
+ instanceTracking?: boolean;
16
19
  }
17
20
  export interface CreateUrbanAppOptions {
18
21
  host: HostContext;
@@ -7,10 +7,12 @@ import { validateManifest } from "./validate.js";
7
7
  import { makeRouter } from "./router.js";
8
8
  import { deployModels } from "./modules/deploy.js";
9
9
  import { provisionData, DataLayer } from "./modules/datasource.js";
10
+ import { installExecStore } from "./execContext.js";
10
11
  import { mountWorkers } from "./modules/workers.js";
11
12
  import { mountConnectors } from "./modules/connectors.js";
12
13
  import { mountSurfaces } from "./modules/surfaces.js";
13
14
  import { mountTriggers } from "./modules/triggers.js";
15
+ import { mountInstanceTracking } from "./modules/instance-tracking.js";
14
16
  import { mountSecurity } from "./modules/security.js";
15
17
  /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
16
18
  * error when $PORT is set but not a valid integer in 0..65535. */
@@ -43,8 +45,13 @@ export async function createUrbanApp(opts) {
43
45
  surfaces: opts.mount?.surfaces ?? true,
44
46
  triggers: opts.mount?.triggers ?? true,
45
47
  security: opts.mount?.security ?? true,
48
+ instanceTracking: opts.mount?.instanceTracking ?? true,
46
49
  };
47
50
  const ctx = { manifest, host, engine, root, templates: opts.templates };
51
+ // Install the ambient job-execution store once per process (idempotent) so worker dispatch can
52
+ // stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
53
+ // leaves provenance capture disabled.
54
+ installExecStore(() => host.createAsyncStore?.());
48
55
  let data;
49
56
  let security;
50
57
  let server;
@@ -144,6 +151,11 @@ export async function createUrbanApp(opts) {
144
151
  mounted.push(t);
145
152
  describe.triggers = t.describe?.();
146
153
  }
154
+ if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
155
+ const it = mountInstanceTracking(ctx, api);
156
+ mounted.push(it);
157
+ describe.instanceTracking = it.describe?.();
158
+ }
147
159
  if (security)
148
160
  describe.security = security.describe();
149
161
  if (data)
@@ -131,6 +131,44 @@ export function collectManifestIssues(m) {
131
131
  issues.push({ path: `triggers[${i}].type`, message: "missing type" });
132
132
  });
133
133
  }
134
+ // instanceTracking: each binding must name the table + key column it tracks and a
135
+ // non-empty `onTerminated.set` patch — a reconciler with nothing to write is inert
136
+ // and almost certainly a mistake. `activeStatuses` without `statusField` is incoherent
137
+ // (no column to read the statuses from), so the runtime would silently poll every row.
138
+ const tracking = Array.isArray(obj.instanceTracking) ? obj.instanceTracking : undefined;
139
+ if (tracking) {
140
+ tracking.forEach((t, i) => {
141
+ const b = isRecord(t) ? t : undefined;
142
+ if (typeof b?.table !== "string" || b.table.length === 0) {
143
+ issues.push({ path: `instanceTracking[${i}].table`, message: "missing table" });
144
+ }
145
+ if (typeof b?.keyField !== "string" || b.keyField.length === 0) {
146
+ issues.push({ path: `instanceTracking[${i}].keyField`, message: "missing keyField" });
147
+ }
148
+ const onTerminated = isRecord(b?.onTerminated) ? b.onTerminated : undefined;
149
+ const set = isRecord(onTerminated?.set) ? onTerminated.set : undefined;
150
+ if (!set || Object.keys(set).length === 0) {
151
+ issues.push({
152
+ path: `instanceTracking[${i}].onTerminated.set`,
153
+ message: "missing onTerminated.set patch (a non-empty column → value map)",
154
+ });
155
+ }
156
+ if (Array.isArray(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
157
+ issues.push({
158
+ path: `instanceTracking[${i}].activeStatuses`,
159
+ message: "activeStatuses requires statusField",
160
+ });
161
+ }
162
+ // `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
163
+ // value would become a 0-delay hot loop at runtime, so reject it at author time.
164
+ if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
165
+ issues.push({
166
+ path: `instanceTracking[${i}].pollMs`,
167
+ message: "pollMs must be a finite positive number of milliseconds",
168
+ });
169
+ }
170
+ });
171
+ }
134
172
  return issues;
135
173
  }
136
174
  export function assertValidManifest(m) {
@@ -1,8 +1,13 @@
1
- import type { EngineClient, JobHandler, WorkerSubscription } from "../core/host.ts";
1
+ import type { EngineClient, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, WorkerSubscription } from "../core/host.ts";
2
2
  import type { EngineSdkClient } from "./sdk.ts";
3
3
  /** Coerce an engine response's process-instance key to a non-empty string, or
4
4
  * throw — a missing key means a malformed/partial response, not a real instance. */
5
5
  export declare function requireProcessInstanceKey(key: string | number | null | undefined): string;
6
+ /** Map the engine's process-instance `state` onto the transport-agnostic
7
+ * {@link ProcessInstanceState} union, or `undefined` for an unrecognized value
8
+ * (which the caller skips rather than mis-reconciling). Case-insensitive so a
9
+ * future casing tweak on the engine side doesn't silently drop instances. */
10
+ export declare function normalizeProcessInstanceState(raw: unknown): ProcessInstanceState | undefined;
6
11
  /** A job as delivered to a nano-sdk job handler: the frame fields plus the
7
12
  * acknowledgement actions the handler must call. */
8
13
  export interface NanoSdkActivatedJob {
@@ -76,6 +81,10 @@ export interface NanoSdkClient {
76
81
  userTaskKey: string;
77
82
  variables?: Record<string, unknown>;
78
83
  }, options?: unknown): Promise<unknown>;
84
+ searchProcessInstances(input: {
85
+ filter?: Record<string, unknown>;
86
+ page?: Record<string, unknown>;
87
+ }, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
79
88
  createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
80
89
  /** Stop every worker created on this client. Used on teardown to also drain a
81
90
  * REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
@@ -134,6 +143,10 @@ export declare class SdkEngineClient implements EngineClient {
134
143
  variables?: Record<string, unknown>;
135
144
  }[]>;
136
145
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
146
+ searchProcessInstances(filter?: {
147
+ processInstanceKeys?: string[];
148
+ state?: ProcessInstanceState;
149
+ }): Promise<ProcessInstanceSnapshot[]>;
137
150
  registerWorker(jobType: string, handler: JobHandler, options?: {
138
151
  workerName?: string;
139
152
  maxParallelJobs?: number;
@@ -28,6 +28,24 @@ export function requireProcessInstanceKey(key) {
28
28
  }
29
29
  return String(key);
30
30
  }
31
+ /** Map the engine's process-instance `state` onto the transport-agnostic
32
+ * {@link ProcessInstanceState} union, or `undefined` for an unrecognized value
33
+ * (which the caller skips rather than mis-reconciling). Case-insensitive so a
34
+ * future casing tweak on the engine side doesn't silently drop instances. */
35
+ export function normalizeProcessInstanceState(raw) {
36
+ if (typeof raw !== "string")
37
+ return undefined;
38
+ switch (raw.toUpperCase()) {
39
+ case "ACTIVE":
40
+ return "ACTIVE";
41
+ case "COMPLETED":
42
+ return "COMPLETED";
43
+ case "TERMINATED":
44
+ return "TERMINATED";
45
+ default:
46
+ return undefined;
47
+ }
48
+ }
31
49
  /**
32
50
  * An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
33
51
  * message, and user-task calls map one-to-one onto the SDK's client methods; workers
@@ -101,6 +119,37 @@ export class SdkEngineClient {
101
119
  async completeUserTask(userTaskKey, variables) {
102
120
  await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
103
121
  }
122
+ async searchProcessInstances(filter) {
123
+ const f = {};
124
+ if (filter?.state)
125
+ f.state = filter.state;
126
+ const keys = filter?.processInstanceKeys?.filter((k) => k != null && k !== "");
127
+ if (keys && keys.length > 0)
128
+ f.processInstanceKey = { $in: keys };
129
+ // A process-instance search is an eventually consistent read; ask for zero-wait
130
+ // consistency so it reflects what is currently visible without blocking. Cap the page
131
+ // to the number of keys asked for (each key matches at most one instance), so a bounded
132
+ // reconcile query never silently truncates on the API's default page size.
133
+ const page = keys && keys.length > 0 ? { limit: keys.length } : undefined;
134
+ const body = await this.client.searchProcessInstances({ filter: f, ...(page ? { page } : {}) }, { consistency: { waitUpToMs: 0 } });
135
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
136
+ return items.flatMap((it) => {
137
+ const key = it.processInstanceKey;
138
+ if (key == null || key === "") {
139
+ this.log("warn", "skipping process instance with no key in engine response");
140
+ return [];
141
+ }
142
+ const state = normalizeProcessInstanceState(it.state);
143
+ if (!state) {
144
+ this.log("warn", "skipping process instance with unrecognized state", {
145
+ processInstanceKey: String(key),
146
+ state: String(it.state),
147
+ });
148
+ return [];
149
+ }
150
+ return [{ processInstanceKey: String(key), state }];
151
+ });
152
+ }
104
153
  async registerWorker(jobType, handler, options) {
105
154
  const worker = this.client.createJobWorker({
106
155
  jobType,
@@ -120,9 +169,9 @@ export class SdkEngineClient {
120
169
  elementId: job.elementId,
121
170
  variables: job.variables ?? {},
122
171
  };
172
+ let out;
123
173
  try {
124
- const out = await handler(engineJob);
125
- return await job.complete(out ?? {});
174
+ out = await handler(engineJob);
126
175
  }
127
176
  catch (err) {
128
177
  // A handler-raised BPMN error is a modelled, non-retryable outcome:
@@ -152,17 +201,51 @@ export class SdkEngineClient {
152
201
  }
153
202
  }
154
203
  const message = err instanceof Error ? err.message : String(err);
155
- 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
+ });
156
210
  try {
157
- // Best-effort failure report; the engine redelivers after the lock
158
- // times out (at-least-once → handlers must be idempotent). Await +
159
- // swallow so a rejected `fail` cannot escape as an unhandled rejection.
160
- 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);
161
225
  }
162
226
  catch {
163
227
  return undefined;
164
228
  }
165
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
+ }
166
249
  },
167
250
  });
168
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";