@nanobpm/urban 0.29.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 (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 +17 -1
  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 +46 -2
  34. package/src/runtime/core/modules/pages.ts +23 -1
  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
@@ -339,7 +339,41 @@ test("registerWorker fails the job when the handler throws", async () => {
339
339
  };
340
340
  await rec.dispatch(job);
341
341
  assert.equal(failBody?.errorMessage, "boom");
342
- assert.equal(failBody?.retries, 0);
342
+ // No retry count is pinned: the SDK decrements the job's remaining retries
343
+ // (`job.retries - 1`), so a transient handler failure self-heals on redelivery and
344
+ // only parks as an incident once the budget is exhausted.
345
+ assert.equal(failBody?.retries, undefined);
346
+ assert.equal("retries" in (failBody ?? {}), false);
347
+ });
348
+
349
+ test("registerWorker leaves a job for redelivery when complete() fails (transport error, not a handler bug)", async () => {
350
+ const client = fakeSdkClient();
351
+ const engine = new SdkEngineClient(client);
352
+ let handlerRan = false;
353
+ await engine.registerWorker("svc", () => {
354
+ handlerRan = true;
355
+ return { ok: true };
356
+ });
357
+ const rec = client.workers[0];
358
+ let failCalled = false;
359
+ const job: NanoSdkActivatedJob = {
360
+ jobKey: "j1",
361
+ variables: {},
362
+ // The handler succeeded, but reporting the result to the engine fails transiently.
363
+ async complete() {
364
+ throw new Error("FOREIGN KEY constraint failed");
365
+ },
366
+ async fail() {
367
+ failCalled = true;
368
+ throw new Error("must not hard-park a job whose handler succeeded");
369
+ },
370
+ };
371
+ // Must not throw, and must NOT call fail(retries:0) — the job is left locked for the
372
+ // engine to redeliver on lock timeout.
373
+ const result = await rec.dispatch(job);
374
+ assert.equal(handlerRan, true);
375
+ assert.equal(failCalled, false);
376
+ assert.equal(result, undefined);
343
377
  });
344
378
 
