@nanobpm/urban 0.35.0 → 0.37.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/cli.d.ts +0 -1
- package/dist/cli.js +32 -47
- package/dist/openapi/spec.d.ts +9 -0
- package/dist/openapi/spec.js +22 -0
- package/dist/runtime/core/modules/api.d.ts +19 -4
- package/dist/runtime/core/modules/api.js +81 -3
- package/dist/runtime/core/runtime.d.ts +10 -0
- package/dist/runtime/core/runtime.js +2 -2
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +2 -1
- package/dist/toolkit/api-dir.d.ts +11 -0
- package/dist/toolkit/api-dir.js +36 -0
- package/dist/toolkit/derivers/api.d.ts +25 -2
- package/dist/toolkit/derivers/api.js +60 -5
- package/dist/toolkit/gen.js +4 -1
- package/dist/toolkit/generate.d.ts +27 -0
- package/dist/toolkit/generate.js +47 -0
- package/dist/toolkit/index.d.ts +8 -1
- package/dist/toolkit/index.js +5 -1
- package/dist/toolkit/scaffold/operations.d.ts +21 -0
- package/dist/toolkit/scaffold/operations.js +55 -0
- package/dist/toolkit/scaffold/workers.d.ts +7 -5
- package/dist/toolkit/scaffold/workers.js +12 -5
- package/dist/toolkit/scaffold.d.ts +16 -0
- package/dist/toolkit/scaffold.js +41 -1
- package/package.json +74 -74
- package/src/cli.test.ts +0 -1
- package/src/cli.ts +30 -45
- package/src/openapi/spec.ts +24 -0
- package/src/runtime/core/modules/api.test.ts +104 -2
- package/src/runtime/core/modules/api.ts +95 -8
- package/src/runtime/core/runtime.test.ts +53 -1
- package/src/runtime/core/runtime.ts +12 -2
- package/src/runtime/index.ts +3 -1
- package/src/toolkit/api-dir.test.ts +32 -0
- package/src/toolkit/api-dir.ts +41 -0
- package/src/toolkit/derivers/api.test.ts +41 -3
- package/src/toolkit/derivers/api.ts +75 -5
- package/src/toolkit/gen.ts +5 -2
- package/src/toolkit/generate.test.ts +135 -0
- package/src/toolkit/generate.ts +71 -0
- package/src/toolkit/index.ts +10 -0
- package/src/toolkit/scaffold/operations.test.ts +136 -0
- package/src/toolkit/scaffold/operations.ts +75 -0
- package/src/toolkit/scaffold/workers.test.ts +35 -0
- package/src/toolkit/scaffold/workers.ts +13 -5
- package/src/toolkit/scaffold.ts +63 -1
package/src/cli.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { denoGlobal, processGlobal } from "./runtime/adapters/globals.ts";
|
|
|
25
25
|
import { errorMessage, isRecord } from "./runtime/core/guards.ts";
|
|
26
26
|
import { scaffold, slugify } from "create-urban-app";
|
|
27
27
|
import type { DataRequest } from "./runtime/index.ts";
|
|
28
|
-
import { addConnector, createNodeGenIO, previewModels,
|
|
28
|
+
import { addConnector, createNodeGenIO, previewModels, generate, runGen } from "./toolkit/index.ts";
|
|
29
29
|
import { pathToFileURL } from "node:url";
|
|
30
30
|
import { readFileSync } from "node:fs";
|
|
31
31
|
import { spawnSync } from "node:child_process";
|
|
@@ -50,7 +50,6 @@ interface Flags {
|
|
|
50
50
|
manifest: string;
|
|
51
51
|
port?: number;
|
|
52
52
|
check: boolean;
|
|
53
|
-
write: boolean;
|
|
54
53
|
deno: boolean;
|
|
55
54
|
models: boolean;
|
|
56
55
|
stdout: boolean;
|
|
@@ -62,7 +61,7 @@ interface Flags {
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
function parse(argv: string[]): Flags {
|
|
65
|
-
const f: Flags = { root: ".", manifest: "nano.app.json", check: false,
|
|
64
|
+
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
|
|
66
65
|
const need = (i: number, flag: string): string => {
|
|
67
66
|
const v = argv[i];
|
|
68
67
|
if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
|
|
@@ -73,7 +72,6 @@ function parse(argv: string[]): Flags {
|
|
|
73
72
|
if (a === "-h" || a === "--help") f.help = true;
|
|
74
73
|
else if (a === "-v" || a === "--version") f.version = true;
|
|
75
74
|
else if (a === "--check") f.check = true;
|
|
76
|
-
else if (a === "--write") f.write = true;
|
|
77
75
|
else if (a === "--deno") f.deno = true;
|
|
78
76
|
else if (a === "--no-models") f.models = false;
|
|
79
77
|
else if (a === "--models") f.models = true;
|
|
@@ -108,11 +106,12 @@ Usage:
|
|
|
108
106
|
urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
|
|
109
107
|
(--code-first is shorthand for --style code)
|
|
110
108
|
urban check validate the manifest
|
|
111
|
-
urban gen [--check] [--no-models] derive artifacts
|
|
109
|
+
urban gen [--check] [--no-models] derive artifacts + scaffold write-once handler stubs
|
|
110
|
+
(--check: verify up-to-date without writing; a missing stub or
|
|
111
|
+
drifted artifact fails — run \`urban gen\`)
|
|
112
112
|
(--no-models: skip writing derived .bpmn; type-contracts only)
|
|
113
113
|
urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
|
|
114
114
|
(--stdout: print {models,incomplete} JSON without writing)
|
|
115
|
-
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
116
115
|
urban add <pkg> [--no-install] install a connector pack + wire it into nano.app.json
|
|
117
116
|
urban run materialize + serve the app
|
|
118
117
|
urban dev run + watch sources, hot-reload on change
|
|
@@ -148,23 +147,43 @@ async function cmdCheck(f: Flags): Promise<number> {
|
|
|
148
147
|
|
|
149
148
|
async function cmdGen(f: Flags): Promise<number> {
|
|
150
149
|
const io = createNodeGenIO();
|
|
151
|
-
const res = await
|
|
150
|
+
const res = await generate({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
|
|
152
151
|
if (res.incomplete) {
|
|
153
152
|
console.error(`✖ ${res.modelErrors.length} workflow(s) failed to derive:`);
|
|
154
153
|
for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
|
|
155
154
|
return 1;
|
|
156
155
|
}
|
|
157
156
|
if (f.check) {
|
|
158
|
-
if (res.drift.length === 0) {
|
|
159
|
-
console.log(`✔ generated artifacts are up to date (${res.artifacts.length} checked)`);
|
|
157
|
+
if (res.drift.length === 0 && res.missingStubs.length === 0) {
|
|
158
|
+
console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
|
|
160
159
|
return 0;
|
|
161
160
|
}
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
if (res.drift.length > 0) {
|
|
162
|
+
console.error(`✖ ${res.drift.length} generated artifact(s) are out of date — run \`urban gen\`:`);
|
|
163
|
+
for (const p of res.drift) console.error(` • ${p}`);
|
|
164
|
+
}
|
|
165
|
+
if (res.missingStubs.length > 0) {
|
|
166
|
+
console.error(`✖ ${res.missingStubs.length} handler stub(s) missing — run \`urban gen\`:`);
|
|
167
|
+
for (const p of res.missingStubs) console.error(` • ${p}`);
|
|
168
|
+
}
|
|
164
169
|
return 1;
|
|
165
170
|
}
|
|
166
171
|
console.log(`✔ generated ${res.artifacts.length} artifact(s):`);
|
|
167
172
|
for (const a of res.artifacts) console.log(` • ${a.path}`);
|
|
173
|
+
|
|
174
|
+
// Write-once handler stubs scaffolded as part of generation (workers from the model, operation
|
|
175
|
+
// delegates from the OpenAPI spec). Existing, human-owned stubs are kept verbatim.
|
|
176
|
+
const created = [
|
|
177
|
+
...res.workerStubs.filter((o) => o.status === "created").map((o) => o.handlerPath),
|
|
178
|
+
...res.operationStubs.filter((o) => o.status === "created").map((o) => o.handlerPath),
|
|
179
|
+
];
|
|
180
|
+
if (created.length > 0) {
|
|
181
|
+
console.log(`✔ scaffolded ${created.length} handler stub(s):`);
|
|
182
|
+
for (const p of created) console.log(` + ${p}`);
|
|
183
|
+
}
|
|
184
|
+
if (res.manifestPatched) {
|
|
185
|
+
console.log(`✔ wired ${res.wiredWorkers.length} worker(s) into ${f.manifest}`);
|
|
186
|
+
}
|
|
168
187
|
return 0;
|
|
169
188
|
}
|
|
170
189
|
|
|
@@ -217,38 +236,6 @@ async function cmdDerive(f: Flags): Promise<number> {
|
|
|
217
236
|
return 0;
|
|
218
237
|
}
|
|
219
238
|
|
|
220
|
-
async function cmdStubs(f: Flags): Promise<number> {
|
|
221
|
-
const io = createNodeGenIO();
|
|
222
|
-
const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
|
|
223
|
-
|
|
224
|
-
const created = run.outcomes.filter((o) => o.status === "created");
|
|
225
|
-
const would = run.outcomes.filter((o) => o.status === "would-create");
|
|
226
|
-
const kept = run.outcomes.filter((o) => o.status === "kept");
|
|
227
|
-
const typing = (o: { typedIn: boolean; typedOut: boolean }) =>
|
|
228
|
-
o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
|
|
229
|
-
|
|
230
|
-
if (run.write) {
|
|
231
|
-
console.log(`✔ scaffolded ${created.length} worker stub(s):`);
|
|
232
|
-
for (const o of created) console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
233
|
-
if (run.manifestPatched) {
|
|
234
|
-
console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
|
|
235
|
-
}
|
|
236
|
-
} else {
|
|
237
|
-
if (would.length === 0) {
|
|
238
|
-
console.log(`✔ no new worker stubs to scaffold`);
|
|
239
|
-
} else {
|
|
240
|
-
console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
|
|
241
|
-
for (const o of would) console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
for (const o of kept) console.log(` = ${o.handlerPath} (kept — already exists)`);
|
|
245
|
-
if (run.skipped.length > 0) {
|
|
246
|
-
console.log(` skipped ${run.skipped.length}: ` +
|
|
247
|
-
run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
|
|
248
|
-
}
|
|
249
|
-
return 0;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
239
|
async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number> {
|
|
253
240
|
const app = await runFromEnv({
|
|
254
241
|
root: f.root,
|
|
@@ -430,8 +417,6 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
430
417
|
return cmdGen(f);
|
|
431
418
|
case "derive":
|
|
432
419
|
return cmdDerive(f);
|
|
433
|
-
case "stubs":
|
|
434
|
-
return cmdStubs(f);
|
|
435
420
|
case "add":
|
|
436
421
|
return cmdAdd(f);
|
|
437
422
|
case "data":
|
package/src/openapi/spec.ts
CHANGED
|
@@ -265,6 +265,30 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
|
|
|
265
265
|
return out;
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Throw if the document declares the same `operationId` on more than one operation. An operationId
|
|
270
|
+
* must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
|
|
271
|
+
* AND the generated controller registry key), so a duplicate would silently overwrite an earlier
|
|
272
|
+
* entry in the emitted object literal — dispatching the wrong delegate while still type-checking — and
|
|
273
|
+
* make the scaffolder plan the same stub twice. Called by the gen/scaffold path so `urban gen`
|
|
274
|
+
* fails loudly on an incoherent spec instead of shipping a mis-dispatching registry.
|
|
275
|
+
*/
|
|
276
|
+
export function assertUniqueOperationIds(doc: OpenApiDoc): void {
|
|
277
|
+
const seen = new Set<string>();
|
|
278
|
+
const duplicates = new Set<string>();
|
|
279
|
+
for (const op of collectOperations(doc)) {
|
|
280
|
+
if (seen.has(op.operationId)) duplicates.add(op.operationId);
|
|
281
|
+
seen.add(op.operationId);
|
|
282
|
+
}
|
|
283
|
+
if (duplicates.size > 0) {
|
|
284
|
+
const list = [...duplicates].sort().join(", ");
|
|
285
|
+
throw new Error(
|
|
286
|
+
`OpenAPI spec declares duplicate operationId(s): ${list}. Each operationId must be unique — ` +
|
|
287
|
+
"it names the delegate module and the generated controller registry key.",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
268
292
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
269
293
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
270
294
|
export function operationsWithoutId(doc: OpenApiDoc): string[] {
|
|
@@ -5,7 +5,7 @@ import type { AppManifest } from "../manifest.ts";
|
|
|
5
5
|
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
6
6
|
import { makeRouter } from "../router.ts";
|
|
7
7
|
import { DataLayer } from "./datasource.ts";
|
|
8
|
-
import { mountApi, resolveOperationHandler, apiDocsPath, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
8
|
+
import { mountApi, resolveOperationHandler, apiDocsPath, NotImplemented, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
9
9
|
|
|
10
10
|
function req(
|
|
11
11
|
method: string,
|
|
@@ -87,15 +87,17 @@ function build(
|
|
|
87
87
|
modules: Record<string, Record<string, unknown>>,
|
|
88
88
|
specText = spec,
|
|
89
89
|
env: (v: string) => string | undefined = () => undefined,
|
|
90
|
+
existsPaths: string[] = [],
|
|
90
91
|
): Built {
|
|
91
92
|
const imported: string[] = [];
|
|
92
93
|
const logs: Array<{ level: string; msg: string }> = [];
|
|
94
|
+
const existsSet = new Set(existsPaths);
|
|
93
95
|
const host: HostContext = {
|
|
94
96
|
runtime: "node",
|
|
95
97
|
env,
|
|
96
98
|
readTextFile: async () => specText,
|
|
97
99
|
listDir: async () => [],
|
|
98
|
-
exists: async () =>
|
|
100
|
+
exists: async (p: string) => existsSet.has(p),
|
|
99
101
|
openSqlite: () => {
|
|
100
102
|
throw new Error("sqlite not used in this test");
|
|
101
103
|
},
|
|
@@ -597,3 +599,103 @@ test("secured operation: an unset secret env fails closed with 500 (misconfigura
|
|
|
597
599
|
assert.equal(res.status, 500);
|
|
598
600
|
assert.ok(logs.some((l) => l.level === "error" && /security is misconfigured/.test(l.msg)));
|
|
599
601
|
});
|
|
602
|
+
|
|
603
|
+
// ── Generated controller registry (ADR 0059) ────────────────────────────────────────────────────
|
|
604
|
+
// When `nano-generated/controller.ts` is present, dispatch resolves delegates from its exported
|
|
605
|
+
// `operations` map (deterministic, drift-checked) instead of per-op path imports.
|
|
606
|
+
|
|
607
|
+
test("controller present: dispatch uses the registry, not a per-op import", async () => {
|
|
608
|
+
const seen: unknown[] = [];
|
|
609
|
+
const handler: OperationHandler = (input) => {
|
|
610
|
+
seen.push(input.params);
|
|
611
|
+
return { status: 200, body: { ok: true } };
|
|
612
|
+
};
|
|
613
|
+
const { router, imported } = build(
|
|
614
|
+
{ spec: "openapi.json" },
|
|
615
|
+
{
|
|
616
|
+
"/app/nano-generated/controller": { operations: { getInvoice: handler } },
|
|
617
|
+
},
|
|
618
|
+
spec,
|
|
619
|
+
() => undefined,
|
|
620
|
+
["/app/nano-generated/controller.ts"],
|
|
621
|
+
);
|
|
622
|
+
const res = await router(req("GET", "/app/api/invoices/7"));
|
|
623
|
+
assert.equal(res.status, 200);
|
|
624
|
+
assert.deepEqual(seen, [{ id: "7" }]);
|
|
625
|
+
// Only the controller barrel is imported — never a per-operation delegate path.
|
|
626
|
+
assert.deepEqual(imported, ["/app/nano-generated/controller"]);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("controller present: request validation still runs before the registry delegate", async () => {
|
|
630
|
+
const handler: OperationHandler = () => ({ status: 201, body: { id: "i", amount: 1 } });
|
|
631
|
+
const { router } = build(
|
|
632
|
+
{ spec: "openapi.json" },
|
|
633
|
+
{ "/app/nano-generated/controller": { operations: { createInvoice: handler } } },
|
|
634
|
+
spec,
|
|
635
|
+
() => undefined,
|
|
636
|
+
["/app/nano-generated/controller.ts"],
|
|
637
|
+
);
|
|
638
|
+
// amount:0 violates minimum:1 → a 400 from validation, before the delegate.
|
|
639
|
+
const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ id: "i", amount: 0 }) }));
|
|
640
|
+
assert.equal(res.status, 400);
|
|
641
|
+
assert.match(JSON.parse(res.body!).error, /request validation failed/);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("controller present but missing a delegate key → 500 with a run-`urban gen` hint", async () => {
|
|
645
|
+
const { router, logs } = build(
|
|
646
|
+
{ spec: "openapi.json" },
|
|
647
|
+
{ "/app/nano-generated/controller": { operations: {} } }, // stale: getInvoice not registered
|
|
648
|
+
spec,
|
|
649
|
+
() => undefined,
|
|
650
|
+
["/app/nano-generated/controller.ts"],
|
|
651
|
+
);
|
|
652
|
+
const res = await router(req("GET", "/app/api/invoices/7"));
|
|
653
|
+
assert.equal(res.status, 500);
|
|
654
|
+
assert.match(JSON.parse(res.body!).error, /is not registered/);
|
|
655
|
+
assert.ok(logs.some((l) => l.level === "error" && /not registered in the generated controller/.test(l.msg)));
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("a NotImplemented throw from a delegate maps to HTTP 501", async () => {
|
|
659
|
+
const stub: OperationHandler = () => {
|
|
660
|
+
throw new NotImplemented("getInvoice");
|
|
661
|
+
};
|
|
662
|
+
const { router } = build(
|
|
663
|
+
{ spec: "openapi.json" },
|
|
664
|
+
{ "/app/nano-generated/controller": { operations: { getInvoice: stub } } },
|
|
665
|
+
spec,
|
|
666
|
+
() => undefined,
|
|
667
|
+
["/app/nano-generated/controller.ts"],
|
|
668
|
+
);
|
|
669
|
+
const res = await router(req("GET", "/app/api/invoices/7"));
|
|
670
|
+
assert.equal(res.status, 501);
|
|
671
|
+
const bodyJson = JSON.parse(res.body!);
|
|
672
|
+
assert.equal(bodyJson.operationId, "getInvoice");
|
|
673
|
+
assert.match(bodyJson.error, /not implemented/);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("no controller present → back-compat per-op import path still works", async () => {
|
|
677
|
+
const { router, imported } = build(
|
|
678
|
+
{ spec: "openapi.json" },
|
|
679
|
+
{ "/app/operations/getInvoice": { default: () => ({ body: { ok: true } }) } },
|
|
680
|
+
// no exists paths → controller absent
|
|
681
|
+
);
|
|
682
|
+
const res = await router(req("GET", "/app/api/invoices/9"));
|
|
683
|
+
assert.equal(res.status, 200);
|
|
684
|
+
assert.deepEqual(imported, ["/app/operations/getInvoice"]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("compiled app: controller.js present (no .ts) → still uses the registry", async () => {
|
|
688
|
+
// A published / `urban run` build ships controller.js with TS sources absent. Probing only .ts
|
|
689
|
+
// would wrongly report the registry missing and fall back to per-op imports; probe all extensions.
|
|
690
|
+
const handler: OperationHandler = () => ({ status: 200, body: { ok: true } });
|
|
691
|
+
const { router, imported } = build(
|
|
692
|
+
{ spec: "openapi.json" },
|
|
693
|
+
{ "/app/nano-generated/controller": { operations: { getInvoice: handler } } },
|
|
694
|
+
spec,
|
|
695
|
+
() => undefined,
|
|
696
|
+
["/app/nano-generated/controller.js"], // only the compiled artifact exists on disk
|
|
697
|
+
);
|
|
698
|
+
const res = await router(req("GET", "/app/api/invoices/7"));
|
|
699
|
+
assert.equal(res.status, 200);
|
|
700
|
+
assert.deepEqual(imported, ["/app/nano-generated/controller"]);
|
|
701
|
+
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
16
16
|
import { errorMessage } from "../guards.ts";
|
|
17
17
|
import type { HttpRequest, HttpResponse } from "../host.ts";
|
|
18
|
+
import { MODULE_EXTENSION_CANDIDATES } from "../module-path.ts";
|
|
18
19
|
import { html, json, normalizeRoutePath, type Route } from "../router.ts";
|
|
19
20
|
import { resolveAppPath } from "./datasource.ts";
|
|
20
21
|
import {
|
|
@@ -91,18 +92,24 @@ window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger
|
|
|
91
92
|
</html>`;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
/** The `{ params, query, body }` contract a typed operation is generic over.
|
|
95
|
+
/** The `{ params, query, body }` contract a typed operation is generic over. `body` is optional so
|
|
96
|
+
* a generated request type for an operation without a required requestBody (its `body?` is
|
|
97
|
+
* optional/`undefined`) still satisfies the constraint — see the generated `operations.ts` /
|
|
98
|
+
* `controller.ts`, which parameterise `OperationHandler` over the per-op request type. */
|
|
95
99
|
export interface OperationContract {
|
|
96
100
|
params: unknown;
|
|
97
101
|
query: unknown;
|
|
98
|
-
body
|
|
102
|
+
body?: unknown;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
/** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body).
|
|
105
|
+
/** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body). `body` is
|
|
106
|
+
* optional so a raw (untyped) delegate typed `OperationHandler<DefaultContract>` still satisfies the
|
|
107
|
+
* generated registry's per-operation `OperationHandler<ReqFor<K>>` slot — an op whose request body
|
|
108
|
+
* is optional/absent must be a subtype of the default contract (handler args are contravariant). */
|
|
102
109
|
export interface DefaultContract {
|
|
103
110
|
params: Record<string, string>;
|
|
104
111
|
query: Record<string, string | string[] | undefined>;
|
|
105
|
-
body
|
|
112
|
+
body?: unknown;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
/** The input handed to an operation delegate: the raw request plus the parsed (and, unless ejected,
|
|
@@ -127,6 +134,27 @@ export type OperationHandler<Req extends OperationContract = DefaultContract, Re
|
|
|
127
134
|
app: AppApi,
|
|
128
135
|
) => Promise<OperationResult<Res> | void> | OperationResult<Res> | void;
|
|
129
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Thrown by a write-once operation stub to signal the endpoint exists but has no implementation
|
|
139
|
+
* yet; the dispatcher maps it to HTTP `501 Not Implemented` (ADR 0059). Delete the throw once you
|
|
140
|
+
* implement the handler body. A brand property makes the check robust across module realms.
|
|
141
|
+
*/
|
|
142
|
+
export class NotImplemented extends Error {
|
|
143
|
+
readonly urbanNotImplemented = true as const;
|
|
144
|
+
constructor(operationId: string) {
|
|
145
|
+
super(`operation not implemented: ${operationId}`);
|
|
146
|
+
this.name = "NotImplemented";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True for a `NotImplemented` throw (instanceof, or the brand — resilient to realm/dup-copy). */
|
|
151
|
+
function isNotImplemented(e: unknown): boolean {
|
|
152
|
+
return (
|
|
153
|
+
e instanceof NotImplemented ||
|
|
154
|
+
(typeof e === "object" && e !== null && Reflect.get(e, "urbanNotImplemented") === true)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
130
158
|
/**
|
|
131
159
|
* Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
|
|
132
160
|
* `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
|
|
@@ -145,6 +173,10 @@ function isFunction(v: unknown): v is OperationHandler {
|
|
|
145
173
|
return typeof v === "function";
|
|
146
174
|
}
|
|
147
175
|
|
|
176
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
177
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
/** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
|
|
149
181
|
export function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined {
|
|
150
182
|
if (isFunction(mod.default)) return mod.default;
|
|
@@ -290,7 +322,8 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
290
322
|
return docPromise;
|
|
291
323
|
};
|
|
292
324
|
|
|
293
|
-
// Lazily import + cache each delegate module, mirroring mountActions.
|
|
325
|
+
// Lazily import + cache each delegate module, mirroring mountActions. This is the back-compat
|
|
326
|
+
// fallback for apps without a generated controller registry (see loadController below).
|
|
294
327
|
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
|
295
328
|
const loadModule = (operationId: string): Promise<Record<string, unknown>> => {
|
|
296
329
|
// Defense-in-depth at the import sink: collectOperations already skips unsafe ids, but never
|
|
@@ -310,6 +343,43 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
310
343
|
return pending;
|
|
311
344
|
};
|
|
312
345
|
|
|
346
|
+
// Prefer the generated delegate registry (ADR 0059): one statically-typed barrel the toolkit
|
|
347
|
+
// emits from the spec, so a missing/mismatched/orphan delegate is a `tsc` error, and dispatch is
|
|
348
|
+
// a deterministic map lookup rather than a per-request path import. Imported ONCE and cached.
|
|
349
|
+
// Absent (an app that hasn't re-genned) → `null`, and dispatch falls back to `loadModule`. A
|
|
350
|
+
// present-but-broken controller (a delegate that throws at import) is NOT swallowed — it retries
|
|
351
|
+
// and surfaces as a 500 with the reason, exactly like a bad delegate does today.
|
|
352
|
+
let controllerPromise: Promise<Record<string, unknown> | null> | undefined;
|
|
353
|
+
const loadController = (): Promise<Record<string, unknown> | null> => {
|
|
354
|
+
if (!controllerPromise) {
|
|
355
|
+
const rel = "nano-generated/controller";
|
|
356
|
+
controllerPromise = (async () => {
|
|
357
|
+
// Probe every supported module extension (compiled .js first, mirroring resolveModulePath),
|
|
358
|
+
// NOT just `.ts`: a published / `urban run` app ships `controller.js` with the TS sources
|
|
359
|
+
// absent, and probing only `.ts` there would wrongly report the registry missing and fall
|
|
360
|
+
// back to per-op dynamic imports — defeating deterministic dispatch in production.
|
|
361
|
+
let present = false;
|
|
362
|
+
for (const ext of MODULE_EXTENSION_CANDIDATES) {
|
|
363
|
+
if (await ctx.host.exists(resolveAppPath(ctx.root, rel + ext))) {
|
|
364
|
+
present = true;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (!present) return null;
|
|
369
|
+
const mod = await ctx.host.importModule(resolveAppPath(ctx.root, rel));
|
|
370
|
+
const ops = mod.operations;
|
|
371
|
+
if (!isRecord(ops)) {
|
|
372
|
+
throw new Error("nano-generated/controller must export an `operations` registry");
|
|
373
|
+
}
|
|
374
|
+
return ops;
|
|
375
|
+
})().catch((e) => {
|
|
376
|
+
controllerPromise = undefined; // don't cache the failure — let a later request retry
|
|
377
|
+
throw e;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return controllerPromise;
|
|
381
|
+
};
|
|
382
|
+
|
|
313
383
|
// Build the operation table lazily from the loaded doc (cached).
|
|
314
384
|
let opsPromise: Promise<MountedOp[]> | undefined;
|
|
315
385
|
const loadOps = (): Promise<MountedOp[]> => {
|
|
@@ -496,7 +566,21 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
496
566
|
|
|
497
567
|
let handler: OperationHandler | undefined;
|
|
498
568
|
try {
|
|
499
|
-
|
|
569
|
+
const controller = await loadController();
|
|
570
|
+
if (controller) {
|
|
571
|
+
// Deterministic dispatch: the generated registry guarantees (at `tsc` time) one delegate
|
|
572
|
+
// per operationId. A missing/non-function key here means the controller is stale vs the spec.
|
|
573
|
+
const candidate = controller[op.operationId];
|
|
574
|
+
if (!isFunction(candidate)) {
|
|
575
|
+
ctx.host.log("error", "operation not registered in the generated controller (run `urban gen`)", {
|
|
576
|
+
operationId: op.operationId,
|
|
577
|
+
});
|
|
578
|
+
return json({ error: `operation ${op.operationId} is not registered — run \`urban gen\`` }, 500);
|
|
579
|
+
}
|
|
580
|
+
handler = candidate;
|
|
581
|
+
} else {
|
|
582
|
+
handler = resolveOperationHandler(await loadModule(op.operationId));
|
|
583
|
+
}
|
|
500
584
|
} catch (e) {
|
|
501
585
|
ctx.host.log("error", "operation delegate failed to load", {
|
|
502
586
|
operationId: op.operationId,
|
|
@@ -541,11 +625,14 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
541
625
|
body: result.body === undefined ? undefined : JSON.stringify(result.body),
|
|
542
626
|
};
|
|
543
627
|
} catch (e) {
|
|
628
|
+
// A write-once stub (or any delegate) can signal "endpoint exists, not implemented yet" with
|
|
629
|
+
// a NotImplemented throw → 501, distinct from an unexpected fault (500).
|
|
630
|
+
if (isNotImplemented(e)) {
|
|
631
|
+
return json({ error: errorMessage(e), operationId: op.operationId }, 501);
|
|
632
|
+
}
|
|
544
633
|
return json({ error: errorMessage(e) }, 500);
|
|
545
634
|
}
|
|
546
635
|
};
|
|
547
|
-
|
|
548
|
-
// Categorical safety net: any unexpected throw during dispatch — e.g. a malformed spec
|
|
549
636
|
// `pattern` surfacing from validation — becomes a controlled 500 rather than escaping into
|
|
550
637
|
// the host request handler, which has no top-level catch of its own.
|
|
551
638
|
const dispatch = async (req: HttpRequest): Promise<HttpResponse> => {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createNodeHost } from "../adapters/node.ts";
|
|
7
7
|
import { createUrbanApp, resolvePort } from "./runtime.ts";
|
|
8
|
+
import type { SchedulerDeps } from "./modules/scheduler.ts";
|
|
8
9
|
import { runFromEnv } from "../run.ts";
|
|
9
10
|
import { isRecord } from "./guards.ts";
|
|
10
11
|
import type {
|
|
@@ -405,3 +406,54 @@ test("treats NANOBPMN_APP_HANDSHAKE other than \"1\" as opt-out", async () => {
|
|
|
405
406
|
await rm(dir, { recursive: true, force: true });
|
|
406
407
|
}
|
|
407
408
|
});
|
|
409
|
+
|
|
410
|
+
// A minimal deterministic scheduler seam: records armed timers so a test can assert the
|
|
411
|
+
// injected scheduler — not the live one — drives createUrbanApp's background loops.
|
|
412
|
+
function countingScheduler(): SchedulerDeps & { armed: () => number } {
|
|
413
|
+
let clock = 0;
|
|
414
|
+
let armed = 0;
|
|
415
|
+
return {
|
|
416
|
+
now: () => clock,
|
|
417
|
+
setTimer: (_fn, _ms) => {
|
|
418
|
+
armed += 1;
|
|
419
|
+
return armed;
|
|
420
|
+
},
|
|
421
|
+
clearTimer: () => {},
|
|
422
|
+
armed: () => armed,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function makeTrackingFixture(): Promise<string> {
|
|
427
|
+
const dir = await makeFixture();
|
|
428
|
+
const manifestPath = join(dir, "nano.app.json");
|
|
429
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
430
|
+
manifest.instanceTracking = [
|
|
431
|
+
{
|
|
432
|
+
table: "crew_tasks",
|
|
433
|
+
keyField: "id",
|
|
434
|
+
statusField: "status",
|
|
435
|
+
activeStatuses: ["claimed"],
|
|
436
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
437
|
+
pollMs: 1000,
|
|
438
|
+
},
|
|
439
|
+
];
|
|
440
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
441
|
+
return dir;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
test("threads an injected scheduler into the instance-tracking reconciler loop", async () => {
|
|
445
|
+
const dir = await makeTrackingFixture();
|
|
446
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
447
|
+
const engine = new FakeEngine();
|
|
448
|
+
const sched = countingScheduler();
|
|
449
|
+
const app = await createUrbanApp({ host, engine, root: ".", port: 0, scheduler: sched });
|
|
450
|
+
try {
|
|
451
|
+
await app.start();
|
|
452
|
+
// The reconciler arms its first poll on the injected scheduler, not the live one — so the
|
|
453
|
+
// whole-app settle loop can drive it deterministically over a virtual clock.
|
|
454
|
+
assert.ok(sched.armed() >= 1, "injected scheduler armed the reconciler poll");
|
|
455
|
+
} finally {
|
|
456
|
+
await app.stop();
|
|
457
|
+
await rm(dir, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
@@ -18,6 +18,7 @@ import { mountConnectors } from "./modules/connectors.ts";
|
|
|
18
18
|
import { mountSurfaces } from "./modules/surfaces.ts";
|
|
19
19
|
import { mountTriggers } from "./modules/triggers.ts";
|
|
20
20
|
import { mountInstanceTracking } from "./modules/instance-tracking.ts";
|
|
21
|
+
import type { SchedulerDeps } from "./modules/scheduler.ts";
|
|
21
22
|
import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
|
|
22
23
|
|
|
23
24
|
/** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
|
|
@@ -76,6 +77,15 @@ export interface CreateUrbanAppOptions {
|
|
|
76
77
|
* `name → content` map). Merged over the manifest's `models.templates` (this wins on
|
|
77
78
|
* collision). See `deployModels`. */
|
|
78
79
|
templates?: TemplateSource;
|
|
80
|
+
/**
|
|
81
|
+
* Injectable timer + clock seam driving every background loop (the cron trigger loops
|
|
82
|
+
* and the instance-tracking reconciler). Defaults to the live scheduler (real timers,
|
|
83
|
+
* wall clock). The e2e test kit injects a manual scheduler so those loops advance
|
|
84
|
+
* deterministically over a virtual clock — the single seam that makes a whole-app
|
|
85
|
+
* `settle()` possible. Threaded verbatim into `mountTriggers` and `mountInstanceTracking`
|
|
86
|
+
* so one injected scheduler drives them both (no per-module wiring, no drift).
|
|
87
|
+
*/
|
|
88
|
+
scheduler?: SchedulerDeps;
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
export interface UrbanApp {
|
|
@@ -210,13 +220,13 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
210
220
|
describe.surfaces = s.describe();
|
|
211
221
|
}
|
|
212
222
|
if (flags.triggers) {
|
|
213
|
-
const t = mountTriggers(ctx, api);
|
|
223
|
+
const t = mountTriggers(ctx, api, opts.scheduler);
|
|
214
224
|
routes.push(...t.routes);
|
|
215
225
|
mounted.push(t);
|
|
216
226
|
describe.triggers = t.describe?.();
|
|
217
227
|
}
|
|
218
228
|
if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
|
|
219
|
-
const it = mountInstanceTracking(ctx, api);
|
|
229
|
+
const it = mountInstanceTracking(ctx, api, opts.scheduler);
|
|
220
230
|
mounted.push(it);
|
|
221
231
|
describe.instanceTracking = it.describe?.();
|
|
222
232
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -87,7 +87,7 @@ export { cancelInstanceReconciling } from "./core/modules/cancel.ts";
|
|
|
87
87
|
export type { CancelInstanceResult, CancelInstanceState } from "./core/modules/cancel.ts";
|
|
88
88
|
export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
|
|
89
89
|
export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
|
|
90
|
-
export { defineOperation, mountApi, resolveOperationHandler } from "./core/modules/api.ts";
|
|
90
|
+
export { defineOperation, mountApi, resolveOperationHandler, NotImplemented } from "./core/modules/api.ts";
|
|
91
91
|
export type {
|
|
92
92
|
ApiBinding,
|
|
93
93
|
ApiHandle,
|
|
@@ -119,6 +119,8 @@ export { evalCorrelation } from "./core/modules/triggers.ts";
|
|
|
119
119
|
export { deployModels } from "./core/modules/deploy.ts";
|
|
120
120
|
export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
|
|
121
121
|
export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
|
|
122
|
+
export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
|
|
123
|
+
export type { SchedulerDeps } from "./core/modules/scheduler.ts";
|
|
122
124
|
|
|
123
125
|
// Adapters + engine + run entrypoint
|
|
124
126
|
export { createNodeHost } from "./adapters/node.ts";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "./api-dir.ts";
|
|
5
|
+
|
|
6
|
+
test("normalizeApiDir: empty/whitespace falls back to the default", () => {
|
|
7
|
+
assert.equal(normalizeApiDir(""), DEFAULT_OPERATIONS_DIR);
|
|
8
|
+
assert.equal(normalizeApiDir(" "), DEFAULT_OPERATIONS_DIR);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("normalizeApiDir: strips leading ./ and surrounding slashes, folds backslashes", () => {
|
|
12
|
+
assert.equal(normalizeApiDir("ops"), "ops");
|
|
13
|
+
assert.equal(normalizeApiDir("./ops/"), "ops");
|
|
14
|
+
assert.equal(normalizeApiDir("api\\ops"), "api/ops");
|
|
15
|
+
assert.equal(normalizeApiDir("nested/ops/"), "nested/ops");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("normalizeApiDir: rejects absolute POSIX paths", () => {
|
|
19
|
+
assert.throws(() => normalizeApiDir("/ops"), /app-relative/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("normalizeApiDir: rejects absolute Windows paths", () => {
|
|
23
|
+
assert.throws(() => normalizeApiDir("C:\\ops"), /app-relative/);
|
|
24
|
+
assert.throws(() => normalizeApiDir("C:/ops"), /app-relative/);
|
|
25
|
+
assert.throws(() => normalizeApiDir("\\ops"), /app-relative/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("normalizeApiDir: rejects .. traversal segments", () => {
|
|
29
|
+
assert.throws(() => normalizeApiDir("../ops"), /\.\./);
|
|
30
|
+
assert.throws(() => normalizeApiDir("ops/../escape"), /\.\./);
|
|
31
|
+
assert.throws(() => normalizeApiDir("a/../../b"), /\.\./);
|
|
32
|
+
});
|