@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
@@ -2,6 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  createNanoSdkEngineClient,
5
+ normalizeProcessInstanceState,
5
6
  requireProcessInstanceKey,
6
7
  SdkEngineClient,
7
8
  type NanoSdkActivatedJob,
@@ -66,6 +67,10 @@ function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient &
66
67
  calls.push("searchUserTasks");
67
68
  return { items: [] };
68
69
  },
70
+ async searchProcessInstances() {
71
+ calls.push("searchProcessInstances");
72
+ return { items: [] };
73
+ },
69
74
  async completeUserTask() {
70
75
  calls.push("completeUserTask");
71
76
  return {};
@@ -202,6 +207,74 @@ test("completeUserTask routes through the SDK", async () => {
202
207
  assert.deepEqual(seen, { userTaskKey: "utk", variables: { done: true } });
203
208
  });
204
209
 
210
+ test("searchProcessInstances filters by keys ($in) + state with a matching page cap", async () => {
211
+ let seenInput: unknown;
212
+ let seenConsistency: unknown;
213
+ const client = fakeSdkClient({
214
+ searchProcessInstances: async (input, c) => {
215
+ seenInput = input;
216
+ seenConsistency = c;
217
+ return {
218
+ items: [
219
+ { processInstanceKey: 7, state: "TERMINATED" },
220
+ { processInstanceKey: "8", state: "ACTIVE" },
221
+ ],
222
+ };
223
+ },
224
+ });
225
+ const engine = new SdkEngineClient(client);
226
+ const out = await engine.searchProcessInstances({
227
+ processInstanceKeys: ["7", "8", ""], // empty key dropped from the $in
228
+ state: "TERMINATED",
229
+ });
230
+ assert.deepEqual(seenInput, {
231
+ filter: { state: "TERMINATED", processInstanceKey: { $in: ["7", "8"] } },
232
+ page: { limit: 2 },
233
+ });
234
+ assert.deepEqual(seenConsistency, { consistency: { waitUpToMs: 0 } });
235
+ assert.deepEqual(out, [
236
+ { processInstanceKey: "7", state: "TERMINATED" },
237
+ { processInstanceKey: "8", state: "ACTIVE" },
238
+ ]);
239
+ });
240
+
241
+ test("searchProcessInstances with no keys sends no key filter and no page cap", async () => {
242
+ let seenInput: unknown;
243
+ const client = fakeSdkClient({
244
+ searchProcessInstances: async (input) => {
245
+ seenInput = input;
246
+ return { items: [] };
247
+ },
248
+ });
249
+ const engine = new SdkEngineClient(client);
250
+ await engine.searchProcessInstances();
251
+ assert.deepEqual(seenInput, { filter: {} });
252
+ });
253
+
254
+ test("searchProcessInstances skips keyless and unrecognized-state rows", async () => {
255
+ const client = fakeSdkClient({
256
+ searchProcessInstances: async () => ({
257
+ items: [
258
+ { processInstanceKey: "", state: "TERMINATED" }, // keyless → skipped
259
+ { processInstanceKey: 9, state: "SUSPENDED" }, // unknown state → skipped
260
+ { processInstanceKey: 10, state: "completed" }, // case-insensitive → mapped
261
+ ],
262
+ }),
263
+ });
264
+ const engine = new SdkEngineClient(client);
265
+ const out = await engine.searchProcessInstances();
266
+ assert.deepEqual(out, [{ processInstanceKey: "10", state: "COMPLETED" }]);
267
+ });
268
+
269
+ test("normalizeProcessInstanceState maps the terminal set and rejects others", () => {
270
+ assert.equal(normalizeProcessInstanceState("ACTIVE"), "ACTIVE");
271
+ assert.equal(normalizeProcessInstanceState("completed"), "COMPLETED");
272
+ assert.equal(normalizeProcessInstanceState("Terminated"), "TERMINATED");
273
+ assert.equal(normalizeProcessInstanceState("SUSPENDED"), undefined);
274
+ assert.equal(normalizeProcessInstanceState(42), undefined);
275
+ assert.equal(normalizeProcessInstanceState(undefined), undefined);
276
+ });
277
+
205
278
  test("registerWorker creates a worker the SDK auto-starts, and dispatches + completes", async () => {
206
279
  const client = fakeSdkClient();
207
280
  const engine = new SdkEngineClient(client);
@@ -266,7 +339,41 @@ test("registerWorker fails the job when the handler throws", async () => {
266
339
  };
267
340
  await rec.dispatch(job);
268
341
  assert.equal(failBody?.errorMessage, "boom");
269
- 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);
270
377
  });
271
378
 
272
379
  test("registerWorker routes a thrown BpmnError to the engine's error()", async () => {
@@ -303,14 +410,14 @@ test("registerWorker falls back to fail() for a BpmnError when the SDK has no er
303
410
  throw new BpmnError("NOT_FOUND", "no such record");
304
411
  });
305
412
  const rec = client.workers[0];
306
- let failBody: { errorMessage: string } | undefined;
413
+ let failBody: { errorMessage: string; retries?: number } | undefined;
307
414
  const job: NanoSdkActivatedJob = {
308
415
  jobKey: "j1",
309
416
  variables: {},
310
417
  async complete() {
311
418
  throw new Error("should not complete");
312
419
  },
313
- async fail(body: { errorMessage: string }) {
420
+ async fail(body: { errorMessage: string; retries?: number }) {
314
421
  failBody = body;
315
422
  return "failed";
316
423
  },
@@ -318,6 +425,9 @@ test("registerWorker falls back to fail() for a BpmnError when the SDK has no er
318
425
  };
319
426
  await rec.dispatch(job);
320
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);
321
431
  });
322
432
 
323
433
  test("registerWorker falls back to fail() when the BPMN error report itself throws", async () => {
@@ -327,7 +437,7 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
327
437
  throw new BpmnError("NOT_FOUND", "no such record");
328
438
  });
329
439
  const rec = client.workers[0];
330
- let failBody: { errorMessage: string } | undefined;
440
+ let failBody: { errorMessage: string; retries?: number } | undefined;
331
441
  const job: NanoSdkActivatedJob = {
332
442
  jobKey: "j1",
333
443
  variables: {},
@@ -337,7 +447,7 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
337
447
  async error() {
338
448
  throw new Error("transport down");
339
449
  },
340
- async fail(body: { errorMessage: string }) {
450
+ async fail(body: { errorMessage: string; retries?: number }) {
341
451
  failBody = body;
342
452
  return "failed";
343
453
  },
@@ -345,6 +455,9 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
345
455
  await rec.dispatch(job);
346
456
  // The job must still be acknowledged via fail() rather than silently dropped.
347
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);
348
461
  });