345
379
  test("registerWorker routes a thrown BpmnError to the engine's error()", async () => {
@@ -376,14 +410,14 @@ test("registerWorker falls back to fail() for a BpmnError when the SDK has no er
376
410
  throw new BpmnError("NOT_FOUND", "no such record");
377
411
  });
378
412
  const rec = client.workers[0];
379
- let failBody: { errorMessage: string } | undefined;
413
+ let failBody: { errorMessage: string; retries?: number } | undefined;
380
414
  const job: NanoSdkActivatedJob = {
381
415
  jobKey: "j1",
382
416
  variables: {},
383
417
  async complete() {
384
418
  throw new Error("should not complete");
385
419
  },
386
- async fail(body: { errorMessage: string }) {
420
+ async fail(body: { errorMessage: string; retries?: number }) {
387
421
  failBody = body;
388
422
  return "failed";
389
423
  },
@@ -391,6 +425,9 @@ test("registerWorker falls back to fail() for a BpmnError when the SDK has no er
391
425
  };
392
426
  await rec.dispatch(job);
393
427
  assert.equal(failBody?.errorMessage, "no such record");
428
+ // A BpmnError is a modelled, deterministic outcome: pin retries:0 so it does NOT consume the
429
+ // retry budget (unlike a generic handler failure, which omits retries to self-heal).
430
+ assert.equal(failBody?.retries, 0);
394
431
  });
395
432
 
396
433
  test("registerWorker falls back to fail() when the BPMN error report itself throws", async () => {
@@ -400,7 +437,7 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
400
437
  throw new BpmnError("NOT_FOUND", "no such record");
401
438
  });
402
439
  const rec = client.workers[0];
403
- let failBody: { errorMessage: string } | undefined;
440
+ let failBody: { errorMessage: string; retries?: number } | undefined;
404
441
  const job: NanoSdkActivatedJob = {
405
442
  jobKey: "j1",
406
443
  variables: {},
@@ -410,7 +447,7 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
410
447
  async error() {
411
448
  throw new Error("transport down");
412
449
  },
413
- async fail(body: { errorMessage: string }) {
450
+ async fail(body: { errorMessage: string; retries?: number }) {
414
451
  failBody = body;
415
452
  return "failed";
416
453
  },
@@ -418,6 +455,9 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
418
455
  await rec.dispatch(job);
419
456
  // The job must still be acknowledged via fail() rather than silently dropped.
420
457
  assert.equal(failBody?.errorMessage, "no such record");
458
+ // A BpmnError is a modelled, deterministic outcome: pin retries:0 so it does NOT consume the
459
+ // retry budget (unlike a generic handler failure, which omits retries to self-heal).
460
+ assert.equal(failBody?.retries, 0);
421
461
  });
422
462
 
423
463
  test("close stops every worker and closes the SDK client", async () => {
@@ -300,9 +300,9 @@ export class SdkEngineClient implements EngineClient {
300
300
  elementId: job.elementId,
301
301
  variables: job.variables ?? {},
302
302
  };
303
+ let out: Record<string, unknown> | void;
303
304
  try {
304
- const out = await handler(engineJob);
305
- return await job.complete(out ?? {});
305
+ out = await handler(engineJob);
306
306
  } catch (err) {
307
307
  // A handler-raised BPMN error is a modelled, non-retryable outcome:
308
308
  // report it as a BPMN error (routed to an error boundary/event) rather
@@ -330,16 +330,48 @@ export class SdkEngineClient implements EngineClient {
330
330
  }
331
331
  }
332
332
  const message = err instanceof Error ? err.message : String(err);
333
- this.log("error", `handler ${jobType} threw`, { err: message });
333
+ this.log("error", `handler ${jobType} threw`, {
334
+ err: message,
335
+ jobKey: engineJob.jobKey,
336
+ processInstanceKey: engineJob.processInstanceKey,
337
+ elementId: engineJob.elementId,
338
+ });
334
339
  try {
335
- // Best-effort failure report; the engine redelivers after the lock
336
- // times out (at-least-once → handlers must be idempotent). Await +
337
- // swallow so a rejected `fail` cannot escape as an unhandled rejection.
338
- return await job.fail({ errorMessage: message.slice(0, 500), retries: 0 });
340
+ // A generic handler failure omits the retry count so the SDK decrements the job's
341
+ // remaining retries (`job.retries - 1`): a transient failure (e.g. a fleeting store
342
+ // error) self-heals on redelivery and only parks as an incident once the budget is
343
+ // exhausted. A `BpmnError` reaching here (no `job.error()` transport, or the error
344
+ // report itself threw) is the exception: it is a modelled, DETERMINISTIC outcome, so
345
+ // retrying would re-throw the same error and burn the budget for nothing — pin
346
+ // `retries: 0` to keep it non-retryable. Await + swallow so a rejected `fail` cannot
347
+ // escape as an unhandled rejection.
348
+ const body: { errorMessage: string; retries?: number } = {
349
+ errorMessage: message.slice(0, 500),
350
+ };
351
+ if (isBpmnError(err)) body.retries = 0;
352
+ return await job.fail(body);
339
353
  } catch {
340
354
  return undefined;
341
355
  }
342
356
  }
357
+ // The handler resolved: its work is done. Reporting that result to the engine
358
+ // (`complete`) is a SEPARATE concern — a failure here is a transport/engine problem
359
+ // (e.g. a transient store error), NOT a handler bug. Failing it (even with a decrementing
360
+ // retry) would burn the job's retry budget for a completion we can't confirm didn't land.
361
+ // Instead, log and leave the job locked so the engine redelivers it on lock timeout;
362
+ // handlers are idempotent (at-least-once), so a redelivery re-completes cleanly.
363
+ try {
364
+ return await job.complete(out ?? {});
365
+ } catch (completeErr) {
366
+ const message = completeErr instanceof Error ? completeErr.message : String(completeErr);
367
+ this.log("error", `complete ${jobType} failed; leaving job for engine redelivery`, {
368
+ err: message,
369
+ jobKey: engineJob.jobKey,
370
+ processInstanceKey: engineJob.processInstanceKey,
371
+ elementId: engineJob.elementId,
372
+ });
373
+ return undefined;
374
+ }
343
375
  },
344
376
  });
