@nanobpm/urban 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/core/modules/api.d.ts +61 -0
  4. package/dist/runtime/core/modules/api.js +359 -0
  5. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  6. package/dist/runtime/core/modules/cancel.js +90 -0
  7. package/dist/runtime/core/modules/datasource.d.ts +5 -2
  8. package/dist/runtime/core/modules/datasource.js +5 -4
  9. package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
  10. package/dist/runtime/core/modules/instance-tracking.js +28 -11
  11. package/dist/runtime/core/modules/pages.d.ts +8 -0
  12. package/dist/runtime/core/modules/pages.js +43 -7
  13. package/dist/runtime/core/modules/surfaces.js +9 -0
  14. package/dist/runtime/engine/nanosdk.js +41 -7
  15. package/dist/runtime/index.d.ts +4 -0
  16. package/dist/runtime/index.js +2 -0
  17. package/dist/toolkit/artifact.d.ts +5 -0
  18. package/dist/toolkit/artifact.js +7 -0
  19. package/dist/toolkit/derivers/api.d.ts +19 -0
  20. package/dist/toolkit/derivers/api.js +224 -0
  21. package/dist/toolkit/gen.js +23 -4
  22. package/dist/toolkit/index.d.ts +5 -0
  23. package/dist/toolkit/index.js +12 -1
  24. package/package.json +1 -1
  25. package/src/openapi/spec.test.ts +292 -0
  26. package/src/openapi/spec.ts +540 -0
  27. package/src/runtime/core/modules/api.test.ts +445 -0
  28. package/src/runtime/core/modules/api.ts +453 -0
  29. package/src/runtime/core/modules/cancel.test.ts +310 -0
  30. package/src/runtime/core/modules/cancel.ts +119 -0
  31. package/src/runtime/core/modules/datasource.ts +5 -4
  32. package/src/runtime/core/modules/instance-tracking.ts +33 -11
  33. package/src/runtime/core/modules/pages.test.ts +63 -2
  34. package/src/runtime/core/modules/pages.ts +49 -7
  35. package/src/runtime/core/modules/surfaces.ts +10 -0
  36. package/src/runtime/engine/nanosdk.test.ts +45 -5
  37. package/src/runtime/engine/nanosdk.ts +39 -7
  38. package/src/runtime/index.ts +12 -0
  39. package/src/toolkit/artifact.ts +8 -0
  40. package/src/toolkit/derivers/api.test.ts +219 -0
  41. package/src/toolkit/derivers/api.ts +255 -0
  42. package/src/toolkit/gen.test.ts +39 -0
  43. package/src/toolkit/gen.ts +24 -4
  44. package/src/toolkit/index.ts +41 -1
@@ -19,6 +19,7 @@ import type { AppApi, RuntimeContext } from "../context.ts";
19
19
  import { errorMessage, isRecord } from "../guards.ts";
20
20
  import type { EngineClient } from "../host.ts";
21
21
  import { html, json, type Route } from "../router.ts";
22
+ import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
22
23
  import { quoteIdent } from "./gateway.ts";
23
24
 
24
25
  /** The subset of the datasource gateway the page runtime needs. */
@@ -49,6 +50,13 @@ export interface PagesDeps {
49
50
  * Optional; when absent the index is empty. Injectable for tests.
50
51
  */
51
52
  listPages?(): Promise<string[]>;
53
+ /**
54
+ * Cancel a process instance honestly: terminate it, verify it is actually gone, and reconcile
55
+ * any `instanceTracking` row bound to its key. Injected by `mountPages` (bound to the app's
56
+ * engine, datasource, and instanceTracking bindings). When absent, the `/app/actions/cancel`
57
+ * route falls back to a bare `engine.cancelInstance` (used by tests that inject no cancel dep).
58
+ */
59
+ cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
52
60
  }
53
61
 
54
62
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
@@ -70,7 +78,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
70
78
  const rowLimitRaw = opts.rowLimit ?? 200;
71
79
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
72
80
  const sourceName = opts.sourceName ?? "app";
73
- const { db, engine, readPage, listPages } = deps;
81
+ const { db, engine, readPage, listPages, cancel } = deps;
74
82
 
75
83
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
76
84
  // (before serving), so the schema is stable for the process lifetime, and the renderer
@@ -285,6 +293,18 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
285
293
  return json({ error: "body must be JSON" }, 400);
286
294
  }
287
295
  if (key === undefined || key === "") return json({ error: "processInstanceKey is required" }, 400);
