@nanobpm/urban 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cli.d.ts +0 -1
  2. package/dist/cli.js +32 -47
  3. package/dist/openapi/spec.d.ts +9 -0
  4. package/dist/openapi/spec.js +22 -0
  5. package/dist/runtime/core/modules/api.d.ts +19 -4
  6. package/dist/runtime/core/modules/api.js +81 -3
  7. package/dist/runtime/core/runtime.d.ts +10 -0
  8. package/dist/runtime/core/runtime.js +2 -2
  9. package/dist/runtime/index.d.ts +3 -1
  10. package/dist/runtime/index.js +2 -1
  11. package/dist/toolkit/api-dir.d.ts +11 -0
  12. package/dist/toolkit/api-dir.js +36 -0
  13. package/dist/toolkit/derivers/api.d.ts +25 -2
  14. package/dist/toolkit/derivers/api.js +60 -5
  15. package/dist/toolkit/gen.js +4 -1
  16. package/dist/toolkit/generate.d.ts +27 -0
  17. package/dist/toolkit/generate.js +47 -0
  18. package/dist/toolkit/index.d.ts +8 -1
  19. package/dist/toolkit/index.js +5 -1
  20. package/dist/toolkit/scaffold/operations.d.ts +21 -0
  21. package/dist/toolkit/scaffold/operations.js +55 -0
  22. package/dist/toolkit/scaffold/workers.js +1 -1
  23. package/dist/toolkit/scaffold.d.ts +16 -0
  24. package/dist/toolkit/scaffold.js +40 -0
  25. package/package.json +74 -74
  26. package/src/cli.test.ts +0 -1
  27. package/src/cli.ts +30 -45
  28. package/src/openapi/spec.ts +24 -0
  29. package/src/runtime/core/modules/api.test.ts +104 -2
  30. package/src/runtime/core/modules/api.ts +95 -8
  31. package/src/runtime/core/runtime.test.ts +53 -1
  32. package/src/runtime/core/runtime.ts +12 -2
  33. package/src/runtime/index.ts +3 -1
  34. package/src/toolkit/api-dir.test.ts +32 -0
  35. package/src/toolkit/api-dir.ts +41 -0
  36. package/src/toolkit/derivers/api.test.ts +41 -3
  37. package/src/toolkit/derivers/api.ts +75 -5
  38. package/src/toolkit/gen.ts +5 -2
  39. package/src/toolkit/generate.test.ts +135 -0
  40. package/src/toolkit/generate.ts +71 -0
  41. package/src/toolkit/index.ts +10 -0
  42. package/src/toolkit/scaffold/operations.test.ts +136 -0
  43. package/src/toolkit/scaffold/operations.ts +75 -0
  44. package/src/toolkit/scaffold/workers.ts +1 -1
  45. package/src/toolkit/scaffold.ts +56 -0
@@ -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 () => false,
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: unknown;
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: unknown;
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
- handler = resolveOperationHandler(await loadModule(op.operationId));
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
  }
@@ -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
+ });
@@ -0,0 +1,41 @@
1
+ // Shared normalization + validation for the OpenAPI surface's `api.dir` (the directory operation
2
+ // delegates live in). Used by BOTH the deriver (which bakes `<dir>/<operationId>` into the generated
3
+ // controller's static import paths) and the scaffolder (which writes stubs to `<dir>/<operationId>.ts`)
4
+ // so the two can never disagree on where a delegate lives — a single rule, one place.
5
+ //
6
+ // The rule is intentionally strict: `api.dir` must be a safe app-relative directory. An absolute path
7
+ // or a `..` segment is REJECTED with a clear error rather than silently rewritten, because a silent
8
+ // rewrite would let `urban gen` scaffold into one directory while the runtime resolves delegates
9
+ // from another (drift), and a `..` segment could escape the app root when later joined.
10
+
11
+ /** The default directory operation delegates live in (mirrors the runtime's `api.dir` default). */
12
+ export const DEFAULT_OPERATIONS_DIR = "operations";
13
+
14
+ /**
15
+ * Normalize an `api.dir` to a clean, app-relative directory segment (no leading/trailing slashes,
16
+ * backslashes folded to `/`). An empty value falls back to {@link DEFAULT_OPERATIONS_DIR}.
17
+ *
18
+ * Throws if `dir` is absolute (POSIX `/…` or Windows `C:\…`/`\…`) or contains a `..` traversal
19
+ * segment — both would let the generated controller's import paths or the scaffolder's write target
20
+ * diverge from (or escape) the app root.
21
+ */
22
+ export function normalizeApiDir(dir: string): string {
23
+ const raw = dir.trim();
24
+ if (raw.length === 0) return DEFAULT_OPERATIONS_DIR;
25
+
26
+ if (isAbsolutePath(raw)) {
27
+ throw new Error(`api.dir must be an app-relative directory, not an absolute path: ${dir}`);
28
+ }
29
+
30
+ const cleaned = raw.replace(/\\/g, "/").replace(/^\.?\/+/, "").replace(/\/+$/, "");
31
+ if (cleaned.split("/").some((seg) => seg === "..")) {
32
+ throw new Error(`api.dir must not contain ".." path segments: ${dir}`);
33
+ }
34
+
35
+ return cleaned.length > 0 ? cleaned : DEFAULT_OPERATIONS_DIR;
36
+ }
37
+
38
+ /** True for a POSIX absolute path (`/…`) or a Windows absolute/drive path (`C:\…`, `C:/…`, `\…`). */
39
+ function isAbsolutePath(path: string): boolean {
40
+ return /^([/\\]|[A-Za-z]:[/\\])/.test(path);
41
+ }
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS } from "./api.ts";
3
+ import { deriveApi, emitApiBindings, emitApiBindingsRuntime, emitApiController, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, API_CONTROLLER_TS } from "./api.ts";
4
4
  import type { OpenApiDoc } from "../../openapi/spec.ts";
