@nanobpm/urban 0.34.0 → 0.36.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.d.ts +0 -1
- package/dist/cli.js +32 -47
- package/dist/openapi/spec.d.ts +63 -0
- package/dist/openapi/spec.js +168 -0
- package/dist/runtime/core/modules/api.d.ts +19 -4
- package/dist/runtime/core/modules/api.js +99 -4
- package/dist/runtime/core/runtime.d.ts +10 -0
- package/dist/runtime/core/runtime.js +2 -2
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +2 -1
- package/dist/toolkit/api-dir.d.ts +11 -0
- package/dist/toolkit/api-dir.js +36 -0
- package/dist/toolkit/derivers/api.d.ts +25 -2
- package/dist/toolkit/derivers/api.js +60 -5
- package/dist/toolkit/gen.js +4 -1
- package/dist/toolkit/generate.d.ts +27 -0
- package/dist/toolkit/generate.js +47 -0
- package/dist/toolkit/index.d.ts +8 -1
- package/dist/toolkit/index.js +5 -1
- package/dist/toolkit/scaffold/operations.d.ts +21 -0
- package/dist/toolkit/scaffold/operations.js +55 -0
- package/dist/toolkit/scaffold/workers.js +1 -1
- package/dist/toolkit/scaffold.d.ts +16 -0
- package/dist/toolkit/scaffold.js +40 -0
- package/package.json +74 -74
- package/src/cli.test.ts +0 -1
- package/src/cli.ts +30 -45
- package/src/openapi/spec.test.ts +171 -0
- package/src/openapi/spec.ts +219 -1
- package/src/runtime/core/modules/api.test.ts +183 -5
- package/src/runtime/core/modules/api.ts +125 -8
- package/src/runtime/core/runtime.test.ts +53 -1
- package/src/runtime/core/runtime.ts +12 -2
- package/src/runtime/index.ts +3 -1
- package/src/toolkit/api-dir.test.ts +32 -0
- package/src/toolkit/api-dir.ts +41 -0
- package/src/toolkit/derivers/api.test.ts +41 -3
- package/src/toolkit/derivers/api.ts +75 -5
- package/src/toolkit/gen.ts +5 -2
- package/src/toolkit/generate.test.ts +135 -0
- package/src/toolkit/generate.ts +71 -0
- package/src/toolkit/index.ts +10 -0
- package/src/toolkit/scaffold/operations.test.ts +136 -0
- package/src/toolkit/scaffold/operations.ts +75 -0
- package/src/toolkit/scaffold/workers.ts +1 -1
- package/src/toolkit/scaffold.ts +56 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { generate } from "./generate.ts";
|
|
4
|
+
import type { GenIO } from "./gen.ts";
|
|
5
|
+
|
|
6
|
+
/** In-memory filesystem mirroring gen.test.ts's memIO (IO-free, deterministic). */
|
|
7
|
+
function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
|
|
8
|
+
return {
|
|
9
|
+
files,
|
|
10
|
+
async readText(p) {
|
|
11
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
12
|
+
return files[p];
|
|
13
|
+
},
|
|
14
|
+
async writeText(p, c) {
|
|
15
|
+
files[p] = c;
|
|
16
|
+
},
|
|
17
|
+
async listDir(p) {
|
|
18
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
19
|
+
const names = new Set<string>();
|
|
20
|
+
for (const f of Object.keys(files)) {
|
|
21
|
+
if (f.startsWith(prefix)) {
|
|
22
|
+
const rest = f.slice(prefix.length);
|
|
23
|
+
if (!rest.includes("/")) names.add(rest);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...names];
|
|
27
|
+
},
|
|
28
|
+
async exists(p) {
|
|
29
|
+
return p in files;
|
|
30
|
+
},
|
|
31
|
+
async remove(p) {
|
|
32
|
+
delete files[p];
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MANIFEST = JSON.stringify({
|
|
38
|
+
id: "demo",
|
|
39
|
+
data: { default: "app" },
|
|
40
|
+
models: { processes: ["processes/*.bpmn"] },
|
|
41
|
+
types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
|
|
42
|
+
api: { spec: "openapi.json" },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const BPMN = `<bpmn:process id="p" xmlns:bpmn="x" xmlns:zeebe="y">
|
|
46
|
+
<bpmn:serviceTask id="T"><bpmn:extensionElements>
|
|
47
|
+
<zeebe:taskDefinition type="demo.do" />
|
|
48
|
+
</bpmn:extensionElements></bpmn:serviceTask>
|
|
49
|
+
</bpmn:process>`;
|
|
50
|
+
|
|
51
|
+
const OPENAPI = JSON.stringify({
|
|
52
|
+
openapi: "3.0.0",
|
|
53
|
+
paths: {
|
|
54
|
+
"/ping": { get: { operationId: "ping", responses: { "200": {} } } },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function fixture(): Record<string, string> {
|
|
59
|
+
return {
|
|
60
|
+
"/app/nano.app.json": MANIFEST,
|
|
61
|
+
"/app/processes/p.bpmn": BPMN,
|
|
62
|
+
"/app/openapi.json": OPENAPI,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("generate write mode scaffolds worker + operation stubs alongside derived artifacts", async () => {
|
|
67
|
+
const io = memIO(fixture());
|
|
68
|
+
const res = await generate({ root: "/app", io });
|
|
69
|
+
|
|
70
|
+
// Derived artifacts landed (the runGen half).
|
|
71
|
+
assert.ok(io.files["/app/nano-generated/worker-io.d.ts"], "expected derived worker-io");
|
|
72
|
+
assert.ok(io.files["/app/nano-generated/app.schema.sql"], "expected derived migrations");
|
|
73
|
+
|
|
74
|
+
// Worker stub created + wired into the manifest.
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
res.workerStubs.map((s) => s.status),
|
|
77
|
+
["created"],
|
|
78
|
+
);
|
|
79
|
+
assert.ok(io.files["/app/workers/demo-do/worker.ts"], "expected a worker stub on disk");
|
|
80
|
+
assert.ok(res.manifestPatched, "expected workers[] to be patched");
|
|
81
|
+
assert.equal(res.wiredWorkers.length, 1);
|
|
82
|
+
|
|
83
|
+
// Operation stub created.
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
res.operationStubs.map((s) => s.status),
|
|
86
|
+
["created"],
|
|
87
|
+
);
|
|
88
|
+
assert.ok(io.files["/app/operations/ping.ts"], "expected an operation stub on disk");
|
|
89
|
+
|
|
90
|
+
// Write mode never reports missing stubs.
|
|
91
|
+
assert.deepEqual(res.missingStubs, []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("generate --check writes nothing and reports would-create stubs as missing", async () => {
|
|
95
|
+
const io = memIO(fixture());
|
|
96
|
+
// Pre-derive so the only drift under --check comes from the missing stubs.
|
|
97
|
+
await generate({ root: "/app", io });
|
|
98
|
+
const before = { ...io.files };
|
|
99
|
+
|
|
100
|
+
const res = await generate({ root: "/app", io, check: true });
|
|
101
|
+
|
|
102
|
+
// Nothing changed on disk (both derivation and scaffolding are write-free under --check).
|
|
103
|
+
assert.deepEqual(io.files, before, "--check must not write");
|
|
104
|
+
|
|
105
|
+
// The already-created stubs are kept, so nothing is missing this run.
|
|
106
|
+
assert.deepEqual(res.missingStubs, []);
|
|
107
|
+
assert.deepEqual(res.drift, []);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("generate --check surfaces an uncommitted stub via missingStubs (the CI drift catch)", async () => {
|
|
111
|
+
const io = memIO(fixture());
|
|
112
|
+
const before = { ...io.files };
|
|
113
|
+
|
|
114
|
+
const res = await generate({ root: "/app", io, check: true });
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(io.files, before, "--check must not write");
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
res.missingStubs.sort(),
|
|
119
|
+
["operations/ping.ts", "workers/demo-do/worker.ts"],
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("generate keeps existing human-owned stubs (write-once, never clobbered)", async () => {
|
|
124
|
+
const io = memIO(fixture());
|
|
125
|
+
io.files["/app/workers/demo-do/worker.ts"] = "// hand-authored body";
|
|
126
|
+
io.files["/app/operations/ping.ts"] = "// hand-authored delegate";
|
|
127
|
+
|
|
128
|
+
const res = await generate({ root: "/app", io });
|
|
129
|
+
|
|
130
|
+
assert.equal(io.files["/app/workers/demo-do/worker.ts"], "// hand-authored body");
|
|
131
|
+
assert.equal(io.files["/app/operations/ping.ts"], "// hand-authored delegate");
|
|
132
|
+
assert.deepEqual(res.workerStubs.map((s) => s.status), ["kept"]);
|
|
133
|
+
assert.deepEqual(res.operationStubs.map((s) => s.status), ["kept"]);
|
|
134
|
+
assert.deepEqual(res.missingStubs, []);
|
|
135
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `urban gen`: the whole generation concern in one pass — derive artifacts (migrations, worker-I/O,
|
|
2
|
+
// code-first models, OpenAPI contracts + controller) AND scaffold the write-once handler stubs those
|
|
3
|
+
// artifacts reference (worker stubs from the model, operation-delegate stubs from the OpenAPI spec).
|
|
4
|
+
//
|
|
5
|
+
// This composes the pure derivation (`runGen`, gen.ts) with the write-once scaffolders (scaffold.ts)
|
|
6
|
+
// WITHOUT a cycle: scaffold.ts already depends on gen.ts, so this leaf module depends on both and
|
|
7
|
+
// neither depends back. Scaffolding is write-once (an existing, human-owned stub is never clobbered),
|
|
8
|
+
// so folding it into `gen` is idempotent. `--check` writes nothing: a missing stub surfaces in
|
|
9
|
+
// `missingStubs` exactly like a drifted artifact surfaces in `drift`, so CI fails when a stub the
|
|
10
|
+
// generated controller imports isn't committed.
|
|
11
|
+
|
|
12
|
+
import type { GenOptions, GenResult } from "./gen.ts";
|
|
13
|
+
import { runGen } from "./gen.ts";
|
|
14
|
+
import type { OperationStubOutcome, StubOutcome } from "./scaffold.ts";
|
|
15
|
+
import { scaffoldOperations, scaffoldWorkers } from "./scaffold.ts";
|
|
16
|
+
import type { StubManifestEntry } from "./scaffold/workers.ts";
|
|
17
|
+
|
|
18
|
+
export interface GenerateResult extends GenResult {
|
|
19
|
+
/** Worker-stub scaffolding outcomes (created / kept / would-create). */
|
|
20
|
+
workerStubs: StubOutcome[];
|
|
21
|
+
/** Operation-delegate scaffolding outcomes (created / kept / would-create). */
|
|
22
|
+
operationStubs: OperationStubOutcome[];
|
|
23
|
+
/** Worker manifest entries wired into `workers[]` (write mode only). */
|
|
24
|
+
wiredWorkers: StubManifestEntry[];
|
|
25
|
+
/** True when `workers[]` was patched (write mode, unwired workers present). */
|
|
26
|
+
manifestPatched: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* App-relative paths of stubs that would be created — the scaffolding equivalent of `drift`.
|
|
29
|
+
* Populated in `check` mode (nothing is written); a caller should fail CI when non-empty.
|
|
30
|
+
*/
|
|
31
|
+
missingStubs: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Derive artifacts and scaffold the write-once handler stubs they reference, in one pass.
|
|
36
|
+
* In `check` mode nothing is written and any would-be-created stub is reported in `missingStubs`.
|
|
37
|
+
* Scaffolding is skipped when derivation is `incomplete` (never scaffold from a half-derived model);
|
|
38
|
+
* the caller reports the derivation failure first.
|
|
39
|
+
*/
|
|
40
|
+
export async function generate(opts: GenOptions & { check?: boolean }): Promise<GenerateResult> {
|
|
41
|
+
const res = await runGen(opts);
|
|
42
|
+
|
|
43
|
+
const empty: GenerateResult = {
|
|
44
|
+
...res,
|
|
45
|
+
workerStubs: [],
|
|
46
|
+
operationStubs: [],
|
|
47
|
+
wiredWorkers: [],
|
|
48
|
+
manifestPatched: false,
|
|
49
|
+
missingStubs: [],
|
|
50
|
+
};
|
|
51
|
+
if (res.incomplete) return empty;
|
|
52
|
+
|
|
53
|
+
const write = !opts.check;
|
|
54
|
+
const scaffoldOpts = { root: opts.root, io: opts.io, manifestFile: opts.manifestFile, write };
|
|
55
|
+
const workers = await scaffoldWorkers(scaffoldOpts);
|
|
56
|
+
const operations = await scaffoldOperations(scaffoldOpts);
|
|
57
|
+
|
|
58
|
+
const missingStubs = [
|
|
59
|
+
...workers.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
|
|
60
|
+
...operations.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...res,
|
|
65
|
+
workerStubs: workers.outcomes,
|
|
66
|
+
operationStubs: operations.outcomes,
|
|
67
|
+
wiredWorkers: workers.wired,
|
|
68
|
+
manifestPatched: workers.manifestPatched,
|
|
69
|
+
missingStubs,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/toolkit/index.ts
CHANGED
|
@@ -103,9 +103,11 @@ export {
|
|
|
103
103
|
deriveApi,
|
|
104
104
|
emitApiBindings,
|
|
105
105
|
emitApiBindingsRuntime,
|
|
106
|
+
emitApiController,
|
|
106
107
|
schemaToTs,
|
|
107
108
|
API_BINDINGS_DTS,
|
|
108
109
|
API_BINDINGS_TS,
|
|
110
|
+
API_CONTROLLER_TS,
|
|
109
111
|
} from "./derivers/api.ts";
|
|
110
112
|
|
|
111
113
|
// The OpenAPI machinery the deriver + runtime share (ADR 0058).
|
|
@@ -149,6 +151,8 @@ export const DERIVERS = [
|
|
|
149
151
|
// Gen orchestrator + IO
|
|
150
152
|
export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
|
|
151
153
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
|
154
|
+
export { generate } from "./generate.ts";
|
|
155
|
+
export type { GenerateResult } from "./generate.ts";
|
|
152
156
|
export { createNodeGenIO } from "./fsio.ts";
|
|
153
157
|
|
|
154
158
|
// Code-first model derivation (workflows/*.ts → BPMN)
|
|
@@ -180,5 +184,11 @@ export type {
|
|
|
180
184
|
export { scaffoldWorkers } from "./scaffold.ts";
|
|
181
185
|
export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
|
|
182
186
|
|
|
187
|
+
// Operation-delegate scaffolder (ADR 0059): write-once typed delegate stubs from the OpenAPI spec.
|
|
188
|
+
export { planOperationScaffold, renderOperationStub } from "./scaffold/operations.ts";
|
|
189
|
+
export type { OperationStubPlan } from "./scaffold/operations.ts";
|
|
190
|
+
export { scaffoldOperations } from "./scaffold.ts";
|
|
191
|
+
export type { OperationScaffoldRun, OperationStubOutcome } from "./scaffold.ts";
|
|
192
|
+
|
|
183
193
|
export { addConnector } from "./addConnector.ts";
|
|
184
194
|
export type { AddConnectorOptions, AddConnectorResult } from "./addConnector.ts";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { planOperationScaffold, renderOperationStub } from "./operations.ts";
|
|
4
|
+
import { scaffoldOperations } from "../scaffold.ts";
|
|
5
|
+
import type { GenIO } from "../gen.ts";
|
|
6
|
+
import type { OpenApiDoc } from "../../openapi/spec.ts";
|
|
7
|
+
|
|
8
|
+
const doc: OpenApiDoc = {
|
|
9
|
+
openapi: "3.0.0",
|
|
10
|
+
paths: {
|
|
11
|
+
"/invoices/{id}": {
|
|
12
|
+
get: {
|
|
13
|
+
operationId: "getInvoice",
|
|
14
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
15
|
+
responses: { "200": {} },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
"/invoices": {
|
|
19
|
+
post: { operationId: "createInvoice", responses: { "201": {} } },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test("renderOperationStub is a typed default-exported delegate that throws NotImplemented", () => {
|
|
25
|
+
const stub = renderOperationStub("getInvoice");
|
|
26
|
+
assert.match(stub, /import \{ NotImplemented \} from "@nanobpm\/urban";/);
|
|
27
|
+
assert.match(stub, /import \{ defineOperation \} from "\.\.\/nano-generated\/operations\.ts";/);
|
|
28
|
+
assert.match(stub, /export default defineOperation\("getInvoice", async \(input, app\) =>/);
|
|
29
|
+
assert.match(stub, /throw new NotImplemented\("getInvoice"\);/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("renderOperationStub adjusts the generated-import depth for a nested api.dir", () => {
|
|
33
|
+
const stub = renderOperationStub("getInvoice", "api/handlers");
|
|
34
|
+
assert.match(stub, /import \{ defineOperation \} from "\.\.\/\.\.\/nano-generated\/operations\.ts";/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("planOperationScaffold plans one stub per declared operationId", () => {
|
|
38
|
+
const plans = planOperationScaffold(doc);
|
|
39
|
+
const byId = Object.fromEntries(plans.map((p) => [p.operationId, p.handlerPath]));
|
|
40
|
+
assert.deepEqual(byId, {
|
|
41
|
+
getInvoice: "operations/getInvoice.ts",
|
|
42
|
+
createInvoice: "operations/createInvoice.ts",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("planOperationScaffold honors a custom api.dir", () => {
|
|
47
|
+
const plans = planOperationScaffold(doc, "handlers");
|
|
48
|
+
assert.ok(plans.every((p) => p.handlerPath.startsWith("handlers/")));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("planOperationScaffold throws on a duplicate operationId (incoherent spec)", () => {
|
|
52
|
+
const dupe: OpenApiDoc = {
|
|
53
|
+
openapi: "3.0.0",
|
|
54
|
+
paths: {
|
|
55
|
+
"/a": { get: { operationId: "same", responses: { "200": {} } } },
|
|
56
|
+
"/b": { get: { operationId: "same", responses: { "200": {} } } },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
assert.throws(() => planOperationScaffold(dupe), /duplicate operationId\(s\): same/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
|
|
63
|
+
return {
|
|
64
|
+
files,
|
|
65
|
+
async readText(p) {
|
|
66
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
67
|
+
return files[p];
|
|
68
|
+
},
|
|
69
|
+
async writeText(p, c) {
|
|
70
|
+
files[p] = c;
|
|
71
|
+
},
|
|
72
|
+
async listDir(p) {
|
|
73
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
74
|
+
const names = new Set<string>();
|
|
75
|
+
for (const f of Object.keys(files)) {
|
|
76
|
+
if (f.startsWith(prefix)) {
|
|
77
|
+
const rest = f.slice(prefix.length);
|
|
78
|
+
if (!rest.includes("/")) names.add(rest);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...names];
|
|
82
|
+
},
|
|
83
|
+
async exists(p) {
|
|
84
|
+
return p in files;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function appFixture(): Record<string, string> {
|
|
90
|
+
return {
|
|
91
|
+
"/app/nano.app.json": JSON.stringify({ id: "demo", api: { spec: "openapi.json" } }),
|
|
92
|
+
"/app/openapi.json": JSON.stringify(doc),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("scaffoldOperations is a no-op when the app declares no api binding", async () => {
|
|
97
|
+
const io = memIO({ "/app/nano.app.json": JSON.stringify({ id: "demo" }) });
|
|
98
|
+
const run = await scaffoldOperations({ root: "/app", io, write: true });
|
|
99
|
+
assert.deepEqual(run.outcomes, []);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("scaffoldOperations dry-run writes nothing and reports would-create per op", async () => {
|
|
103
|
+
const io = memIO(appFixture());
|
|
104
|
+
const before = Object.keys(io.files).length;
|
|
105
|
+
const run = await scaffoldOperations({ root: "/app", io });
|
|
106
|
+
assert.equal(run.write, false);
|
|
107
|
+
assert.equal(Object.keys(io.files).length, before, "dry-run must not write");
|
|
108
|
+
assert.deepEqual(run.outcomes.map((o) => o.status).sort(), ["would-create", "would-create"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("scaffoldOperations --write creates one typed stub per op", async () => {
|
|
112
|
+
const io = memIO(appFixture());
|
|
113
|
+
const run = await scaffoldOperations({ root: "/app", io, write: true });
|
|
114
|
+
assert.ok(run.outcomes.every((o) => o.status === "created"));
|
|
115
|
+
assert.match(io.files["/app/operations/getInvoice.ts"], /NotImplemented\("getInvoice"\)/);
|
|
116
|
+
assert.match(io.files["/app/operations/createInvoice.ts"], /NotImplemented\("createInvoice"\)/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("scaffoldOperations never clobbers an existing delegate (keeps it)", async () => {
|
|
120
|
+
const files = appFixture();
|
|
121
|
+
files["/app/operations/getInvoice.ts"] = "// hand-written, do not touch\n";
|
|
122
|
+
const io = memIO(files);
|
|
123
|
+
const run = await scaffoldOperations({ root: "/app", io, write: true });
|
|
124
|
+
assert.equal(io.files["/app/operations/getInvoice.ts"], "// hand-written, do not touch\n");
|
|
125
|
+
assert.equal(run.outcomes.find((o) => o.operationId === "getInvoice")?.status, "kept");
|
|
126
|
+
assert.equal(run.outcomes.find((o) => o.operationId === "createInvoice")?.status, "created");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("scaffoldOperations is idempotent: a second --write run keeps everything", async () => {
|
|
130
|
+
const io = memIO(appFixture());
|
|
131
|
+
await scaffoldOperations({ root: "/app", io, write: true });
|
|
132
|
+
const snapshot = JSON.stringify(io.files);
|
|
133
|
+
const run2 = await scaffoldOperations({ root: "/app", io, write: true });
|
|
134
|
+
assert.ok(run2.outcomes.every((o) => o.status === "kept"));
|
|
135
|
+
assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
|
|
136
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Pure planner for the write-once operation-delegate scaffolder (ADR 0059). Given a parsed OpenAPI
|
|
2
|
+
// document, it plans one typed delegate stub per declared `operationId` — the ONE part of the HTTP
|
|
3
|
+
// surface that cannot be derived from the spec (the handler *body* is human logic). Everything here
|
|
4
|
+
// is pure and testable like a deriver; the impure write-if-absent edge lives in `../scaffold.ts`.
|
|
5
|
+
//
|
|
6
|
+
// scaffold ≠ derive: stubs are human-owned files under `<dir>/<operationId>.ts`, so they are planned
|
|
7
|
+
// once and never overwritten — unlike the derived `nano-generated/` tree (which includes the
|
|
8
|
+
// controller registry that statically imports these delegates and type-checks the whole set).
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "../api-dir.ts";
|
|
11
|
+
import { assertUniqueOperationIds, collectOperations, type OpenApiDoc } from "../../openapi/spec.ts";
|
|
12
|
+
|
|
13
|
+
export { DEFAULT_OPERATIONS_DIR };
|
|
14
|
+
|
|
15
|
+
/** One planned operation-delegate stub. */
|
|
16
|
+
export interface OperationStubPlan {
|
|
17
|
+
operationId: string;
|
|
18
|
+
/** App-relative delegate path: `<dir>/<operationId>.ts`. */
|
|
19
|
+
handlerPath: string;
|
|
20
|
+
/** The stub file contents (a typed `defineOperation` that throws `NotImplemented` → HTTP 501). */
|
|
21
|
+
stub: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Normalize an `api.dir` to a clean relative segment (see `normalizeApiDir`); rejects absolute
|
|
25
|
+
* paths and `..` segments so stubs are scaffolded where the runtime resolves delegates from. */
|
|
26
|
+
function normalizeDir(dir: string): string {
|
|
27
|
+
return normalizeApiDir(dir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Number of `../` hops from `<dir>/<file>.ts` back up to the app root (dir may be nested). */
|
|
31
|
+
function upToRoot(dir: string): string {
|
|
32
|
+
const depth = dir.split("/").filter((s) => s.length > 0).length;
|
|
33
|
+
return "../".repeat(depth);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Render a stub matching a real hand-authored operation delegate (default-exported, typed via the
|
|
37
|
+
* generated `defineOperation`). It throws `NotImplemented` so the endpoint returns 501 until the
|
|
38
|
+
* body is implemented — never a silent 500, never a drift from the contract. */
|
|
39
|
+
export function renderOperationStub(operationId: string, dir: string = DEFAULT_OPERATIONS_DIR): string {
|
|
40
|
+
const up = upToRoot(normalizeDir(dir));
|
|
41
|
+
const key = JSON.stringify(operationId);
|
|
42
|
+
return (
|
|
43
|
+
`// Operation delegate stub for the \`${operationId}\` endpoint, scaffolded from the OpenAPI spec\n` +
|
|
44
|
+
`// (ADR 0059). This file is yours to edit — \`urban gen\` will never overwrite it. The\n` +
|
|
45
|
+
`// generated controller (nano-generated/controller.ts) imports this default export and\n` +
|
|
46
|
+
`// type-checks it against the spec, so the signature cannot drift. Implement the body below\n` +
|
|
47
|
+
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`) and delete the throw.\n` +
|
|
48
|
+
`import { NotImplemented } from "@nanobpm/urban";\n` +
|
|
49
|
+
`import { defineOperation } from ${JSON.stringify(`${up}nano-generated/operations.ts`)};\n` +
|
|
50
|
+
`\n` +
|
|
51
|
+
`export default defineOperation(${key}, async (input, app) => {\n` +
|
|
52
|
+
` // \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
|
|
53
|
+
` // through the injected \`app\` API. Return \`{ status?, headers?, body? }\` (or nothing → 204).\n` +
|
|
54
|
+
` throw new NotImplemented(${key});\n` +
|
|
55
|
+
`});\n`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Plan write-once delegate stubs from the spec: one per declared `operationId` (collectOperations
|
|
61
|
+
* already skips ops without a safe id). The planner emits a plan for every operation; the impure
|
|
62
|
+
* edge (`scaffoldOperations`) keeps an existing file verbatim and creates only the absent ones.
|
|
63
|
+
*/
|
|
64
|
+
export function planOperationScaffold(
|
|
65
|
+
doc: OpenApiDoc,
|
|
66
|
+
dir: string = DEFAULT_OPERATIONS_DIR,
|
|
67
|
+
): OperationStubPlan[] {
|
|
68
|
+
assertUniqueOperationIds(doc);
|
|
69
|
+
const cleanDir = normalizeDir(dir);
|
|
70
|
+
return collectOperations(doc).map((op) => ({
|
|
71
|
+
operationId: op.operationId,
|
|
72
|
+
handlerPath: `${cleanDir}/${op.operationId}.ts`,
|
|
73
|
+
stub: renderOperationStub(op.operationId, cleanDir),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
@@ -92,7 +92,7 @@ export function renderWorkerStub(
|
|
|
92
92
|
|
|
93
93
|
return (
|
|
94
94
|
`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
95
|
-
`// This file is yours to edit — \`urban
|
|
95
|
+
`// This file is yours to edit — \`urban gen\` will never overwrite it. Implement the body\n` +
|
|
96
96
|
`// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
|
|
97
97
|
`import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
|
|
98
98
|
generatedImport +
|
package/src/toolkit/scaffold.ts
CHANGED
|
@@ -11,12 +11,15 @@ import {
|
|
|
11
11
|
type SkippedWorker,
|
|
12
12
|
type StubManifestEntry,
|
|
13
13
|
} from "./scaffold/workers.ts";
|
|
14
|
+
import { planOperationScaffold } from "./scaffold/operations.ts";
|
|
15
|
+
import { parseSpec } from "../openapi/spec.ts";
|
|
14
16
|
|
|
15
17
|
/** The manifest fields the scaffolder reads. */
|
|
16
18
|
interface ScaffoldManifest {
|
|
17
19
|
types?: Record<string, unknown>;
|
|
18
20
|
models?: { processes?: string[] };
|
|
19
21
|
workers?: ScaffoldWorker[];
|
|
22
|
+
api?: { spec?: string; dir?: string };
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export interface ScaffoldOptions {
|
|
@@ -102,3 +105,56 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
|
|
|
102
105
|
|
|
103
106
|
return { outcomes, skipped, wired, manifestPatched, write };
|
|
104
107
|
}
|
|
108
|
+
|
|
109
|
+
/** One planned operation-delegate outcome (write-once). */
|
|
110
|
+
export interface OperationStubOutcome {
|
|
111
|
+
operationId: string;
|
|
112
|
+
handlerPath: string;
|
|
113
|
+
status: StubStatus;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface OperationScaffoldRun {
|
|
117
|
+
outcomes: OperationStubOutcome[];
|
|
118
|
+
write: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Scaffold write-once operation-delegate stubs from the app's OpenAPI spec (ADR 0059). One typed
|
|
123
|
+
* `defineOperation` stub per declared `operationId`, created only if absent (human-owned files are
|
|
124
|
+
* kept verbatim). Dry-run by default. A no-op when the app declares no `api.spec`.
|
|
125
|
+
*/
|
|
126
|
+
export async function scaffoldOperations(opts: ScaffoldOptions): Promise<OperationScaffoldRun> {
|
|
127
|
+
const { root, io } = opts;
|
|
128
|
+
const write = opts.write ?? false;
|
|
129
|
+
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
130
|
+
const manifestPath = joinPath(root, manifestFile);
|
|
131
|
+
|
|
132
|
+
const manifest: ScaffoldManifest = JSON.parse(await io.readText(manifestPath));
|
|
133
|
+
const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
|
|
134
|
+
if (specRef.length === 0) return { outcomes: [], write };
|
|
135
|
+
|
|
136
|
+
const specText = await io.readText(joinPath(root, specRef));
|
|
137
|
+
const dir =
|
|
138
|
+
typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
|
|
139
|
+
? manifest.api.dir.trim()
|
|
140
|
+
: undefined;
|
|
141
|
+
const plans = planOperationScaffold(parseSpec(specText), dir);
|
|
142
|
+
|
|
143
|
+
const outcomes: OperationStubOutcome[] = [];
|
|
144
|
+
for (const plan of plans) {
|
|
145
|
+
const abs = joinPath(root, plan.handlerPath);
|
|
146
|
+
const exists = await io.exists(abs);
|
|
147
|
+
let status: StubStatus;
|
|
148
|
+
if (exists) {
|
|
149
|
+
status = "kept"; // human-owned — never clobber
|
|
150
|
+
} else if (write) {
|
|
151
|
+
await io.writeText(abs, plan.stub);
|
|
152
|
+
status = "created";
|
|
153
|
+
} else {
|
|
154
|
+
status = "would-create";
|
|
155
|
+
}
|
|
156
|
+
outcomes.push({ operationId: plan.operationId, handlerPath: plan.handlerPath, status });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { outcomes, write };
|
|
160
|
+
}
|