@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,140 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { deriveMeta, emitMeta, foldMeta, scanModelMeta, type MetaDecl } from "./meta.ts";
|
|
4
|
+
|
|
5
|
+
// Goldens captured byte-for-byte from the console's canonical `emitMeta`
|
|
6
|
+
// (server/src/console/domain_types.ts) so this port is provably a drop-in (ADR 0053).
|
|
7
|
+
const HEADER =
|
|
8
|
+
"// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 §5): the typed\n" +
|
|
9
|
+
"// model-metadata accessor. Each `nano:meta` key/value the models declare becomes\n" +
|
|
10
|
+
"// a checked `AppMeta` property, so `meta(key)` is typed and app-wide queryable\n" +
|
|
11
|
+
"// without a second store. Do not edit — regenerated from the model.\n" +
|
|
12
|
+
"// eslint-disable\n\n" +
|
|
13
|
+
"/** Every model-level metadata key the App's models declare (ADR 0040 §5). */\n";
|
|
14
|
+
const ACCESSOR =
|
|
15
|
+
"\n/** Read a model-level metadata value. A declared key is typed; a dynamic key\n" +
|
|
16
|
+
" * returns `string | undefined`. */\n" +
|
|
17
|
+
"export function meta<K extends keyof AppMeta>(key: K): AppMeta[K];\n" +
|
|
18
|
+
"export function meta(key: string): string | undefined;\n" +
|
|
19
|
+
"export function meta(key: string): string | undefined {\n" +
|
|
20
|
+
" return (appMeta as Record<string, string>)[key];\n" +
|
|
21
|
+
"}\n";
|
|
22
|
+
|
|
23
|
+
const GOLDEN_EMPTY = HEADER +
|
|
24
|
+
"export interface AppMeta {}\n\n" +
|
|
25
|
+
"export const appMeta: AppMeta = Object.create(null) as AppMeta;\n" +
|
|
26
|
+
ACCESSOR;
|
|
27
|
+
|
|
28
|
+
const GOLDEN_MULTI = HEADER +
|
|
29
|
+
"export interface AppMeta {\n classification: string;\n owner: string;\n}\n\n" +
|
|
30
|
+
"export const appMeta: AppMeta = (() => {\n" +
|
|
31
|
+
" const m: Record<string, string> = Object.create(null);\n" +
|
|
32
|
+
' m["classification"] = "internal";\n' +
|
|
33
|
+
' m["owner"] = "ops";\n' +
|
|
34
|
+
" return m as AppMeta;\n})();\n" +
|
|
35
|
+
ACCESSOR;
|
|
36
|
+
|
|
37
|
+
const GOLDEN_NONIDENT = HEADER +
|
|
38
|
+
'export interface AppMeta {\n "data-classification": string;\n}\n\n' +
|
|
39
|
+
"export const appMeta: AppMeta = (() => {\n" +
|
|
40
|
+
" const m: Record<string, string> = Object.create(null);\n" +
|
|
41
|
+
' m["data-classification"] = "pii";\n' +
|
|
42
|
+
" return m as AppMeta;\n})();\n" +
|
|
43
|
+
ACCESSOR;
|
|
44
|
+
|
|
45
|
+
const GOLDEN_PROTO = HEADER +
|
|
46
|
+
"export interface AppMeta {\n __proto__: string;\n}\n\n" +
|
|
47
|
+
"export const appMeta: AppMeta = (() => {\n" +
|
|
48
|
+
" const m: Record<string, string> = Object.create(null);\n" +
|
|
49
|
+
' m["__proto__"] = "danger";\n' +
|
|
50
|
+
" return m as AppMeta;\n})();\n" +
|
|
51
|
+
ACCESSOR;
|
|
52
|
+
|
|
53
|
+
test("emitMeta emits the empty accessor byte-for-byte (matches console)", () => {
|
|
54
|
+
assert.equal(emitMeta([]), GOLDEN_EMPTY);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("emitMeta emits declared keys in scan order byte-for-byte (matches console)", () => {
|
|
58
|
+
const metas: MetaDecl[] = [
|
|
59
|
+
{ process: "Proc_a", key: "classification", value: "internal" },
|
|
60
|
+
{ process: "Proc_a", key: "owner", value: "ops" },
|
|
61
|
+
];
|
|
62
|
+
assert.equal(emitMeta(metas), GOLDEN_MULTI);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("emitMeta quotes non-identifier keys byte-for-byte (matches console)", () => {
|
|
66
|
+
assert.equal(emitMeta([{ key: "data-classification", value: "pii" }]), GOLDEN_NONIDENT);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("emitMeta handles a literal __proto__ key via a null-prototype dict (matches console)", () => {
|
|
70
|
+
assert.equal(emitMeta([{ key: "__proto__", value: "danger" }]), GOLDEN_PROTO);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("foldMeta: last declaration of a key wins; empty keys are dropped", () => {
|
|
74
|
+
const folded = foldMeta([
|
|
75
|
+
{ key: "env", value: "staging" },
|
|
76
|
+
{ key: " ", value: "ignored" },
|
|
77
|
+
{ key: "env", value: "prod" },
|
|
78
|
+
]);
|
|
79
|
+
assert.equal(folded.env, "prod");
|
|
80
|
+
assert.deepEqual(Object.keys(folded), ["env"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const XML = `<?xml version="1.0"?>
|
|
84
|
+
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
|
85
|
+
xmlns:nano="https://nanobpm.io/schema/shapes/1.0">
|
|
86
|
+
<bpmn:process id="orders" isExecutable="true">
|
|
87
|
+
<bpmn:extensionElements>
|
|
88
|
+
<nano:shapes><nano:shape id="S"><nano:carry ref="Order" /></nano:shape></nano:shapes>
|
|
89
|
+
<nano:meta key="classification" value="internal" />
|
|
90
|
+
<nano:meta key="owner" value="ops" />
|
|
91
|
+
</bpmn:extensionElements>
|
|
92
|
+
</bpmn:process>
|
|
93
|
+
</bpmn:definitions>`;
|
|
94
|
+
|
|
95
|
+
test("scanModelMeta lifts nano:meta siblings in document order, tagged with the process", () => {
|
|
96
|
+
assert.deepEqual(scanModelMeta(XML), [
|
|
97
|
+
{ process: "orders", key: "classification", value: "internal" },
|
|
98
|
+
{ process: "orders", key: "owner", value: "ops" },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("scanModelMeta does not mistake a shape's op children for model meta", () => {
|
|
103
|
+
const xml = `<bpmn:process id="p"><bpmn:extensionElements>
|
|
104
|
+
<nano:shapes><nano:shape id="S">
|
|
105
|
+
<nano:carry ref="Order" />
|
|
106
|
+
<nano:extend name="approved" type="boolean" />
|
|
107
|
+
</nano:shape></nano:shapes>
|
|
108
|
+
</bpmn:extensionElements></bpmn:process>`;
|
|
109
|
+
assert.deepEqual(scanModelMeta(xml), []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("scanModelMeta ignores a meta with no key; a missing value folds to empty string", () => {
|
|
113
|
+
const xml = `<bpmn:process id="p"><bpmn:extensionElements>
|
|
114
|
+
<nano:meta value="orphan" />
|
|
115
|
+
<nano:meta key="flag" />
|
|
116
|
+
</bpmn:extensionElements></bpmn:process>`;
|
|
117
|
+
assert.deepEqual(scanModelMeta(xml), [{ process: "p", key: "flag", value: "" }]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("deriveMeta is deterministic and emits nano-generated/meta.ts", () => {
|
|
121
|
+
const models = [{ path: "processes/orders.bpmn", xml: XML }];
|
|
122
|
+
const a1 = deriveMeta(models);
|
|
123
|
+
const a2 = deriveMeta(models);
|
|
124
|
+
assert.deepEqual(a1, a2);
|
|
125
|
+
assert.equal(a1[0].path, "nano-generated/meta.ts");
|
|
126
|
+
assert.equal(a1[0].content, GOLDEN_MULTI);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("deriveMeta folds across models in path order (last write wins)", () => {
|
|
130
|
+
const mk = (id: string, v: string) =>
|
|
131
|
+
`<bpmn:process id="${id}"><bpmn:extensionElements><nano:meta key="env" value="${v}" /></bpmn:extensionElements></bpmn:process>`;
|
|
132
|
+
// Provided out of path order; the deriver sorts by path so `b.bpmn` (prod) wins.
|
|
133
|
+
const models = [
|
|
134
|
+
{ path: "processes/b.bpmn", xml: mk("b", "prod") },
|
|
135
|
+
{ path: "processes/a.bpmn", xml: mk("a", "staging") },
|
|
136
|
+
];
|
|
137
|
+
const content = deriveMeta(models)[0].content;
|
|
138
|
+
assert.match(content, /m\["env"\] = "prod";/);
|
|
139
|
+
assert.doesNotMatch(content, /"staging"/);
|
|
140
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Deriver: BPMN models → `meta.ts`, the typed model-metadata accessor (`@nanobpm/meta`,
|
|
2
|
+
// ADR 0040 §5). Scans each process for `nano:meta` key/value siblings of the `nano:shapes`
|
|
3
|
+
// container and folds them into a single app-wide record. It emits the SAME `meta.ts` the
|
|
4
|
+
// console generates (a faithful port of `foldMeta`/`emitMeta` in the server's domain_types.ts),
|
|
5
|
+
// so the toolkit is a drop-in for the IDE's codegen (ADR 0053).
|
|
6
|
+
|
|
7
|
+
import type { DerivedArtifact, Deriver } from "../artifact.ts";
|
|
8
|
+
import { GENERATED_DIR } from "../artifact.ts";
|
|
9
|
+
import { byModelPath, type ModelSource } from "./worker-io.ts";
|
|
10
|
+
|
|
11
|
+
/** Basename the console uses for the generated accessor; kept identical (drop-in). */
|
|
12
|
+
export const META_TS = "meta.ts";
|
|
13
|
+
|
|
14
|
+
/** One model-level metadata entry (`nano:meta`), tagged with its defining process for
|
|
15
|
+
* the `model:<processId>` provenance (ADR 0040 §5). `process` is carried for the fuse;
|
|
16
|
+
* the emitted `meta.ts` folds on key/value only. */
|
|
17
|
+
export interface MetaDecl {
|
|
18
|
+
process?: string;
|
|
19
|
+
key: string;
|
|
20
|
+
value: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function attr(tag: string, name: string): string | undefined {
|
|
24
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
|
|
25
|
+
return m ? m[1] : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function processId(xml: string): string | undefined {
|
|
29
|
+
const m = xml.match(/<[\w.-]*:?process\b[^>]*>/);
|
|
30
|
+
return m ? attr(m[0], "id") : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A `nano:shape` carrier (`<...:shape>…</...:shape>`, and self-closing) — meta is only
|
|
34
|
+
// recognised *outside* a shape carrier (its op children, e.g. `extend`, are a different
|
|
35
|
+
// vocabulary), so strip carriers before matching `nano:meta` (mirrors envelope_scan.rs).
|
|
36
|
+
const SHAPE_BLOCK_RE = /<([\w.-]*:)?shape\b[^>]*(?:\/>|>[\s\S]*?<\/\1?shape\s*>)/g;
|
|
37
|
+
// A `nano:meta` element (any namespace prefix; matches the Rust local-name scan).
|
|
38
|
+
const META_RE = /<([\w.-]*:)?meta\b([^>]*?)\/?>/g;
|
|
39
|
+
|
|
40
|
+
/** Scan one BPMN document for model-level `nano:meta` key/value entries, in document
|
|
41
|
+
* order, tagged with the enclosing process. A meta with no `key` attribute is ignored;
|
|
42
|
+
* a missing `value` folds to the empty string (matches envelope_scan.rs). */
|
|
43
|
+
export function scanModelMeta(xml: string): MetaDecl[] {
|
|
44
|
+
const proc = processId(xml);
|
|
45
|
+
const withoutShapes = xml.replace(SHAPE_BLOCK_RE, "");
|
|
46
|
+
const out: MetaDecl[] = [];
|
|
47
|
+
let m: RegExpExecArray | null;
|
|
48
|
+
while ((m = META_RE.exec(withoutShapes)) !== null) {
|
|
49
|
+
const key = attr(`<x ${m[2]}>`, "key");
|
|
50
|
+
if (key == null) continue;
|
|
51
|
+
out.push({ process: proc, key, value: attr(`<x ${m[2]}>`, "value") ?? "" });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Faithful port of the console's foldMeta/emitMeta (domain_types.ts) so the emitted
|
|
57
|
+
// meta.ts is byte-compatible with the IDE's own codegen. ---
|
|
58
|
+
|
|
59
|
+
function isIdent(name: string): boolean {
|
|
60
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Fold the scanned model-level metadata into a single key→value record. When two models
|
|
64
|
+
* declare the same key the last (scan order) wins. Empty keys are ignored. The accumulator
|
|
65
|
+
* is a **null-prototype** dict so user-authored keys (including `__proto__`) round-trip
|
|
66
|
+
* deterministically as own data properties. */
|
|
67
|
+
export function foldMeta(metas: MetaDecl[]): Record<string, string> {
|
|
68
|
+
const out: Record<string, string> = Object.create(null);
|
|
69
|
+
for (const m of metas) {
|
|
70
|
+
const key = (m?.key ?? "").trim();
|
|
71
|
+
if (!key) continue;
|
|
72
|
+
out[key] = m.value ?? "";
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Emit `meta.ts`: the typed model-metadata accessor (`@nanobpm/meta`, ADR 0040 §5).
|
|
78
|
+
* `AppMeta` keys the declared metadata as string-literal properties; `appMeta` is the
|
|
79
|
+
* concrete null-prototype record; the overloaded `meta()` returns the typed value for a
|
|
80
|
+
* known key and `string | undefined` for a dynamic one. Strip-safe (ADR 0036). */
|
|
81
|
+
export function emitMeta(metas: MetaDecl[]): string {
|
|
82
|
+
const folded = foldMeta(metas);
|
|
83
|
+
const keys = Object.keys(folded);
|
|
84
|
+
const propKey = (k: string) => (isIdent(k) ? k : JSON.stringify(k));
|
|
85
|
+
const header =
|
|
86
|
+
"// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 §5): the typed\n" +
|
|
87
|
+
"// model-metadata accessor. Each `nano:meta` key/value the models declare becomes\n" +
|
|
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";
|
|
91
|
+
const iface = keys.length > 0
|
|
92
|
+
? `export interface AppMeta {\n${keys.map((k) => ` ${propKey(k)}: string;`).join("\n")}\n}\n`
|
|
93
|
+
: `export interface AppMeta {}\n`;
|
|
94
|
+
const constDecl = keys.length > 0
|
|
95
|
+
? `export const appMeta: AppMeta = (() => {\n` +
|
|
96
|
+
` const m: Record<string, string> = Object.create(null);\n` +
|
|
97
|
+
keys.map((k) => ` m[${JSON.stringify(k)}] = ${JSON.stringify(folded[k])};`).join("\n") +
|
|
98
|
+
`\n return m as AppMeta;\n})();\n`
|
|
99
|
+
: `export const appMeta: AppMeta = Object.create(null) as AppMeta;\n`;
|
|
100
|
+
const accessor =
|
|
101
|
+
`\n/** Read a model-level metadata value. A declared key is typed; a dynamic key\n` +
|
|
102
|
+
` * returns \`string | undefined\`. */\n` +
|
|
103
|
+
`export function meta<K extends keyof AppMeta>(key: K): AppMeta[K];\n` +
|
|
104
|
+
`export function meta(key: string): string | undefined;\n` +
|
|
105
|
+
`export function meta(key: string): string | undefined {\n` +
|
|
106
|
+
` return (appMeta as Record<string, string>)[key];\n` +
|
|
107
|
+
`}\n`;
|
|
108
|
+
return `${header}/** Every model-level metadata key the App's models declare (ADR 0040 §5). */\n` +
|
|
109
|
+
iface + "\n" + constDecl + accessor;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Derive `nano-generated/meta.ts` from BPMN models. Deterministic: models are scanned in
|
|
114
|
+
* path order, then their `nano:meta` entries folded (last write wins) into the accessor.
|
|
115
|
+
*/
|
|
116
|
+
export function deriveMeta(models: ModelSource[]): DerivedArtifact[] {
|
|
117
|
+
const metas: MetaDecl[] = [];
|
|
118
|
+
for (const model of [...models].sort(byModelPath)) {
|
|
119
|
+
metas.push(...scanModelMeta(model.xml));
|
|
120
|
+
}
|
|
121
|
+
return [{ path: `${GENERATED_DIR}/${META_TS}`, content: emitMeta(metas) }];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const metaDeriver: Deriver<{ models: ModelSource[] }> = {
|
|
125
|
+
id: "model->meta",
|
|
126
|
+
describe: "Derive meta.ts (the typed @nanobpm/meta accessor) from BPMN nano:meta entries.",
|
|
127
|
+
derive: ({ models }) => deriveMeta(models),
|
|
128
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
4
|
import { createTableSql, deriveMigrations, sqlType } from "./migrations.ts";
|
|
5
|
+
import { deriveDomain } from "./domain.ts";
|
|
4
6
|
|
|
5
7
|
test("sqlType maps domain types to SQLite affinities", () => {
|
|
6
8
|
assert.equal(sqlType("string"), "TEXT");
|
|
@@ -17,10 +19,10 @@ test("createTableSql emits id PK, NOT NULL by default, nullable when optional",
|
|
|
17
19
|
table: "greetings",
|
|
18
20
|
fields: { who: { type: "string" }, when: { type: "datetime", optional: true } },
|
|
19
21
|
});
|
|
20
|
-
assert.match(sql, /CREATE TABLE IF NOT EXISTS greetings/);
|
|
21
|
-
assert.match(sql, /id INTEGER PRIMARY KEY AUTOINCREMENT/);
|
|
22
|
-
assert.match(sql, /who TEXT NOT NULL/);
|
|
23
|
-
assert.match(sql, /when TEXT(?! NOT NULL)/);
|
|
22
|
+
assert.match(sql, /CREATE TABLE IF NOT EXISTS "greetings"/);
|
|
23
|
+
assert.match(sql, /"id" INTEGER PRIMARY KEY AUTOINCREMENT/);
|
|
24
|
+
assert.match(sql, /"who" TEXT NOT NULL/);
|
|
25
|
+
assert.match(sql, /"when" TEXT(?! NOT NULL)/);
|
|
24
26
|
});
|
|
25
27
|
|
|
26
28
|
test("createTableSql rejects a reserved 'id' field and non-identifier names", () => {
|
|
@@ -48,3 +50,39 @@ test("deriveMigrations groups by datasource and is deterministic", () => {
|
|
|
48
50
|
const paths = a1.map((x) => x.path).sort();
|
|
49
51
|
assert.deepEqual(paths, ["nano-generated/app.schema.sql", "nano-generated/audit.schema.sql"]);
|
|
50
52
|
});
|
|
53
|
+
|
|
54
|
+
// #88: reserved-word table/column names (e.g. `order`, `group`) are valid SQL identifiers
|
|
55
|
+
// only when quoted. Guard the defect class — not just `order` — by executing the derived DDL
|
|
56
|
+
// against a real SQLite engine, so any unquoted reserved identifier fails here, not at runtime.
|
|
57
|
+
test("createTableSql quotes identifiers so reserved-word names produce executable DDL", () => {
|
|
58
|
+
const sql = createTableSql("order", {
|
|
59
|
+
fields: { group: { type: "string" }, select: { type: "int", optional: true } },
|
|
60
|
+
});
|
|
61
|
+
// Identifiers are double-quoted.
|
|
62
|
+
assert.match(sql, /CREATE TABLE IF NOT EXISTS "order"/);
|
|
63
|
+
assert.match(sql, /"group" TEXT NOT NULL/);
|
|
64
|
+
assert.match(sql, /"select" INTEGER(?! NOT NULL)/);
|
|
65
|
+
// And the DDL actually executes against a real SQLite engine.
|
|
66
|
+
const db = new DatabaseSync(":memory:");
|
|
67
|
+
try {
|
|
68
|
+
assert.doesNotThrow(() => db.exec(sql), "derived DDL must execute against node:sqlite");
|
|
69
|
+
} finally {
|
|
70
|
+
db.close();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// #88 companion: the domain deriver keys `DomainTables` off the raw wire name via
|
|
75
|
+
// JSON.stringify, so a reserved-word type name still resolves as a string-literal key.
|
|
76
|
+
test("deriveDomain keeps reserved-word table names addressable in DomainTables", () => {
|
|
77
|
+
const [artifact] = deriveDomain({
|
|
78
|
+
data: { default: "app" },
|
|
79
|
+
types: { order: { fields: { group: { type: "string" } } } },
|
|
80
|
+
});
|
|
81
|
+
// Anchor to the DomainTables interface specifically — DomainTypes also emits an
|
|
82
|
+
// "order" key, so a bare /"order":/ would pass even if the DomainTables spine regressed.
|
|
83
|
+
assert.match(
|
|
84
|
+
artifact.content,
|
|
85
|
+
/export interface DomainTables \{\s*"order":/,
|
|
86
|
+
'DomainTables["order"] must resolve',
|
|
87
|
+
);
|
|
88
|
+
});
|
|
@@ -8,6 +8,8 @@ import { GENERATED_DIR } from "../artifact.ts";
|
|
|
8
8
|
export interface ToolkitField {
|
|
9
9
|
type?: string;
|
|
10
10
|
optional?: boolean;
|
|
11
|
+
/** Marks a repeated field: the domain row-type projects it as `T[]` in the `DomainTypes` registry. */
|
|
12
|
+
list?: boolean;
|
|
11
13
|
}
|
|
12
14
|
export interface ToolkitType {
|
|
13
15
|
table?: string;
|
|
@@ -52,27 +54,59 @@ export function assertSqlIdent(kind: string, name: string): string {
|
|
|
52
54
|
return name;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
+
/** Double-quote an identifier for DDL. `assertSqlIdent` already rejects non-identifier
|
|
58
|
+
* chars (the injection guard), but quoting is what makes valid-but-reserved words
|
|
59
|
+
* (`order`, `group`, `select`, …) legal SQL — no reserved-word denylist to maintain.
|
|
60
|
+
* Embedded double quotes are doubled per the SQL standard (defensive; can't occur here). */
|
|
61
|
+
export function quoteSqlIdent(name: string): string {
|
|
62
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A column in a manifest type's canonical table schema. `sqlType` is the SQLite
|
|
66
|
+
* declared type (the affinity source for both the CREATE TABLE and the row types). */
|
|
67
|
+
export interface DerivedColumn {
|
|
68
|
+
name: string;
|
|
69
|
+
sqlType: string;
|
|
70
|
+
notNull: boolean;
|
|
71
|
+
primaryKey: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The canonical table schema for one manifest type. */
|
|
75
|
+
export interface DerivedTable {
|
|
76
|
+
table: string;
|
|
77
|
+
columns: DerivedColumn[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Derive the canonical table schema for one manifest type: an implicit
|
|
82
|
+
* autoincrement `id` primary key plus one column per declared field. This is the
|
|
83
|
+
* single source of truth both the SQL migration (`createTableSql`) and the domain
|
|
84
|
+
* row-types (`domain.ts`) derive from, so the schema can never drift between them.
|
|
85
|
+
*/
|
|
86
|
+
export function tableSchemaForType(typeName: string, def: ToolkitType): DerivedTable {
|
|
57
87
|
const table = assertSqlIdent("table name", def.table ?? typeName);
|
|
58
|
-
const
|
|
88
|
+
const columns: DerivedColumn[] = [
|
|
89
|
+
{ name: "id", sqlType: "INTEGER", notNull: false, primaryKey: true },
|
|
90
|
+
];
|
|
59
91
|
const fields = def.fields ?? {};
|
|
60
92
|
for (const [name, f] of Object.entries(fields)) {
|
|
61
93
|
assertSqlIdent("field name", name);
|
|
62
94
|
if (name === "id") {
|
|
63
95
|
throw new Error(`type "${typeName}": field "id" is reserved (the table's implicit primary key)`);
|
|
64
96
|
}
|
|
65
|
-
|
|
66
|
-
cols.push(` ${name} ${sqlType(f.type)}${notNull}`);
|
|
97
|
+
columns.push({ name, sqlType: sqlType(f.type), notNull: !f.optional, primaryKey: false });
|
|
67
98
|
}
|
|
68
|
-
return
|
|
99
|
+
return { table, columns };
|
|
69
100
|
}
|
|
70
101
|
|
|
71
|
-
|
|
102
|
+
/** Group manifest types by their datasource, preserving declaration order within each
|
|
103
|
+
* source. Shared by the migrations and domain derivers so both partition and order
|
|
104
|
+
* sources identically (a single source of truth for the source spine). */
|
|
105
|
+
export function groupTypesBySource(
|
|
106
|
+
manifest: ToolkitManifest,
|
|
107
|
+
): Map<string, Array<[string, ToolkitType]>> {
|
|
72
108
|
const types = manifest.types ?? {};
|
|
73
109
|
const defaultSource = manifest.data?.default ?? "app";
|
|
74
|
-
|
|
75
|
-
// Group types by their datasource.
|
|
76
110
|
const bySource = new Map<string, Array<[string, ToolkitType]>>();
|
|
77
111
|
for (const [name, def] of Object.entries(types)) {
|
|
78
112
|
const src = def.source ?? defaultSource;
|
|
@@ -80,6 +114,24 @@ export function deriveMigrations(manifest: ToolkitManifest): DerivedArtifact[] {
|
|
|
80
114
|
list.push([name, def]);
|
|
81
115
|
bySource.set(src, list);
|
|
82
116
|
}
|
|
117
|
+
return bySource;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Build the CREATE TABLE statement for one type, from its canonical schema. Table and
|
|
121
|
+
* column identifiers are quoted so valid-but-reserved names (`order`, `group`, …) emit
|
|
122
|
+
* legal SQL. */
|
|
123
|
+
export function createTableSql(typeName: string, def: ToolkitType): string {
|
|
124
|
+
const { table, columns } = tableSchemaForType(typeName, def);
|
|
125
|
+
const cols = columns.map((c) =>
|
|
126
|
+
c.primaryKey
|
|
127
|
+
? ` ${quoteSqlIdent(c.name)} ${c.sqlType} PRIMARY KEY AUTOINCREMENT`
|
|
128
|
+
: ` ${quoteSqlIdent(c.name)} ${c.sqlType}${c.notNull ? " NOT NULL" : ""}`
|
|
129
|
+
);
|
|
130
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteSqlIdent(table)} (\n${cols.join(",\n")}\n);`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function deriveMigrations(manifest: ToolkitManifest): DerivedArtifact[] {
|
|
134
|
+
const bySource = groupTypesBySource(manifest);
|
|
83
135
|
|
|
84
136
|
const artifacts: DerivedArtifact[] = [];
|
|
85
137
|
for (const [src, list] of bySource) {
|
|
@@ -4,9 +4,19 @@ import {
|
|
|
4
4
|
deriveWorkerBindings,
|
|
5
5
|
emitWorkerBindings,
|
|
6
6
|
scanModelWorkers,
|
|
7
|
+
byModelPath,
|
|
7
8
|
type WorkerIo,
|
|
8
9
|
} from "./worker-io.ts";
|
|
9
10
|
|
|
11
|
+
test("byModelPath is a spec-compliant comparator: returns 0 for equal paths", () => {
|
|
12
|
+
assert.equal(byModelPath({ path: "a" }, { path: "a" }), 0);
|
|
13
|
+
assert.equal(byModelPath({ path: "a" }, { path: "b" }), -1);
|
|
14
|
+
assert.equal(byModelPath({ path: "b" }, { path: "a" }), 1);
|
|
15
|
+
// Antisymmetry (the property the old `? -1 : 1` form violated for equal paths).
|
|
16
|
+
const eq = [{ path: "x" }, { path: "x" }];
|
|
17
|
+
assert.equal(byModelPath(eq[0], eq[1]) + byModelPath(eq[1], eq[0]), 0);
|
|
18
|
+
});
|
|
19
|
+
|
|
10
20
|
const XML = `<?xml version="1.0"?>
|
|
11
21
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
|
12
22
|
xmlns:zeebe="http://camunda.org/schema/zeebe/1.0">
|
|
@@ -13,6 +13,15 @@ export interface ModelSource {
|
|
|
13
13
|
xml: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Deterministic comparator for ordering `ModelSource` lists by path. Returns 0 on equal
|
|
17
|
+
* paths so it satisfies the JS sort contract (a comparator that reports both `a>b` and
|
|
18
|
+
* `b>a` can reorder equal elements unpredictably) — keeping the derivers' cross-model
|
|
19
|
+
* fold order stable even if a manifest pattern yields duplicate paths. Shared by every
|
|
20
|
+
* model-scanning deriver so there is one canonical ordering. */
|
|
21
|
+
export function byModelPath(a: { path: string }, b: { path: string }): number {
|
|
22
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
export interface WorkerIo {
|
|
17
26
|
taskType: string;
|
|
18
27
|
elementId?: string;
|
|
@@ -73,7 +82,10 @@ export function scanModelWorkers(xml: string): WorkerIo[] {
|
|
|
73
82
|
// --- Faithful port of the console's emitWorkerBindings (domain_types.ts) so the emitted
|
|
74
83
|
// worker-io.d.ts is byte-compatible with the IDE's own codegen. ---
|
|
75
84
|
|
|
76
|
-
|
|
85
|
+
/** A `DomainTypes[...]` ref for a declared type id (else undefined). The one canonical
|
|
86
|
+
* ref helper shared by the worker- and message-IO emitters (matches the console's single
|
|
87
|
+
* `typeRefFor` in domain_types.ts), so both stay identical to the host codegen. */
|
|
88
|
+
export function typeRefFor(id: string | undefined, declared: Set<string>): string | undefined {
|
|
77
89
|
return id != null && declared.has(id) ? `DomainTypes[${JSON.stringify(id)}]` : undefined;
|
|
78
90
|
}
|
|
79
91
|
|
|
@@ -160,7 +172,7 @@ export function deriveWorkerBindings(
|
|
|
160
172
|
declaredTypeIds: Iterable<string> = [],
|
|
161
173
|
): DerivedArtifact[] {
|
|
162
174
|
const workers: WorkerBindingDecl[] = [];
|
|
163
|
-
for (const model of [...models].sort(
|
|
175
|
+
for (const model of [...models].sort(byModelPath)) {
|
|
164
176
|
for (const w of scanModelWorkers(model.xml)) {
|
|
165
177
|
workers.push({ taskType: w.taskType, inputType: w.in, outputType: w.out });
|
|
166
178
|
}
|
package/src/toolkit/fsio.test.ts
CHANGED
|
@@ -19,3 +19,26 @@ test("listDir returns empty for a missing directory", async () => {
|
|
|
19
19
|
const io = createNodeGenIO();
|
|
20
20
|
assert.deepEqual(await io.listDir(join(tmpdir(), "urban-fsio-does-not-exist-xyz")), []);
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
test("importModule caches on mtime: unchanged reuses, edit reloads", async () => {
|
|
24
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-fsio-imp-"));
|
|
25
|
+
const file = join(dir, "mod.mjs");
|
|
26
|
+
await writeFile(file, "export const v = 1;");
|
|
27
|
+
const io = createNodeGenIO();
|
|
28
|
+
|
|
29
|
+
const first = await io.importModule!(file);
|
|
30
|
+
assert.equal(first.v, 1);
|
|
31
|
+
// Same mtime → same module URL → cached instance (same object identity).
|
|
32
|
+
const again = await io.importModule!(file);
|
|
33
|
+
assert.equal(again, first);
|
|
34
|
+
|
|
35
|
+
// Edit with a strictly newer mtime → new URL → fresh module with the new value.
|
|
36
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
37
|
+
await writeFile(file, "export const v = 2;");
|
|
38
|
+
const utimes = (await import("node:fs/promises")).utimes;
|
|
39
|
+
const t = Date.now() / 1000 + 5;
|
|
40
|
+
await utimes(file, t, t);
|
|
41
|
+
const edited = await io.importModule!(file);
|
|
42
|
+
assert.equal(edited.v, 2);
|
|
43
|
+
assert.notEqual(edited, first);
|
|
44
|
+
});
|
package/src/toolkit/fsio.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Node/Deno filesystem implementation of the GenIO port. Uses node:fs/promises, which both
|
|
2
2
|
// Node and Deno provide, so a single implementation serves both runtimes.
|
|
3
3
|
|
|
4
|
-
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
|
-
import { dirname } from "node:path";
|
|
4
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
6
7
|
import type { GenIO } from "./gen.ts";
|
|
7
8
|
|
|
8
9
|
export function createNodeGenIO(): GenIO {
|
|
@@ -32,5 +33,24 @@ export function createNodeGenIO(): GenIO {
|
|
|
32
33
|
return false;
|
|
33
34
|
}
|
|
34
35
|
},
|
|
36
|
+
async remove(path: string): Promise<void> {
|
|
37
|
+
await rm(path, { force: true });
|
|
38
|
+
},
|
|
39
|
+
// Import an app module for code-first model derivation. `import()` of a file URL loads TS on
|
|
40
|
+
// Deno natively and on Node with type-stripping (≥22.18 default, or `--experimental-strip-types`).
|
|
41
|
+
// The cache key is the file's mtime, not a monotonic clock: an unchanged workflow reuses the
|
|
42
|
+
// cached module instance (bounding the ESM module map in long-lived processes like `urban dev`)
|
|
43
|
+
// while an edited one still reloads.
|
|
44
|
+
async importModule(path: string): Promise<Record<string, unknown>> {
|
|
45
|
+
const abs = resolve(path);
|
|
46
|
+
let version = "";
|
|
47
|
+
try {
|
|
48
|
+
version = `?v=${(await stat(abs)).mtimeMs}`;
|
|
49
|
+
} catch {
|
|
50
|
+
// stat failed (e.g. a virtual path); fall back to no cache key and let import() surface it.
|
|
51
|
+
}
|
|
52
|
+
const href = `${pathToFileURL(abs).href}${version}`;
|
|
53
|
+
return import(href) as Promise<Record<string, unknown>>;
|
|
54
|
+
},
|
|
35
55
|
};
|
|
36
56
|
}
|