@nanobpm/urban 0.14.0 → 0.16.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 +54 -5
- package/dist/toolkit/gen.d.ts +7 -0
- package/dist/toolkit/gen.js +12 -7
- package/dist/toolkit/index.d.ts +5 -1
- package/dist/toolkit/index.js +4 -1
- package/dist/toolkit/scaffold/workers.d.ts +48 -0
- package/dist/toolkit/scaffold/workers.js +114 -0
- package/dist/toolkit/scaffold.d.ts +31 -0
- package/dist/toolkit/scaffold.js +54 -0
- package/dist/worker.d.ts +3 -0
- package/dist/worker.js +18 -0
- package/package.json +7 -3
- package/src/cli.ts +51 -5
- package/src/toolkit/gen.ts +17 -7
- package/src/toolkit/index.ts +18 -1
- package/src/toolkit/scaffold/workers.test.ts +197 -0
- package/src/toolkit/scaffold/workers.ts +178 -0
- package/src/toolkit/scaffold.ts +104 -0
- package/src/worker.test.ts +104 -0
- package/src/worker.ts +21 -0
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// --port <n>, -h/--help, -v/--version.
|
|
12
12
|
import { collectManifestIssues, installSignalHandlers, loadManifest, runDev, runFromEnv, selectHost, } from "./runtime/index.js";
|
|
13
13
|
import { scaffold, slugify } from "create-urban-app";
|
|
14
|
-
import { createNodeGenIO, runGen } from "./toolkit/index.js";
|
|
14
|
+
import { createNodeGenIO, runGen, scaffoldWorkers } from "./toolkit/index.js";
|
|
15
15
|
import { pathToFileURL } from "node:url";
|
|
16
16
|
import { readFileSync } from "node:fs";
|
|
17
17
|
// Read the package version from package.json (one hop up from both src/cli.ts and the
|
|
@@ -27,7 +27,7 @@ function readVersion() {
|
|
|
27
27
|
}
|
|
28
28
|
const VERSION = readVersion();
|
|
29
29
|
function parse(argv) {
|
|
30
|
-
const f = { root: ".", manifest: "nano.app.json", check: false, deno: false, help: false, version: false, _: [] };
|
|
30
|
+
const f = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, help: false, version: false, _: [] };
|
|
31
31
|
const need = (i, flag) => {
|
|
32
32
|
const v = argv[i];
|
|
33
33
|
if (v === undefined || v.startsWith("-"))
|
|
@@ -42,8 +42,18 @@ function parse(argv) {
|
|
|
42
42
|
f.version = true;
|
|
43
43
|
else if (a === "--check")
|
|
44
44
|
f.check = true;
|
|
45
|
+
else if (a === "--write")
|
|
46
|
+
f.write = true;
|
|
45
47
|
else if (a === "--deno")
|
|
46
48
|
f.deno = true;
|
|
49
|
+
else if (a === "--code-first")
|
|
50
|
+
f.style = "code";
|
|
51
|
+
else if (a === "--style") {
|
|
52
|
+
const v = need(++i, a);
|
|
53
|
+
if (v !== "model" && v !== "code")
|
|
54
|
+
throw new Error(`flag --style requires "model" or "code" (got "${v}")`);
|
|
55
|
+
f.style = v;
|
|
56
|
+
}
|
|
47
57
|
else if (a === "--")
|
|
48
58
|
continue; // tolerate a bare "--" some runners inject (not an option terminator here)
|
|
49
59
|
else if (a === "--root")
|
|
@@ -68,9 +78,11 @@ function parse(argv) {
|
|
|
68
78
|
const USAGE = `urban — build and run Urban apps (nano.app.json)
|
|
69
79
|
|
|
70
80
|
Usage:
|
|
71
|
-
urban new <name> [--root <path>] [--deno] scaffold a new Urban app
|
|
81
|
+
urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
|
|
82
|
+
(--code-first is shorthand for --style code)
|
|
72
83
|
urban check validate the manifest
|
|
73
84
|
urban gen [--check] derive artifacts (migrations, worker-io)
|
|
85
|
+
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
74
86
|
urban run materialize + serve the app
|
|
75
87
|
urban dev run + watch sources, hot-reload on change
|
|
76
88
|
urban deploy deploy models only, then exit
|
|
@@ -117,6 +129,39 @@ async function cmdGen(f) {
|
|
|
117
129
|
console.log(` • ${a.path}`);
|
|
118
130
|
return 0;
|
|
119
131
|
}
|
|
132
|
+
async function cmdStubs(f) {
|
|
133
|
+
const io = createNodeGenIO();
|
|
134
|
+
const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
|
|
135
|
+
const created = run.outcomes.filter((o) => o.status === "created");
|
|
136
|
+
const would = run.outcomes.filter((o) => o.status === "would-create");
|
|
137
|
+
const kept = run.outcomes.filter((o) => o.status === "kept");
|
|
138
|
+
const typing = (o) => o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
|
|
139
|
+
if (run.write) {
|
|
140
|
+
console.log(`✔ scaffolded ${created.length} worker stub(s):`);
|
|
141
|
+
for (const o of created)
|
|
142
|
+
console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
143
|
+
if (run.manifestPatched) {
|
|
144
|
+
console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
if (would.length === 0) {
|
|
149
|
+
console.log(`✔ no new worker stubs to scaffold`);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
|
|
153
|
+
for (const o of would)
|
|
154
|
+
console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const o of kept)
|
|
158
|
+
console.log(` = ${o.handlerPath} (kept — already exists)`);
|
|
159
|
+
if (run.skipped.length > 0) {
|
|
160
|
+
console.log(` skipped ${run.skipped.length}: ` +
|
|
161
|
+
run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
|
|
162
|
+
}
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
120
165
|
async function cmdRun(f, mount) {
|
|
121
166
|
const app = await runFromEnv({
|
|
122
167
|
root: f.root,
|
|
@@ -147,14 +192,16 @@ async function cmdDeploy(f) {
|
|
|
147
192
|
async function cmdNew(f) {
|
|
148
193
|
const name = f._[1];
|
|
149
194
|
if (!name) {
|
|
150
|
-
console.error("usage: urban new <name> [--root <path>] [--deno]");
|
|
195
|
+
console.error("usage: urban new <name> [--root <path>] [--deno] [--style model|code] (or --code-first)");
|
|
151
196
|
return 1;
|
|
152
197
|
}
|
|
153
198
|
const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
|
|
154
|
-
const res = await scaffold({ name, dir, deno: f.deno });
|
|
199
|
+
const res = await scaffold({ name, dir, deno: f.deno, style: f.style });
|
|
155
200
|
console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
|
|
156
201
|
console.log(` cd ${res.dir}`);
|
|
157
202
|
console.log(` npm install && npm start`);
|
|
203
|
+
if (f.style === "code")
|
|
204
|
+
console.log(` npm run greet -- Adam # start a code-first instance`);
|
|
158
205
|
if (f.deno)
|
|
159
206
|
console.log(` # or on Deno: deno task start`);
|
|
160
207
|
return 0;
|
|
@@ -185,6 +232,8 @@ export async function main(argv) {
|
|
|
185
232
|
return cmdCheck(f);
|
|
186
233
|
case "gen":
|
|
187
234
|
return cmdGen(f);
|
|
235
|
+
case "stubs":
|
|
236
|
+
return cmdStubs(f);
|
|
188
237
|
case "run":
|
|
189
238
|
return cmdRun(f);
|
|
190
239
|
case "dev":
|
package/dist/toolkit/gen.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DerivedArtifact } from "./artifact.ts";
|
|
2
|
+
import { type ModelSource } from "./derivers/worker-io.ts";
|
|
2
3
|
/** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
|
|
3
4
|
export interface GenIO {
|
|
4
5
|
readText(path: string): Promise<string>;
|
|
@@ -19,6 +20,12 @@ export interface GenResult {
|
|
|
19
20
|
}
|
|
20
21
|
declare function join(root: string, rel: string): string;
|
|
21
22
|
declare function dirOf(p: string): string;
|
|
23
|
+
/** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
|
|
24
|
+
export declare function readModels(root: string, io: GenIO, manifest: {
|
|
25
|
+
models?: {
|
|
26
|
+
processes?: string[];
|
|
27
|
+
};
|
|
28
|
+
}): Promise<ModelSource[]>;
|
|
22
29
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
|
23
30
|
export declare function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]>;
|
|
24
31
|
/** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -30,6 +30,17 @@ async function expandPattern(root, io, pattern) {
|
|
|
30
30
|
.map((n) => (dir === "." ? n : `${dir}/${n}`))
|
|
31
31
|
.sort();
|
|
32
32
|
}
|
|
33
|
+
/** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
|
|
34
|
+
export async function readModels(root, io, manifest) {
|
|
35
|
+
const procPatterns = manifest.models?.processes ?? [];
|
|
36
|
+
const models = [];
|
|
37
|
+
for (const pat of procPatterns) {
|
|
38
|
+
for (const rel of await expandPattern(root, io, pat)) {
|
|
39
|
+
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return models;
|
|
43
|
+
}
|
|
33
44
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
|
34
45
|
export async function collectArtifacts(opts) {
|
|
35
46
|
const { root, io } = opts;
|
|
@@ -41,13 +52,7 @@ export async function collectArtifacts(opts) {
|
|
|
41
52
|
artifacts.push(...deriveMigrations(manifest));
|
|
42
53
|
}
|
|
43
54
|
// 2. models → worker I/O index
|
|
44
|
-
const
|
|
45
|
-
const models = [];
|
|
46
|
-
for (const pat of procPatterns) {
|
|
47
|
-
for (const rel of await expandPattern(root, io, pat)) {
|
|
48
|
-
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
49
|
-
}
|
|
50
|
-
}
|
|
55
|
+
const models = await readModels(root, io, manifest);
|
|
51
56
|
if (models.length > 0) {
|
|
52
57
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
53
58
|
artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
|
package/dist/toolkit/index.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import(
|
|
|
8
8
|
models: import("./derivers/worker-io.ts").ModelSource[];
|
|
9
9
|
declaredTypeIds: Iterable<string>;
|
|
10
10
|
}>];
|
|
11
|
-
export { collectArtifacts, runGen, joinPath } from "./gen.ts";
|
|
11
|
+
export { collectArtifacts, runGen, joinPath, readModels } from "./gen.ts";
|
|
12
12
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
|
13
13
|
export { createNodeGenIO } from "./fsio.ts";
|
|
14
|
+
export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.ts";
|
|
15
|
+
export type { ScaffoldWorker, SkippedWorker, SkipReason, StubManifestEntry, WorkerScaffoldPlan, WorkerStubPlan, } from "./scaffold/workers.ts";
|
|
16
|
+
export { scaffoldWorkers } from "./scaffold.ts";
|
|
17
|
+
export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
|
package/dist/toolkit/index.js
CHANGED
|
@@ -11,5 +11,8 @@ import { migrationsDeriver } from "./derivers/migrations.js";
|
|
|
11
11
|
import { workerIoDeriver } from "./derivers/worker-io.js";
|
|
12
12
|
export const DERIVERS = [migrationsDeriver, workerIoDeriver];
|
|
13
13
|
// Gen orchestrator + IO
|
|
14
|
-
export { collectArtifacts, runGen, joinPath } from "./gen.js";
|
|
14
|
+
export { collectArtifacts, runGen, joinPath, readModels } from "./gen.js";
|
|
15
15
|
export { createNodeGenIO } from "./fsio.js";
|
|
16
|
+
// Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
|
|
17
|
+
export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.js";
|
|
18
|
+
export { scaffoldWorkers } from "./scaffold.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ModelSource } from "../derivers/worker-io.ts";
|
|
2
|
+
/** A worker to plan against: the manifest's already-wired workers. */
|
|
3
|
+
export interface ScaffoldWorker {
|
|
4
|
+
taskType: string;
|
|
5
|
+
handler?: string;
|
|
6
|
+
llm?: string;
|
|
7
|
+
}
|
|
8
|
+
/** The `workers[]` manifest entry a stub is wired in as. */
|
|
9
|
+
export interface StubManifestEntry {
|
|
10
|
+
taskType: string;
|
|
11
|
+
handler: string;
|
|
12
|
+
}
|
|
13
|
+
/** One planned handler stub. */
|
|
14
|
+
export interface WorkerStubPlan {
|
|
15
|
+
taskType: string;
|
|
16
|
+
process?: string;
|
|
17
|
+
elementId?: string;
|
|
18
|
+
/** Directory slug under `workers/` (unique across the plan). */
|
|
19
|
+
slug: string;
|
|
20
|
+
/** App-relative handler path: `workers/<slug>/worker.ts`. */
|
|
21
|
+
handlerPath: string;
|
|
22
|
+
/** True when the data-envelope `in`/`out` names a declared domain type (so it is typed). */
|
|
23
|
+
typedIn: boolean;
|
|
24
|
+
typedOut: boolean;
|
|
25
|
+
/** The stub file contents. */
|
|
26
|
+
stub: string;
|
|
27
|
+
manifestEntry: StubManifestEntry;
|
|
28
|
+
}
|
|
29
|
+
export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
|
|
30
|
+
export interface SkippedWorker {
|
|
31
|
+
taskType: string;
|
|
32
|
+
reason: SkipReason;
|
|
33
|
+
}
|
|
34
|
+
export interface WorkerScaffoldPlan {
|
|
35
|
+
plans: WorkerStubPlan[];
|
|
36
|
+
skipped: SkippedWorker[];
|
|
37
|
+
}
|
|
38
|
+
/** Turn a task type into a filesystem-safe directory slug. */
|
|
39
|
+
export declare function slugifyTaskType(taskType: string): string;
|
|
40
|
+
/** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
|
|
41
|
+
export declare function renderWorkerStub(taskType: string, typedIn: boolean, typedOut: boolean): string;
|
|
42
|
+
/**
|
|
43
|
+
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
44
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
|
|
45
|
+
* not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
|
|
46
|
+
* envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
|
|
47
|
+
*/
|
|
48
|
+
export declare function planWorkerScaffold(models: ModelSource[], workers?: ScaffoldWorker[], declaredTypeIds?: Iterable<string>): WorkerScaffoldPlan;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Pure planner for the write-once worker-stub scaffolder (ADR 0056). Given the app's models,
|
|
2
|
+
// its manifest workers, and the set of declared domain type ids, it plans one typed handler
|
|
3
|
+
// stub per un-wired service task — the ONE part of an Urban app that cannot be derived from
|
|
4
|
+
// the model (the handler *body* is human logic). Everything here is pure and testable like a
|
|
5
|
+
// deriver; the impure write-if-absent edge lives in `../scaffold.ts`.
|
|
6
|
+
//
|
|
7
|
+
// scaffold ≠ derive: stubs are human-owned files under `workers/<slug>/worker.ts`, so they are
|
|
8
|
+
// planned once and never overwritten — unlike the derived `nano-generated/` tree.
|
|
9
|
+
import { scanModelWorkers } from "../derivers/worker-io.js";
|
|
10
|
+
/** Suffix the imperative orchestrator task type carries (ADR 0054 / defineWorkflow). */
|
|
11
|
+
const ORCHESTRATOR_SUFFIX = ":__orchestrate";
|
|
12
|
+
/** Relative import from `workers/<slug>/worker.ts` to the generated worker-io types. */
|
|
13
|
+
const GENERATED_IMPORT = "../../nano-generated/worker-io.d.ts";
|
|
14
|
+
/** Turn a task type into a filesystem-safe directory slug. */
|
|
15
|
+
export function slugifyTaskType(taskType) {
|
|
16
|
+
const s = taskType
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
19
|
+
.replace(/^-+|-+$/g, "");
|
|
20
|
+
return s.length > 0 ? s : "worker";
|
|
21
|
+
}
|
|
22
|
+
/** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
|
|
23
|
+
export function renderWorkerStub(taskType, typedIn, typedOut) {
|
|
24
|
+
const key = JSON.stringify(taskType); // e.g. "pr.finalize" — matches the deriver's property keys
|
|
25
|
+
const syms = [];
|
|
26
|
+
if (typedIn)
|
|
27
|
+
syms.push("WorkerInputs");
|
|
28
|
+
if (typedOut)
|
|
29
|
+
syms.push("WorkerOutputs");
|
|
30
|
+
const generatedImport = syms.length > 0
|
|
31
|
+
? `import type { ${syms.join(", ")} } from ${JSON.stringify(GENERATED_IMPORT)};\n`
|
|
32
|
+
: "";
|
|
33
|
+
let generic = "";
|
|
34
|
+
if (typedIn && typedOut)
|
|
35
|
+
generic = `<WorkerInputs[${key}], WorkerOutputs[${key}]>`;
|
|
36
|
+
else if (typedIn)
|
|
37
|
+
generic = `<WorkerInputs[${key}]>`;
|
|
38
|
+
else if (typedOut)
|
|
39
|
+
generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
|
|
40
|
+
const varsNote = typedIn
|
|
41
|
+
? ` // \`job.variables\` is typed as WorkerInputs[${key}].`
|
|
42
|
+
: ` // \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
|
|
43
|
+
return (`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
44
|
+
`// This file is yours to edit — \`urban stubs\` will never overwrite it. Implement the body\n` +
|
|
45
|
+
`// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
|
|
46
|
+
`import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
|
|
47
|
+
generatedImport +
|
|
48
|
+
`\n` +
|
|
49
|
+
`const handler: AppJobHandler${generic} = async (job, app) => {\n` +
|
|
50
|
+
varsNote +
|
|
51
|
+
`\n` +
|
|
52
|
+
` // Reach app state and services through the injected \`app\` API.\n` +
|
|
53
|
+
` throw new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
|
|
54
|
+
`};\n` +
|
|
55
|
+
`\n` +
|
|
56
|
+
`export default handler;\n`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
60
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
|
|
61
|
+
* not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
|
|
62
|
+
* envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
|
|
63
|
+
*/
|
|
64
|
+
export function planWorkerScaffold(models, workers = [], declaredTypeIds = []) {
|
|
65
|
+
const wired = new Set(workers
|
|
66
|
+
.map((w) => w?.taskType)
|
|
67
|
+
.filter((t) => typeof t === "string" && t.length > 0));
|
|
68
|
+
const declared = new Set(declaredTypeIds);
|
|
69
|
+
const plans = [];
|
|
70
|
+
const skipped = [];
|
|
71
|
+
const seenTaskTypes = new Set();
|
|
72
|
+
const usedSlugs = new Set();
|
|
73
|
+
for (const model of [...models].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
74
|
+
for (const io of scanModelWorkers(model.xml)) {
|
|
75
|
+
const taskType = io.taskType;
|
|
76
|
+
if (taskType.endsWith(ORCHESTRATOR_SUFFIX)) {
|
|
77
|
+
skipped.push({ taskType, reason: "orchestrator" });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (wired.has(taskType)) {
|
|
81
|
+
skipped.push({ taskType, reason: "already-wired" });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (seenTaskTypes.has(taskType)) {
|
|
85
|
+
skipped.push({ taskType, reason: "duplicate" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
seenTaskTypes.add(taskType);
|
|
89
|
+
let slug = slugifyTaskType(taskType);
|
|
90
|
+
if (usedSlugs.has(slug)) {
|
|
91
|
+
let n = 2;
|
|
92
|
+
while (usedSlugs.has(`${slug}-${n}`))
|
|
93
|
+
n++;
|
|
94
|
+
slug = `${slug}-${n}`;
|
|
95
|
+
}
|
|
96
|
+
usedSlugs.add(slug);
|
|
97
|
+
const typedIn = io.in != null && declared.has(io.in);
|
|
98
|
+
const typedOut = io.out != null && declared.has(io.out);
|
|
99
|
+
const handlerPath = `workers/${slug}/worker.ts`;
|
|
100
|
+
plans.push({
|
|
101
|
+
taskType,
|
|
102
|
+
process: io.process,
|
|
103
|
+
elementId: io.elementId,
|
|
104
|
+
slug,
|
|
105
|
+
handlerPath,
|
|
106
|
+
typedIn,
|
|
107
|
+
typedOut,
|
|
108
|
+
stub: renderWorkerStub(taskType, typedIn, typedOut),
|
|
109
|
+
manifestEntry: { taskType, handler: handlerPath },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { plans, skipped };
|
|
114
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { GenIO } from "./gen.ts";
|
|
2
|
+
import { type SkippedWorker, type StubManifestEntry } from "./scaffold/workers.ts";
|
|
3
|
+
export interface ScaffoldOptions {
|
|
4
|
+
root: string;
|
|
5
|
+
io: GenIO;
|
|
6
|
+
manifestFile?: string;
|
|
7
|
+
/** Apply changes (create files + patch manifest). Default false = dry-run. */
|
|
8
|
+
write?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type StubStatus = "created" | "would-create" | "kept";
|
|
11
|
+
export interface StubOutcome {
|
|
12
|
+
taskType: string;
|
|
13
|
+
handlerPath: string;
|
|
14
|
+
status: StubStatus;
|
|
15
|
+
typedIn: boolean;
|
|
16
|
+
typedOut: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ScaffoldRun {
|
|
19
|
+
outcomes: StubOutcome[];
|
|
20
|
+
skipped: SkippedWorker[];
|
|
21
|
+
/**
|
|
22
|
+
* Manifest entries that would be appended to `workers[]` — one per planned stub, so this is
|
|
23
|
+
* populated on a dry-run too (letting a caller preview the wiring). They are actually written
|
|
24
|
+
* to the manifest only when `write` is set (see `manifestPatched`).
|
|
25
|
+
*/
|
|
26
|
+
wired: StubManifestEntry[];
|
|
27
|
+
manifestPatched: boolean;
|
|
28
|
+
write: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
|
|
31
|
+
export declare function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRun>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The stub-scaffolder's one impure edge (ADR 0056): read the manifest + models, run the pure
|
|
2
|
+
// planner, and — write-if-absent only — create each handler stub and wire it into the manifest.
|
|
3
|
+
// Dry-run by default (`write: false`); nothing is touched unless `write` is set. Stubs are
|
|
4
|
+
// human-owned, so an existing file is KEPT verbatim, never clobbered (the opposite of `runGen`).
|
|
5
|
+
import { joinPath, readModels } from "./gen.js";
|
|
6
|
+
import { planWorkerScaffold, } from "./scaffold/workers.js";
|
|
7
|
+
/** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
|
|
8
|
+
export async function scaffoldWorkers(opts) {
|
|
9
|
+
const { root, io } = opts;
|
|
10
|
+
const write = opts.write ?? false;
|
|
11
|
+
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
12
|
+
const manifestPath = joinPath(root, manifestFile);
|
|
13
|
+
const manifest = JSON.parse(await io.readText(manifestPath));
|
|
14
|
+
const models = await readModels(root, io, manifest);
|
|
15
|
+
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
16
|
+
const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
|
|
17
|
+
const outcomes = [];
|
|
18
|
+
const wired = [];
|
|
19
|
+
for (const plan of plans) {
|
|
20
|
+
const abs = joinPath(root, plan.handlerPath);
|
|
21
|
+
const exists = await io.exists(abs);
|
|
22
|
+
let status;
|
|
23
|
+
if (exists) {
|
|
24
|
+
status = "kept"; // human-owned — never clobber
|
|
25
|
+
}
|
|
26
|
+
else if (write) {
|
|
27
|
+
await io.writeText(abs, plan.stub);
|
|
28
|
+
status = "created";
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
status = "would-create";
|
|
32
|
+
}
|
|
33
|
+
outcomes.push({
|
|
34
|
+
taskType: plan.taskType,
|
|
35
|
+
handlerPath: plan.handlerPath,
|
|
36
|
+
status,
|
|
37
|
+
typedIn: plan.typedIn,
|
|
38
|
+
typedOut: plan.typedOut,
|
|
39
|
+
});
|
|
40
|
+
// The planner already excluded already-wired task types, so every plan needs wiring —
|
|
41
|
+
// including an orphan stub file that exists on disk but isn't in the manifest yet.
|
|
42
|
+
wired.push(plan.manifestEntry);
|
|
43
|
+
}
|
|
44
|
+
let manifestPatched = false;
|
|
45
|
+
if (write && wired.length > 0) {
|
|
46
|
+
const withWorkers = {
|
|
47
|
+
...manifest,
|
|
48
|
+
workers: [...(manifest.workers ?? []), ...wired],
|
|
49
|
+
};
|
|
50
|
+
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, 2)}\n`);
|
|
51
|
+
manifestPatched = true;
|
|
52
|
+
}
|
|
53
|
+
return { outcomes, skipped, wired, manifestPatched, write };
|
|
54
|
+
}
|
package/dist/worker.d.ts
ADDED
package/dist/worker.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// `@nanobpm/urban/worker` — the runtime-free worker-authoring surface.
|
|
2
|
+
//
|
|
3
|
+
// A service-task handler only needs a handful of *types*: the handler signature and the
|
|
4
|
+
// shapes it is handed. Those all live under `runtime/core/`, which is guaranteed free of any
|
|
5
|
+
// `node:*` import or `Deno` reference (the ADR 0052 purity invariant, enforced by
|
|
6
|
+
// core/purity.test.ts). Importing them from the package barrel (`@nanobpm/urban`) instead
|
|
7
|
+
// drags in `runtime/index.ts`, which re-exports the Node and Deno host adapters — so the
|
|
8
|
+
// barrel's type graph transitively references `node:*` and a consumer must have `@types/node`
|
|
9
|
+
// resolvable (or `skipLibCheck` on) just to typecheck a handler.
|
|
10
|
+
//
|
|
11
|
+
// This entry re-exports ONLY those core authoring types, so a worker module (e.g. a scaffolded
|
|
12
|
+
// stub, ADR 0056) typechecks on Node or Deno, with or without `skipLibCheck`, and never needs
|
|
13
|
+
// `@types/node`. It is types-only: nothing here emits runtime code.
|
|
14
|
+
//
|
|
15
|
+
// (One caveat: `AppApi.sdk` exposes the full `@nanobpm/nano-sdk` client by design, so the
|
|
16
|
+
// handler surface also reaches the SDK's types. Those are kept node-free by the
|
|
17
|
+
// `@nanobpm/nano-sdk` >= 1.2.2 floor in package.json — see ADR 0056.)
|
|
18
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"types": "./dist/toolkit/index.d.ts",
|
|
44
44
|
"default": "./dist/toolkit/index.js"
|
|
45
45
|
},
|
|
46
|
+
"./worker": {
|
|
47
|
+
"types": "./dist/worker.d.ts",
|
|
48
|
+
"default": "./dist/worker.js"
|
|
49
|
+
},
|
|
46
50
|
"./source": "./src/index.ts"
|
|
47
51
|
},
|
|
48
52
|
"files": [
|
|
@@ -58,10 +62,10 @@
|
|
|
58
62
|
"test:deno": "deno test -A --no-check src/"
|
|
59
63
|
},
|
|
60
64
|
"dependencies": {
|
|
61
|
-
"@nanobpm/nano-sdk": "^1.2.
|
|
65
|
+
"@nanobpm/nano-sdk": "^1.2.2",
|
|
62
66
|
"@nanobpm/workflow": "^0.7.0",
|
|
63
67
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
64
|
-
"create-urban-app": "^0.
|
|
68
|
+
"create-urban-app": "^0.5.0"
|
|
65
69
|
},
|
|
66
70
|
"devDependencies": {
|
|
67
71
|
"@types/node": "^22.20.1",
|
package/src/cli.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
selectHost,
|
|
20
20
|
} from "./runtime/index.ts";
|
|
21
21
|
import { scaffold, slugify } from "create-urban-app";
|
|
22
|
-
import { createNodeGenIO, runGen } from "./toolkit/index.ts";
|
|
22
|
+
import { createNodeGenIO, runGen, scaffoldWorkers } from "./toolkit/index.ts";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
24
|
import { readFileSync } from "node:fs";
|
|
25
25
|
|
|
@@ -42,14 +42,16 @@ interface Flags {
|
|
|
42
42
|
manifest: string;
|
|
43
43
|
port?: number;
|
|
44
44
|
check: boolean;
|
|
45
|
+
write: boolean;
|
|
45
46
|
deno: boolean;
|
|
47
|
+
style?: "model" | "code";
|
|
46
48
|
help: boolean;
|
|
47
49
|
version: boolean;
|
|
48
50
|
_: string[];
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
function parse(argv: string[]): Flags {
|
|
52
|
-
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, deno: false, help: false, version: false, _: [] };
|
|
54
|
+
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, help: false, version: false, _: [] };
|
|
53
55
|
const need = (i: number, flag: string): string => {
|
|
54
56
|
const v = argv[i];
|
|
55
57
|
if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
|
|
@@ -60,7 +62,14 @@ function parse(argv: string[]): Flags {
|
|
|
60
62
|
if (a === "-h" || a === "--help") f.help = true;
|
|
61
63
|
else if (a === "-v" || a === "--version") f.version = true;
|
|
62
64
|
else if (a === "--check") f.check = true;
|
|
65
|
+
else if (a === "--write") f.write = true;
|
|
63
66
|
else if (a === "--deno") f.deno = true;
|
|
67
|
+
else if (a === "--code-first") f.style = "code";
|
|
68
|
+
else if (a === "--style") {
|
|
69
|
+
const v = need(++i, a);
|
|
70
|
+
if (v !== "model" && v !== "code") throw new Error(`flag --style requires "model" or "code" (got "${v}")`);
|
|
71
|
+
f.style = v;
|
|
72
|
+
}
|
|
64
73
|
else if (a === "--") continue; // tolerate a bare "--" some runners inject (not an option terminator here)
|
|
65
74
|
else if (a === "--root") f.root = need(++i, a);
|
|
66
75
|
else if (a === "--manifest") f.manifest = need(++i, a);
|
|
@@ -80,9 +89,11 @@ function parse(argv: string[]): Flags {
|
|
|
80
89
|
const USAGE = `urban — build and run Urban apps (nano.app.json)
|
|
81
90
|
|
|
82
91
|
Usage:
|
|
83
|
-
urban new <name> [--root <path>] [--deno] scaffold a new Urban app
|
|
92
|
+
urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
|
|
93
|
+
(--code-first is shorthand for --style code)
|
|
84
94
|
urban check validate the manifest
|
|
85
95
|
urban gen [--check] derive artifacts (migrations, worker-io)
|
|
96
|
+
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
86
97
|
urban run materialize + serve the app
|
|
87
98
|
urban dev run + watch sources, hot-reload on change
|
|
88
99
|
urban deploy deploy models only, then exit
|
|
@@ -129,6 +140,38 @@ async function cmdGen(f: Flags): Promise<number> {
|
|
|
129
140
|
return 0;
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
async function cmdStubs(f: Flags): Promise<number> {
|
|
144
|
+
const io = createNodeGenIO();
|
|
145
|
+
const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
|
|
146
|
+
|
|
147
|
+
const created = run.outcomes.filter((o) => o.status === "created");
|
|
148
|
+
const would = run.outcomes.filter((o) => o.status === "would-create");
|
|
149
|
+
const kept = run.outcomes.filter((o) => o.status === "kept");
|
|
150
|
+
const typing = (o: { typedIn: boolean; typedOut: boolean }) =>
|
|
151
|
+
o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
|
|
152
|
+
|
|
153
|
+
if (run.write) {
|
|
154
|
+
console.log(`✔ scaffolded ${created.length} worker stub(s):`);
|
|
155
|
+
for (const o of created) console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
156
|
+
if (run.manifestPatched) {
|
|
157
|
+
console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
if (would.length === 0) {
|
|
161
|
+
console.log(`✔ no new worker stubs to scaffold`);
|
|
162
|
+
} else {
|
|
163
|
+
console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
|
|
164
|
+
for (const o of would) console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const o of kept) console.log(` = ${o.handlerPath} (kept — already exists)`);
|
|
168
|
+
if (run.skipped.length > 0) {
|
|
169
|
+
console.log(` skipped ${run.skipped.length}: ` +
|
|
170
|
+
run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
|
|
171
|
+
}
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
132
175
|
async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number> {
|
|
133
176
|
const app = await runFromEnv({
|
|
134
177
|
root: f.root,
|
|
@@ -162,14 +205,15 @@ async function cmdDeploy(f: Flags): Promise<number> {
|
|
|
162
205
|
async function cmdNew(f: Flags): Promise<number> {
|
|
163
206
|
const name = f._[1];
|
|
164
207
|
if (!name) {
|
|
165
|
-
console.error("usage: urban new <name> [--root <path>] [--deno]");
|
|
208
|
+
console.error("usage: urban new <name> [--root <path>] [--deno] [--style model|code] (or --code-first)");
|
|
166
209
|
return 1;
|
|
167
210
|
}
|
|
168
211
|
const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
|
|
169
|
-
const res = await scaffold({ name, dir, deno: f.deno });
|
|
212
|
+
const res = await scaffold({ name, dir, deno: f.deno, style: f.style });
|
|
170
213
|
console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
|
|
171
214
|
console.log(` cd ${res.dir}`);
|
|
172
215
|
console.log(` npm install && npm start`);
|
|
216
|
+
if (f.style === "code") console.log(` npm run greet -- Adam # start a code-first instance`);
|
|
173
217
|
if (f.deno) console.log(` # or on Deno: deno task start`);
|
|
174
218
|
return 0;
|
|
175
219
|
}
|
|
@@ -199,6 +243,8 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
199
243
|
return cmdCheck(f);
|
|
200
244
|
case "gen":
|
|
201
245
|
return cmdGen(f);
|
|
246
|
+
case "stubs":
|
|
247
|
+
return cmdStubs(f);
|
|
202
248
|
case "run":
|
|
203
249
|
return cmdRun(f);
|
|
204
250
|
case "dev":
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -57,6 +57,22 @@ async function expandPattern(root: string, io: GenIO, pattern: string): Promise<
|
|
|
57
57
|
.sort();
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
|
|
61
|
+
export async function readModels(
|
|
62
|
+
root: string,
|
|
63
|
+
io: GenIO,
|
|
64
|
+
manifest: { models?: { processes?: string[] } },
|
|
65
|
+
): Promise<ModelSource[]> {
|
|
66
|
+
const procPatterns = manifest.models?.processes ?? [];
|
|
67
|
+
const models: ModelSource[] = [];
|
|
68
|
+
for (const pat of procPatterns) {
|
|
69
|
+
for (const rel of await expandPattern(root, io, pat)) {
|
|
70
|
+
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return models;
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
|
61
77
|
export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]> {
|
|
62
78
|
const { root, io } = opts;
|
|
@@ -73,13 +89,7 @@ export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifac
|
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
// 2. models → worker I/O index
|
|
76
|
-
const
|
|
77
|
-
const models: ModelSource[] = [];
|
|
78
|
-
for (const pat of procPatterns) {
|
|
79
|
-
for (const rel of await expandPattern(root, io, pat)) {
|
|
80
|
-
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
81
|
-
}
|
|
82
|
-
}
|
|
92
|
+
const models = await readModels(root, io, manifest);
|
|
83
93
|
if (models.length > 0) {
|
|
84
94
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
85
95
|
artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
|
package/src/toolkit/index.ts
CHANGED
|
@@ -32,6 +32,23 @@ import { workerIoDeriver } from "./derivers/worker-io.ts";
|
|
|
32
32
|
export const DERIVERS = [migrationsDeriver, workerIoDeriver] as const;
|
|
33
33
|
|
|
34
34
|
// Gen orchestrator + IO
|
|
35
|
-
export { collectArtifacts, runGen, joinPath } from "./gen.ts";
|
|
35
|
+
export { collectArtifacts, runGen, joinPath, readModels } from "./gen.ts";
|
|
36
36
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
|
37
37
|
export { createNodeGenIO } from "./fsio.ts";
|
|
38
|
+
|
|
39
|
+
// Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
|
|
40
|
+
export {
|
|
41
|
+
planWorkerScaffold,
|
|
42
|
+
renderWorkerStub,
|
|
43
|
+
slugifyTaskType,
|
|
44
|
+
} from "./scaffold/workers.ts";
|
|
45
|
+
export type {
|
|
46
|
+
ScaffoldWorker,
|
|
47
|
+
SkippedWorker,
|
|
48
|
+
SkipReason,
|
|
49
|
+
StubManifestEntry,
|
|
50
|
+
WorkerScaffoldPlan,
|
|
51
|
+
WorkerStubPlan,
|
|
52
|
+
} from "./scaffold/workers.ts";
|
|
53
|
+
export { scaffoldWorkers } from "./scaffold.ts";
|
|
54
|
+
export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
planWorkerScaffold,
|
|
5
|
+
renderWorkerStub,
|
|
6
|
+
slugifyTaskType,
|
|
7
|
+
type ScaffoldWorker,
|
|
8
|
+
} from "./workers.ts";
|
|
9
|
+
import type { ModelSource } from "../derivers/worker-io.ts";
|
|
10
|
+
import { scaffoldWorkers } from "../scaffold.ts";
|
|
11
|
+
import type { GenIO } from "../gen.ts";
|
|
12
|
+
|
|
13
|
+
// A service task with a data-envelope in/out contract; `type` is the taskType, and the
|
|
14
|
+
// io.nanobpm.dataEnvelope.in/out zeebe:property carry the envelope type ids.
|
|
15
|
+
function serviceTask(id: string, taskType: string, inType?: string, outType?: string): string {
|
|
16
|
+
const props = [
|
|
17
|
+
inType ? `<zeebe:property name="io.nanobpm.dataEnvelope.in" value="${inType}" />` : "",
|
|
18
|
+
outType ? `<zeebe:property name="io.nanobpm.dataEnvelope.out" value="${outType}" />` : "",
|
|
19
|
+
].join("");
|
|
20
|
+
return `<bpmn:serviceTask id="${id}"><bpmn:extensionElements>
|
|
21
|
+
<zeebe:taskDefinition type="${taskType}" />
|
|
22
|
+
${props ? `<zeebe:taskHeaders>${props}</zeebe:taskHeaders>` : ""}
|
|
23
|
+
</bpmn:extensionElements></bpmn:serviceTask>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function model(path: string, procId: string, ...tasks: string[]): ModelSource {
|
|
27
|
+
return {
|
|
28
|
+
path,
|
|
29
|
+
xml: `<bpmn:process id="${procId}" xmlns:bpmn="x" xmlns:zeebe="y">
|
|
30
|
+
${tasks.join("\n")}
|
|
31
|
+
</bpmn:process>`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("slugifyTaskType makes a filesystem-safe slug", () => {
|
|
36
|
+
assert.equal(slugifyTaskType("pr.finalize"), "pr-finalize");
|
|
37
|
+
assert.equal(slugifyTaskType("Orders::Charge Card!"), "orders-charge-card");
|
|
38
|
+
assert.equal(slugifyTaskType("---"), "worker");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("typed in+out stub imports both symbols and parameterises the handler", () => {
|
|
42
|
+
const stub = renderWorkerStub("pr.finalize", true, true);
|
|
43
|
+
assert.match(stub, /import type \{ AppJobHandler \} from "@nanobpm\/urban\/worker";/);
|
|
44
|
+
assert.match(stub, /import type \{ WorkerInputs, WorkerOutputs \} from "\.\.\/\.\.\/nano-generated\/worker-io\.d\.ts";/);
|
|
45
|
+
assert.match(stub, /AppJobHandler<WorkerInputs\["pr\.finalize"\], WorkerOutputs\["pr\.finalize"\]>/);
|
|
46
|
+
assert.match(stub, /export default handler;/);
|
|
47
|
+
assert.match(stub, /throw new Error\("worker not implemented: pr\.finalize"\)/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("untyped stub imports no generated types and uses bare AppJobHandler", () => {
|
|
51
|
+
const stub = renderWorkerStub("demo.do", false, false);
|
|
52
|
+
assert.doesNotMatch(stub, /worker-io\.d\.ts/);
|
|
53
|
+
assert.match(stub, /const handler: AppJobHandler = async \(job, app\) =>/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("typed-out-only stub fills In with the open default", () => {
|
|
57
|
+
const stub = renderWorkerStub("demo.out", false, true);
|
|
58
|
+
assert.match(stub, /import type \{ WorkerOutputs \} from/);
|
|
59
|
+
assert.match(stub, /AppJobHandler<Record<string, unknown>, WorkerOutputs\["demo\.out"\]>/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("planWorkerScaffold: typed vs untyped from declared types", () => {
|
|
63
|
+
const m = model(
|
|
64
|
+
"a.bpmn",
|
|
65
|
+
"orders",
|
|
66
|
+
serviceTask("A", "orders.charge", "ChargeIn", "ChargeOut"),
|
|
67
|
+
serviceTask("B", "orders.ship"),
|
|
68
|
+
);
|
|
69
|
+
const { plans } = planWorkerScaffold([m], [], ["ChargeIn", "ChargeOut"]);
|
|
70
|
+
assert.equal(plans.length, 2);
|
|
71
|
+
const charge = plans.find((p) => p.taskType === "orders.charge")!;
|
|
72
|
+
assert.equal(charge.typedIn, true);
|
|
73
|
+
assert.equal(charge.typedOut, true);
|
|
74
|
+
assert.equal(charge.handlerPath, "workers/orders-charge/worker.ts");
|
|
75
|
+
assert.deepEqual(charge.manifestEntry, { taskType: "orders.charge", handler: "workers/orders-charge/worker.ts" });
|
|
76
|
+
const ship = plans.find((p) => p.taskType === "orders.ship")!;
|
|
77
|
+
assert.equal(ship.typedIn, false);
|
|
78
|
+
assert.equal(ship.typedOut, false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("planWorkerScaffold: an in/out that is not a declared type stays untyped", () => {
|
|
82
|
+
const m = model("a.bpmn", "p", serviceTask("A", "t.a", "Undeclared", "AlsoUndeclared"));
|
|
83
|
+
const { plans } = planWorkerScaffold([m], [], ["SomethingElse"]);
|
|
84
|
+
assert.equal(plans[0].typedIn, false);
|
|
85
|
+
assert.equal(plans[0].typedOut, false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("planWorkerScaffold: skips already-wired, orchestrator, and duplicates", () => {
|
|
89
|
+
const wired: ScaffoldWorker[] = [{ taskType: "t.wired", handler: "workers/x/worker.ts" }];
|
|
90
|
+
const models = [
|
|
91
|
+
model("a.bpmn", "p1", serviceTask("A", "t.wired"), serviceTask("B", "t.new"), serviceTask("O", "wf:__orchestrate")),
|
|
92
|
+
model("b.bpmn", "p2", serviceTask("C", "t.new")), // duplicate taskType across models
|
|
93
|
+
];
|
|
94
|
+
const { plans, skipped } = planWorkerScaffold(models, wired, []);
|
|
95
|
+
assert.deepEqual(plans.map((p) => p.taskType), ["t.new"]);
|
|
96
|
+
const reasons = Object.fromEntries(skipped.map((s) => [s.taskType, s.reason]));
|
|
97
|
+
assert.equal(reasons["t.wired"], "already-wired");
|
|
98
|
+
assert.equal(reasons["wf:__orchestrate"], "orchestrator");
|
|
99
|
+
assert.equal(reasons["t.new"], "duplicate");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("planWorkerScaffold: colliding slugs get a numeric suffix", () => {
|
|
103
|
+
const m = model("a.bpmn", "p", serviceTask("A", "orders.charge"), serviceTask("B", "orders-charge"));
|
|
104
|
+
const { plans } = planWorkerScaffold([m], [], []);
|
|
105
|
+
const slugs = plans.map((p) => p.slug).sort();
|
|
106
|
+
assert.deepEqual(slugs, ["orders-charge", "orders-charge-2"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── runner (impure edge, in-memory IO) ──────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
|
|
112
|
+
return {
|
|
113
|
+
files,
|
|
114
|
+
async readText(p) {
|
|
115
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
116
|
+
return files[p];
|
|
117
|
+
},
|
|
118
|
+
async writeText(p, c) {
|
|
119
|
+
files[p] = c;
|
|
120
|
+
},
|
|
121
|
+
async listDir(p) {
|
|
122
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
123
|
+
const names = new Set<string>();
|
|
124
|
+
for (const f of Object.keys(files)) {
|
|
125
|
+
if (f.startsWith(prefix)) {
|
|
126
|
+
const rest = f.slice(prefix.length);
|
|
127
|
+
if (!rest.includes("/")) names.add(rest);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...names];
|
|
131
|
+
},
|
|
132
|
+
async exists(p) {
|
|
133
|
+
return p in files;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function appFixture(): Record<string, string> {
|
|
139
|
+
return {
|
|
140
|
+
"/app/nano.app.json": JSON.stringify({
|
|
141
|
+
id: "demo",
|
|
142
|
+
models: { processes: ["processes/*.bpmn"] },
|
|
143
|
+
workers: [{ taskType: "t.wired", handler: "workers/wired/worker.ts" }],
|
|
144
|
+
}),
|
|
145
|
+
"/app/processes/p.bpmn": model("p.bpmn", "p",
|
|
146
|
+
serviceTask("A", "t.new"),
|
|
147
|
+
serviceTask("B", "t.wired"),
|
|
148
|
+
).xml,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
test("scaffoldWorkers dry-run writes nothing and reports would-create", async () => {
|
|
153
|
+
const io = memIO(appFixture());
|
|
154
|
+
const before = Object.keys(io.files).length;
|
|
155
|
+
const run = await scaffoldWorkers({ root: "/app", io });
|
|
156
|
+
assert.equal(run.write, false);
|
|
157
|
+
assert.equal(Object.keys(io.files).length, before, "dry-run must not write");
|
|
158
|
+
assert.deepEqual(run.outcomes.map((o) => o.status), ["would-create"]);
|
|
159
|
+
assert.equal(run.outcomes[0].taskType, "t.new");
|
|
160
|
+
assert.equal(run.manifestPatched, false);
|
|
161
|
+
assert.ok(run.skipped.some((s) => s.taskType === "t.wired" && s.reason === "already-wired"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("scaffoldWorkers --write creates the stub and patches the manifest once", async () => {
|
|
165
|
+
const io = memIO(appFixture());
|
|
166
|
+
const run = await scaffoldWorkers({ root: "/app", io, write: true });
|
|
167
|
+
assert.equal(run.outcomes[0].status, "created");
|
|
168
|
+
const stub = io.files["/app/workers/t-new/worker.ts"];
|
|
169
|
+
assert.ok(stub, "stub file written");
|
|
170
|
+
assert.match(stub, /worker not implemented: t\.new/);
|
|
171
|
+
const manifest = JSON.parse(io.files["/app/nano.app.json"]);
|
|
172
|
+
assert.deepEqual(manifest.workers.map((w: { taskType: string }) => w.taskType), ["t.wired", "t.new"]);
|
|
173
|
+
assert.ok(io.files["/app/nano.app.json"].endsWith("\n"), "manifest ends with newline");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("scaffoldWorkers never clobbers an existing stub (keeps it), still wires it", async () => {
|
|
177
|
+
const files = appFixture();
|
|
178
|
+
files["/app/workers/t-new/worker.ts"] = "// hand-edited, do not touch\n";
|
|
179
|
+
const io = memIO(files);
|
|
180
|
+
const run = await scaffoldWorkers({ root: "/app", io, write: true });
|
|
181
|
+
assert.equal(run.outcomes[0].status, "kept");
|
|
182
|
+
assert.equal(io.files["/app/workers/t-new/worker.ts"], "// hand-edited, do not touch\n");
|
|
183
|
+
const manifest = JSON.parse(io.files["/app/nano.app.json"]);
|
|
184
|
+
assert.ok(manifest.workers.some((w: { taskType: string }) => w.taskType === "t.new"), "orphan stub gets wired");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("scaffoldWorkers is idempotent: a second --write run is a no-op", async () => {
|
|
188
|
+
const io = memIO(appFixture());
|
|
189
|
+
await scaffoldWorkers({ root: "/app", io, write: true });
|
|
190
|
+
const snapshot = JSON.stringify(io.files);
|
|
191
|
+
const run2 = await scaffoldWorkers({ root: "/app", io, write: true });
|
|
192
|
+
// t.new is now wired in the manifest, so the planner skips it entirely (nothing to do).
|
|
193
|
+
assert.deepEqual(run2.outcomes, []);
|
|
194
|
+
assert.ok(run2.skipped.some((s) => s.taskType === "t.new" && s.reason === "already-wired"));
|
|
195
|
+
assert.equal(run2.manifestPatched, false, "no new wiring on the second run");
|
|
196
|
+
assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
|
|
197
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Pure planner for the write-once worker-stub scaffolder (ADR 0056). Given the app's models,
|
|
2
|
+
// its manifest workers, and the set of declared domain type ids, it plans one typed handler
|
|
3
|
+
// stub per un-wired service task — the ONE part of an Urban app that cannot be derived from
|
|
4
|
+
// the model (the handler *body* is human logic). Everything here is pure and testable like a
|
|
5
|
+
// deriver; the impure write-if-absent edge lives in `../scaffold.ts`.
|
|
6
|
+
//
|
|
7
|
+
// scaffold ≠ derive: stubs are human-owned files under `workers/<slug>/worker.ts`, so they are
|
|
8
|
+
// planned once and never overwritten — unlike the derived `nano-generated/` tree.
|
|
9
|
+
|
|
10
|
+
import { scanModelWorkers, type ModelSource } from "../derivers/worker-io.ts";
|
|
11
|
+
|
|
12
|
+
/** A worker to plan against: the manifest's already-wired workers. */
|
|
13
|
+
export interface ScaffoldWorker {
|
|
14
|
+
taskType: string;
|
|
15
|
+
handler?: string;
|
|
16
|
+
llm?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The `workers[]` manifest entry a stub is wired in as. */
|
|
20
|
+
export interface StubManifestEntry {
|
|
21
|
+
taskType: string;
|
|
22
|
+
handler: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One planned handler stub. */
|
|
26
|
+
export interface WorkerStubPlan {
|
|
27
|
+
taskType: string;
|
|
28
|
+
process?: string;
|
|
29
|
+
elementId?: string;
|
|
30
|
+
/** Directory slug under `workers/` (unique across the plan). */
|
|
31
|
+
slug: string;
|
|
32
|
+
/** App-relative handler path: `workers/<slug>/worker.ts`. */
|
|
33
|
+
handlerPath: string;
|
|
34
|
+
/** True when the data-envelope `in`/`out` names a declared domain type (so it is typed). */
|
|
35
|
+
typedIn: boolean;
|
|
36
|
+
typedOut: boolean;
|
|
37
|
+
/** The stub file contents. */
|
|
38
|
+
stub: string;
|
|
39
|
+
manifestEntry: StubManifestEntry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
|
|
43
|
+
|
|
44
|
+
export interface SkippedWorker {
|
|
45
|
+
taskType: string;
|
|
46
|
+
reason: SkipReason;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkerScaffoldPlan {
|
|
50
|
+
plans: WorkerStubPlan[];
|
|
51
|
+
skipped: SkippedWorker[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Suffix the imperative orchestrator task type carries (ADR 0054 / defineWorkflow). */
|
|
55
|
+
const ORCHESTRATOR_SUFFIX = ":__orchestrate";
|
|
56
|
+
|
|
57
|
+
/** Relative import from `workers/<slug>/worker.ts` to the generated worker-io types. */
|
|
58
|
+
const GENERATED_IMPORT = "../../nano-generated/worker-io.d.ts";
|
|
59
|
+
|
|
60
|
+
/** Turn a task type into a filesystem-safe directory slug. */
|
|
61
|
+
export function slugifyTaskType(taskType: string): string {
|
|
62
|
+
const s = taskType
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
65
|
+
.replace(/^-+|-+$/g, "");
|
|
66
|
+
return s.length > 0 ? s : "worker";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
|
|
70
|
+
export function renderWorkerStub(
|
|
71
|
+
taskType: string,
|
|
72
|
+
typedIn: boolean,
|
|
73
|
+
typedOut: boolean,
|
|
74
|
+
): string {
|
|
75
|
+
const key = JSON.stringify(taskType); // e.g. "pr.finalize" — matches the deriver's property keys
|
|
76
|
+
const syms: string[] = [];
|
|
77
|
+
if (typedIn) syms.push("WorkerInputs");
|
|
78
|
+
if (typedOut) syms.push("WorkerOutputs");
|
|
79
|
+
|
|
80
|
+
const generatedImport = syms.length > 0
|
|
81
|
+
? `import type { ${syms.join(", ")} } from ${JSON.stringify(GENERATED_IMPORT)};\n`
|
|
82
|
+
: "";
|
|
83
|
+
|
|
84
|
+
let generic = "";
|
|
85
|
+
if (typedIn && typedOut) generic = `<WorkerInputs[${key}], WorkerOutputs[${key}]>`;
|
|
86
|
+
else if (typedIn) generic = `<WorkerInputs[${key}]>`;
|
|
87
|
+
else if (typedOut) generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
|
|
88
|
+
|
|
89
|
+
const varsNote = typedIn
|
|
90
|
+
? ` // \`job.variables\` is typed as WorkerInputs[${key}].`
|
|
91
|
+
: ` // \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
95
|
+
`// This file is yours to edit — \`urban stubs\` will never overwrite it. Implement the body\n` +
|
|
96
|
+
`// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
|
|
97
|
+
`import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
|
|
98
|
+
generatedImport +
|
|
99
|
+
`\n` +
|
|
100
|
+
`const handler: AppJobHandler${generic} = async (job, app) => {\n` +
|
|
101
|
+
varsNote +
|
|
102
|
+
`\n` +
|
|
103
|
+
` // Reach app state and services through the injected \`app\` API.\n` +
|
|
104
|
+
` throw new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
|
|
105
|
+
`};\n` +
|
|
106
|
+
`\n` +
|
|
107
|
+
`export default handler;\n`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
113
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
|
|
114
|
+
* not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
|
|
115
|
+
* envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
|
|
116
|
+
*/
|
|
117
|
+
export function planWorkerScaffold(
|
|
118
|
+
models: ModelSource[],
|
|
119
|
+
workers: ScaffoldWorker[] = [],
|
|
120
|
+
declaredTypeIds: Iterable<string> = [],
|
|
121
|
+
): WorkerScaffoldPlan {
|
|
122
|
+
const wired = new Set(
|
|
123
|
+
workers
|
|
124
|
+
.map((w) => w?.taskType)
|
|
125
|
+
.filter((t): t is string => typeof t === "string" && t.length > 0),
|
|
126
|
+
);
|
|
127
|
+
const declared = new Set(declaredTypeIds);
|
|
128
|
+
|
|
129
|
+
const plans: WorkerStubPlan[] = [];
|
|
130
|
+
const skipped: SkippedWorker[] = [];
|
|
131
|
+
const seenTaskTypes = new Set<string>();
|
|
132
|
+
const usedSlugs = new Set<string>();
|
|
133
|
+
|
|
134
|
+
for (const model of [...models].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
135
|
+
for (const io of scanModelWorkers(model.xml)) {
|
|
136
|
+
const taskType = io.taskType;
|
|
137
|
+
if (taskType.endsWith(ORCHESTRATOR_SUFFIX)) {
|
|
138
|
+
skipped.push({ taskType, reason: "orchestrator" });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (wired.has(taskType)) {
|
|
142
|
+
skipped.push({ taskType, reason: "already-wired" });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (seenTaskTypes.has(taskType)) {
|
|
146
|
+
skipped.push({ taskType, reason: "duplicate" });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
seenTaskTypes.add(taskType);
|
|
150
|
+
|
|
151
|
+
let slug = slugifyTaskType(taskType);
|
|
152
|
+
if (usedSlugs.has(slug)) {
|
|
153
|
+
let n = 2;
|
|
154
|
+
while (usedSlugs.has(`${slug}-${n}`)) n++;
|
|
155
|
+
slug = `${slug}-${n}`;
|
|
156
|
+
}
|
|
157
|
+
usedSlugs.add(slug);
|
|
158
|
+
|
|
159
|
+
const typedIn = io.in != null && declared.has(io.in);
|
|
160
|
+
const typedOut = io.out != null && declared.has(io.out);
|
|
161
|
+
const handlerPath = `workers/${slug}/worker.ts`;
|
|
162
|
+
|
|
163
|
+
plans.push({
|
|
164
|
+
taskType,
|
|
165
|
+
process: io.process,
|
|
166
|
+
elementId: io.elementId,
|
|
167
|
+
slug,
|
|
168
|
+
handlerPath,
|
|
169
|
+
typedIn,
|
|
170
|
+
typedOut,
|
|
171
|
+
stub: renderWorkerStub(taskType, typedIn, typedOut),
|
|
172
|
+
manifestEntry: { taskType, handler: handlerPath },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { plans, skipped };
|
|
178
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The stub-scaffolder's one impure edge (ADR 0056): read the manifest + models, run the pure
|
|
2
|
+
// planner, and — write-if-absent only — create each handler stub and wire it into the manifest.
|
|
3
|
+
// Dry-run by default (`write: false`); nothing is touched unless `write` is set. Stubs are
|
|
4
|
+
// human-owned, so an existing file is KEPT verbatim, never clobbered (the opposite of `runGen`).
|
|
5
|
+
|
|
6
|
+
import type { GenIO } from "./gen.ts";
|
|
7
|
+
import { joinPath, readModels } from "./gen.ts";
|
|
8
|
+
import {
|
|
9
|
+
planWorkerScaffold,
|
|
10
|
+
type ScaffoldWorker,
|
|
11
|
+
type SkippedWorker,
|
|
12
|
+
type StubManifestEntry,
|
|
13
|
+
} from "./scaffold/workers.ts";
|
|
14
|
+
|
|
15
|
+
/** The manifest fields the scaffolder reads. */
|
|
16
|
+
interface ScaffoldManifest {
|
|
17
|
+
types?: Record<string, unknown>;
|
|
18
|
+
models?: { processes?: string[] };
|
|
19
|
+
workers?: ScaffoldWorker[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScaffoldOptions {
|
|
23
|
+
root: string;
|
|
24
|
+
io: GenIO;
|
|
25
|
+
manifestFile?: string;
|
|
26
|
+
/** Apply changes (create files + patch manifest). Default false = dry-run. */
|
|
27
|
+
write?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type StubStatus = "created" | "would-create" | "kept";
|
|
31
|
+
|
|
32
|
+
export interface StubOutcome {
|
|
33
|
+
taskType: string;
|
|
34
|
+
handlerPath: string;
|
|
35
|
+
status: StubStatus;
|
|
36
|
+
typedIn: boolean;
|
|
37
|
+
typedOut: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ScaffoldRun {
|
|
41
|
+
outcomes: StubOutcome[];
|
|
42
|
+
skipped: SkippedWorker[];
|
|
43
|
+
/**
|
|
44
|
+
* Manifest entries that would be appended to `workers[]` — one per planned stub, so this is
|
|
45
|
+
* populated on a dry-run too (letting a caller preview the wiring). They are actually written
|
|
46
|
+
* to the manifest only when `write` is set (see `manifestPatched`).
|
|
47
|
+
*/
|
|
48
|
+
wired: StubManifestEntry[];
|
|
49
|
+
manifestPatched: boolean;
|
|
50
|
+
write: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
|
|
54
|
+
export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRun> {
|
|
55
|
+
const { root, io } = opts;
|
|
56
|
+
const write = opts.write ?? false;
|
|
57
|
+
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
58
|
+
const manifestPath = joinPath(root, manifestFile);
|
|
59
|
+
|
|
60
|
+
const manifest = JSON.parse(await io.readText(manifestPath)) as ScaffoldManifest;
|
|
61
|
+
const models = await readModels(root, io, manifest);
|
|
62
|
+
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
63
|
+
|
|
64
|
+
const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
|
|
65
|
+
|
|
66
|
+
const outcomes: StubOutcome[] = [];
|
|
67
|
+
const wired: StubManifestEntry[] = [];
|
|
68
|
+
|
|
69
|
+
for (const plan of plans) {
|
|
70
|
+
const abs = joinPath(root, plan.handlerPath);
|
|
71
|
+
const exists = await io.exists(abs);
|
|
72
|
+
let status: StubStatus;
|
|
73
|
+
if (exists) {
|
|
74
|
+
status = "kept"; // human-owned — never clobber
|
|
75
|
+
} else if (write) {
|
|
76
|
+
await io.writeText(abs, plan.stub);
|
|
77
|
+
status = "created";
|
|
78
|
+
} else {
|
|
79
|
+
status = "would-create";
|
|
80
|
+
}
|
|
81
|
+
outcomes.push({
|
|
82
|
+
taskType: plan.taskType,
|
|
83
|
+
handlerPath: plan.handlerPath,
|
|
84
|
+
status,
|
|
85
|
+
typedIn: plan.typedIn,
|
|
86
|
+
typedOut: plan.typedOut,
|
|
87
|
+
});
|
|
88
|
+
// The planner already excluded already-wired task types, so every plan needs wiring —
|
|
89
|
+
// including an orphan stub file that exists on disk but isn't in the manifest yet.
|
|
90
|
+
wired.push(plan.manifestEntry);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let manifestPatched = false;
|
|
94
|
+
if (write && wired.length > 0) {
|
|
95
|
+
const withWorkers = {
|
|
96
|
+
...manifest,
|
|
97
|
+
workers: [...(manifest.workers ?? []), ...wired],
|
|
98
|
+
};
|
|
99
|
+
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, 2)}\n`);
|
|
100
|
+
manifestPatched = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { outcomes, skipped, wired, manifestPatched, write };
|
|
104
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join, normalize } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Guard for the `@nanobpm/urban/worker` authoring surface. Its job is to keep urban's OWN
|
|
8
|
+
// host adapters (runtime/adapters/node.ts, runtime/adapters/deno.ts and their node:* imports)
|
|
9
|
+
// out of the type graph a worker handler imports — the coupling that otherwise forces
|
|
10
|
+
// `@types/node` / `skipLibCheck` on a consumer just to typecheck a handler.
|
|
11
|
+
//
|
|
12
|
+
// This walks the *relative*-import closure from worker.ts, so it covers urban's own reachable
|
|
13
|
+
// sources. It does NOT follow the bare `@nanobpm/nano-sdk` dependency behind `AppApi.sdk`
|
|
14
|
+
// (engine/sdk.ts) — that dependency's own node-freeness is enforced separately by the
|
|
15
|
+
// `@nanobpm/nano-sdk` >= 1.2.2 floor in package.json (which pulls @camunda8 >= 10.0.0-alpha.20,
|
|
16
|
+
// whose public types no longer reference node:worker_threads). Together, urban's node-free
|
|
17
|
+
// relative closure + that SDK floor make a scaffolded stub (ADR 0056) typecheck on Node or Deno
|
|
18
|
+
// with no `@types/node` and no `skipLibCheck`.
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
/** Collect relative-specifier imports/re-exports from a source file. */
|
|
23
|
+
function relativeSpecifiers(src: string): string[] {
|
|
24
|
+
const out: string[] = [];
|
|
25
|
+
const re = /\bfrom\s+["'](\.[^"']+)["']/g;
|
|
26
|
+
let m: RegExpExecArray | null;
|
|
27
|
+
while ((m = re.exec(src)) !== null) {
|
|
28
|
+
// Skip template-placeholder specifiers (e.g. `./${DOMAIN_DTS}` emitted as codegen strings),
|
|
29
|
+
// which are not real static import paths.
|
|
30
|
+
if (!m[1].includes("${")) out.push(m[1]);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve a relative specifier (with explicit .ts) against a containing file. */
|
|
36
|
+
function resolveSpec(fromFile: string, spec: string): string {
|
|
37
|
+
return normalize(join(dirname(fromFile), spec));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function closureFrom(entry: string): Promise<string[]> {
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const stack = [entry];
|
|
43
|
+
while (stack.length > 0) {
|
|
44
|
+
const file = stack.pop()!;
|
|
45
|
+
if (seen.has(file)) continue;
|
|
46
|
+
seen.add(file);
|
|
47
|
+
let src: string;
|
|
48
|
+
try {
|
|
49
|
+
src = await readFile(file, "utf8");
|
|
50
|
+
} catch {
|
|
51
|
+
// A statically-extracted specifier that doesn't resolve to a real file (dynamic/generated
|
|
52
|
+
// path). Skip it — the purity assertions below only care about files that actually exist.
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const spec of relativeSpecifiers(src)) {
|
|
56
|
+
stack.push(resolveSpec(file, spec));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...seen];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("@nanobpm/urban/worker closure is free of node:* and Deno", async () => {
|
|
63
|
+
const files = await closureFrom(join(here, "worker.ts"));
|
|
64
|
+
// Sanity: the walk actually reached the core authoring modules.
|
|
65
|
+
assert.ok(
|
|
66
|
+
files.some((f) => f.endsWith(join("core", "modules", "workers.ts"))),
|
|
67
|
+
"closure should include core/modules/workers.ts",
|
|
68
|
+
);
|
|
69
|
+
const offenders: string[] = [];
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const src = await readFile(file, "utf8");
|
|
72
|
+
if (/from\s+["']node:/.test(src)) offenders.push(`${file}: imports node:*`);
|
|
73
|
+
if (/\bDeno\s*\./.test(src)) offenders.push(`${file}: references Deno.*`);
|
|
74
|
+
}
|
|
75
|
+
assert.deepEqual(offenders, [], `worker surface purity violations:\n${offenders.join("\n")}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("@nanobpm/urban/worker excludes urban's host adapters (the barrel includes them)", async () => {
|
|
79
|
+
const workerClosure = await closureFrom(join(here, "worker.ts"));
|
|
80
|
+
const barrelClosure = await closureFrom(join(here, "index.ts"));
|
|
81
|
+
const adapters = [
|
|
82
|
+
join("runtime", "adapters", "node.ts"),
|
|
83
|
+
join("runtime", "adapters", "deno.ts"),
|
|
84
|
+
];
|
|
85
|
+
for (const a of adapters) {
|
|
86
|
+
assert.ok(
|
|
87
|
+
!workerClosure.some((f) => f.endsWith(a)),
|
|
88
|
+
`worker closure must NOT include ${a}`,
|
|
89
|
+
);
|
|
90
|
+
assert.ok(
|
|
91
|
+
barrelClosure.some((f) => f.endsWith(a)),
|
|
92
|
+
`barrel closure is expected to include ${a} (control)`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("@nanobpm/urban/worker re-exports the authoring types", async () => {
|
|
98
|
+
const src = await readFile(join(here, "worker.ts"), "utf8");
|
|
99
|
+
for (const sym of ["AppJobHandler", "EngineJob", "JobHandler", "AppApi"]) {
|
|
100
|
+
assert.match(src, new RegExp(`\\b${sym}\\b`), `worker.ts should export ${sym}`);
|
|
101
|
+
}
|
|
102
|
+
// Types-only surface: no value exports (nothing that would emit runtime code).
|
|
103
|
+
assert.doesNotMatch(src, /^export\s+(?!type\b)/m, "worker.ts must be a types-only re-export");
|
|
104
|
+
});
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// `@nanobpm/urban/worker` — the runtime-free worker-authoring surface.
|
|
2
|
+
//
|
|
3
|
+
// A service-task handler only needs a handful of *types*: the handler signature and the
|
|
4
|
+
// shapes it is handed. Those all live under `runtime/core/`, which is guaranteed free of any
|
|
5
|
+
// `node:*` import or `Deno` reference (the ADR 0052 purity invariant, enforced by
|
|
6
|
+
// core/purity.test.ts). Importing them from the package barrel (`@nanobpm/urban`) instead
|
|
7
|
+
// drags in `runtime/index.ts`, which re-exports the Node and Deno host adapters — so the
|
|
8
|
+
// barrel's type graph transitively references `node:*` and a consumer must have `@types/node`
|
|
9
|
+
// resolvable (or `skipLibCheck` on) just to typecheck a handler.
|
|
10
|
+
//
|
|
11
|
+
// This entry re-exports ONLY those core authoring types, so a worker module (e.g. a scaffolded
|
|
12
|
+
// stub, ADR 0056) typechecks on Node or Deno, with or without `skipLibCheck`, and never needs
|
|
13
|
+
// `@types/node`. It is types-only: nothing here emits runtime code.
|
|
14
|
+
//
|
|
15
|
+
// (One caveat: `AppApi.sdk` exposes the full `@nanobpm/nano-sdk` client by design, so the
|
|
16
|
+
// handler surface also reaches the SDK's types. Those are kept node-free by the
|
|
17
|
+
// `@nanobpm/nano-sdk` >= 1.2.2 floor in package.json — see ADR 0056.)
|
|
18
|
+
|
|
19
|
+
export type { AppJobHandler } from "./runtime/core/modules/workers.ts";
|
|
20
|
+
export type { EngineJob, JobHandler } from "./runtime/core/host.ts";
|
|
21
|
+
export type { AppApi } from "./runtime/core/context.ts";
|