@nanobpm/urban 0.77.1 → 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/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/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
|
@@ -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
|
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { runGen, type GenIO } from "../gen.ts";
|
|
9
|
+
import {
|
|
10
|
+
emitDomainBindings,
|
|
11
|
+
emitDomainDts,
|
|
12
|
+
emitDomainModel,
|
|
13
|
+
type SourceSchema,
|
|
14
|
+
} from "./domain.ts";
|
|
15
|
+
import { emitMeta } from "./meta.ts";
|
|
16
|
+
import { emitMessageBindings, emitMessageBindingsRuntime } from "./messages.ts";
|
|
17
|
+
import { emitWorkerBindings, emitWorkerBindingsRuntime } from "./worker-io.ts";
|
|
18
|
+
import { emitApiBindings, emitApiController } from "./api.ts";
|
|
19
|
+
import type { OpenApiDoc } from "../../openapi/spec.ts";
|
|
20
|
+
|
|
21
|
+
// These guards spawn the scaffold's Biome *via Node* (`process.execPath <biome> check`). Under
|
|
22
|
+
// `npm run test:deno` `process.execPath` is the Deno binary, so that spawn would launch Deno rather
|
|
23
|
+
// than Biome and the lint command would fail spuriously. The generated output is runtime-independent
|
|
24
|
+
// (it's plain text the emitters produce identically under either runtime), so running this guard once
|
|
25
|
+
// under Node is sufficient — skip it under Deno instead of mis-spawning.
|
|
26
|
+
const runtimeIsNode = !("Deno" in globalThis);
|
|
27
|
+
const nodeOnly = {
|
|
28
|
+
skip: runtimeIsNode ? false : "Node-only: spawns Biome via process.execPath (Deno's execPath is not Node)",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Defect-class guard (AGENTS.md "write a test guard for the defect class"): the emitters must
|
|
32
|
+
// produce `nano-generated/` code that passes the `create-urban-app` scaffold's own Biome lint
|
|
33
|
+
// ruleset — the exact standard a scaffolded app enforces via `npm run lint`. This locks the whole
|
|
34
|
+
// generator surface (types, meta accessor, domain/message/worker bindings, and the API controller's
|
|
35
|
+
// import order) to lint-clean output, so a future emitter change that reintroduces a banned type,
|
|
36
|
+
// an `as` cast, or a mis-sorted import fails here instead of only surfacing in a user's project.
|
|
37
|
+
//
|
|
38
|
+
// The generated code is deliberately console/deno-styled (2-space, quoted keys) for byte-parity
|
|
39
|
+
// with the console emitters (ADR 0053), so — mirroring the scaffold's `biome.json` — the formatter
|
|
40
|
+
// is scoped away from `nano-generated/` while the linter (and import-organizing assist) still apply.
|
|
41
|
+
|
|
42
|
+
/** In-memory filesystem so gen runs without touching disk. */
|
|
43
|
+
function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
|
|
44
|
+
return {
|
|
45
|
+
files,
|
|
46
|
+
async readText(p) {
|
|
47
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
48
|
+
return files[p];
|
|
49
|
+
},
|
|
50
|
+
async writeText(p, c) {
|
|
51
|
+
files[p] = c;
|
|
52
|
+
},
|
|
53
|
+
async listDir(p) {
|
|
54
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
55
|
+
const names = new Set<string>();
|
|
56
|
+
for (const f of Object.keys(files)) {
|
|
57
|
+
if (f.startsWith(prefix)) {
|
|
58
|
+
const rest = f.slice(prefix.length);
|
|
59
|
+
if (!rest.includes("/")) names.add(rest);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...names];
|
|
63
|
+
},
|
|
64
|
+
async listSubdirs(p) {
|
|
65
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
66
|
+
const names = new Set<string>();
|
|
67
|
+
for (const f of Object.keys(files)) {
|
|
68
|
+
if (f.startsWith(prefix)) {
|
|
69
|
+
const rest = f.slice(prefix.length);
|
|
70
|
+
const slash = rest.indexOf("/");
|
|
71
|
+
if (slash > 0) names.add(rest.slice(0, slash));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...names];
|
|
75
|
+
},
|
|
76
|
+
async exists(p) {
|
|
77
|
+
return p in files;
|
|
78
|
+
},
|
|
79
|
+
async remove(p) {
|
|
80
|
+
delete files[p];
|
|
81
|
+
},
|
|
82
|
+
async importModule(p) {
|
|
83
|
+
throw new Error(`no module registered for ${p}`);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** An OpenAPI surface whose operationIds exercise Biome's natural, case-sensitive import sort
|
|
89
|
+
* (`getAgentInstructions` < `getAgenticRegistry` < `getAgentSkill`; `item2` < `item10`) — the exact
|
|
90
|
+
* collation the controller's delegate imports must reproduce to pass `organizeImports`. The
|
|
91
|
+
* path→operationId assignment is deliberately *scrambled* relative to that collation: `collectOperations`
|
|
92
|
+
* yields ops in sorted-path order (`/a`…`/e`), so the emitter's own `naturalCompare` sort — not the
|
|
93
|
+
* input order — is what must reorder the imports into Biome's canonical sequence. A broken/removed
|
|
94
|
+
* sort would leave them in path order and fail the `organizeImports` assist, so this input actually
|
|
95
|
+
* regression-guards `naturalCompare` rather than passing on already-sorted data. */
|
|
96
|
+
const OPENAPI = JSON.stringify({
|
|
97
|
+
openapi: "3.0.0",
|
|
98
|
+
info: { title: "guard", version: "1" },
|
|
99
|
+
paths: {
|
|
100
|
+
"/a": { get: { operationId: "item10", responses: { "200": { description: "ok" } } } },
|
|
101
|
+
"/b": { get: { operationId: "getAgentSkill", responses: { "200": { description: "ok" } } } },
|
|
102
|
+
"/c": { get: { operationId: "getAgentInstructions", responses: { "200": { description: "ok" } } } },
|
|
103
|
+
"/d": { get: { operationId: "getAgenticRegistry", responses: { "200": { description: "ok" } } } },
|
|
104
|
+
"/e": { get: { operationId: "item2", responses: { "200": { description: "ok" } } } },
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const BPMN = `<bpmn:process id="p" xmlns:bpmn="x" xmlns:zeebe="y"><bpmn:serviceTask id="T">` +
|
|
109
|
+
`<bpmn:extensionElements><zeebe:taskDefinition type="demo.do" /></bpmn:extensionElements>` +
|
|
110
|
+
`</bpmn:serviceTask></bpmn:process>`;
|
|
111
|
+
|
|
112
|
+
/** A populated app (workers + domain types + declared metadata + an API surface) and an empty app
|
|
113
|
+
* (no workers/types/messages) — together they cover both the populated (`interface X { ... }`) and
|
|
114
|
+
* the empty (`Record<never, never>`) branches of every emitter reachable through `runGen`. */
|
|
115
|
+
const FIXTURES: Record<string, Record<string, string>> = {
|
|
116
|
+
populated: {
|
|
117
|
+
"/app/nano.app.json": JSON.stringify({
|
|
118
|
+
id: "demo",
|
|
119
|
+
data: { default: "app" },
|
|
120
|
+
models: { processes: ["processes/*.bpmn"] },
|
|
121
|
+
types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
|
|
122
|
+
api: { spec: "openapi.json" },
|
|
123
|
+
}),
|
|
124
|
+
"/app/processes/p.bpmn": BPMN,
|
|
125
|
+
"/app/openapi.json": OPENAPI,
|
|
126
|
+
},
|
|
127
|
+
empty: {
|
|
128
|
+
"/app/nano.app.json": JSON.stringify({
|
|
129
|
+
id: "empty",
|
|
130
|
+
data: { default: "app" },
|
|
131
|
+
models: { processes: ["processes/*.bpmn"] },
|
|
132
|
+
}),
|
|
133
|
+
"/app/processes/p.bpmn": `<bpmn:process id="p" xmlns:bpmn="x"></bpmn:process>`,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Resolve the scaffold's own Biome binary — the exact linter a scaffolded app runs. */
|
|
138
|
+
async function resolveBiomeBin(): Promise<string> {
|
|
139
|
+
return fileURLToPath(await import.meta.resolve("@biomejs/biome/bin/biome"));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Lint `dirs` under `work` with the scaffold template's *exact* ruleset (plugin included), scoped
|
|
143
|
+
* to the generated dirs and with the formatter disabled — the generated code is intentionally
|
|
144
|
+
* console/deno-styled (ADR 0053 byte-parity), which the scaffold scopes away from its formatter, so
|
|
145
|
+
* the guard tracks lint-rule + import-organizing (assist) cleanliness only. */
|
|
146
|
+
function lintUnderScaffold(
|
|
147
|
+
work: string,
|
|
148
|
+
templateDir: string,
|
|
149
|
+
biomeBin: string,
|
|
150
|
+
dirs: string[],
|
|
151
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
152
|
+
const templateBiome = JSON.parse(readFileSync(join(templateDir, "biome.json"), "utf8"));
|
|
153
|
+
// Copy the scaffold's GritQL plugin (bans `as` assertions) next to the config so its relative
|
|
154
|
+
// path resolves, then reuse the template's exact ruleset — force-scoped to the generated dirs so
|
|
155
|
+
// the guard tracks the scaffold standard without depending on the template's own `includes`.
|
|
156
|
+
cpSync(join(templateDir, "plugins"), join(work, "plugins"), { recursive: true });
|
|
157
|
+
const config = {
|
|
158
|
+
...templateBiome,
|
|
159
|
+
$schema: undefined,
|
|
160
|
+
root: true,
|
|
161
|
+
files: { includes: dirs.map((d) => `${d}/**/*.ts`) },
|
|
162
|
+
formatter: { enabled: false },
|
|
163
|
+
linter: { ...templateBiome.linter, includes: dirs.map((d) => `${d}/**/*.ts`) },
|
|
164
|
+
};
|
|
165
|
+
writeFileSync(join(work, "biome.json"), `${JSON.stringify(config, null, "\t")}\n`);
|
|
166
|
+
// Run with cwd = work so Biome auto-discovers this `root: true` config (which also stops it
|
|
167
|
+
// walking up into any ancestor config). Passing --config-path *and* a path under it would load
|
|
168
|
+
// the config twice and error with "nested root configuration", so we rely on discovery.
|
|
169
|
+
return spawnSync(process.execPath, [biomeBin, "check", "."], { cwd: work, encoding: "utf8" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
test("generated nano-generated/ code passes the scaffold's Biome lint ruleset", nodeOnly, async () => {
|
|
173
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
174
|
+
const templateDir = join(here, "..", "..", "..", "..", "create-urban-app", "template");
|
|
175
|
+
const biomeBin = await resolveBiomeBin();
|
|
176
|
+
|
|
177
|
+
const work = mkdtempSync(join(tmpdir(), "urban-genlint-"));
|
|
178
|
+
try {
|
|
179
|
+
const dirs: string[] = [];
|
|
180
|
+
const perFixture: Record<string, number> = {};
|
|
181
|
+
let tsFileCount = 0;
|
|
182
|
+
for (const [name, files] of Object.entries(FIXTURES)) {
|
|
183
|
+
const io = memIO({ ...files });
|
|
184
|
+
const res = await runGen({ root: "/app", io });
|
|
185
|
+
const outDir = join(work, name);
|
|
186
|
+
mkdirSync(outDir, { recursive: true });
|
|
187
|
+
dirs.push(name);
|
|
188
|
+
perFixture[name] = 0;
|
|
189
|
+
for (const artifact of res.artifacts) {
|
|
190
|
+
if (!/\.ts$/.test(artifact.path)) continue; // .ts + .d.ts, skip .sql/.json/.md
|
|
191
|
+
const fileName = artifact.path.split("/").pop();
|
|
192
|
+
if (!fileName) continue;
|
|
193
|
+
writeFileSync(join(outDir, fileName), io.files[`/app/${artifact.path}`]);
|
|
194
|
+
perFixture[name]++;
|
|
195
|
+
tsFileCount++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A fixture that silently produced no TypeScript artifacts would make the whole guard vacuous
|
|
200
|
+
// (Biome would report `Checked 0 files` yet still satisfy `>= 0`). Assert each fixture emitted a
|
|
201
|
+
// nonzero set *before* trusting Biome's own file count.
|
|
202
|
+
for (const [name, count] of Object.entries(perFixture)) {
|
|
203
|
+
assert.ok(count > 0, `fixture "${name}" produced no TypeScript artifacts — the guard is vacuous`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const run = lintUnderScaffold(work, templateDir, biomeBin, dirs);
|
|
207
|
+
|
|
208
|
+
// The guard is only meaningful if Biome actually inspected the generated files — a mis-scoped
|
|
209
|
+
// config that silently checks nothing must not pass vacuously.
|
|
210
|
+
const checked = /Checked (\d+) files/.exec(run.stdout);
|
|
211
|
+
assert.ok(checked && Number(checked[1]) >= tsFileCount, `Biome did not check the generated files:\n${run.stdout}`);
|
|
212
|
+
assert.equal(run.status, 0, `Biome flagged generated code:\n${run.stdout}\n${run.stderr}`);
|
|
213
|
+
} finally {
|
|
214
|
+
rmSync(work, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("every emitter branch (populated, empty, and the data-path runtime wrappers) is lint-clean", nodeOnly, async () => {
|
|
219
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
220
|
+
const templateDir = join(here, "..", "..", "..", "..", "create-urban-app", "template");
|
|
221
|
+
const biomeBin = await resolveBiomeBin();
|
|
222
|
+
|
|
223
|
+
// `runGen` emits only the `*.d.ts` type maps + the API runtime. The *data path* (`urban data`,
|
|
224
|
+
// `dataops.ts`) additionally materializes the runtime wrappers `workers.ts`, `messages.ts`, and the
|
|
225
|
+
// single-/multi-source `domain.ts` bindings into `nano-generated/`, so the scaffold's
|
|
226
|
+
// `nano-generated/**/*.ts` lint scope covers them too. Those wrappers carry the pass-through
|
|
227
|
+
// `as`-casts (and, multi-source, a keyed accessor) that only this plugin/assist ruleset can catch —
|
|
228
|
+
// exercise them here alongside every populated and empty (`Record<never, never>`) emitter branch so
|
|
229
|
+
// a regression in any of them fails at this guard, not only in a scaffolded user's project.
|
|
230
|
+
const sources: SourceSchema[] = [
|
|
231
|
+
{
|
|
232
|
+
source: "app",
|
|
233
|
+
tables: [{
|
|
234
|
+
name: "customers",
|
|
235
|
+
kind: "table",
|
|
236
|
+
columns: [{ name: "id", type: "INTEGER", notNull: true, primaryKey: true }],
|
|
237
|
+
indexes: [],
|
|
238
|
+
foreignKeys: [],
|
|
239
|
+
}],
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
source: "analytics",
|
|
243
|
+
tables: [{
|
|
244
|
+
name: "events",
|
|
245
|
+
kind: "table",
|
|
246
|
+
columns: [{ name: "id", type: "INTEGER", notNull: true, primaryKey: true }],
|
|
247
|
+
indexes: [],
|
|
248
|
+
foreignKeys: [],
|
|
249
|
+
}],
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
// An empty OpenAPI document (zero operations) → the empty-branch of the API emitters.
|
|
254
|
+
const EMPTY_SPEC: OpenApiDoc = { openapi: "3.0.0", info: { title: "empty", version: "1" }, paths: {} };
|
|
255
|
+
|
|
256
|
+
const files: Record<string, string> = {
|
|
257
|
+
// data-path runtime wrappers (the `as`-cast / keyed-accessor carriers):
|
|
258
|
+
"workers.ts": emitWorkerBindingsRuntime(),
|
|
259
|
+
"messages.ts": emitMessageBindingsRuntime(),
|
|
260
|
+
"domain-single.ts": emitDomainBindings([sources[0]], "app"),
|
|
261
|
+
"domain-multi.ts": emitDomainBindings(sources, "app"),
|
|
262
|
+
// populated emitter branches:
|
|
263
|
+
"worker-io-populated.d.ts": emitWorkerBindings(
|
|
264
|
+
[{ taskType: "review", inputType: "Order", outputType: "Order", headerKeys: ["priority", "x-flag"] }],
|
|
265
|
+
["Order"],
|
|
266
|
+
),
|
|
267
|
+
"message-io-populated.d.ts": emitMessageBindings([{ messageName: "approved", inputType: "Order" }], ["Order"]),
|
|
268
|
+
"meta-populated.ts": emitMeta([
|
|
269
|
+
{ key: "classification", value: "internal" },
|
|
270
|
+
{ key: "data-classification", value: "pii" },
|
|
271
|
+
]),
|
|
272
|
+
"domain-rows-populated.d.ts": emitDomainModel(sources, "app", {
|
|
273
|
+
greeting: { fields: { who: { type: "string" } } },
|
|
274
|
+
EmptyThing: { fields: {} }, // a declared type with no fields → the `Record<never, never>` registry fallback
|
|
275
|
+
}),
|
|
276
|
+
// empty emitter branches (the `Record<never, never>` fallbacks):
|
|
277
|
+
"worker-io-empty.d.ts": emitWorkerBindings([], []),
|
|
278
|
+
"message-io-empty.d.ts": emitMessageBindings([], []),
|
|
279
|
+
"meta-empty.ts": emitMeta([]),
|
|
280
|
+
"domain-rows-empty.d.ts": emitDomainDts([]),
|
|
281
|
+
// a *multi-source* schema where one source has NO tables → the multi-source empty-source
|
|
282
|
+
// `Record<never, never>` fallback in `emitDomainDtsForSources` (domain.ts), which the
|
|
283
|
+
// single-/no-source `emitDomainDts([])` above never reaches:
|
|
284
|
+
"domain-rows-empty-source.d.ts": emitDomainModel([sources[0], { source: "audit", tables: [] }], "app", {}),
|
|
285
|
+
// an empty OpenAPI spec (zero operations) → the `ApiOperations = Record<never, never>` fallback
|
|
286
|
+
// (not an empty `interface`) and a delegate-less controller:
|
|
287
|
+
"api-io-empty.d.ts": emitApiBindings(EMPTY_SPEC),
|
|
288
|
+
"operations-empty.ts": emitApiController(EMPTY_SPEC),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const work = mkdtempSync(join(tmpdir(), "urban-genwrap-"));
|
|
292
|
+
try {
|
|
293
|
+
const outDir = join(work, "gen");
|
|
294
|
+
mkdirSync(outDir, { recursive: true });
|
|
295
|
+
for (const [name, content] of Object.entries(files)) writeFileSync(join(outDir, name), content);
|
|
296
|
+
|
|
297
|
+
const run = lintUnderScaffold(work, templateDir, biomeBin, ["gen"]);
|
|
298
|
+
|
|
299
|
+
const expected = Object.keys(files).length;
|
|
300
|
+
const checked = /Checked (\d+) files/.exec(run.stdout);
|
|
301
|
+
assert.ok(
|
|
302
|
+
checked && Number(checked[1]) >= expected,
|
|
303
|
+
`Biome did not check every branch/wrapper file (expected >= ${expected}):\n${run.stdout}`,
|
|
304
|
+
);
|
|
305
|
+
assert.equal(run.status, 0, `Biome flagged a generated emitter branch:\n${run.stdout}\n${run.stderr}`);
|
|
306
|
+
} finally {
|
|
307
|
+
rmSync(work, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/** Lint files laid out at `nano-generated/` using the template's `biome.json` *as shipped* — its own
|
|
312
|
+
* `files`/`linter.includes` and its formatter (enabled, with the `!nano-generated/**` exclusion),
|
|
313
|
+
* only flipping `root` (so Biome doesn't discover the monorepo's root config) and dropping the
|
|
314
|
+
* node_modules-relative `$schema`. Scoping the check to `nano-generated` lets Biome discover this
|
|
315
|
+
* config from `cwd` while checking only the generated tree. `lintUnderScaffold` above deliberately
|
|
316
|
+
* re-scopes `includes` and disables the formatter, so it can't catch a template regression that drops
|
|
317
|
+
* the `nano-generated/**` lint include or the formatter's `!nano-generated/**` exclusion — this can. */
|
|
318
|
+
function lintWithTemplateConfigVerbatim(
|
|
319
|
+
work: string,
|
|
320
|
+
templateDir: string,
|
|
321
|
+
biomeBin: string,
|
|
322
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
323
|
+
const templateBiome = JSON.parse(readFileSync(join(templateDir, "biome.json"), "utf8"));
|
|
324
|
+
cpSync(join(templateDir, "plugins"), join(work, "plugins"), { recursive: true });
|
|
325
|
+
const config = { ...templateBiome, $schema: undefined, root: true };
|
|
326
|
+
writeFileSync(join(work, "biome.json"), `${JSON.stringify(config, null, "\t")}\n`);
|
|
327
|
+
return spawnSync(process.execPath, [biomeBin, "check", "nano-generated"], { cwd: work, encoding: "utf8" });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
test("both scaffold templates lint (and format-exclude) nano-generated/ code with their biome.json as shipped", nodeOnly, async () => {
|
|
331
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
332
|
+
const biomeBin = await resolveBiomeBin();
|
|
333
|
+
|
|
334
|
+
// Deliberately console/deno-styled (2-space, quoted keys, `as`-cast carriers) generated output: it
|
|
335
|
+
// is lint-clean under the scaffold ruleset yet *not* Biome's default (tab) format — so if a template
|
|
336
|
+
// ever dropped its formatter `!nano-generated/**` exclusion, `biome check` would report a format diff
|
|
337
|
+
// here and fail, and if it dropped the `nano-generated/**` lint include Biome would check 0 files.
|
|
338
|
+
const generated: Record<string, string> = {
|
|
339
|
+
"workers.ts": emitWorkerBindingsRuntime(),
|
|
340
|
+
"messages.ts": emitMessageBindingsRuntime(),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
for (const template of ["template", "template-code-first"]) {
|
|
344
|
+
const templateDir = join(here, "..", "..", "..", "..", "create-urban-app", template);
|
|
345
|
+
const work = mkdtempSync(join(tmpdir(), `urban-tmpl-${template}-`));
|
|
346
|
+
try {
|
|
347
|
+
const genDir = join(work, "nano-generated");
|
|
348
|
+
mkdirSync(genDir, { recursive: true });
|
|
349
|
+
for (const [name, content] of Object.entries(generated)) writeFileSync(join(genDir, name), content);
|
|
350
|
+
|
|
351
|
+
const run = lintWithTemplateConfigVerbatim(work, templateDir, biomeBin);
|
|
352
|
+
|
|
353
|
+
// The template's own `nano-generated/**/*.ts` include must actually pick the files up — a
|
|
354
|
+
// dropped include would leave Biome checking 0 files while still exiting 0 (vacuous pass).
|
|
355
|
+
const checked = /Checked (\d+) files/.exec(run.stdout);
|
|
356
|
+
assert.ok(
|
|
357
|
+
checked && Number(checked[1]) >= Object.keys(generated).length,
|
|
358
|
+
`[${template}] template biome.json did not lint nano-generated/ via its own includes:\n${run.stdout}`,
|
|
359
|
+
);
|
|
360
|
+
assert.equal(
|
|
361
|
+
run.status,
|
|
362
|
+
0,
|
|
363
|
+
`[${template}] template biome.json flagged (lint) or reformatted generated code:\n${run.stdout}\n${run.stderr}`,
|
|
364
|
+
);
|
|
365
|
+
} finally {
|
|
366
|
+
rmSync(work, { recursive: true, force: true });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
@@ -15,7 +15,6 @@ const GOLD_EMPTY =
|
|
|
15
15
|
"// each named message maps to the TS type of its payload, so the typed\n" +
|
|
16
16
|
"// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
|
|
17
17
|
"// from the model. Erased to plain JS at compile.\n" +
|
|
18
|
-
"// eslint-disable\n" +
|
|
19
18
|
"\n" +
|
|
20
19
|
"\n" +
|
|
21
20
|
"/** Untyped fallback payload for a message that declares no envelope type. */\n" +
|
|
@@ -26,7 +25,7 @@ const GOLD_EMPTY =
|
|
|
26
25
|
"export type MessageName = string;\n" +
|
|
27
26
|
"\n" +
|
|
28
27
|
"/** Payload (`variables`) per declared message, keyed by `name`. */\n" +
|
|
29
|
-
"export
|
|
28
|
+
"export type MessagePayloads = Record<never, never>;\n";
|
|
30
29
|
|
|
31
30
|
const GOLD_NAMESONLY =
|
|
32
31
|
"// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 slice 2).\n" +
|
|
@@ -34,7 +33,6 @@ const GOLD_NAMESONLY =
|
|
|
34
33
|
"// each named message maps to the TS type of its payload, so the typed\n" +
|
|
35
34
|
"// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
|
|
36
35
|
"// from the model. Erased to plain JS at compile.\n" +
|
|
37
|
-
"// eslint-disable\n" +
|
|
38
36
|
"\n" +
|
|
39
37
|
"\n" +
|
|
40
38
|
"/** Untyped fallback payload for a message that declares no envelope type. */\n" +
|
|
@@ -45,7 +43,7 @@ const GOLD_NAMESONLY =
|
|
|
45
43
|
"export type MessageName = \"OrderPlaced\" | \"OrderShipped\";\n" +
|
|
46
44
|
"\n" +
|
|
47
45
|
"/** Payload (`variables`) per declared message, keyed by `name`. */\n" +
|
|
48
|
-
"export
|
|
46
|
+
"export type MessagePayloads = Record<never, never>;\n";
|
|
49
47
|
|
|
50
48
|
const GOLD_TYPED =
|
|
51
49
|
"// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 slice 2).\n" +
|
|
@@ -53,7 +51,6 @@ const GOLD_TYPED =
|
|
|
53
51
|
"// each named message maps to the TS type of its payload, so the typed\n" +
|
|
54
52
|
"// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
|
|
55
53
|
"// from the model. Erased to plain JS at compile.\n" +
|
|
56
|
-
"// eslint-disable\n" +
|
|
57
54
|
"\n" +
|
|
58
55
|
"import type { DomainTypes } from \"./domain-rows.d.ts\";\n" +
|
|
59
56
|
"\n" +
|
|
@@ -75,7 +72,6 @@ const GOLD_MIXED =
|
|
|
75
72
|
"// each named message maps to the TS type of its payload, so the typed\n" +
|
|
76
73
|
"// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
|
|
77
74
|
"// from the model. Erased to plain JS at compile.\n" +
|
|
78
|
-
"// eslint-disable\n" +
|
|
79
75
|
"\n" +
|
|
80
76
|
"import type { DomainTypes } from \"./domain-rows.d.ts\";\n" +
|
|
81
77
|
"\n" +
|
|
@@ -104,8 +104,7 @@ export function emitMessageBindings(
|
|
|
104
104
|
"// The bridge from a `bpmn:message`'s data envelope to the message type system:\n" +
|
|
105
105
|
"// each named message maps to the TS type of its payload, so the typed\n" +
|
|
106
106
|
"// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
|
|
107
|
-
"// from the model. Erased to plain JS at compile.\n"
|
|
108
|
-
"// eslint-disable\n";
|
|
107
|
+
"// from the model. Erased to plain JS at compile.\n";
|
|
109
108
|
|
|
110
109
|
const needsRegistry = payloads.length > 0;
|
|
111
110
|
const importTypes = needsRegistry
|
|
@@ -114,7 +113,7 @@ export function emitMessageBindings(
|
|
|
114
113
|
|
|
115
114
|
const payloadsIface = payloads.length > 0
|
|
116
115
|
? `export interface MessagePayloads {\n${payloads.join("\n")}\n}\n`
|
|
117
|
-
: `export
|
|
116
|
+
: `export type MessagePayloads = Record<never, never>;\n`;
|
|
118
117
|
|
|
119
118
|
return `${header}\n` +
|
|
120
119
|
importTypes +
|
|
@@ -182,11 +181,10 @@ export function emitMessageBindingsRuntime(): string {
|
|
|
182
181
|
return "// AUTO-GENERATED by nanobpmn (ADR 0040 slice 2): the typed `publishMessage`.\n" +
|
|
183
182
|
"// Re-exports the worker SDK's `publishMessage` and overrides it with a\n" +
|
|
184
183
|
"// messageName-keyed typed signature (variables typed from the message's declared\n" +
|
|
185
|
-
"// envelope). Erased to a pass-through at runtime. Do not edit.\n" +
|
|
186
|
-
"
|
|
187
|
-
`import { publishMessage as publishMessageRaw } from "./worker-sdk.ts";\n` +
|
|
184
|
+
"// envelope). Erased to a pass-through at runtime. Do not edit.\n\n" +
|
|
185
|
+
`import type { MessageName, MessagePayloads, MessageVars } from "./${MESSAGE_BINDINGS_DTS}";\n` +
|
|
188
186
|
`import type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n` +
|
|
189
|
-
`import
|
|
187
|
+
`import { publishMessage as publishMessageRaw } from "./worker-sdk.ts";\n\n` +
|
|
190
188
|
`export type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n\n` +
|
|
191
189
|
`type PayloadFor<K extends MessageName> = K extends keyof MessagePayloads ? MessagePayloads[K] : MessageVars;\n\n` +
|
|
192
190
|
`/**\n` +
|
|
@@ -199,6 +197,7 @@ export function emitMessageBindingsRuntime(): string {
|
|
|
199
197
|
` name: K,\n` +
|
|
200
198
|
` opts?: PublishMessageOptions<PayloadFor<K> & object>,\n` +
|
|
201
199
|
`): Promise<PublishMessageResult> {\n` +
|
|
200
|
+
` // biome-ignore lint/plugin: generic-to-raw erasure at the message-SDK boundary; the wire stays untyped JSON (ADR 0040 slice 2).\n` +
|
|
202
201
|
` return publishMessageRaw(name, opts as unknown as PublishMessageOptions);\n` +
|
|
203
202
|
`}\n`;
|
|
204
203
|
}
|
|
@@ -9,7 +9,7 @@ const HEADER =
|
|
|
9
9
|
"// model-metadata accessor. Each `nano:meta` key/value the models declare becomes\n" +
|
|
10
10
|
"// a checked `AppMeta` property, so `meta(key)` is typed and app-wide queryable\n" +
|
|
11
11
|
"// without a second store. Do not edit — regenerated from the model.\n" +
|
|
12
|
-
"
|
|
12
|
+
"\n" +
|
|
13
13
|
"/** Every model-level metadata key the App's models declare (ADR 0040 §5). */\n";
|
|
14
14
|
const ACCESSOR =
|
|
15
15
|
"\n/** Read a model-level metadata value. A declared key is typed; a dynamic key\n" +
|
|
@@ -17,37 +17,37 @@ const ACCESSOR =
|
|
|
17
17
|
"export function meta<K extends keyof AppMeta>(key: K): AppMeta[K];\n" +
|
|
18
18
|
"export function meta(key: string): string | undefined;\n" +
|
|
19
19
|
"export function meta(key: string): string | undefined {\n" +
|
|
20
|
-
" return (appMeta
|
|
20
|
+
" return Reflect.get(appMeta, key);\n" +
|
|
21
21
|
"}\n";
|
|
22
22
|
|
|
23
23
|
const GOLDEN_EMPTY = HEADER +
|
|
24
|
-
"export
|
|
25
|
-
"export const appMeta: AppMeta = Object.create(null)
|
|
24
|
+
"export type AppMeta = Record<never, never>;\n\n" +
|
|
25
|
+
"export const appMeta: AppMeta = Object.create(null);\n" +
|
|
26
26
|
ACCESSOR;
|
|
27
27
|
|
|
28
28
|
const GOLDEN_MULTI = HEADER +
|
|
29
29
|
"export interface AppMeta {\n classification: string;\n owner: string;\n}\n\n" +
|
|
30
30
|
"export const appMeta: AppMeta = (() => {\n" +
|
|
31
|
-
" const m:
|
|
32
|
-
' m
|
|
33
|
-
' m
|
|
34
|
-
" return m
|
|
31
|
+
" const m: AppMeta = Object.create(null);\n" +
|
|
32
|
+
' m.classification = "internal";\n' +
|
|
33
|
+
' m.owner = "ops";\n' +
|
|
34
|
+
" return m;\n})();\n" +
|
|
35
35
|
ACCESSOR;
|
|
36
36
|
|
|
37
37
|
const GOLDEN_NONIDENT = HEADER +
|
|
38
38
|
'export interface AppMeta {\n "data-classification": string;\n}\n\n' +
|
|
39
39
|
"export const appMeta: AppMeta = (() => {\n" +
|
|
40
|
-
" const m:
|
|
40
|
+
" const m: AppMeta = Object.create(null);\n" +
|
|
41
41
|
' m["data-classification"] = "pii";\n' +
|
|
42
|
-
" return m
|
|
42
|
+
" return m;\n})();\n" +
|
|
43
43
|
ACCESSOR;
|
|
44
44
|
|
|
45
45
|
const GOLDEN_PROTO = HEADER +
|
|
46
46
|
"export interface AppMeta {\n __proto__: string;\n}\n\n" +
|
|
47
47
|
"export const appMeta: AppMeta = (() => {\n" +
|
|
48
|
-
" const m:
|
|
48
|
+
" const m: AppMeta = Object.create(null);\n" +
|
|
49
49
|
' m["__proto__"] = "danger";\n' +
|
|
50
|
-
" return m
|
|
50
|
+
" return m;\n})();\n" +
|
|
51
51
|
ACCESSOR;
|
|
52
52
|
|
|
53
53
|
test("emitMeta emits the empty accessor byte-for-byte (matches console)", () => {
|
|
@@ -135,6 +135,6 @@ test("deriveMeta folds across models in path order (last write wins)", () => {
|
|
|
135
135
|
{ path: "processes/a.bpmn", xml: mk("a", "staging") },
|
|
136
136
|
];
|
|
137
137
|
const content = deriveMeta(models)[0].content;
|
|
138
|
-
assert.match(content, /m
|
|
138
|
+
assert.match(content, /m\.env = "prod";/);
|
|
139
139
|
assert.doesNotMatch(content, /"staging"/);
|
|
140
140
|
});
|
|
@@ -86,24 +86,27 @@ export function emitMeta(metas: MetaDecl[]): string {
|
|
|
86
86
|
"// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 §5): the typed\n" +
|
|
87
87
|
"// model-metadata accessor. Each `nano:meta` key/value the models declare becomes\n" +
|
|
88
88
|
"// a checked `AppMeta` property, so `meta(key)` is typed and app-wide queryable\n" +
|
|
89
|
-
"// without a second store. Do not edit — regenerated from the model.\n"
|
|
90
|
-
"// eslint-disable\n\n";
|
|
89
|
+
"// without a second store. Do not edit — regenerated from the model.\n\n";
|
|
91
90
|
const iface = keys.length > 0
|
|
92
91
|
? `export interface AppMeta {\n${keys.map((k) => ` ${propKey(k)}: string;`).join("\n")}\n}\n`
|
|
93
|
-
: `export
|
|
92
|
+
: `export type AppMeta = Record<never, never>;\n`;
|
|
93
|
+
// Dot-access satisfies Biome's `useLiteralKeys`, but `__proto__` must stay a computed key: dot
|
|
94
|
+
// access would trip `noProto` and (off a null-prototype dict) change semantics.
|
|
95
|
+
const assignTarget = (k: string) =>
|
|
96
|
+
isIdent(k) && k !== "__proto__" ? `m.${k}` : `m[${JSON.stringify(k)}]`;
|
|
94
97
|
const constDecl = keys.length > 0
|
|
95
98
|
? `export const appMeta: AppMeta = (() => {\n` +
|
|
96
|
-
` const m:
|
|
97
|
-
keys.map((k) => `
|
|
98
|
-
`\n return m
|
|
99
|
-
: `export const appMeta: AppMeta = Object.create(null)
|
|
99
|
+
` const m: AppMeta = Object.create(null);\n` +
|
|
100
|
+
keys.map((k) => ` ${assignTarget(k)} = ${JSON.stringify(folded[k])};`).join("\n") +
|
|
101
|
+
`\n return m;\n})();\n`
|
|
102
|
+
: `export const appMeta: AppMeta = Object.create(null);\n`;
|
|
100
103
|
const accessor =
|
|
101
104
|
`\n/** Read a model-level metadata value. A declared key is typed; a dynamic key\n` +
|
|
102
105
|
` * returns \`string | undefined\`. */\n` +
|
|
103
106
|
`export function meta<K extends keyof AppMeta>(key: K): AppMeta[K];\n` +
|
|
104
107
|
`export function meta(key: string): string | undefined;\n` +
|
|
105
108
|
`export function meta(key: string): string | undefined {\n` +
|
|
106
|
-
` return (appMeta
|
|
109
|
+
` return Reflect.get(appMeta, key);\n` +
|
|
107
110
|
`}\n`;
|
|
108
111
|
return `${header}/** Every model-level metadata key the App's models declare (ADR 0040 §5). */\n` +
|
|
109
112
|
iface + "\n" + constDecl + accessor;
|
|
@@ -72,7 +72,7 @@ test("emitWorkerBindings falls back to string union + no import when nothing is
|
|
|
72
72
|
const out = emitWorkerBindings([{ taskType: "orders.notify" }], []);
|
|
73
73
|
assert.match(out, /export type WorkerTaskType = "orders.notify";/);
|
|
74
74
|
assert.doesNotMatch(out, /import type \{ DomainTypes \}/);
|
|
75
|
-
assert.match(out, /export
|
|
75
|
+
assert.match(out, /export type WorkerInputs = Record<never, never>;/);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
test("emitWorkerBindings unions distinct envelopes bound to one taskType (shared handler)", () => {
|