296
+ // The reconcile-aware primitive verifies the real post-cancel state and flips the tracked
297
+ // row immediately; a !ok result means the engine did NOT stop the instance, so surface 502.
298
+ if (cancel) {
299
+ try {
300
+ const result = await cancel(String(key));
301
+ return json(result, result.ok ? 200 : 502);
302
+ } catch (e) {
303
+ // Defensive: the primitive is designed not to throw, but if it ever does, mirror the
304
+ // fallback path's honest 502 rather than letting the request reject as a 500.
305
+ return json({ ok: false, processInstanceKey: String(key), error: errorMessage(e) }, 502);
306
+ }
307
+ }
288
308
  try {
289
309
  await engine.cancelInstance({ processInstanceKey: String(key) });
290
310
  return json({ ok: true });
@@ -354,9 +374,11 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
354
374
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
355
375
  };
356
376
  const sourceName = opts.sourceName ?? "app";
377
+ const bindings = ctx.manifest.instanceTracking ?? [];
357
378
  const routes = createPagesRoutes(opts, {
358
379
  db: app.data.open(sourceName),
359
380
  engine: app.engine,
381
+ cancel: (key) => cancelInstanceReconciling(app, bindings, key),
360
382
  readPage: (p) => ctx.host.readTextFile(p),
361
383
  listPages: async () => {
362
384
  const dir = opts.pagesDir ?? "pages";
@@ -599,12 +621,20 @@ function renderText(node) {
599
621
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
600
622
  }
601
623
 
602
- // A grid td cell. When the column declares linkField, the cell text (the
603
- // column's own value) becomes a link to the URL held in that other field,
604
- // opened in a new tab. Only http(s) hrefs are linked — anything else (e.g. a
605
- // javascript: URL smuggled through row data) falls back to plain text — and
606
- // external links get rel=noopener noreferrer so the opened page can't reach
607
- // window.opener. Shared by the top-level grid and child grids.
624
+ // A grid td cell. Two column-declared linking modes, checked in order:
625
+ // 1. linkField — the cell text becomes a link to the URL held in that other
626
+ // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
627
+ // smuggled through row data falls back to plain text).
628
+ // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
629
+ // link: the cell text links to the Nano console's explorer view for the
630
+ // process instance whose key is held in the row's keyField. The console
631
+ // path is constructed HERE (never taken from row data) and the key is
632
+ // URL-encoded, so row data can't smuggle a path or scheme; only a
633
+ // non-empty key (after trimming surrounding whitespace) produces a link.
634
+ // Unknown link kinds fall back to plain text so an unrecognised schema
635
+ // can't render a broken anchor.
636
+ // Both open in a new tab with rel=noopener noreferrer so the opened page can't
637
+ // reach window.opener. Shared by the top-level grid and child grids.
608
638
  function gridCell(col, row) {
609
639
  const text = row[col.field] == null ? "" : String(row[col.field]);
610
640
  if (col.linkField) {
@@ -617,6 +647,18 @@ function gridCell(col, row) {
617
647
  );
618
648
  }
619
649
  }
650
+ if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
651
+ const key = row[col.link.keyField];
652
+ const keyStr = key == null ? "" : String(key).trim();
653
+ if (text !== "" && keyStr !== "") {
654
+ const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
655
+ return el(
656
+ "td",
657
+ {},
658
+ el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
659
+ );
660
+ }
661
+ }
620
662
  return el("td", {}, text);
621
663
  }
622
664
 
@@ -6,6 +6,7 @@
6
6
  import type { AppApi, RuntimeContext } from "../context.ts";
7
7
  import { html, json, normalizeRoutePath, type Route } from "../router.ts";
8
8
  import { mountActions } from "./actions.ts";
9
+ import { mountApi } from "./api.ts";
9
10
  import { mountPages } from "./pages.ts";
10
11
 
11
12
  /** Escape HTML-significant characters before embedding a value in markup. */
@@ -109,6 +110,15 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
109
110
  enabled.push(`actions(${actions.routes.length})`);
110
111
  }
111
112
 
113
+ // The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
114
+ // still shadows an operation) and BEFORE the generic pages routes. It owns the `api.base`
115
+ // namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
116
+ const api = mountApi(ctx, app);
117
+ if (api.routes.length > 0) {
118
+ routes.push(...api.routes);
119
+ enabled.push("api");
120
+ }
121
+
112
122
  // The pages surface (the schema-driven page runtime) mounts its own routes.
113
123
  const pages = mountPages(ctx, app);
114
124
  if (pages.routes.length > 0) {
@@ -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
+ });