@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.
- 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 +17 -1
- 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 +46 -2
- package/src/runtime/core/modules/pages.ts +23 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -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
|
+
};
|