345
377
  // The nano-sdk job worker owns its own start lifecycle: `createJobWorker` runs
@@ -83,8 +83,20 @@ export type {
83
83
  } from "./core/modules/gateway.ts";
84
84
  export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
85
85
  export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./core/modules/pages.ts";
86
+ export { cancelInstanceReconciling } from "./core/modules/cancel.ts";
87
+ export type { CancelInstanceResult, CancelInstanceState } from "./core/modules/cancel.ts";
86
88
  export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
87
89
  export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
90
+ export { defineOperation, mountApi, resolveOperationHandler } from "./core/modules/api.ts";
91
+ export type {
92
+ ApiBinding,
93
+ ApiHandle,
94
+ DefaultContract,
95
+ OperationContract,
96
+ OperationHandler,
97
+ OperationInput,
98
+ OperationResult,
99
+ } from "./core/modules/api.ts";
88
100
  export type { AppJobHandler } from "./core/modules/workers.ts";
89
101
  export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts";
90
102
  export {
@@ -27,3 +27,11 @@ export const GENERATED_DIR = "nano-generated";
27
27
  export function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[] {
28
28
  return [...a].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
29
29
  }
30
+
31
+ /** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
32
+ * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
33
+ * shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
34
+ * whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
35
+ export function isAbsolutePath(p: string): boolean {
36
+ return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
37
+ }
@@ -0,0 +1,219 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS } from "./api.ts";
4
+ import type { OpenApiDoc } from "../../openapi/spec.ts";
5
+
6
+ const doc: OpenApiDoc = {
7
+ openapi: "3.0.0",
8
+ components: {
9
+ schemas: {
10
+ Invoice: {
11
+ type: "object",
12
+ properties: { id: { type: "string" }, amount: { type: "integer" }, status: { type: "string", enum: ["draft", "sent"] } },
13
+ required: ["id", "amount"],
14
+ },
15
+ },
16
+ },
17
+ paths: {
18
+ "/invoices/{id}": {
19
+ get: {
20
+ operationId: "getInvoice",
21
+ summary: "Fetch one invoice",
22
+ parameters: [
23
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
24
+ { name: "expand", in: "query", schema: { type: "string" } },
25
+ ],
26
+ responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } } },
27
+ },
28
+ },
29
+ },
30
+ };
31
+
32
+ test("schemaToTs renders primitives, enums, arrays, refs, and unknown fallback", () => {
33
+ assert.equal(schemaToTs({ type: "string" }), "string");
34
+ assert.equal(schemaToTs({ type: "integer" }), "number");
35
+ assert.equal(schemaToTs({ type: "string", enum: ["a", "b"] }), '"a" | "b"');
36
+ assert.equal(schemaToTs({ type: "array", items: { type: "string" } }), "Array<string>");
37
+ assert.equal(schemaToTs({ $ref: "#/components/schemas/Invoice" }), "Invoice");
38
+ assert.equal(schemaToTs(undefined), "unknown");
39
+ });
40
+
41
+ test("emitApiBindings emits component types + per-op request/response + the id map", () => {
42
+ const out = emitApiBindings(doc);
43
+ assert.match(out, /export type Invoice = \{/);
44
+ assert.match(out, /export interface GetInvoiceRequest \{/);
45
+ assert.match(out, /params: \{ "id": string \}/);
46
+ assert.match(out, /export type GetInvoiceResponse = Invoice;/);
47
+ assert.match(out, /export type ApiOperationId = "getInvoice";/);
48
+ assert.match(out, /"getInvoice": \{ request: GetInvoiceRequest; response: GetInvoiceResponse \};/);
49
+ });
50
+
51
+ test("emitApiBindingsRuntime re-types the runtime defineOperation", () => {
52
+ const out = emitApiBindingsRuntime();
53
+ assert.match(out, /import \{ defineOperation as defineOperationRaw \} from "@nanobpm\/urban";/);
54
+ assert.match(out, /export function defineOperation<K extends ApiOperationId>/);
55
+ assert.match(out, /return defineOperationRaw\(id, handler\);/);
56
+ });
57
+
58
+ test("deriveApi emits both artifacts under nano-generated/", () => {
59
+ const arts = deriveApi(doc);
60
+ const paths = arts.map((a) => a.path).sort();
61
+ assert.deepEqual(paths, [`nano-generated/${API_BINDINGS_DTS}`, `nano-generated/${API_BINDINGS_TS}`]);
62
+ });
63
+
64
+ test("emitted object types mirror runtime additionalProperties semantics", () => {
65
+ // default (absent) → extras allowed at runtime, so the emitted type carries an index signature
66
+ assert.match(schemaToTs({ type: "object", properties: { a: { type: "string" } } }), /\[key: string\]: unknown/);
67
+ // additionalProperties: false → closed object, no index signature
68
+ assert.doesNotMatch(
69
+ schemaToTs({ type: "object", properties: { a: { type: "string" } }, additionalProperties: false }),
70
+ /\[key: string\]/,
71
+ );
72
+ // typed additionalProperties → typed index signature
73
+ assert.match(
74
+ schemaToTs({ type: "object", properties: {}, additionalProperties: { type: "number" } }),
75
+ /\[key: string\]: number/,
76
+ );
77
+ // a closed, empty object is Record<string, never>, not a loose Record<string, unknown>
78
+ assert.equal(schemaToTs({ type: "object", additionalProperties: false }), "Record<string, never>");
79
+ });
80
+
81
+ test("a typeless schema with properties is emitted as an object (matches the validator)", () => {
82
+ const out = schemaToTs({ properties: { a: { type: "string" } }, required: ["a"] });
83
+ assert.match(out, /"a": string/);
84
+ assert.match(out, /\[key: string\]: unknown/);
85
+ });
86
+
87
+ test("required query params aren't undefined-widened; optional ones use the ? modifier", () => {
88
+ const qdoc: OpenApiDoc = {
89
+ openapi: "3.0.0",
90
+ paths: {
91
+ "/search": {
92
+ get: {
93
+ operationId: "search",
94
+ parameters: [
95
+ { name: "q", in: "query", required: true, schema: { type: "string" } },
96
+ { name: "page", in: "query", schema: { type: "string" } },
97
+ ],
98
+ responses: { "200": {} },
99
+ },
100
+ },
101
+ },
102
+ };
103
+ const out = emitApiBindings(qdoc);
104
+ assert.match(out, /"q": string \| string\[\]/); // required → present, not optional
105
+ assert.doesNotMatch(out, /"q"\?:/); // not optional
106
+ assert.doesNotMatch(out, /"q": string \| string\[\] \| undefined/); // not undefined-widened
107
+ assert.match(out, /"page"\?: string \| string\[\]/); // optional → ? modifier
108
+ });
109
+
110
+ test("request body optionality follows requestBody.required (runtime passes undefined when absent)", () => {
111
+ const mk = (required: boolean): OpenApiDoc => ({
112
+ openapi: "3.0.0",
113
+ paths: {
114
+ "/n": {
115
+ post: {
116
+ operationId: "op",
117
+ requestBody: {
118
+ required,
119
+ content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" } } } } },
120
+ },
121
+ responses: { "200": {} },
122
+ },
123
+ },
124
+ },
125
+ });
126
+ assert.match(emitApiBindings(mk(false)), /body\?: \{/); // optional body → `?`
127
+ const req = emitApiBindings(mk(true));
128
+ assert.match(req, /\n {2}body: \{/); // required body → no `?`
129
+ assert.doesNotMatch(req, /body\?:/);
130
+ });
131
+
132
+ test("typed additionalProperties alongside declared props falls back to unknown (valid TS, no TS2411)", () => {
133
+ // A narrow index type (number) would make `{ "a": string; [key: string]: number }` invalid TS,
134
+ // so with declared properties the index signature widens to unknown; a pure map keeps its type.
135
+ const withProps = schemaToTs({
136
+ type: "object",
137
+ properties: { a: { type: "string" } },
138
+ required: ["a"],
139
+ additionalProperties: { type: "number" },
140
+ });
141
+ assert.match(withProps, /"a": string/);
142
+ assert.match(withProps, /\[key: string\]: unknown/);
143
+ assert.doesNotMatch(withProps, /\[key: string\]: number/);
144
+ // No declared properties → precise map type is safe.
145
+ assert.match(
146
+ schemaToTs({ type: "object", additionalProperties: { type: "number" } }),
147
+ /\[key: string\]: number/,
148
+ );
149
+ });
150
+
151
+ test("a required body with no JSON schema is typed `unknown`, not `undefined`", () => {
152
+ const mk = (required: boolean): OpenApiDoc => ({
153
+ openapi: "3.0.0",
154
+ paths: {
155
+ "/n": {
156
+ post: {
157
+ operationId: "op",
158
+ // requestBody with no parseable JSON schema (e.g. unsupported content)
159
+ requestBody: { required, content: { "text/plain": {} } },
160
+ responses: { "200": {} },
161
+ },
162
+ },
163
+ },
164
+ });
165
+ assert.match(emitApiBindings(mk(true)), /\n {2}body: unknown;/); // required → body IS present
166
+ assert.match(emitApiBindings(mk(false)), /body\?: undefined;/); // optional/absent
167
+ });
168
+
169
+ test("request body types derive only from JSON media types (JSON-only surface)", () => {
170
+ const doc2: OpenApiDoc = {
171
+ openapi: "3.0.0",
172
+ paths: {
173
+ "/n": {
174
+ post: {
175
+ operationId: "op",
176
+ requestBody: {
177
+ required: true,
178
+ content: {
179
+ "application/xml": { schema: { type: "string" } }, // ignored
180
+ "application/vnd.acme+json": { schema: { type: "object", properties: { a: { type: "string" } } } },
181
+ },
182
+ },
183
+ responses: { "200": {} },
184
+ },
185
+ },
186
+ },
187
+ };
188
+ const out = emitApiBindings(doc2);
189
+ assert.match(out, /body: \{ "a"\?: string/); // picked the +json schema, not the xml string
190
+ });
191
+
192
+ test("emitApiBindings fails closed on operationIds that collapse to the same TS type name", () => {
193
+ const clash: OpenApiDoc = {
194
+ openapi: "3.0.0",
195
+ paths: {
196
+ "/a": { get: { operationId: "get-invoice", responses: { "200": {} } } },
197
+ "/b": { get: { operationId: "get_invoice", responses: { "200": {} } } },
198
+ },
199
+ };
200
+ assert.throws(() => emitApiBindings(clash), /duplicate TypeScript type "GetInvoiceRequest"/);
201
+ });
202
+
203
+ test("emitApiBindings fails closed on component schema names that collapse to the same TS type name", () => {
204
+ const clash: OpenApiDoc = {
205
+ openapi: "3.0.0",
206
+ components: { schemas: { "Foo-Bar": { type: "object" }, "Foo_Bar": { type: "object" } } },
207
+ paths: {},
208
+ };
209
+ assert.throws(() => emitApiBindings(clash), /duplicate TypeScript type "FooBar"/);
210
+ });
211
+
212
+ test("apiDeriver is registered in the DERIVERS discovery registry", async () => {
213
+ const { DERIVERS, apiDeriver } = await import("../index.ts");
214
+ assert.ok(DERIVERS.includes(apiDeriver), "apiDeriver must be in DERIVERS");
215
+ assert.ok(DERIVERS.some((d) => d.id === "openapi->api-io"));
216
+ // Registry hygiene: no deriver is registered twice and every id is unique.
217
+ const ids = DERIVERS.map((d) => d.id);
218
+ assert.equal(new Set(ids).size, ids.length);
219
+ });
@@ -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
+ };