@nanobpm/urban 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +69 -5
- package/dist/toolkit/derivers/domain.d.ts +69 -0
- package/dist/toolkit/derivers/domain.js +243 -0
- package/dist/toolkit/derivers/messages.d.ts +36 -0
- package/dist/toolkit/derivers/messages.js +134 -0
- package/dist/toolkit/derivers/meta.d.ts +34 -0
- package/dist/toolkit/derivers/meta.js +106 -0
- package/dist/toolkit/derivers/migrations.d.ts +34 -1
- package/dist/toolkit/derivers/migrations.js +37 -8
- package/dist/toolkit/derivers/worker-io.d.ts +14 -0
- package/dist/toolkit/derivers/worker-io.js +13 -2
- package/dist/toolkit/fsio.js +31 -2
- package/dist/toolkit/gen.d.ts +28 -0
- package/dist/toolkit/gen.js +60 -11
- package/dist/toolkit/index.d.ts +18 -4
- package/dist/toolkit/index.js +12 -3
- package/dist/toolkit/models.d.ts +63 -0
- package/dist/toolkit/models.js +142 -0
- package/package.json +1 -1
- package/src/cli.ts +70 -5
- package/src/toolkit/derivers/domain.test.ts +140 -0
- package/src/toolkit/derivers/domain.ts +317 -0
- package/src/toolkit/derivers/messages.test.ts +174 -0
- package/src/toolkit/derivers/messages.ts +166 -0
- package/src/toolkit/derivers/meta.test.ts +140 -0
- package/src/toolkit/derivers/meta.ts +128 -0
- package/src/toolkit/derivers/migrations.test.ts +42 -4
- package/src/toolkit/derivers/migrations.ts +61 -9
- package/src/toolkit/derivers/worker-io.test.ts +10 -0
- package/src/toolkit/derivers/worker-io.ts +14 -2
- package/src/toolkit/fsio.test.ts +23 -0
- package/src/toolkit/fsio.ts +22 -2
- package/src/toolkit/gen.test.ts +84 -2
- package/src/toolkit/gen.ts +86 -12
- package/src/toolkit/index.ts +66 -3
- package/src/toolkit/models.test.ts +159 -0
- package/src/toolkit/models.ts +193 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Model derivation for code-first apps — the toolkit's one *executing* edge.
|
|
2
|
+
//
|
|
3
|
+
// Unlike the pure derivers (migrations, domain, worker I/O), which transform strings, this
|
|
4
|
+
// imports the app's `workflows/*.ts`, turns each `defineFlow` into executable BPMN via
|
|
5
|
+
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `processes/<id>.bpmn`.
|
|
6
|
+
// Executing the app's TypeScript needs a runtime module loader, so this is gated on the
|
|
7
|
+
// optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
|
|
8
|
+
// loader is absent — or the app authors its models directly (model-first) — derivation is a
|
|
9
|
+
// no-op and the rest of `urban gen` proceeds on the authored/derived models unchanged.
|
|
10
|
+
import { toDeployableBpmn } from "@nanobpm/workflow";
|
|
11
|
+
import { expandPattern, joinPath } from "./gen.js";
|
|
12
|
+
/** Default source glob for code-first flows (matches the code-first scaffold layout). */
|
|
13
|
+
export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
|
|
14
|
+
/** Default output dir for derived models (matches the model-first scaffold, so a derived
|
|
15
|
+
* model is ejectable to model-first and picked up by `models.processes: ["processes/*.bpmn"]`). */
|
|
16
|
+
export const PROCESSES_DIR = "processes";
|
|
17
|
+
/** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
|
|
18
|
+
* stale sweep deletes ONLY derived output — never an authored, human-owned model. */
|
|
19
|
+
export const MODEL_PROVENANCE = "Generated by @nanobpm/urban from workflows/*.ts — do not edit; edit the flow and re-run `urban gen`.";
|
|
20
|
+
/** A `defineFlow` export: has a string `id` and an `imperative`/`declarative` `kind`. */
|
|
21
|
+
export function isWorkflow(v) {
|
|
22
|
+
if (!v || typeof v !== "object")
|
|
23
|
+
return false;
|
|
24
|
+
const o = v;
|
|
25
|
+
return typeof o.id === "string" && (o.kind === "imperative" || o.kind === "declarative");
|
|
26
|
+
}
|
|
27
|
+
/** Sanitize a flow id into a safe `.bpmn` filename (mirrors the host generator). */
|
|
28
|
+
export function bpmnFilename(id) {
|
|
29
|
+
return `${id.replace(/[^A-Za-z0-9._-]/g, "-")}.bpmn`;
|
|
30
|
+
}
|
|
31
|
+
/** The directory derived models are written to: the dir of the first `models.processes`
|
|
32
|
+
* glob if the manifest declares one, else the `processes/` default. */
|
|
33
|
+
export function processesOutDir(manifest) {
|
|
34
|
+
for (const pat of manifest.models?.processes ?? []) {
|
|
35
|
+
const star = pat.indexOf("*");
|
|
36
|
+
const slash = star === -1 ? pat.lastIndexOf("/") : pat.lastIndexOf("/", star);
|
|
37
|
+
if (slash > 0)
|
|
38
|
+
return pat.slice(0, slash);
|
|
39
|
+
}
|
|
40
|
+
return PROCESSES_DIR;
|
|
41
|
+
}
|
|
42
|
+
/** Insert the provenance marker as an XML comment right after the `<?xml … ?>` prolog. */
|
|
43
|
+
function withProvenance(xml) {
|
|
44
|
+
const comment = `<!-- ${MODEL_PROVENANCE} -->`;
|
|
45
|
+
const m = xml.match(/^\uFEFF?\s*<\?xml[^>]*\?>/);
|
|
46
|
+
if (m)
|
|
47
|
+
return `${m[0]}\n${comment}\n${xml.slice(m[0].length).replace(/^\r?\n/, "")}`;
|
|
48
|
+
return `${comment}\n${xml}`;
|
|
49
|
+
}
|
|
50
|
+
function emptyDerived(outDir) {
|
|
51
|
+
return {
|
|
52
|
+
artifacts: [],
|
|
53
|
+
models: [],
|
|
54
|
+
ids: [],
|
|
55
|
+
list: [],
|
|
56
|
+
outDir,
|
|
57
|
+
attempted: false,
|
|
58
|
+
incomplete: false,
|
|
59
|
+
errors: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function messageOf(e) {
|
|
63
|
+
return e instanceof Error ? e.message : String(e);
|
|
64
|
+
}
|
|
65
|
+
/** Append an actionable hint when a `.ts` import fails because the runtime can't strip types. */
|
|
66
|
+
function importHint(rel, e) {
|
|
67
|
+
const base = messageOf(e);
|
|
68
|
+
const code = e?.code ?? "";
|
|
69
|
+
const looksLikeNoTsLoader = rel.endsWith(".ts") &&
|
|
70
|
+
(code === "ERR_UNKNOWN_FILE_EXTENSION" || /Unknown file extension "\.ts"/.test(base));
|
|
71
|
+
if (!looksLikeNoTsLoader)
|
|
72
|
+
return base;
|
|
73
|
+
return (`${base}\n ↳ importing TypeScript workflows needs a runtime that strips types: ` +
|
|
74
|
+
"Node ≥22.18 (default) or `node --experimental-strip-types`, or run under Deno.");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Derive executable BPMN models from an app's code-first `workflows/*.ts`.
|
|
78
|
+
*
|
|
79
|
+
* Returns everything in-memory (artifacts + `ModelSource`s + ids). The caller decides whether
|
|
80
|
+
* to write the `.bpmn` to disk (`urban gen` / `urban derive`) or keep them in memory to feed
|
|
81
|
+
* the type-contract derivers only (`urban gen --no-models`) or preview them (`urban derive
|
|
82
|
+
* --stdout`). Never throws on a bad workflow: a failing module is recorded in `errors`,
|
|
83
|
+
* `incomplete` is set, and the sweep is suppressed so a partial failure can't delete good models.
|
|
84
|
+
*/
|
|
85
|
+
export async function deriveModels(root, io, manifest) {
|
|
86
|
+
const outDir = processesOutDir(manifest);
|
|
87
|
+
const importModule = io.importModule;
|
|
88
|
+
if (!importModule)
|
|
89
|
+
return emptyDerived(outDir);
|
|
90
|
+
const patterns = manifest.models?.workflows ?? DEFAULT_WORKFLOW_PATTERNS;
|
|
91
|
+
const files = [];
|
|
92
|
+
for (const pat of patterns) {
|
|
93
|
+
for (const rel of await expandPattern(root, io, pat))
|
|
94
|
+
files.push(rel);
|
|
95
|
+
}
|
|
96
|
+
if (files.length === 0)
|
|
97
|
+
return emptyDerived(outDir);
|
|
98
|
+
const out = { ...emptyDerived(outDir), attempted: true };
|
|
99
|
+
// id → { source file, workflow ref } — distinguishes a genuine re-export (same
|
|
100
|
+
// object under two export names) from a true collision (two distinct flows, one id).
|
|
101
|
+
const byId = new Map();
|
|
102
|
+
for (const rel of files.sort()) {
|
|
103
|
+
let mod;
|
|
104
|
+
try {
|
|
105
|
+
mod = await importModule(joinPath(root, rel));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
out.incomplete = true;
|
|
109
|
+
out.errors.push({ path: rel, message: importHint(rel, e) });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
for (const value of Object.values(mod)) {
|
|
113
|
+
if (!isWorkflow(value))
|
|
114
|
+
continue;
|
|
115
|
+
const id = value.id;
|
|
116
|
+
const prior = byId.get(id);
|
|
117
|
+
if (prior) {
|
|
118
|
+
if (prior.ref === value && prior.rel === rel)
|
|
119
|
+
continue; // genuine intra-module re-export
|
|
120
|
+
out.incomplete = true;
|
|
121
|
+
const where = prior.rel === rel ? "also defined in the same module" : `also defined in ${prior.rel}`;
|
|
122
|
+
out.errors.push({ path: rel, message: `duplicate flow id "${id}" (${where})` });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
byId.set(id, { rel, ref: value });
|
|
126
|
+
try {
|
|
127
|
+
const semantic = await toDeployableBpmn(value);
|
|
128
|
+
const xml = withProvenance(semantic);
|
|
129
|
+
const path = `${outDir}/${bpmnFilename(id)}`;
|
|
130
|
+
out.artifacts.push({ path, content: xml });
|
|
131
|
+
out.models.push({ path, xml });
|
|
132
|
+
out.list.push({ id, kind: value.kind, xml });
|
|
133
|
+
out.ids.push(id);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
out.incomplete = true;
|
|
137
|
+
out.errors.push({ path: rel, message: `deriving "${id}": ${messageOf(e)}` });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// urban new <name> scaffold a new app (delegates to create-urban-app)
|
|
4
4
|
// urban check load + validate the manifest, report issues
|
|
5
|
-
// urban gen derive generated artifacts (migrations, worker I/O)
|
|
5
|
+
// urban gen derive generated artifacts (migrations, worker I/O, code-first models)
|
|
6
|
+
// urban derive derive code-first models only (workflows/*.ts → processes/*.bpmn)
|
|
6
7
|
// urban run materialize + serve the app (deploy, data, workers, surfaces, triggers)
|
|
7
8
|
// urban dev like run, plus watch sources and hot-reload on change
|
|
8
9
|
// urban deploy deploy models only, then exit
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
selectHost,
|
|
20
21
|
} from "./runtime/index.ts";
|
|
21
22
|
import { scaffold, slugify } from "create-urban-app";
|
|
22
|
-
import { createNodeGenIO, runGen, scaffoldWorkers } from "./toolkit/index.ts";
|
|
23
|
+
import { createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
|
|
23
24
|
import { pathToFileURL } from "node:url";
|
|
24
25
|
import { readFileSync } from "node:fs";
|
|
25
26
|
|
|
@@ -44,6 +45,8 @@ interface Flags {
|
|
|
44
45
|
check: boolean;
|
|
45
46
|
write: boolean;
|
|
46
47
|
deno: boolean;
|
|
48
|
+
models: boolean;
|
|
49
|
+
stdout: boolean;
|
|
47
50
|
style?: "model" | "code";
|
|
48
51
|
help: boolean;
|
|
49
52
|
version: boolean;
|
|
@@ -51,7 +54,7 @@ interface Flags {
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
function parse(argv: string[]): Flags {
|
|
54
|
-
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, help: false, version: false, _: [] };
|
|
57
|
+
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, help: false, version: false, _: [] };
|
|
55
58
|
const need = (i: number, flag: string): string => {
|
|
56
59
|
const v = argv[i];
|
|
57
60
|
if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
|
|
@@ -64,6 +67,9 @@ function parse(argv: string[]): Flags {
|
|
|
64
67
|
else if (a === "--check") f.check = true;
|
|
65
68
|
else if (a === "--write") f.write = true;
|
|
66
69
|
else if (a === "--deno") f.deno = true;
|
|
70
|
+
else if (a === "--no-models") f.models = false;
|
|
71
|
+
else if (a === "--models") f.models = true;
|
|
72
|
+
else if (a === "--stdout") f.stdout = true;
|
|
67
73
|
else if (a === "--code-first") f.style = "code";
|
|
68
74
|
else if (a === "--style") {
|
|
69
75
|
const v = need(++i, a);
|
|
@@ -92,7 +98,10 @@ Usage:
|
|
|
92
98
|
urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
|
|
93
99
|
(--code-first is shorthand for --style code)
|
|
94
100
|
urban check validate the manifest
|
|
95
|
-
urban gen [--check]
|
|
101
|
+
urban gen [--check] [--no-models] derive artifacts (migrations, worker-io, + code-first models)
|
|
102
|
+
(--no-models: skip writing derived .bpmn; type-contracts only)
|
|
103
|
+
urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
|
|
104
|
+
(--stdout: print {models,incomplete} JSON without writing)
|
|
96
105
|
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
97
106
|
urban run materialize + serve the app
|
|
98
107
|
urban dev run + watch sources, hot-reload on change
|
|
@@ -125,7 +134,12 @@ async function cmdCheck(f: Flags): Promise<number> {
|
|
|
125
134
|
|
|
126
135
|
async function cmdGen(f: Flags): Promise<number> {
|
|
127
136
|
const io = createNodeGenIO();
|
|
128
|
-
const res = await runGen({ root: f.root, io, manifestFile: f.manifest, check: f.check });
|
|
137
|
+
const res = await runGen({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
|
|
138
|
+
if (res.incomplete) {
|
|
139
|
+
console.error(`✖ ${res.modelErrors.length} workflow(s) failed to derive:`);
|
|
140
|
+
for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
129
143
|
if (f.check) {
|
|
130
144
|
if (res.drift.length === 0) {
|
|
131
145
|
console.log(`✔ generated artifacts are up to date (${res.artifacts.length} checked)`);
|
|
@@ -140,6 +154,55 @@ async function cmdGen(f: Flags): Promise<number> {
|
|
|
140
154
|
return 0;
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
async function cmdDerive(f: Flags): Promise<number> {
|
|
158
|
+
// `urban derive` IS model derivation, so `--no-models` is contradictory. Reject it explicitly
|
|
159
|
+
// rather than silently ignoring it (flags are global, so it's easy to pass by accident).
|
|
160
|
+
if (!f.models) {
|
|
161
|
+
console.error(
|
|
162
|
+
"✖ `urban derive` derives models — `--no-models` is contradictory. Use `urban gen --no-models` for type-contracts only.",
|
|
163
|
+
);
|
|
164
|
+
return 2;
|
|
165
|
+
}
|
|
166
|
+
const io = createNodeGenIO();
|
|
167
|
+
|
|
168
|
+
// Non-writing preview: emit `{ models: [{ id, kind, xml }], incomplete }` to stdout for the
|
|
169
|
+
// console's read-only model viewer (never touches `processes/`).
|
|
170
|
+
if (f.stdout) {
|
|
171
|
+
const d = await previewModels({ root: f.root, io, manifestFile: f.manifest });
|
|
172
|
+
process.stdout.write(
|
|
173
|
+
JSON.stringify({
|
|
174
|
+
models: d.list.map((m) => ({ id: m.id, kind: m.kind, xml: m.xml })),
|
|
175
|
+
incomplete: d.incomplete,
|
|
176
|
+
}) + "\n",
|
|
177
|
+
);
|
|
178
|
+
if (d.incomplete) {
|
|
179
|
+
for (const e of d.errors) console.error(` • ${e.path}: ${e.message}`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Derive + write models only (skip the type-contract derivers); `generate_models` delegates here.
|
|
186
|
+
const res = await runGen({ root: f.root, io, manifestFile: f.manifest, check: f.check, modelsOnly: true });
|
|
187
|
+
if (res.incomplete) {
|
|
188
|
+
console.error(`✖ ${res.modelErrors.length} workflow(s) failed to derive:`);
|
|
189
|
+
for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
if (f.check) {
|
|
193
|
+
if (res.drift.length === 0) {
|
|
194
|
+
console.log(`✔ derived models are up to date (${res.artifacts.length} checked)`);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
console.error(`✖ ${res.drift.length} derived model(s) are out of date — run \`urban derive\`:`);
|
|
198
|
+
for (const p of res.drift) console.error(` • ${p}`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
console.log(`✔ derived ${res.artifacts.length} model(s):`);
|
|
202
|
+
for (const a of res.artifacts) console.log(` • ${a.path}`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
143
206
|
async function cmdStubs(f: Flags): Promise<number> {
|
|
144
207
|
const io = createNodeGenIO();
|
|
145
208
|
const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
|
|
@@ -243,6 +306,8 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
243
306
|
return cmdCheck(f);
|
|
244
307
|
case "gen":
|
|
245
308
|
return cmdGen(f);
|
|
309
|
+
case "derive":
|
|
310
|
+
return cmdDerive(f);
|
|
246
311
|
case "stubs":
|
|
247
312
|
return cmdStubs(f);
|
|
248
313
|
case "run":
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
emitDomainModel,
|
|
5
|
+
emitDomain,
|
|
6
|
+
deriveDomain,
|
|
7
|
+
sourcesFromManifest,
|
|
8
|
+
registryFromManifest,
|
|
9
|
+
sqliteAffinityToTs,
|
|
10
|
+
interfaceName,
|
|
11
|
+
type SourceSchema,
|
|
12
|
+
type TableMeta,
|
|
13
|
+
type DomainTypeRegistry,
|
|
14
|
+
} from "./domain.ts";
|
|
15
|
+
import type { ToolkitManifest } from "./migrations.ts";
|
|
16
|
+
|
|
17
|
+
// Goldens captured verbatim from the console's `emitDomainModel` (server domain_types.ts) via
|
|
18
|
+
// Deno — this IS the per-emitter drift gate (ADR 0053): the toolkit must be byte-identical.
|
|
19
|
+
const GOLDENS: Record<string, string> = JSON.parse("{\n \"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// eslint-disable\\n\\nexport interface DomainTables {}\\n\",\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// eslint-disable\\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\",\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// eslint-disable\\n\\nexport interface DomainTables {}\\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\",\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// eslint-disable\\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\",\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// eslint-disable\\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\"\n}");
|
|
20
|
+
|
|
21
|
+
const col = (name: string, type: string, notNull: boolean, primaryKey: boolean) =>
|
|
22
|
+
({ name, type, notNull, primaryKey });
|
|
23
|
+
const table = (name: string, cols: TableMeta["columns"]): TableMeta => ({ name, columns: cols, indexes: [], foreignKeys: [] });
|
|
24
|
+
|
|
25
|
+
test("emitDomainModel: empty app → bare DomainTables (byte-parity)", () => {
|
|
26
|
+
assert.equal(emitDomainModel([], undefined, {}), GOLDENS.empty);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("emitDomainModel: table spine only (byte-parity)", () => {
|
|
30
|
+
const sources: SourceSchema[] = [{ source: "app", tables: [
|
|
31
|
+
table("customers", [col("id","INTEGER",false,true), col("email","TEXT",true,false), col("tier","TEXT",false,false)]),
|
|
32
|
+
table("order_items", [col("id","INTEGER",false,true), col("qty","INTEGER",true,false), col("unit price","REAL",false,false)]),
|
|
33
|
+
]}];
|
|
34
|
+
assert.equal(emitDomainModel(sources, "app", {}), GOLDENS.tablesOnly);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("emitDomainModel: registry only — nominal ref, list, optional, non-ident key (byte-parity)", () => {
|
|
38
|
+
const types: DomainTypeRegistry = {
|
|
39
|
+
taxReturn: { fields: { year: { type: "integer" }, notes: { type: "string", optional: true }, lines: { type: "lineItem", list: true } } },
|
|
40
|
+
lineItem: { fields: { "line no": { type: "integer" }, amount: { type: "number" } } },
|
|
41
|
+
};
|
|
42
|
+
assert.equal(emitDomainModel([], undefined, types), GOLDENS.typesOnly);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("emitDomainModel: table + registry coexist for same type (byte-parity)", () => {
|
|
46
|
+
const sources: SourceSchema[] = [{ source: "app", tables: [
|
|
47
|
+
table("greetings", [col("id","INTEGER",false,true), col("who","TEXT",true,false), col("message","TEXT",true,false), col("createdAt","TEXT",false,false)]),
|
|
48
|
+
]}];
|
|
49
|
+
const types: DomainTypeRegistry = { greeting: { table: "greetings", fields: { who: { type: "string" }, message: { type: "string" }, createdAt: { type: "datetime", optional: true } } } };
|
|
50
|
+
assert.equal(emitDomainModel(sources, "app", types), GOLDENS.both);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("emitDomainModel: multi-source → DomainSources + DomainTables alias to default (byte-parity)", () => {
|
|
54
|
+
const sources: SourceSchema[] = [
|
|
55
|
+
{ source: "app", tables: [ table("customers", [col("id","INTEGER",false,true), col("email","TEXT",true,false)]) ] },
|
|
56
|
+
{ source: "analytics", tables: [ table("events", [col("id","INTEGER",false,true), col("kind","TEXT",true,false)]) ] },
|
|
57
|
+
];
|
|
58
|
+
assert.equal(emitDomainModel(sources, "analytics", {}), GOLDENS.multi);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// --- manifest bridge: code-first derivation reproduces the console output --------------------
|
|
62
|
+
|
|
63
|
+
test("emitDomain: manifest types drive BOTH the table spine and the registry (byte-parity)", () => {
|
|
64
|
+
const manifest: ToolkitManifest = {
|
|
65
|
+
data: { default: "app" },
|
|
66
|
+
types: { greeting: { table: "greetings", fields: {
|
|
67
|
+
who: { type: "string" }, message: { type: "string" }, createdAt: { type: "datetime", optional: true },
|
|
68
|
+
} } },
|
|
69
|
+
};
|
|
70
|
+
// The manifest 'greeting' type yields DomainTables["greetings"] (id + fields) and
|
|
71
|
+
// DomainTypes["greeting"] (declared fields only) — identical to the console's `both` case.
|
|
72
|
+
assert.equal(emitDomain(manifest), GOLDENS.both);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("sourcesFromManifest: implicit id PK + field affinity + nullability", () => {
|
|
76
|
+
const manifest: ToolkitManifest = { data: { default: "app" }, types: {
|
|
77
|
+
greeting: { table: "greetings", fields: { who: { type: "string" }, createdAt: { type: "datetime", optional: true } } },
|
|
78
|
+
} };
|
|
79
|
+
const [src] = sourcesFromManifest(manifest);
|
|
80
|
+
assert.equal(src.source, "app");
|
|
81
|
+
assert.deepEqual(src.tables[0], table("greetings", [
|
|
82
|
+
col("id", "INTEGER", false, true),
|
|
83
|
+
col("who", "TEXT", true, false),
|
|
84
|
+
col("createdAt", "TEXT", false, false),
|
|
85
|
+
]));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("sourcesFromManifest: table name defaults to the type id; types group by source", () => {
|
|
89
|
+
const manifest: ToolkitManifest = { data: { default: "app" }, types: {
|
|
90
|
+
order: { fields: { total: { type: "number" } } },
|
|
91
|
+
event: { source: "analytics", fields: { kind: { type: "string" } } },
|
|
92
|
+
} };
|
|
93
|
+
const sources = sourcesFromManifest(manifest);
|
|
94
|
+
assert.deepEqual(sources.map((s) => s.source), ["app", "analytics"]);
|
|
95
|
+
assert.equal(sources[0].tables[0].name, "order");
|
|
96
|
+
assert.equal(sources[1].tables[0].name, "event");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("registryFromManifest: declared fields only (no persisted id); type defaults to string", () => {
|
|
100
|
+
const manifest: ToolkitManifest = { types: {
|
|
101
|
+
greeting: { table: "greetings", fields: { who: {}, note: { type: "string", optional: true } } },
|
|
102
|
+
} };
|
|
103
|
+
assert.deepEqual(registryFromManifest(manifest), {
|
|
104
|
+
greeting: { table: "greetings", fields: { who: { type: "string", optional: undefined, list: undefined }, note: { type: "string", optional: true, list: undefined } } },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("registryFromManifest + emitDomain: the `list` modifier is preserved as `T[]`", () => {
|
|
109
|
+
const manifest: ToolkitManifest = { types: {
|
|
110
|
+
order: { fields: { tags: { type: "string", list: true } } },
|
|
111
|
+
} };
|
|
112
|
+
// The bridge must forward `list` (not drop it) so the emitter can project the repeated field.
|
|
113
|
+
assert.equal(registryFromManifest(manifest).order.fields.tags.list, true);
|
|
114
|
+
assert.match(emitDomain(manifest), /tags: string\[\];/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("deriveDomain: emits domain-rows.d.ts only when the manifest declares types", () => {
|
|
118
|
+
assert.deepEqual(deriveDomain({}), []);
|
|
119
|
+
assert.deepEqual(deriveDomain({ types: {} }), []);
|
|
120
|
+
const arts = deriveDomain({ data: { default: "app" }, types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } } });
|
|
121
|
+
assert.equal(arts.length, 1);
|
|
122
|
+
assert.equal(arts[0].path, "nano-generated/domain-rows.d.ts");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("sqliteAffinityToTs: SQLite affinity rules with boolean/datetime overrides", () => {
|
|
126
|
+
assert.equal(sqliteAffinityToTs("INTEGER"), "number");
|
|
127
|
+
assert.equal(sqliteAffinityToTs("TEXT"), "string");
|
|
128
|
+
assert.equal(sqliteAffinityToTs("REAL"), "number");
|
|
129
|
+
assert.equal(sqliteAffinityToTs("BLOB"), "Uint8Array");
|
|
130
|
+
assert.equal(sqliteAffinityToTs("BOOLEAN"), "boolean");
|
|
131
|
+
assert.equal(sqliteAffinityToTs("DATETIME"), "string");
|
|
132
|
+
assert.equal(sqliteAffinityToTs("NUMERIC"), "number");
|
|
133
|
+
assert.equal(sqliteAffinityToTs(""), "unknown");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("interfaceName: PascalCases + sanitises table names to identifiers", () => {
|
|
137
|
+
assert.equal(interfaceName("order_items"), "OrderItems");
|
|
138
|
+
assert.equal(interfaceName("customers"), "Customers");
|
|
139
|
+
assert.equal(interfaceName("2024_events"), "T_2024Events");
|
|
140
|
+
});
|