@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
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { runGen, collectArtifacts, type GenIO } from "./gen.ts";
|
|
4
|
+
import { defineFlow, envelope } from "@nanobpm/workflow";
|
|
5
|
+
import { MODEL_PROVENANCE } from "./models.ts";
|
|
4
6
|
|
|
5
7
|
/** In-memory filesystem for deterministic, IO-free gen tests. */
|
|
6
|
-
function memIO(
|
|
8
|
+
function memIO(
|
|
9
|
+
files: Record<string, string>,
|
|
10
|
+
modules: Record<string, Record<string, unknown>> = {},
|
|
11
|
+
): GenIO & { files: Record<string, string> } {
|
|
7
12
|
return {
|
|
8
13
|
files,
|
|
9
14
|
async readText(p) {
|
|
@@ -27,6 +32,13 @@ function memIO(files: Record<string, string>): GenIO & { files: Record<string, s
|
|
|
27
32
|
async exists(p) {
|
|
28
33
|
return p in files;
|
|
29
34
|
},
|
|
35
|
+
async remove(p) {
|
|
36
|
+
delete files[p];
|
|
37
|
+
},
|
|
38
|
+
async importModule(p) {
|
|
39
|
+
if (!(p in modules)) throw new Error(`no module registered for ${p}`);
|
|
40
|
+
return modules[p];
|
|
41
|
+
},
|
|
30
42
|
};
|
|
31
43
|
}
|
|
32
44
|
|
|
@@ -50,16 +62,23 @@ function fixture(): Record<string, string> {
|
|
|
50
62
|
};
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
test("runGen writes migrations and the
|
|
65
|
+
test("runGen writes migrations, the worker index, the meta accessor, and the message map", async () => {
|
|
54
66
|
const io = memIO(fixture());
|
|
55
67
|
const res = await runGen({ root: "/app", io });
|
|
56
68
|
const paths = res.artifacts.map((a) => a.path).sort();
|
|
57
69
|
assert.deepEqual(paths, [
|
|
58
70
|
"nano-generated/app.schema.sql",
|
|
71
|
+
"nano-generated/domain-rows.d.ts",
|
|
72
|
+
"nano-generated/message-io.d.ts",
|
|
73
|
+
"nano-generated/meta.ts",
|
|
59
74
|
"nano-generated/worker-io.d.ts",
|
|
60
75
|
]);
|
|
61
76
|
assert.ok(io.files["/app/nano-generated/app.schema.sql"].includes("CREATE TABLE"));
|
|
62
77
|
assert.ok(io.files["/app/nano-generated/worker-io.d.ts"].includes("demo.do"));
|
|
78
|
+
assert.ok(io.files["/app/nano-generated/meta.ts"].includes("export function meta"));
|
|
79
|
+
assert.ok(io.files["/app/nano-generated/message-io.d.ts"].includes("export type MessageName"));
|
|
80
|
+
assert.ok(io.files["/app/nano-generated/domain-rows.d.ts"].includes('"greetings": Greetings;'));
|
|
81
|
+
assert.ok(io.files["/app/nano-generated/domain-rows.d.ts"].includes('"greeting": {'));
|
|
63
82
|
});
|
|
64
83
|
|
|
65
84
|
test("gen --check reports no drift right after a write", async () => {
|
|
@@ -83,3 +102,66 @@ test("collectArtifacts touches no writes", async () => {
|
|
|
83
102
|
await collectArtifacts({ root: "/app", io });
|
|
84
103
|
assert.equal(Object.keys(io.files).length, before);
|
|
85
104
|
});
|
|
105
|
+
|
|
106
|
+
// --- code-first model derivation (urban gen / urban derive) ---
|
|
107
|
+
|
|
108
|
+
const GreetIn = envelope("GreetIn", { who: "string" });
|
|
109
|
+
const GreetOut = envelope("GreetOut", { message: "string" });
|
|
110
|
+
const greetFlow = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
|
|
111
|
+
w.run("hello", async () => ({ message: "hi" }));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const CODE_FIRST_MANIFEST = JSON.stringify({
|
|
115
|
+
id: "cf",
|
|
116
|
+
data: { default: "app" },
|
|
117
|
+
models: { workflows: ["workflows/*.ts"] },
|
|
118
|
+
types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function codeFirstIO() {
|
|
122
|
+
return memIO(
|
|
123
|
+
{
|
|
124
|
+
"/cf/nano.app.json": CODE_FIRST_MANIFEST,
|
|
125
|
+
"/cf/workflows/greet.ts": "// derived in-memory via importModule stub",
|
|
126
|
+
},
|
|
127
|
+
{ "/cf/workflows/greet.ts": { greet: greetFlow } },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
|
|
132
|
+
const io = codeFirstIO();
|
|
133
|
+
const res = await runGen({ root: "/cf", io });
|
|
134
|
+
const bpmn = io.files["/cf/processes/greet.bpmn"];
|
|
135
|
+
assert.ok(bpmn, "expected a derived processes/greet.bpmn");
|
|
136
|
+
assert.ok(bpmn.includes(MODEL_PROVENANCE), "derived .bpmn must be provenance-stamped");
|
|
137
|
+
assert.ok(bpmn.includes('type="greet:hello"'), "derived .bpmn carries the task type");
|
|
138
|
+
// worker-io is derived FROM the in-memory model, not from any on-disk file.
|
|
139
|
+
assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
|
|
140
|
+
assert.equal(res.incomplete, false);
|
|
141
|
+
assert.deepEqual(res.modelErrors, []);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("gen --no-models derives in-memory for worker-io but writes no .bpmn", async () => {
|
|
145
|
+
const io = codeFirstIO();
|
|
146
|
+
const res = await runGen({ root: "/cf", io, emitModels: false });
|
|
147
|
+
assert.equal(io.files["/cf/processes/greet.bpmn"], undefined, "must not write .bpmn");
|
|
148
|
+
// still derives the model in-memory so worker-io is populated.
|
|
149
|
+
assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
|
|
150
|
+
assert.equal(res.incomplete, false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("the provenance sweep removes a stale derived .bpmn no longer backed by a workflow", async () => {
|
|
154
|
+
const io = codeFirstIO();
|
|
155
|
+
// a leftover derived model from a since-deleted flow, provenance-stamped.
|
|
156
|
+
io.files["/cf/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
|
|
157
|
+
await runGen({ root: "/cf", io });
|
|
158
|
+
assert.ok(io.files["/cf/processes/greet.bpmn"], "keeps the live derived model");
|
|
159
|
+
assert.equal(io.files["/cf/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("the sweep never touches an authored (un-stamped) .bpmn", async () => {
|
|
163
|
+
const io = codeFirstIO();
|
|
164
|
+
io.files["/cf/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
|
|
165
|
+
await runGen({ root: "/cf", io });
|
|
166
|
+
assert.ok(io.files["/cf/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
|
|
167
|
+
});
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
import type { DerivedArtifact } from "./artifact.ts";
|
|
7
7
|
import { sortArtifacts } from "./artifact.ts";
|
|
8
8
|
import { deriveMigrations, type ToolkitManifest } from "./derivers/migrations.ts";
|
|
9
|
+
import { deriveDomain } from "./derivers/domain.ts";
|
|
9
10
|
import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
|
|
11
|
+
import { deriveMeta } from "./derivers/meta.ts";
|
|
12
|
+
import { deriveMessageBindings } from "./derivers/messages.ts";
|
|
13
|
+
import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
|
|
10
14
|
|
|
11
15
|
/** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
|
|
12
16
|
export interface GenIO {
|
|
@@ -15,18 +19,40 @@ export interface GenIO {
|
|
|
15
19
|
/** File names (not paths) in a directory; empty if it does not exist. */
|
|
16
20
|
listDir(path: string): Promise<string[]>;
|
|
17
21
|
exists(path: string): Promise<boolean>;
|
|
22
|
+
/**
|
|
23
|
+
* Import an app module and return its exports. Optional: only present when the caller runs on
|
|
24
|
+
* a runtime that can load the app's TypeScript (Node with type-stripping, or Deno). Code-first
|
|
25
|
+
* model derivation (`workflows/*.ts` → BPMN) needs it; when absent, gen still derives everything
|
|
26
|
+
* else (migrations, domain, worker I/O from authored/derived models).
|
|
27
|
+
*/
|
|
28
|
+
importModule?(path: string): Promise<Record<string, unknown>>;
|
|
29
|
+
/** Delete a file. Optional: enables the provenance-scoped stale-model sweep (skipped when absent). */
|
|
30
|
+
remove?(path: string): Promise<void>;
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
export interface GenOptions {
|
|
21
34
|
root: string;
|
|
22
35
|
io: GenIO;
|
|
23
36
|
manifestFile?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Whether to emit (write) derived `.bpmn` models. Default true. When false, code-first models
|
|
39
|
+
* are still derived *in-memory* to feed the type-contract derivers (worker I/O, meta, messages)
|
|
40
|
+
* — they are just not written to disk or swept. This is the `urban gen --no-models` path, so
|
|
41
|
+
* the console's type-contract regen never mutates `processes/*.bpmn`.
|
|
42
|
+
*/
|
|
43
|
+
emitModels?: boolean;
|
|
44
|
+
/** Derive ONLY the models (skip migrations/domain/worker-I/O). The `urban derive` path. */
|
|
45
|
+
modelsOnly?: boolean;
|
|
24
46
|
}
|
|
25
47
|
|
|
26
48
|
export interface GenResult {
|
|
27
49
|
artifacts: DerivedArtifact[];
|
|
28
50
|
/** Paths that differ from disk (only populated by `check`). */
|
|
29
51
|
drift: string[];
|
|
52
|
+
/** True if any code-first workflow failed to import/derive. */
|
|
53
|
+
incomplete: boolean;
|
|
54
|
+
/** Per-file model-derivation errors (empty on success). */
|
|
55
|
+
modelErrors: ModelError[];
|
|
30
56
|
}
|
|
31
57
|
|
|
32
58
|
function join(root: string, rel: string): string {
|
|
@@ -41,7 +67,7 @@ function dirOf(p: string): string {
|
|
|
41
67
|
}
|
|
42
68
|
|
|
43
69
|
/** Resolve a `dir/*.ext` (or literal) manifest pattern to file paths relative to root. */
|
|
44
|
-
async function expandPattern(root: string, io: GenIO, pattern: string): Promise<string[]> {
|
|
70
|
+
export async function expandPattern(root: string, io: GenIO, pattern: string): Promise<string[]> {
|
|
45
71
|
const star = pattern.indexOf("*");
|
|
46
72
|
if (star === -1) {
|
|
47
73
|
return (await io.exists(join(root, pattern))) ? [pattern] : [];
|
|
@@ -75,33 +101,55 @@ export async function readModels(
|
|
|
75
101
|
|
|
76
102
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
|
77
103
|
export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]> {
|
|
104
|
+
return (await collectAll(opts)).artifacts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Internal: collect artifacts AND the model-derivation details (needed by `runGen` for the
|
|
108
|
+
* incomplete signal + the provenance-scoped stale sweep). */
|
|
109
|
+
async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifact[]; derived: DerivedModels }> {
|
|
78
110
|
const { root, io } = opts;
|
|
111
|
+
const emitModels = opts.emitModels ?? true;
|
|
79
112
|
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
80
113
|
const manifest = JSON.parse(await io.readText(manifestPath)) as ToolkitManifest & {
|
|
81
|
-
models?: { processes?: string[] };
|
|
114
|
+
models?: { processes?: string[]; workflows?: string[] };
|
|
82
115
|
};
|
|
83
116
|
|
|
84
117
|
const artifacts: DerivedArtifact[] = [];
|
|
85
118
|
|
|
86
|
-
// 1. types → migrations
|
|
87
|
-
if (manifest.types && Object.keys(manifest.types).length > 0) {
|
|
119
|
+
// 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
|
|
120
|
+
if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
|
|
88
121
|
artifacts.push(...deriveMigrations(manifest));
|
|
122
|
+
artifacts.push(...deriveDomain(manifest));
|
|
89
123
|
}
|
|
90
124
|
|
|
91
|
-
// 2. models →
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
125
|
+
// 2. code-first models → derive BPMN from `workflows/*.ts` (executes the app's TS). The derived
|
|
126
|
+
// `.bpmn` are emitted only when `emitModels` (default), but always fed to the type-contract
|
|
127
|
+
// derivers below so worker I/O works for a code-first app even on a `--no-models` run.
|
|
128
|
+
const derived = await deriveModels(root, io, manifest);
|
|
129
|
+
if (emitModels) artifacts.push(...derived.artifacts);
|
|
130
|
+
|
|
131
|
+
// 3. models (authored on-disk + derived) → worker I/O index + meta accessor + message map.
|
|
132
|
+
// Drop any on-disk model whose path a derived model already covers, to avoid double-counting.
|
|
133
|
+
if (!opts.modelsOnly) {
|
|
134
|
+
const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
|
|
135
|
+
const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
|
|
136
|
+
const models = [...diskModels, ...derived.models];
|
|
137
|
+
if (models.length > 0) {
|
|
138
|
+
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
139
|
+
artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
|
|
140
|
+
artifacts.push(...deriveMeta(models));
|
|
141
|
+
artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
|
|
142
|
+
}
|
|
96
143
|
}
|
|
97
144
|
|
|
98
|
-
return sortArtifacts(artifacts);
|
|
145
|
+
return { artifacts: sortArtifacts(artifacts), derived };
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
/** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
|
|
102
149
|
export async function runGen(opts: GenOptions & { check?: boolean }): Promise<GenResult> {
|
|
103
|
-
const artifacts = await
|
|
150
|
+
const { artifacts, derived } = await collectAll(opts);
|
|
104
151
|
const { root, io } = opts;
|
|
152
|
+
const emitModels = opts.emitModels ?? true;
|
|
105
153
|
const drift: string[] = [];
|
|
106
154
|
|
|
107
155
|
for (const a of artifacts) {
|
|
@@ -113,7 +161,33 @@ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<Ge
|
|
|
113
161
|
await io.writeText(abs, a.content);
|
|
114
162
|
}
|
|
115
163
|
}
|
|
116
|
-
|
|
164
|
+
|
|
165
|
+
// Provenance-scoped stale sweep: delete derived `.bpmn` for flows that no longer exist. Only
|
|
166
|
+
// in write mode, only when we emitted models, only on a complete derivation (never delete when
|
|
167
|
+
// a workflow failed to load), and only files bearing our marker (authored models are untouched).
|
|
168
|
+
if (!opts.check && emitModels && derived.attempted && !derived.incomplete && io.remove) {
|
|
169
|
+
const keep = new Set(derived.artifacts.map((a) => a.path));
|
|
170
|
+
for (const name of await io.listDir(join(root, derived.outDir))) {
|
|
171
|
+
if (!name.endsWith(".bpmn")) continue;
|
|
172
|
+
const rel = `${derived.outDir}/${name}`;
|
|
173
|
+
if (keep.has(rel)) continue;
|
|
174
|
+
const content = await io.readText(join(root, rel));
|
|
175
|
+
if (content.includes(MODEL_PROVENANCE)) await io.remove(join(root, rel));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { artifacts, drift, incomplete: derived.incomplete, modelErrors: derived.errors };
|
|
117
180
|
}
|
|
118
181
|
|
|
119
182
|
export { dirOf, join as joinPath };
|
|
183
|
+
|
|
184
|
+
/** Derive code-first models in-memory (no writes) — backs `urban derive --stdout`, the
|
|
185
|
+
* non-mutating preview the console's read-only model viewer uses. */
|
|
186
|
+
export async function previewModels(opts: GenOptions): Promise<DerivedModels> {
|
|
187
|
+
const { root, io } = opts;
|
|
188
|
+
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
189
|
+
const manifest = JSON.parse(await io.readText(manifestPath)) as {
|
|
190
|
+
models?: { processes?: string[]; workflows?: string[] };
|
|
191
|
+
};
|
|
192
|
+
return deriveModels(root, io, manifest);
|
|
193
|
+
}
|
package/src/toolkit/index.ts
CHANGED
|
@@ -13,8 +13,36 @@ export {
|
|
|
13
13
|
migrationsDeriver,
|
|
14
14
|
sqlType,
|
|
15
15
|
createTableSql,
|
|
16
|
+
tableSchemaForType,
|
|
17
|
+
groupTypesBySource,
|
|
16
18
|
} from "./derivers/migrations.ts";
|
|
17
|
-
export type {
|
|
19
|
+
export type {
|
|
20
|
+
ToolkitManifest,
|
|
21
|
+
ToolkitType,
|
|
22
|
+
ToolkitField,
|
|
23
|
+
DerivedColumn,
|
|
24
|
+
DerivedTable,
|
|
25
|
+
} from "./derivers/migrations.ts";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
deriveDomain,
|
|
29
|
+
emitDomain,
|
|
30
|
+
emitDomainModel,
|
|
31
|
+
emitDomainDts,
|
|
32
|
+
emitDomainDtsForSources,
|
|
33
|
+
emitDomainTypeRegistry,
|
|
34
|
+
domainDeriver,
|
|
35
|
+
sourcesFromManifest,
|
|
36
|
+
registryFromManifest,
|
|
37
|
+
sqliteAffinityToTs,
|
|
38
|
+
interfaceName,
|
|
39
|
+
} from "./derivers/domain.ts";
|
|
40
|
+
export type {
|
|
41
|
+
SourceSchema,
|
|
42
|
+
DomainFieldDef,
|
|
43
|
+
DomainTypeDef,
|
|
44
|
+
DomainTypeRegistry,
|
|
45
|
+
} from "./derivers/domain.ts";
|
|
18
46
|
|
|
19
47
|
export {
|
|
20
48
|
deriveWorkerBindings,
|
|
@@ -25,17 +53,52 @@ export {
|
|
|
25
53
|
DOMAIN_DTS,
|
|
26
54
|
} from "./derivers/worker-io.ts";
|
|
27
55
|
export type { ModelSource, WorkerIo, WorkerBindingDecl } from "./derivers/worker-io.ts";
|
|
56
|
+
export { byModelPath, typeRefFor } from "./derivers/worker-io.ts";
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
deriveMeta,
|
|
60
|
+
emitMeta,
|
|
61
|
+
foldMeta,
|
|
62
|
+
metaDeriver,
|
|
63
|
+
scanModelMeta,
|
|
64
|
+
META_TS,
|
|
65
|
+
} from "./derivers/meta.ts";
|
|
66
|
+
export type { MetaDecl } from "./derivers/meta.ts";
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
deriveMessageBindings,
|
|
70
|
+
emitMessageBindings,
|
|
71
|
+
messagesDeriver,
|
|
72
|
+
scanModelMessages,
|
|
73
|
+
MESSAGE_BINDINGS_DTS,
|
|
74
|
+
} from "./derivers/messages.ts";
|
|
75
|
+
export type { MessageIo, MessageBindingDecl } from "./derivers/messages.ts";
|
|
28
76
|
|
|
29
77
|
// The registry of all derivers (for discovery / IDE migration).
|
|
30
78
|
import { migrationsDeriver } from "./derivers/migrations.ts";
|
|
79
|
+
import { domainDeriver } from "./derivers/domain.ts";
|
|
31
80
|
import { workerIoDeriver } from "./derivers/worker-io.ts";
|
|
32
|
-
|
|
81
|
+
import { metaDeriver } from "./derivers/meta.ts";
|
|
82
|
+
import { messagesDeriver } from "./derivers/messages.ts";
|
|
83
|
+
export const DERIVERS = [migrationsDeriver, domainDeriver, workerIoDeriver, metaDeriver, messagesDeriver] as const;
|
|
33
84
|
|
|
34
85
|
// Gen orchestrator + IO
|
|
35
|
-
export { collectArtifacts, runGen, joinPath, readModels } from "./gen.ts";
|
|
86
|
+
export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
|
|
36
87
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
|
37
88
|
export { createNodeGenIO } from "./fsio.ts";
|
|
38
89
|
|
|
90
|
+
// Code-first model derivation (workflows/*.ts → BPMN)
|
|
91
|
+
export {
|
|
92
|
+
deriveModels,
|
|
93
|
+
isWorkflow,
|
|
94
|
+
bpmnFilename,
|
|
95
|
+
processesOutDir,
|
|
96
|
+
DEFAULT_WORKFLOW_PATTERNS,
|
|
97
|
+
PROCESSES_DIR,
|
|
98
|
+
MODEL_PROVENANCE,
|
|
99
|
+
} from "./models.ts";
|
|
100
|
+
export type { DerivedModels, DerivedModelInfo, ModelError, ModelsManifest } from "./models.ts";
|
|
101
|
+
|
|
39
102
|
// Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
|
|
40
103
|
export {
|
|
41
104
|
planWorkerScaffold,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { defineFlow, envelope } from "@nanobpm/workflow";
|
|
4
|
+
import { deriveModels, isWorkflow, bpmnFilename, processesOutDir, MODEL_PROVENANCE } from "./models.ts";
|
|
5
|
+
import type { GenIO } from "./gen.ts";
|
|
6
|
+
|
|
7
|
+
/** In-memory GenIO whose `importModule` returns pre-registered module exports by path. */
|
|
8
|
+
function memIO(
|
|
9
|
+
files: Record<string, string>,
|
|
10
|
+
modules: Record<string, Record<string, unknown>> = {},
|
|
11
|
+
): GenIO {
|
|
12
|
+
return {
|
|
13
|
+
async readText(p) {
|
|
14
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
15
|
+
return files[p];
|
|
16
|
+
},
|
|
17
|
+
async writeText(p, c) {
|
|
18
|
+
files[p] = c;
|
|
19
|
+
},
|
|
20
|
+
async listDir(p) {
|
|
21
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
22
|
+
const names = new Set<string>();
|
|
23
|
+
for (const f of Object.keys(files)) {
|
|
24
|
+
if (f.startsWith(prefix)) {
|
|
25
|
+
const rest = f.slice(prefix.length);
|
|
26
|
+
if (!rest.includes("/")) names.add(rest);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...names];
|
|
30
|
+
},
|
|
31
|
+
async exists(p) {
|
|
32
|
+
return p in files || p in modules;
|
|
33
|
+
},
|
|
34
|
+
importModule(p) {
|
|
35
|
+
const key = Object.keys(modules).find((k) => p.endsWith(k));
|
|
36
|
+
if (!key) return Promise.reject(new Error(`ENOENT ${p}`));
|
|
37
|
+
return Promise.resolve(modules[key]);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const GreetIn = envelope("GreetIn", { who: "string" });
|
|
43
|
+
const GreetOut = envelope("GreetOut", { message: "string" });
|
|
44
|
+
|
|
45
|
+
/** A real one-step declarative flow (exercises the actual toBpmn + layout pipeline). */
|
|
46
|
+
const greet = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
|
|
47
|
+
w.run("hello", async () => ({ message: "hi" }));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const CODE_FIRST_MANIFEST = { models: { workflows: ["workflows/*.ts"] } };
|
|
51
|
+
|
|
52
|
+
test("isWorkflow recognizes defineFlow exports, rejects everything else", () => {
|
|
53
|
+
assert.equal(isWorkflow(greet), true);
|
|
54
|
+
assert.equal(isWorkflow({ id: "x", kind: "imperative" }), true);
|
|
55
|
+
assert.equal(isWorkflow({ id: "x", kind: "nope" }), false);
|
|
56
|
+
assert.equal(isWorkflow({ kind: "declarative" }), false);
|
|
57
|
+
assert.equal(isWorkflow(null), false);
|
|
58
|
+
assert.equal(isWorkflow("greet"), false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("bpmnFilename sanitizes ids into safe .bpmn filenames", () => {
|
|
62
|
+
assert.equal(bpmnFilename("greet"), "greet.bpmn");
|
|
63
|
+
assert.equal(bpmnFilename("orders/place"), "orders-place.bpmn");
|
|
64
|
+
assert.equal(bpmnFilename("a b:c"), "a-b-c.bpmn");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("processesOutDir defaults to processes/, or the dir of a models.processes glob", () => {
|
|
68
|
+
assert.equal(processesOutDir({}), "processes");
|
|
69
|
+
assert.equal(processesOutDir({ models: { processes: ["resources/processes/*.bpmn"] } }), "resources/processes");
|
|
70
|
+
assert.equal(processesOutDir({ models: { processes: ["src/models/*.bpmn"] } }), "src/models");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>.bpmn", async () => {
|
|
74
|
+
const io = memIO(
|
|
75
|
+
{ "/app/workflows/greet.ts": "// source" },
|
|
76
|
+
{ "workflows/greet.ts": { greet } },
|
|
77
|
+
);
|
|
78
|
+
const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
|
|
79
|
+
|
|
80
|
+
assert.equal(d.attempted, true);
|
|
81
|
+
assert.equal(d.incomplete, false);
|
|
82
|
+
assert.deepEqual(d.ids, ["greet"]);
|
|
83
|
+
assert.equal(d.artifacts.length, 1);
|
|
84
|
+
assert.equal(d.artifacts[0].path, "processes/greet.bpmn");
|
|
85
|
+
assert.match(d.artifacts[0].content, /<!--.*@nanobpm\/urban/);
|
|
86
|
+
assert.ok(d.artifacts[0].content.includes(MODEL_PROVENANCE));
|
|
87
|
+
assert.match(d.artifacts[0].content, /greet/);
|
|
88
|
+
// the in-memory ModelSource is fed to the type-contract derivers
|
|
89
|
+
assert.deepEqual(d.models.map((m) => m.path), ["processes/greet.bpmn"]);
|
|
90
|
+
assert.equal(d.list[0].id, "greet");
|
|
91
|
+
assert.equal(d.list[0].kind, "declarative");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("deriveModels is a no-op without a module loader (pure/IDE callers)", async () => {
|
|
95
|
+
const io = memIO({ "/app/workflows/greet.ts": "// source" });
|
|
96
|
+
delete (io as { importModule?: unknown }).importModule;
|
|
97
|
+
const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
|
|
98
|
+
assert.equal(d.attempted, false);
|
|
99
|
+
assert.equal(d.artifacts.length, 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("deriveModels writes into the manifest's models.processes dir when declared", async () => {
|
|
103
|
+
const io = memIO(
|
|
104
|
+
{ "/app/workflows/greet.ts": "// source" },
|
|
105
|
+
{ "workflows/greet.ts": { greet } },
|
|
106
|
+
);
|
|
107
|
+
const d = await deriveModels("/app", io, {
|
|
108
|
+
models: { workflows: ["workflows/*.ts"], processes: ["resources/processes/*.bpmn"] },
|
|
109
|
+
});
|
|
110
|
+
assert.equal(d.outDir, "resources/processes");
|
|
111
|
+
assert.equal(d.artifacts[0].path, "resources/processes/greet.bpmn");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("deriveModels records a failing workflow as incomplete without throwing", async () => {
|
|
115
|
+
const io = memIO(
|
|
116
|
+
{ "/app/workflows/bad.ts": "// source" },
|
|
117
|
+
{}, // no module registered → importModule rejects
|
|
118
|
+
);
|
|
119
|
+
const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
|
|
120
|
+
assert.equal(d.attempted, true);
|
|
121
|
+
assert.equal(d.incomplete, true);
|
|
122
|
+
assert.equal(d.errors.length, 1);
|
|
123
|
+
assert.equal(d.errors[0].path, "workflows/bad.ts");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("deriveModels flags a duplicate flow id across two files", async () => {
|
|
127
|
+
const io = memIO(
|
|
128
|
+
{ "/app/workflows/a.ts": "// a", "/app/workflows/b.ts": "// b" },
|
|
129
|
+
{ "workflows/a.ts": { greet }, "workflows/b.ts": { greet } },
|
|
130
|
+
);
|
|
131
|
+
const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
|
|
132
|
+
assert.equal(d.incomplete, true);
|
|
133
|
+
assert.ok(d.errors.some((e) => /duplicate flow id "greet"/.test(e.message)));
|
|
134
|
+
assert.equal(d.ids.length, 1); // first wins
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("deriveModels flags two distinct flows sharing an id in the same module", async () => {
|
|
138
|
+
// two SEPARATE defineFlow objects, same id, same file — a real collision, not a re-export.
|
|
139
|
+
const greetA = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
|
|
140
|
+
w.run("hello", async () => ({ message: "a" }));
|
|
141
|
+
});
|
|
142
|
+
const greetB = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
|
|
143
|
+
w.run("hello", async () => ({ message: "b" }));
|
|
144
|
+
});
|
|
145
|
+
const io = memIO({ "/app/workflows/dup.ts": "// dup" }, { "workflows/dup.ts": { greetA, greetB } });
|
|
146
|
+
const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
|
|
147
|
+
assert.equal(d.incomplete, true);
|
|
148
|
+
assert.ok(d.errors.some((e) => /duplicate flow id "greet" \(also defined in the same module\)/.test(e.message)));
|
|
149
|
+
assert.equal(d.ids.length, 1); // only the first is derived
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("deriveModels treats a genuine same-module re-export as one model, no error", async () => {
|
|
153
|
+
// the SAME flow object exported under two names in one file — harmless.
|
|
154
|
+
const io = memIO({ "/app/workflows/re.ts": "// re" }, { "workflows/re.ts": { greet, alsoGreet: greet } });
|
|
155
|
+
const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
|
|
156
|
+
assert.equal(d.incomplete, false);
|
|
157
|
+
assert.deepEqual(d.errors, []);
|
|
158
|
+
assert.equal(d.ids.length, 1);
|
|
159
|
+
});
|