5
5
 
6
6
  const doc: OpenApiDoc = {
@@ -55,10 +55,48 @@ test("emitApiBindingsRuntime re-types the runtime defineOperation", () => {
55
55
  assert.match(out, /return defineOperationRaw\(id, handler\);/);
56
56
  });
57
57
 
58
- test("deriveApi emits both artifacts under nano-generated/", () => {
58
+ test("deriveApi emits the type map, typed defineOperation, and the controller under nano-generated/", () => {
59
59
  const arts = deriveApi(doc);
60
60
  const paths = arts.map((a) => a.path).sort();
61
- assert.deepEqual(paths, [`nano-generated/${API_BINDINGS_DTS}`, `nano-generated/${API_BINDINGS_TS}`]);
61
+ assert.deepEqual(paths, [
62
+ `nano-generated/${API_BINDINGS_DTS}`,
63
+ `nano-generated/${API_BINDINGS_TS}`,
64
+ `nano-generated/${API_CONTROLLER_TS}`,
65
+ ].sort());
66
+ });
67
+
68
+ test("emitApiController statically imports each delegate + asserts the set with satisfies", () => {
69
+ const out = emitApiController(doc);
70
+ // one default import per operation, from the default operations/ dir, with an explicit .ts ext
71
+ assert.match(out, /import op0 from "\.\.\/operations\/getInvoice\.ts";/);
72
+ // the registry maps the real operationId to the imported delegate
73
+ assert.match(out, /"getInvoice": op0,/);
74
+ // the load-bearing completeness assertion
75
+ assert.match(out, /satisfies \{\s*\[K in ApiOperationId\]: OperationHandler<ReqFor<K>, ResFor<K>>;\s*\}/);
76
+ assert.match(out, /import type \{ OperationHandler \} from "@nanobpm\/urban";/);
77
+ });
78
+
79
+ test("emitApiController honors a custom api.dir for the delegate import path", () => {
80
+ const out = emitApiController(doc, "handlers");
81
+ assert.match(out, /import op0 from "\.\.\/handlers\/getInvoice\.ts";/);
82
+ });
83
+
84
+ test("emitApiController on an empty spec is a valid empty registry (never = {})", () => {
85
+ const empty: OpenApiDoc = { openapi: "3.0.0", paths: {} };
86
+ const out = emitApiController(empty);
87
+ assert.doesNotMatch(out, /^import op\d/m); // no delegate imports
88
+ assert.match(out, /export const operations = \{\s*\} satisfies \{/);
89
+ });
90
+
91
+ test("emitApiController throws on a duplicate operationId (would silently overwrite a map key)", () => {
92
+ const dupe: OpenApiDoc = {
93
+ openapi: "3.0.0",
94
+ paths: {
95
+ "/a": { get: { operationId: "same", responses: { "200": {} } } },
96
+ "/b": { get: { operationId: "same", responses: { "200": {} } } },
97
+ },
98
+ };
99
+ assert.throws(() => emitApiController(dupe), /duplicate operationId\(s\): same/);
62
100
  });
63
101
 
64
102
  test("emitted object types mirror runtime additionalProperties semantics", () => {