@nanobpm/urban 0.77.0 → 0.77.2
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/runtime/core/modules/pages.js +45 -2
- package/dist/runtime/core/router.d.ts +14 -2
- package/dist/runtime/core/router.js +8 -2
- package/dist/toolkit/derivers/api.js +64 -13
- package/dist/toolkit/derivers/domain.js +10 -12
- package/dist/toolkit/derivers/messages.js +6 -7
- package/dist/toolkit/derivers/meta.js +10 -8
- package/dist/toolkit/derivers/worker-io.js +8 -9
- package/package.json +2 -2
- package/src/runtime/core/modules/agent.test.ts +6 -3
- package/src/runtime/core/modules/pages.test.ts +127 -0
- package/src/runtime/core/modules/pages.ts +44 -2
- package/src/runtime/core/modules/surfaces.test.ts +8 -4
- package/src/runtime/core/router.ts +21 -2
- package/src/toolkit/derivers/api.test.ts +28 -0
- package/src/toolkit/derivers/api.ts +61 -15
- package/src/toolkit/derivers/bindings-parity.test.ts +1 -1
- package/src/toolkit/derivers/domain.test.ts +1 -1
- package/src/toolkit/derivers/domain.ts +10 -12
- package/src/toolkit/derivers/generated-lint.test.ts +369 -0
- package/src/toolkit/derivers/messages.test.ts +2 -6
- package/src/toolkit/derivers/messages.ts +6 -7
- package/src/toolkit/derivers/meta.test.ts +13 -13
- package/src/toolkit/derivers/meta.ts +11 -8
- package/src/toolkit/derivers/worker-io.test.ts +1 -1
- package/src/toolkit/derivers/worker-io.ts +8 -9
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// GET / (+ /app/runtime.js) → the schema-driven browser renderer
|
|
12
12
|
// GET /app/pages/<id> → the page's page.json
|
|
13
13
|
// GET /app/data/<source>/<table>[?where&order] → rows (filtered/ordered, whitelisted)
|
|
14
|
-
// GET /{embed,standalone}.html /mount.js /cockpit.css → appView sidecars (from pagesDir, #420)
|
|
14
|
+
// GET /{embed,standalone}.html /mount.js /cockpit.css → flat appView sidecars (from pagesDir, #420)
|
|
15
|
+
// GET /<subdir>/<asset> → nested appView sidecars (pages/<subdir>/…, #442)
|
|
15
16
|
// GET /dist/<path> → the app dist/ bundle the sidecars reference
|
|
16
17
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
17
18
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
@@ -107,7 +108,10 @@ function javascript(
|
|
|
107
108
|
// document then pulls `./cockpit.css`, `./mount.js`, and (via its import-map)
|
|
108
109
|
// `../dist/…`. A DEPLOYED app served none of these, so the src 404'd and the cockpit
|
|
109
110
|
// iframe loaded nothing (#416's user-facing "nothing shows in the Cockpit"). These are
|
|
110
|
-
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`.
|
|
111
|
+
// the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`. An app that ships
|
|
112
|
+
// MORE than one `appView` gives each its own SUBDIR sidecar (`./cockpit/embed.html`,
|
|
113
|
+
// `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
|
|
114
|
+
// this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
|
|
111
115
|
// import-map references is served separately under the `/dist/` prefix below.
|
|
112
116
|
const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"] as const;
|
|
113
117
|
|
|
@@ -541,6 +545,44 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
541
545
|
},
|
|
542
546
|
});
|
|
543
547
|
|
|
548
|
+
// ── nested (multi-appView) sidecars (#442) ──────────────────────────────
|
|
549
|
+
// An app with more than one `appView` cannot share the four FLAT sidecar names above:
|
|
550
|
+
// each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
|
|
551
|
+
// `./cockpit/embed.html`, `pages/board.page.json` → `./board/embed.html`. The renderer's
|
|
552
|
+
// `baseRelativeUrl(p.embed)` rebases those onto the mount root, so the browser fetches
|
|
553
|
+
// `/cockpit/embed.html`, `/board/mount.js`, … — every one of which #420's flat 4-name
|
|
554
|
+
// serve 404'd. Serve the `pagesDir` as a TREE, mapping `/<subpath>` onto
|
|
555
|
+
// `pagesDir/<subpath>` with the SAME traversal guard as the `/dist/` route (mirroring it).
|
|
556
|
+
// It is a `/`-prefix route, so to avoid a root catch-all silently swallowing `/healthz`,
|
|
557
|
+
// the app's own trigger routes, or any other route, the handler DECLINES (returns
|
|
558
|
+
// undefined → the router falls through to the next route, see `RouteHandler`) whenever it
|
|
559
|
+
// cannot serve a real nested asset:
|
|
560
|
+
// • a bare single-segment path (`/healthz`, the flat sidecars) — owned by its own exact
|
|
561
|
+
// route / the runtime's liveness route, never this tree;
|
|
562
|
+
// • an unsafe subpath (`..`/absolute/percent-escaped/backslash) — traversal-rejected
|
|
563
|
+
// before it reaches the filesystem;
|
|
564
|
+
// • a miss (no such file) — so a same-path route registered AFTER the pages surface (a
|
|
565
|
+
// trigger, the appended `/healthz`) still resolves instead of being masked by a 404.
|
|
566
|
+
// Registered LAST among the pages routes so every exact/prefix pages route above wins first.
|
|
567
|
+
routes.push({
|
|
568
|
+
method: "GET",
|
|
569
|
+
path: "/",
|
|
570
|
+
prefix: true,
|
|
571
|
+
source: "surface:pages",
|
|
572
|
+
handler: async (req) => {
|
|
573
|
+
const rest = req.path.slice(1); // drop the leading "/"
|
|
574
|
+
// Only a SUBDIR asset (has a `/`) belongs to this tree; a single-segment path is left
|
|
575
|
+
// to its own exact route / the appended `/healthz` liveness route.
|
|
576
|
+
if (!rest.includes("/")) return undefined;
|
|
577
|
+
const safe = safeAssetSubpath(rest);
|
|
578
|
+
if (safe === null) return undefined; // traversal/escape → decline (never touch the FS)
|
|
579
|
+
const res = await serveAsset(readAsset, `${pagesDir}/${safe}`);
|
|
580
|
+
// A miss declines rather than 404s, so a later same-path route (e.g. a trigger) can
|
|
581
|
+
// still claim it; the router's own default 404 covers a genuinely absent asset.
|
|
582
|
+
return res.status === 404 ? undefined : res;
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
|
|
544
586
|
return routes;
|
|
545
587
|
}
|
|
546
588
|
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { mountSurfaces } from "./surfaces.ts";
|
|
7
|
-
import type { EngineClient, HostContext,
|
|
7
|
+
import type { EngineClient, HostContext, HttpResponse } from "../host.ts";
|
|
8
8
|
import { DataLayer } from "./datasource.ts";
|
|
9
9
|
import { createLogger } from "../logger.ts";
|
|
10
|
+
import type { Route } from "../router.ts";
|
|
10
11
|
|
|
11
12
|
function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurfaces>[0] {
|
|
12
13
|
const host: HostContext = {
|
|
@@ -63,7 +64,7 @@ const fakeApp: Parameters<typeof mountSurfaces>[1] = {
|
|
|
63
64
|
log: createLogger(() => {}),
|
|
64
65
|
};
|
|
65
66
|
|
|
66
|
-
async function render(route:
|
|
67
|
+
async function render(route: Route): Promise<string> {
|
|
67
68
|
const res = await route.handler({
|
|
68
69
|
method: "GET",
|
|
69
70
|
path: "/",
|
|
@@ -71,6 +72,7 @@ async function render(route: { handler: (req: HttpRequest) => HttpResponse | Pro
|
|
|
71
72
|
headers: new Headers(),
|
|
72
73
|
text: async () => "",
|
|
73
74
|
});
|
|
75
|
+
assert.ok(res, "handler should not decline");
|
|
74
76
|
return String(res.body);
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -118,16 +120,18 @@ function appWith(engine: EngineClient): Parameters<typeof mountSurfaces>[1] {
|
|
|
118
120
|
|
|
119
121
|
/** Invoke a route handler with an optional query string and POST body. */
|
|
120
122
|
async function call(
|
|
121
|
-
route:
|
|
123
|
+
route: Route,
|
|
122
124
|
opts: { method?: string; query?: string; body?: string } = {},
|
|
123
125
|
): Promise<HttpResponse> {
|
|
124
|
-
|
|
126
|
+
const res = await route.handler({
|
|
125
127
|
method: opts.method ?? "GET",
|
|
126
128
|
path: "/",
|
|
127
129
|
query: new URLSearchParams(opts.query ?? ""),
|
|
128
130
|
headers: new Headers(),
|
|
129
131
|
text: async () => opts.body ?? "",
|
|
130
132
|
});
|
|
133
|
+
assert.ok(res, "handler should not decline");
|
|
134
|
+
return res;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
function inboxRoutes(engine: EngineClient) {
|
|
@@ -4,12 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
import type { HttpHandler, HttpRequest, HttpResponse } from "./host.ts";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A route handler that may DECLINE a matched request by returning `undefined`
|
|
9
|
+
* (or a promise of it). The router then treats the route as if it had not matched
|
|
10
|
+
* and continues to the next route (fall-through). This lets a broad prefix route —
|
|
11
|
+
* e.g. the pages surface's nested static serve at `/` — claim only the requests it
|
|
12
|
+
* can actually satisfy (a real, safe asset) and let everything else (`/healthz`, an
|
|
13
|
+
* app's own trigger routes registered *after* it) resolve to their own handlers,
|
|
14
|
+
* instead of a root catch-all silently swallowing them. A handler that returns a
|
|
15
|
+
* concrete `HttpResponse` (including a 404) still short-circuits as before, so this
|
|
16
|
+
* is backwards-compatible with every existing handler (none return `undefined`).
|
|
17
|
+
*/
|
|
18
|
+
export type RouteHandler = (req: HttpRequest) => Promise<HttpResponse | undefined> | HttpResponse | undefined;
|
|
19
|
+
|
|
7
20
|
export interface Route {
|
|
8
21
|
method: string;
|
|
9
22
|
path: string;
|
|
10
23
|
/** When true, match any path that starts with `path`. */
|
|
11
24
|
prefix?: boolean;
|
|
12
|
-
handler:
|
|
25
|
+
handler: RouteHandler;
|
|
13
26
|
/** For diagnostics / inspect(). */
|
|
14
27
|
source?: string;
|
|
15
28
|
}
|
|
@@ -61,7 +74,13 @@ function matches(route: Route, req: HttpRequest): boolean {
|
|
|
61
74
|
export function makeRouter(routes: Route[]): HttpHandler {
|
|
62
75
|
return async (req: HttpRequest): Promise<HttpResponse> => {
|
|
63
76
|
for (const route of routes) {
|
|
64
|
-
if (matches(route, req))
|
|
77
|
+
if (matches(route, req)) {
|
|
78
|
+
// A handler may DECLINE by returning `undefined` (see RouteHandler): treat the
|
|
79
|
+
// route as unmatched and keep looking, so a broad prefix serve can't shadow a
|
|
80
|
+
// later exact route (`/healthz`, a trigger) it didn't actually satisfy.
|
|
81
|
+
const res = await route.handler(req);
|
|
82
|
+
if (res !== undefined) return res;
|
|
83
|
+
}
|
|
65
84
|
}
|
|
66
85
|
return text("not found", 404);
|
|
67
86
|
};
|
|
@@ -108,6 +108,34 @@ test("emitApiController on an empty spec is a valid empty registry (never = {})"
|
|
|
108
108
|
assert.match(out, /export const operations = \{\s*\} satisfies \{/);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
test("emitApiBindings on an empty spec emits the Record<never, never> ApiOperations fallback (not an empty interface)", () => {
|
|
112
|
+
const empty: OpenApiDoc = { openapi: "3.0.0", paths: {} };
|
|
113
|
+
const out = emitApiBindings(empty);
|
|
114
|
+
assert.match(out, /export type ApiOperationId = never;/);
|
|
115
|
+
// A generated `.ts` file is now linted in the scaffold, where `interface ApiOperations {}` would
|
|
116
|
+
// trip `suspicious/noEmptyInterface`; the empty spec must degrade to the non-empty-safe alias.
|
|
117
|
+
assert.match(out, /export type ApiOperations = Record<never, never>;/);
|
|
118
|
+
assert.doesNotMatch(out, /interface ApiOperations/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("emitApiController orders imports by true numeric value for digit runs beyond IEEE-754 precision", () => {
|
|
122
|
+
// 16- vs 17-digit runs: `Number()` collapses these to the same float (and can yield NaN), so a
|
|
123
|
+
// float-subtraction sort would treat them as equal and leave order to chance. The 17-digit run is
|
|
124
|
+
// numerically larger and must sort last regardless.
|
|
125
|
+
const doc: OpenApiDoc = {
|
|
126
|
+
openapi: "3.0.0",
|
|
127
|
+
paths: {
|
|
128
|
+
"/b": { get: { operationId: "op10000000000000001", responses: { "200": {} } } },
|
|
129
|
+
"/a": { get: { operationId: "op9999999999999999", responses: { "200": {} } } },
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const out = emitApiController(doc, "handlers");
|
|
133
|
+
const iShort = out.indexOf("op9999999999999999.ts");
|
|
134
|
+
const iLong = out.indexOf("op10000000000000001.ts");
|
|
135
|
+
assert.ok(iShort >= 0 && iLong >= 0, out);
|
|
136
|
+
assert.ok(iShort < iLong, `16-digit run must sort before the larger 17-digit run:\n${out}`);
|
|
137
|
+
});
|
|
138
|
+
|
|
111
139
|
test("emitApiController throws on a duplicate operationId (would silently overwrite a map key)", () => {
|
|
112
140
|
const dupe: OpenApiDoc = {
|
|
113
141
|
openapi: "3.0.0",
|
|
@@ -34,6 +34,44 @@ export const API_CONTROLLER_TS = "controller.ts";
|
|
|
34
34
|
export { DEFAULT_OPERATIONS_DIR };
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
/** Order two import source specifiers the way Biome's `organizeImports` assist does, so the
|
|
38
|
+
* controller emits its delegate imports in Biome's canonical order and the generated file passes
|
|
39
|
+
* `biome check` without a post-generation import sort. Biome's collation is, per character:
|
|
40
|
+
* digit-runs compared numerically (`item2` before `item10`), letters compared case-insensitively
|
|
41
|
+
* as the primary key, and — when two characters are the same letter differing only in case — the
|
|
42
|
+
* uppercase one sorts first as an immediate tiebreak (`getAgentInstructions` before
|
|
43
|
+
* `getAgenticRegistry` before `getAgentSkill`). */
|
|
44
|
+
function naturalCompare(a: string, b: string): number {
|
|
45
|
+
const tokens = (s: string): string[] => s.match(/\d+|\D/g) ?? [];
|
|
46
|
+
const isDigitRun = (t: string): boolean => t.charCodeAt(0) >= 48 && t.charCodeAt(0) <= 57;
|
|
47
|
+
const ax = tokens(a);
|
|
48
|
+
const bx = tokens(b);
|
|
49
|
+
const n = Math.min(ax.length, bx.length);
|
|
50
|
+
for (let i = 0; i < n; i++) {
|
|
51
|
+
const ca = ax[i];
|
|
52
|
+
const cb = bx[i];
|
|
53
|
+
if (ca === undefined || cb === undefined) break;
|
|
54
|
+
if (isDigitRun(ca) && isDigitRun(cb)) {
|
|
55
|
+
// Compare digit runs by numeric value *without* floating-point loss: strip leading zeros,
|
|
56
|
+
// then a longer run is the larger number, else compare the equal-length digits lexically.
|
|
57
|
+
// (`Number(ca) - Number(cb)` collapses 17+-digit runs to the same IEEE-754 value and yields
|
|
58
|
+
// `NaN` beyond `Number.MAX_SAFE_INTEGER`, both of which break the total order `sort` needs.)
|
|
59
|
+
const na = ca.replace(/^0+/, "");
|
|
60
|
+
const nb = cb.replace(/^0+/, "");
|
|
61
|
+
if (na.length !== nb.length) return na.length < nb.length ? -1 : 1;
|
|
62
|
+
if (na !== nb) return na < nb ? -1 : 1;
|
|
63
|
+
if (ca !== cb) return ca < cb ? -1 : 1; // equal value, differing leading zeros
|
|
64
|
+
} else if (ca !== cb) {
|
|
65
|
+
const la = ca.toLowerCase();
|
|
66
|
+
const lb = cb.toLowerCase();
|
|
67
|
+
if (la !== lb) return la < lb ? -1 : 1; // case-insensitive primary key
|
|
68
|
+
return ca < cb ? -1 : 1; // same letter, different case → uppercase (lower code point) first
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return ax.length - bx.length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
37
75
|
/** A TS identifier form of an operationId (used for the per-op type names). */
|
|
38
76
|
function typeStem(operationId: string): string {
|
|
39
77
|
const cleaned = operationId.replace(/[^A-Za-z0-9]+/g, " ").trim();
|
|
@@ -156,7 +194,7 @@ function objectToTs(schema: OpenApiSchema, depth: number): string {
|
|
|
156
194
|
* not enforce presence for ejected ops, so a "required" query param may in fact be absent. */
|
|
157
195
|
function paramsTs(op: OperationInfo, where: "path" | "query", ejected: boolean): string {
|
|
158
196
|
const ps = op.parameters.filter((p) => p.in === where);
|
|
159
|
-
if (ps.length === 0) return "
|
|
197
|
+
if (ps.length === 0) return "Record<string, never>";
|
|
160
198
|
const rawWire = where === "query" ? "string | string[]" : "string";
|
|
161
199
|
const fields = ps.map((p) => {
|
|
162
200
|
// Coerced to the schema type (number/boolean/array/enum/…); optionality is carried by the `?`
|
|
@@ -211,8 +249,7 @@ export function emitApiBindings(doc: OpenApiDoc, surfaceEject = false): string {
|
|
|
211
249
|
"// The endpoint type map: each operationId maps to its typed request (coerced path/query params\n" +
|
|
212
250
|
"// + JSON body) and response (a union of every documented JSON response body), derived from the\n" +
|
|
213
251
|
"// spec. Do not edit — regenerated from\n" +
|
|
214
|
-
"// the spec; erased to plain JS at compile.\n"
|
|
215
|
-
"// eslint-disable\n\n";
|
|
252
|
+
"// the spec; erased to plain JS at compile.\n\n";
|
|
216
253
|
|
|
217
254
|
const componentDecls = Object.entries(components)
|
|
218
255
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
@@ -272,6 +309,13 @@ export function emitApiBindings(doc: OpenApiDoc, surfaceEject = false): string {
|
|
|
272
309
|
})
|
|
273
310
|
.join("\n");
|
|
274
311
|
|
|
312
|
+
// With generated `.ts` now linted in the scaffold, an empty operation set must NOT emit an empty
|
|
313
|
+
// `interface ApiOperations {}` (`suspicious/noEmptyInterface`); fall back to the same non-empty-safe
|
|
314
|
+
// `Record<never, never>` used elsewhere (its `keyof` is `never`, matching `ApiOperationId = never`).
|
|
315
|
+
const apiOperations = ops.length > 0
|
|
316
|
+
? `export interface ApiOperations {\n${mapEntries}\n}\n`
|
|
317
|
+
: `export type ApiOperations = Record<never, never>;\n`;
|
|
318
|
+
|
|
275
319
|
return (
|
|
276
320
|
header +
|
|
277
321
|
(componentDecls ? `${componentDecls}\n\n` : "") +
|
|
@@ -279,7 +323,7 @@ export function emitApiBindings(doc: OpenApiDoc, surfaceEject = false): string {
|
|
|
279
323
|
`/** Every declared operationId (ADR 0058): the delegate key set. */\n` +
|
|
280
324
|
`export type ApiOperationId = ${idUnion};\n\n` +
|
|
281
325
|
`/** Request/response contract per operationId. */\n` +
|
|
282
|
-
|
|
326
|
+
apiOperations
|
|
283
327
|
);
|
|
284
328
|
}
|
|
285
329
|
|
|
@@ -291,10 +335,9 @@ export function emitApiBindingsRuntime(): string {
|
|
|
291
335
|
"// AUTO-GENERATED by @nanobpm/urban (ADR 0058): the typed `defineOperation`.\n" +
|
|
292
336
|
"// Re-exports the operation SDK and overrides `defineOperation` with an operationId-keyed\n" +
|
|
293
337
|
"// signature (the delegate's validated request + result typed from the spec). Erased to a\n" +
|
|
294
|
-
"// pass-through at runtime. Do not edit.\n" +
|
|
295
|
-
"// eslint-disable\n\n" +
|
|
296
|
-
`import { defineOperation as defineOperationRaw } from "@nanobpm/urban";\n` +
|
|
338
|
+
"// pass-through at runtime. Do not edit.\n\n" +
|
|
297
339
|
`import type { OperationHandler } from "@nanobpm/urban";\n` +
|
|
340
|
+
`import { defineOperation as defineOperationRaw } from "@nanobpm/urban";\n` +
|
|
298
341
|
`import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n\n` +
|
|
299
342
|
`type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
|
|
300
343
|
`type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
|
|
@@ -342,23 +385,26 @@ export function emitApiController(doc: OpenApiDoc, operationsDir: string = DEFAU
|
|
|
342
385
|
"// registry. Statically imports every operation delegate and asserts the complete, correctly-\n" +
|
|
343
386
|
"// typed set via `satisfies` — a missing, mismatched, or orphan delegate fails `tsc`. The\n" +
|
|
344
387
|
"// runtime dispatches through this map, so endpoints cannot drift from the spec. Do not edit —\n" +
|
|
345
|
-
"// regenerated from the spec.\n"
|
|
346
|
-
"// eslint-disable\n\n";
|
|
347
|
-
|
|
348
|
-
const typeImports =
|
|
349
|
-
`import type { OperationHandler } from "@nanobpm/urban";\n` +
|
|
350
|
-
`import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n`;
|
|
388
|
+
"// regenerated from the spec.\n\n";
|
|
351
389
|
|
|
390
|
+
// Emit imports in Biome's canonical `organizeImports` order so the generated file is lint-clean
|
|
391
|
+
// without a post-gen fixup: the bare package import first, then the delegate imports sorted by
|
|
392
|
+
// source specifier (Biome's natural, numeric-aware collation), then the relative `./api-io.d.ts`
|
|
393
|
+
// type import last. Alias bindings (`op<i>`) stay tied to the original index so the `operations`
|
|
394
|
+
// map below still resolves each operationId to its delegate regardless of import line order.
|
|
352
395
|
const delegateImports = ops
|
|
353
|
-
.map((op, i) =>
|
|
396
|
+
.map((op, i) => ({ i, spec: `../${dir}/${op.operationId}.ts` }))
|
|
397
|
+
.sort((a, b) => naturalCompare(a.spec, b.spec))
|
|
398
|
+
.map(({ i, spec }) => `import op${i} from ${JSON.stringify(spec)};`)
|
|
354
399
|
.join("\n");
|
|
355
400
|
|
|
356
401
|
const mapEntries = ops.map((op, i) => ` ${JSON.stringify(op.operationId)}: op${i},`).join("\n");
|
|
357
402
|
|
|
358
403
|
return (
|
|
359
404
|
header +
|
|
360
|
-
|
|
405
|
+
`import type { OperationHandler } from "@nanobpm/urban";\n` +
|
|
361
406
|
(delegateImports ? `${delegateImports}\n` : "") +
|
|
407
|
+
`import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n` +
|
|
362
408
|
`\n` +
|
|
363
409
|
`type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
|
|
364
410
|
`type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
|
|
@@ -7,7 +7,7 @@ import { emitMessageBindings, emitMessageBindingsRuntime } from "./messages.ts";
|
|
|
7
7
|
// Byte-exact goldens for the emitters ported into the toolkit for the domaintypes op (host dry-out
|
|
8
8
|
// nano-bpm#576). Captured verbatim from the console's own emitters via Deno; these lock the toolkit
|
|
9
9
|
// output to the console byte-for-byte so the two can never drift while both exist.
|
|
10
|
-
const GOLDENS: Record<string, string> = JSON.parse("{\"bindings\":\"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1/§6).\\n// The typed data-object layer: `openDomain()` → one `Table<T>` per table, so\\n// workers manipulate typed rows instead of hand-writing SQL. Do not edit —\\n// regenerated from the live tables. Erased to plain JS at `deno compile`.\\n
|
|
10
|
+
const GOLDENS: Record<string, string> = JSON.parse("{\"bindings\":\"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1/§6).\\n// The typed data-object layer: `openDomain()` → one `Table<T>` per table, so\\n// workers manipulate typed rows instead of hand-writing SQL. Do not edit —\\n// regenerated from the live tables. Erased to plain JS at `deno compile`.\\n\\nimport { type DataSource, openDataSource, type Table } from \\\"./data-sdk.ts\\\";\\nimport type { Customers, Orders } from \\\"./domain-rows.d.ts\\\";\\n\\nexport interface Domain {\\n /** The underlying datasource handle — the raw-SQL escape hatch. */\\n readonly raw: DataSource;\\n readonly orders: Table<Orders>;\\n readonly customers: Table<Customers>;\\n /** Close the underlying connection. */\\n close(): void;\\n}\\n\\n/**\\n * Open the App's datasource as a typed domain: each table is\\n * a `Table<T>` gateway (`db.orders.insert(...)`, `db.orders.get(id)`), with the\\n * raw `DataSource` at `db.raw` for anything the gateway doesn't cover.\\n */\\nexport async function openDomain(source?: string): Promise<Domain> {\\n const raw = await openDataSource(source);\\n return {\\n raw,\\n orders: raw.table<Orders>(\\\"orders\\\", \\\"id\\\"),\\n customers: raw.table<Customers>(\\\"customers\\\", \\\"id\\\"),\\n close: () => raw.close(),\\n };\\n}\\n\\nexport type { Table };\\n\",\"workerRuntime\":\"// AUTO-GENERATED by nanobpmn (ADR 0033 §3): the typed `defineWorker`.\\n// Re-exports the worker SDK and overrides `defineWorker` with a taskType-keyed\\n// typed signature (job.variables, job.customHeaders + result typed from the\\n// worker's declared input/output/header contract). Erased to a pass-through at\\n// runtime. Do not edit.\\n\\nimport type { WorkerHdrs, WorkerHeaders, WorkerInputs, WorkerOutputs, WorkerTaskType, WorkerVars } from \\\"./worker-io.d.ts\\\";\\nimport type { WorkerOptions } from \\\"./worker-sdk.ts\\\";\\nimport { defineWorker as defineWorkerRaw } from \\\"./worker-sdk.ts\\\";\\n\\nexport * from \\\"./worker-sdk.ts\\\";\\n\\ntype InFor<K extends WorkerTaskType> = K extends keyof WorkerInputs ? WorkerInputs[K] : WorkerVars;\\ntype OutFor<K extends WorkerTaskType> = K extends keyof WorkerOutputs ? WorkerOutputs[K] : WorkerVars;\\ntype HdrFor<K extends WorkerTaskType> = K extends keyof WorkerHeaders ? WorkerHeaders[K] : WorkerHdrs;\\n\\n/**\\n * Typed `defineWorker`: `type` is constrained to the model's declared job types\\n * (`WorkerTaskType`, ADR 0033 §3) so it autocompletes and rejects unknown jobs,\\n * and the handler's `job.variables`, `job.customHeaders` + result are typed from\\n * the worker's declared `inputType`/`outputType`/header keys. A declared job type\\n * with no declared type falls back to WorkerVars / WorkerHdrs.\\n */\\nexport function defineWorker<K extends WorkerTaskType>(\\n opts: { type: K } & WorkerOptions<InFor<K> & object, OutFor<K> & object, HdrFor<K> & object>,\\n): void {\\n // biome-ignore lint/plugin: generic-to-raw erasure at the worker-SDK boundary; the wire stays untyped JSON (ADR 0033 §3).\\n defineWorkerRaw(opts as unknown as WorkerOptions);\\n}\\n\",\"messageRuntime\":\"// AUTO-GENERATED by nanobpmn (ADR 0040 slice 2): the typed `publishMessage`.\\n// Re-exports the worker SDK's `publishMessage` and overrides it with a\\n// messageName-keyed typed signature (variables typed from the message's declared\\n// envelope). Erased to a pass-through at runtime. Do not edit.\\n\\nimport type { MessageName, MessagePayloads, MessageVars } from \\\"./message-io.d.ts\\\";\\nimport type { PublishMessageOptions, PublishMessageResult } from \\\"./worker-sdk.ts\\\";\\nimport { publishMessage as publishMessageRaw } from \\\"./worker-sdk.ts\\\";\\n\\nexport type { PublishMessageOptions, PublishMessageResult } from \\\"./worker-sdk.ts\\\";\\n\\ntype PayloadFor<K extends MessageName> = K extends keyof MessagePayloads ? MessagePayloads[K] : MessageVars;\\n\\n/**\\n * Typed `publishMessage`: `name` is constrained to the model's declared message\\n * names (`MessageName`, ADR 0040 slice 2) so it autocompletes and rejects unknown\\n * messages, and `variables` are typed from the message's declared envelope. A\\n * declared message with no domain type falls back to MessageVars.\\n */\\nexport function publishMessage<K extends MessageName>(\\n name: K,\\n opts?: PublishMessageOptions<PayloadFor<K> & object>,\\n): Promise<PublishMessageResult> {\\n // biome-ignore lint/plugin: generic-to-raw erasure at the message-SDK boundary; the wire stays untyped JSON (ADR 0040 slice 2).\\n return publishMessageRaw(name, opts as unknown as PublishMessageOptions);\\n}\\n\",\"workerBindingsWithHeaders\":\"// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\\n// The bridge from the process model to the worker type system: each declared\\n// worker's `taskType` maps to the TS type of its input payload (`job.variables`)\\n// and result, so the typed `defineWorker` types a handler by its job type. Do\\n// not edit — regenerated from the manifest. Erased to plain JS at compile.\\n\\nimport type { DomainTypes } from \\\"./domain-rows.d.ts\\\";\\n\\n/** Untyped fallback for a job whose worker declares no input/output type. */\\nexport type WorkerVars = Record<string, unknown>;\\n\\n/** Untyped fallback for a job whose worker declares no custom headers. */\\nexport type WorkerHdrs = Record<string, unknown>;\\n\\n/** Every declared worker `taskType` (ADR 0033 §3): the model-derived set the\\n * typed `defineWorker` accepts, so `type` autocompletes and rejects unknown jobs. */\\nexport type WorkerTaskType = \\\"review\\\";\\n\\n/** Input payload (`job.variables`) per declared worker, keyed by `taskType`. */\\nexport interface WorkerInputs {\\n \\\"review\\\": DomainTypes[\\\"Order\\\"];\\n}\\n\\n/** Output payload (worker result) per declared worker, keyed by `taskType`. */\\nexport interface WorkerOutputs {\\n \\\"review\\\": DomainTypes[\\\"Order\\\"];\\n}\\n\\n/** Custom headers (`job.customHeaders`) per declared worker, keyed by `taskType`.\\n * Header values are strings on the wire, so each declared key maps to `string`;\\n * the extra index signature keeps undeclared headers accessible (non-breaking). */\\nexport interface WorkerHeaders {\\n \\\"review\\\": { \\\"priority\\\": string; \\\"x-flag\\\": string; [key: string]: string };\\n}\\n\",\"messageBindings\":\"// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 slice 2).\\n// The bridge from a `bpmn:message`'s data envelope to the message type system:\\n// each named message maps to the TS type of its payload, so the typed\\n// `publishMessage` types a call by its message name. Do not edit — regenerated\\n// from the model. Erased to plain JS at compile.\\n\\nimport type { DomainTypes } from \\\"./domain-rows.d.ts\\\";\\n\\n/** Untyped fallback payload for a message that declares no envelope type. */\\nexport type MessageVars = Record<string, unknown>;\\n\\n/** Every declared message `name` (ADR 0040 slice 2): the model-derived set the\\n * typed `publishMessage` accepts, so `name` autocompletes and rejects unknown messages. */\\nexport type MessageName = \\\"approved\\\";\\n\\n/** Payload (`variables`) per declared message, keyed by `name`. */\\nexport interface MessagePayloads {\\n \\\"approved\\\": DomainTypes[\\\"Order\\\"];\\n}\\n\"}");
|
|
11
11
|
|
|
12
12
|
const sources: SourceSchema[] = [
|
|
13
13
|
{
|
|
@@ -17,7 +17,7 @@ import type { ToolkitManifest } from "./migrations.ts";
|
|
|
17
17
|
|
|
18
18
|
// Goldens captured verbatim from the console's `emitDomainModel` (server domain_types.ts) via
|
|
19
19
|
// Deno — this IS the per-emitter drift gate (ADR 0053): the toolkit must be byte-identical.
|
|
20
|
-
const GOLDENS: Record<string, string> = JSON.parse("{\
|
|
20
|
+
const GOLDENS: Record<string, string> = JSON.parse("{\"empty\":\"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n\\nexport type DomainTables = Record<never, never>;\\n\",\"tablesOnly\":\"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n\\nexport interface Customers {\\n /** INTEGER (primary key) */\\n id: number;\\n /** TEXT */\\n email: string;\\n /** TEXT */\\n tier: string | null;\\n}\\n\\nexport interface OrderItems {\\n /** INTEGER (primary key) */\\n id: number;\\n /** INTEGER */\\n qty: number;\\n /** REAL */\\n \\\"unit price\\\": number | null;\\n}\\n\\n/** Every table keyed by its wire name — index it for a row type. */\\nexport interface DomainTables {\\n \\\"customers\\\": Customers;\\n \\\"order_items\\\": OrderItems;\\n}\\n\",\"typesOnly\":\"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n\\nexport type DomainTables = Record<never, never>;\\n\\n/** Declared (transient) domain types from the manifest `types` registry (ADR 0029 §4.2), keyed by id. */\\nexport interface DomainTypes {\\n \\\"taxReturn\\\": {\\n year: number;\\n notes?: string;\\n lines: DomainTypes[\\\"lineItem\\\"][];\\n };\\n \\\"lineItem\\\": {\\n \\\"line no\\\": number;\\n amount: number;\\n };\\n}\\n\",\"both\":\"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n\\nexport interface Greetings {\\n /** INTEGER (primary key) */\\n id: number;\\n /** TEXT */\\n who: string;\\n /** TEXT */\\n message: string;\\n /** TEXT */\\n createdAt: string | null;\\n}\\n\\n/** Every table keyed by its wire name — index it for a row type. */\\nexport interface DomainTables {\\n \\\"greetings\\\": Greetings;\\n}\\n\\n/** Declared (transient) domain types from the manifest `types` registry (ADR 0029 §4.2), keyed by id. */\\nexport interface DomainTypes {\\n \\\"greeting\\\": {\\n who: string;\\n message: string;\\n createdAt?: string;\\n };\\n}\\n\",\"multi\":\"// AUTO-GENERATED by nanobpmn from the datasource schemas (ADR 0029 §4.1/§6).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n\\nexport interface AppCustomers {\\n /** INTEGER (primary key) */\\n id: number;\\n /** TEXT */\\n email: string;\\n}\\n\\nexport interface AnalyticsEvents {\\n /** INTEGER (primary key) */\\n id: number;\\n /** TEXT */\\n kind: string;\\n}\\n\\n/** Every datasource keyed by alias, then table by wire name. */\\nexport interface DomainSources {\\n \\\"app\\\": {\\n \\\"customers\\\": AppCustomers;\\n };\\n \\\"analytics\\\": {\\n \\\"events\\\": AnalyticsEvents;\\n };\\n}\\n\\n/** The default datasource's tables — index by wire name. */\\nexport type DomainTables = DomainSources[\\\"analytics\\\"];\\n\"}");
|
|
21
21
|
|
|
22
22
|
const col = (name: string, type: string, notNull: boolean, primaryKey: boolean) =>
|
|
23
23
|
({ name, type, notNull, primaryKey });
|
|
@@ -104,10 +104,9 @@ function tableInterface(t: TableMeta): string {
|
|
|
104
104
|
export function emitDomainDts(tables: TableMeta[]): string {
|
|
105
105
|
const header =
|
|
106
106
|
"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\n" +
|
|
107
|
-
"// Do not edit — regenerated from the live tables. Erased at `deno compile`.\n"
|
|
108
|
-
"// eslint-disable\n";
|
|
107
|
+
"// Do not edit — regenerated from the live tables. Erased at `deno compile`.\n";
|
|
109
108
|
if (tables.length === 0) {
|
|
110
|
-
return `${header}\nexport
|
|
109
|
+
return `${header}\nexport type DomainTables = Record<never, never>;\n`;
|
|
111
110
|
}
|
|
112
111
|
const interfaces = tables.map(tableInterface).join("\n\n");
|
|
113
112
|
const map = tables
|
|
@@ -140,8 +139,7 @@ export function emitDomainDtsForSources(
|
|
|
140
139
|
}
|
|
141
140
|
const header =
|
|
142
141
|
"// AUTO-GENERATED by nanobpmn from the datasource schemas (ADR 0029 §4.1/§6).\n" +
|
|
143
|
-
"// Do not edit — regenerated from the live tables. Erased at `deno compile`.\n"
|
|
144
|
-
"// eslint-disable\n";
|
|
142
|
+
"// Do not edit — regenerated from the live tables. Erased at `deno compile`.\n";
|
|
145
143
|
|
|
146
144
|
const interfaces = sources
|
|
147
145
|
.flatMap((s) =>
|
|
@@ -153,7 +151,7 @@ export function emitDomainDtsForSources(
|
|
|
153
151
|
|
|
154
152
|
const sourceMap = sources
|
|
155
153
|
.map((s) => {
|
|
156
|
-
if (s.tables.length === 0) return ` ${JSON.stringify(s.source)}:
|
|
154
|
+
if (s.tables.length === 0) return ` ${JSON.stringify(s.source)}: Record<never, never>;`;
|
|
157
155
|
const tableMap = s.tables
|
|
158
156
|
.map((t) =>
|
|
159
157
|
` ${JSON.stringify(t.name)}: ${prefixedInterfaceName(s.source, t.name)};`
|
|
@@ -241,7 +239,7 @@ export function emitDomainTypeRegistry(types: DomainTypeRegistry): string {
|
|
|
241
239
|
return ` ${key}${opt}: ${fieldTsType(field, ids)};`;
|
|
242
240
|
})
|
|
243
241
|
.join("\n");
|
|
244
|
-
const body = fields.length > 0 ? `{\n${fields}\n }` : "
|
|
242
|
+
const body = fields.length > 0 ? `{\n${fields}\n }` : "Record<never, never>";
|
|
245
243
|
return ` ${JSON.stringify(id)}: ${body};`;
|
|
246
244
|
})
|
|
247
245
|
.join("\n");
|
|
@@ -308,8 +306,7 @@ export function emitDomainBindings(
|
|
|
308
306
|
"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1/§6).\n" +
|
|
309
307
|
"// The typed data-object layer: `openDomain()` → one `Table<T>` per table, so\n" +
|
|
310
308
|
"// workers manipulate typed rows instead of hand-writing SQL. Do not edit —\n" +
|
|
311
|
-
"// regenerated from the live tables. Erased to plain JS at `deno compile`.\n"
|
|
312
|
-
"// eslint-disable\n";
|
|
309
|
+
"// regenerated from the live tables. Erased to plain JS at `deno compile`.\n";
|
|
313
310
|
|
|
314
311
|
const multi = sources.length > 1;
|
|
315
312
|
const def = defaultSource && sources.some((s) => s.source === defaultSource)
|
|
@@ -365,7 +362,7 @@ export function emitDomainBindings(
|
|
|
365
362
|
` close: () => raw.close(),\n };\n}`;
|
|
366
363
|
|
|
367
364
|
return `${header}\n` +
|
|
368
|
-
`import {
|
|
365
|
+
`import { type DataSource, openDataSource, type Table } from "./data-sdk.ts";\n` +
|
|
369
366
|
importTypes +
|
|
370
367
|
`\n${domainIface}\n\n${openFn}\n\nexport type { Table };\n`;
|
|
371
368
|
}
|
|
@@ -422,14 +419,15 @@ function emitKeyedDomainBindings(
|
|
|
422
419
|
`export async function openDomain<K extends DomainSource = ${JSON.stringify(def)}>(\n` +
|
|
423
420
|
` source?: K,\n` +
|
|
424
421
|
`): Promise<Domain<DomainSources[K]>> {\n` +
|
|
425
|
-
` const key =
|
|
422
|
+
` const key = source ?? DEFAULT_SOURCE;\n` +
|
|
426
423
|
` const raw = await openDataSource(key);\n` +
|
|
427
424
|
` const db: Record<string, unknown> = { raw, close: () => raw.close() };\n` +
|
|
428
425
|
` for (const t of DOMAIN_TABLES[key] ?? []) db[t.name] = raw.table(t.name, t.pk);\n` +
|
|
426
|
+
` // biome-ignore lint/plugin: the per-source table set is bound dynamically from DOMAIN_TABLES; the Domain<M> shape can't be expressed statically over the runtime loop.\n` +
|
|
429
427
|
` return db as Domain<DomainSources[K]>;\n}`;
|
|
430
428
|
|
|
431
429
|
return `${header}\n` +
|
|
432
|
-
`import {
|
|
430
|
+
`import { type DataSource, openDataSource, type Table } from "./data-sdk.ts";\n` +
|
|
433
431
|
`import type { DomainSources } from "./${DOMAIN_DTS}";\n\n` +
|
|
434
432
|
`${runtimeMap}\n\n${types}\n\n${openFn}\n\nexport type { Table };\n`;
|
|
435
433
|
}
|