349
462
 
350
463
  test("close stops every worker and closes the SDK client", async () => {
@@ -15,6 +15,8 @@ import type {
15
15
  EngineClient,
16
16
  EngineJob,
17
17
  JobHandler,
18
+ ProcessInstanceSnapshot,
19
+ ProcessInstanceState,
18
20
  WorkerSubscription,
19
21
  } from "../core/host.ts";
20
22
  import { isBpmnError } from "../core/host.ts";
@@ -30,6 +32,26 @@ export function requireProcessInstanceKey(key: string | number | null | undefine
30
32
  return String(key);
31
33
  }
32
34
 
35
+ /** Map the engine's process-instance `state` onto the transport-agnostic
36
+ * {@link ProcessInstanceState} union, or `undefined` for an unrecognized value
37
+ * (which the caller skips rather than mis-reconciling). Case-insensitive so a
38
+ * future casing tweak on the engine side doesn't silently drop instances. */
39
+ export function normalizeProcessInstanceState(
40
+ raw: unknown,
41
+ ): ProcessInstanceState | undefined {
42
+ if (typeof raw !== "string") return undefined;
43
+ switch (raw.toUpperCase()) {
44
+ case "ACTIVE":
45
+ return "ACTIVE";
46
+ case "COMPLETED":
47
+ return "COMPLETED";
48
+ case "TERMINATED":
49
+ return "TERMINATED";
50
+ default:
51
+ return undefined;
52
+ }
53
+ }
54
+
33
55
  /** A job as delivered to a nano-sdk job handler: the frame fields plus the
34
56
  * acknowledgement actions the handler must call. */
35
57
  export interface NanoSdkActivatedJob {
@@ -103,6 +125,11 @@ export interface NanoSdkClient {
103
125
  input: { userTaskKey: string; variables?: Record<string, unknown> },
104
126
  options?: unknown,
105
127
  ): Promise<unknown>;
128
+ searchProcessInstances(
129
+ input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
130
+ consistency?: unknown,
131
+ options?: unknown,
132
+ ): Promise<Record<string, unknown>>;
106
133
  createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
107
134
  /** Stop every worker created on this client. Used on teardown to also drain a
108
135
  * REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
@@ -213,6 +240,42 @@ export class SdkEngineClient implements EngineClient {
213
240
  await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
214
241
  }
215
242
 
243
+ async searchProcessInstances(filter?: {
244
+ processInstanceKeys?: string[];
245
+ state?: ProcessInstanceState;
246
+ }): Promise<ProcessInstanceSnapshot[]> {
247
+ const f: Record<string, unknown> = {};
248
+ if (filter?.state) f.state = filter.state;
249
+ const keys = filter?.processInstanceKeys?.filter((k) => k != null && k !== "");
250
+ if (keys && keys.length > 0) f.processInstanceKey = { $in: keys };
251
+ // A process-instance search is an eventually consistent read; ask for zero-wait
252
+ // consistency so it reflects what is currently visible without blocking. Cap the page
253
+ // to the number of keys asked for (each key matches at most one instance), so a bounded
254
+ // reconcile query never silently truncates on the API's default page size.
255
+ const page = keys && keys.length > 0 ? { limit: keys.length } : undefined;
256
+ const body = await this.client.searchProcessInstances(
257
+ { filter: f, ...(page ? { page } : {}) },
258
+ { consistency: { waitUpToMs: 0 } },
259
+ );
260
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
261
+ return items.flatMap((it) => {
262
+ const key = it.processInstanceKey;
263
+ if (key == null || key === "") {
264
+ this.log("warn", "skipping process instance with no key in engine response");
265
+ return [];
266
+ }
267
+ const state = normalizeProcessInstanceState(it.state);
268
+ if (!state) {
269
+ this.log("warn", "skipping process instance with unrecognized state", {
270
+ processInstanceKey: String(key),
271
+ state: String(it.state),
272
+ });
273
+ return [];
274
+ }
275
+ return [{ processInstanceKey: String(key), state }];
276
+ });
277
+ }
278
+
216
279
  async registerWorker(
217
280
  jobType: string,
218
281
  handler: JobHandler,
@@ -237,9 +300,9 @@ export class SdkEngineClient implements EngineClient {
237
300
  elementId: job.elementId,
238
301
  variables: job.variables ?? {},
239
302
  };
303
+ let out: Record<string, unknown> | void;
240
304
  try {
241
- const out = await handler(engineJob);
242
- return await job.complete(out ?? {});
305
+ out = await handler(engineJob);
243
306
  } catch (err) {
244
307
  // A handler-raised BPMN error is a modelled, non-retryable outcome:
245
308
  // report it as a BPMN error (routed to an error boundary/event) rather
@@ -267,16 +330,48 @@ export class SdkEngineClient implements EngineClient {
267
330
  }
268
331
  }
269
332
  const message = err instanceof Error ? err.message : String(err);
270
- 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
+ });
271
339
  try {
272
- // Best-effort failure report; the engine redelivers after the lock
273
- // times out (at-least-once → handlers must be idempotent). Await +
274
- // swallow so a rejected `fail` cannot escape as an unhandled rejection.
275
- 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);
276
353
  } catch {
277
354
  return undefined;
278
355
  }
279
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
+ }
280
375
  },
281
376
  });
282
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
+ });