@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.
- package/dist/openapi/spec.d.ts +127 -0
- package/dist/openapi/spec.js +444 -0
- package/dist/runtime/core/modules/api.d.ts +61 -0
- package/dist/runtime/core/modules/api.js +359 -0
- package/dist/runtime/core/modules/cancel.d.ts +31 -0
- package/dist/runtime/core/modules/cancel.js +90 -0
- package/dist/runtime/core/modules/datasource.d.ts +5 -2
- package/dist/runtime/core/modules/datasource.js +5 -4
- package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
- package/dist/runtime/core/modules/instance-tracking.js +28 -11
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +43 -7
- package/dist/runtime/core/modules/surfaces.js +9 -0
- package/dist/runtime/engine/nanosdk.js +41 -7
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/dist/toolkit/artifact.d.ts +5 -0
- package/dist/toolkit/artifact.js +7 -0
- package/dist/toolkit/derivers/api.d.ts +19 -0
- package/dist/toolkit/derivers/api.js +224 -0
- package/dist/toolkit/gen.js +23 -4
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +12 -1
- package/package.json +1 -1
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -0
- package/src/runtime/core/modules/api.test.ts +445 -0
- package/src/runtime/core/modules/api.ts +453 -0
- package/src/runtime/core/modules/cancel.test.ts +310 -0
- package/src/runtime/core/modules/cancel.ts +119 -0
- package/src/runtime/core/modules/datasource.ts +5 -4
- package/src/runtime/core/modules/instance-tracking.ts +33 -11
- package/src/runtime/core/modules/pages.test.ts +63 -2
- package/src/runtime/core/modules/pages.ts +49 -7
- package/src/runtime/core/modules/surfaces.ts +10 -0
- package/src/runtime/engine/nanosdk.test.ts +45 -5
- package/src/runtime/engine/nanosdk.ts +39 -7
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/artifact.ts +8 -0
- package/src/toolkit/derivers/api.test.ts +219 -0
- package/src/toolkit/derivers/api.ts +255 -0
- package/src/toolkit/gen.test.ts +39 -0
- package/src/toolkit/gen.ts +24 -4
- 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.
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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`, {
|
|
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
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
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
|
package/src/runtime/index.ts
CHANGED
|
@@ -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 {
|
package/src/toolkit/artifact.ts
CHANGED
|
@@ -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
|
+
});
|