@nanobpm/urban 0.1.0 → 0.2.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.
Files changed (44) hide show
  1. package/README.md +93 -16
  2. package/package.json +16 -11
  3. package/src/cli.ts +6 -5
  4. package/src/index.ts +46 -0
  5. package/src/runtime/adapters/deno.ts +124 -0
  6. package/src/runtime/adapters/detect.ts +22 -0
  7. package/src/runtime/adapters/node.ts +119 -0
  8. package/src/runtime/core/context.ts +35 -0
  9. package/src/runtime/core/glob.test.ts +45 -0
  10. package/src/runtime/core/glob.ts +63 -0
  11. package/src/runtime/core/host.ts +130 -0
  12. package/src/runtime/core/manifest.test.ts +31 -0
  13. package/src/runtime/core/manifest.ts +112 -0
  14. package/src/runtime/core/modules/datasource.ts +220 -0
  15. package/src/runtime/core/modules/deploy.ts +41 -0
  16. package/src/runtime/core/modules/security.ts +53 -0
  17. package/src/runtime/core/modules/surfaces.test.ts +42 -0
  18. package/src/runtime/core/modules/surfaces.ts +103 -0
  19. package/src/runtime/core/modules/triggers.ts +143 -0
  20. package/src/runtime/core/modules/units.test.ts +22 -0
  21. package/src/runtime/core/modules/workers.ts +96 -0
  22. package/src/runtime/core/purity.test.ts +31 -0
  23. package/src/runtime/core/router.test.ts +16 -0
  24. package/src/runtime/core/router.ts +55 -0
  25. package/src/runtime/core/runtime.test.ts +258 -0
  26. package/src/runtime/core/runtime.ts +213 -0
  27. package/src/runtime/core/validate.test.ts +73 -0
  28. package/src/runtime/core/validate.ts +129 -0
  29. package/src/runtime/engine/nanosdk.test.ts +124 -0
  30. package/src/runtime/engine/nanosdk.ts +123 -0
  31. package/src/runtime/engine/rest.ts +243 -0
  32. package/src/runtime/index.ts +68 -0
  33. package/src/runtime/run.ts +91 -0
  34. package/src/runtime/schema/nano-app.schema.json +471 -0
  35. package/src/toolkit/artifact.ts +29 -0
  36. package/src/toolkit/derivers/migrations.test.ts +50 -0
  37. package/src/toolkit/derivers/migrations.ts +99 -0
  38. package/src/toolkit/derivers/worker-io.test.ts +75 -0
  39. package/src/toolkit/derivers/worker-io.ts +176 -0
  40. package/src/toolkit/fsio.test.ts +21 -0
  41. package/src/toolkit/fsio.ts +36 -0
  42. package/src/toolkit/gen.test.ts +85 -0
  43. package/src/toolkit/gen.ts +109 -0
  44. package/src/toolkit/index.ts +37 -0
@@ -0,0 +1,258 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createNodeHost } from "../adapters/node.ts";
7
+ import { createUrbanApp, resolvePort } from "./runtime.ts";
8
+ import { runFromEnv } from "../run.ts";
9
+ import type {
10
+ EngineClient,
11
+ EngineJob,
12
+ JobHandler,
13
+ WorkerSubscription,
14
+ } from "./host.ts";
15
+
16
+ // A fake engine: records everything and lets the test deliver a job to a registered worker.
17
+ class FakeEngine implements EngineClient {
18
+ deployed = 0;
19
+ workers = new Map<string, JobHandler>();
20
+ messages: { name: string; correlationKey?: string; variables?: Record<string, unknown> }[] = [];
21
+ completedTasks: { key: string; variables?: Record<string, unknown> }[] = [];
22
+ userTasks = [{ userTaskKey: "ut-1", elementId: "approve", variables: {} }];
23
+
24
+ async deployResources(r: { name: string }[]) {
25
+ this.deployed += r.length;
26
+ return { deployed: r.length };
27
+ }
28
+ async createInstance() {
29
+ return { processInstanceKey: "pi-1" };
30
+ }
31
+ async publishMessage(input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) {
32
+ this.messages.push(input);
33
+ }
34
+ async searchUserTasks() {
35
+ return this.userTasks;
36
+ }
37
+ async completeUserTask(key: string, variables?: Record<string, unknown>) {
38
+ this.completedTasks.push({ key, variables });
39
+ }
40
+ async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
41
+ this.workers.set(jobType, handler);
42
+ return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
43
+ }
44
+ async close() {}
45
+ // test helper
46
+ async deliver(jobType: string, job: EngineJob) {
47
+ const h = this.workers.get(jobType);
48
+ if (!h) throw new Error(`no worker for ${jobType}`);
49
+ return h(job);
50
+ }
51
+ }
52
+
53
+ async function makeFixture(): Promise<string> {
54
+ const dir = await mkdtemp(join(tmpdir(), "urban-rt-"));
55
+ await mkdir(join(dir, "processes"));
56
+ await mkdir(join(dir, "decisions"));
57
+ await mkdir(join(dir, "forms"));
58
+ await mkdir(join(dir, "db", "migrations"), { recursive: true });
59
+ await mkdir(join(dir, "workers"));
60
+ await writeFile(join(dir, "processes", "p.bpmn"), "<definitions/>");
61
+ await writeFile(join(dir, "decisions", "d.dmn"), "<definitions/>");
62
+ await writeFile(join(dir, "forms", "f.form"), "{}");
63
+ await writeFile(
64
+ join(dir, "db", "migrations", "001_init.sql"),
65
+ "CREATE TABLE crew_tasks (id INTEGER PRIMARY KEY, title TEXT, status TEXT);",
66
+ );
67
+ await writeFile(
68
+ join(dir, "workers", "handlers.ts"),
69
+ `export const handlers = {
70
+ "wf.claim": async (job, app) => {
71
+ app.data.repo("task").insert({ title: job.variables.title, status: "claimed" });
72
+ return { claimed: true };
73
+ },
74
+ };`,
75
+ );
76
+ const manifest = {
77
+ schemaVersion: 1,
78
+ id: "fixture-app",
79
+ name: "Fixture App",
80
+ models: { processes: ["processes/*.bpmn"], decisions: ["decisions/*.dmn"], forms: ["forms/*.form"] },
81
+ data: { default: "app", sources: { app: { driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" } } },
82
+ types: { task: { table: "crew_tasks", fields: { title: { type: "string" }, status: { type: "string" } } } },
83
+ workers: [{ taskType: "wf.claim", handler: "workers/handlers.ts" }],
84
+ triggers: [{ id: "hook", type: "webhook", path: "/hooks/task", action: { message: "wf.requested", correlationKey: "= body.taskId" } }],
85
+ surfaces: { taskInbox: { enabled: true, path: "/tasks" } },
86
+ };
87
+ await writeFile(join(dir, "nano.app.json"), JSON.stringify(manifest, null, 2));
88
+ return dir;
89
+ }
90
+
91
+ test("runtime materializes the manifest end-to-end against a fake engine", async () => {
92
+ const dir = await makeFixture();
93
+ const host = createNodeHost({ cwd: dir, log: () => {} });
94
+ const engine = new FakeEngine();
95
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
96
+ await app.start();
97
+
98
+ try {
99
+ // deploy
100
+ assert.equal(engine.deployed, 3, "3 model files deployed");
101
+
102
+ // migrations applied
103
+ const insp = app.inspect() as Record<string, any>;
104
+ assert.deepEqual(insp.data.sources[0].migrations, 1);
105
+
106
+ // workers registered + data injected: deliver a job and see a DB row
107
+ await engine.deliver("wf.claim", {
108
+ jobKey: "j1",
109
+ jobType: "wf.claim",
110
+ variables: { title: "Fix bug" },
111
+ });
112
+ const rows = app.data!.repo("task").all<{ title: string; status: string }>();
113
+ assert.equal(rows.length, 1);
114
+ assert.equal(rows[0].title, "Fix bug");
115
+ assert.equal(rows[0].status, "claimed");
116
+
117
+ // HTTP surfaces + triggers on the real node server
118
+ const port = app.httpPort!;
119
+ assert.ok(port > 0);
120
+
121
+ const tasksRes = await fetch(`http://localhost:${port}/tasks/api/tasks`);
122
+ assert.equal(tasksRes.status, 200);
123
+ assert.equal((await tasksRes.json() as unknown[]).length, 1);
124
+
125
+ const hookRes = await fetch(`http://localhost:${port}/hooks/task`, {
126
+ method: "POST",
127
+ headers: { "content-type": "application/json" },
128
+ body: JSON.stringify({ taskId: "T-9", title: "from webhook" }),
129
+ });
130
+ assert.equal(hookRes.status, 200);
131
+ assert.equal(engine.messages.length, 1);
132
+ assert.equal(engine.messages[0].name, "wf.requested");
133
+ assert.equal(engine.messages[0].correlationKey, "T-9");
134
+
135
+ // complete a user task through the surface API
136
+ const done = await fetch(`http://localhost:${port}/tasks/api/complete`, {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json" },
139
+ body: JSON.stringify({ userTaskKey: "ut-1", variables: { approved: true } }),
140
+ });
141
+ assert.equal(done.status, 200);
142
+ assert.equal(engine.completedTasks.length, 1);
143
+ assert.equal(engine.completedTasks[0].key, "ut-1");
144
+
145
+ // healthz
146
+ const health = await fetch(`http://localhost:${port}/healthz`);
147
+ assert.equal((await health.json() as { ok: boolean }).ok, true);
148
+
149
+ // field-drift guard
150
+ assert.throws(() => app.data!.repo("task").insert({ bogus: 1 }));
151
+ } finally {
152
+ await app.stop();
153
+ await rm(dir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("stop() resets state so the app can be cleanly restarted", async () => {
158
+ const dir = await makeFixture();
159
+ const host = createNodeHost({ cwd: dir, log: () => {} });
160
+ const engine = new FakeEngine();
161
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
162
+
163
+ try {
164
+ await app.start();
165
+ const firstPort = app.httpPort!;
166
+ assert.ok(firstPort > 0);
167
+ await app.stop();
168
+
169
+ // after stop, inspect() no longer carries stale describe data / port
170
+ const stopped = app.inspect() as Record<string, unknown>;
171
+ assert.equal(stopped.httpPort, undefined);
172
+ assert.equal(stopped.workers, undefined);
173
+
174
+ // and a fresh start works (would throw "already started" if state leaked)
175
+ await app.start();
176
+ assert.ok(app.httpPort! > 0);
177
+ // deploy ran again on the clean start, not doubled from the first run
178
+ assert.equal(engine.deployed, 6);
179
+ } finally {
180
+ await app.stop();
181
+ await rm(dir, { recursive: true, force: true });
182
+ }
183
+ });
184
+
185
+ test("a failed start() tears down and resets state (no 'already started' wedge)", async () => {
186
+ const dir = await makeFixture();
187
+ const host = createNodeHost({ cwd: dir, log: () => {} });
188
+ let failNextDeploy = true;
189
+ const engine = new FakeEngine();
190
+ const origDeploy = engine.deployResources.bind(engine);
191
+ engine.deployResources = async (r: { name: string }[]) => {
192
+ if (failNextDeploy) {
193
+ failNextDeploy = false;
194
+ throw new Error("boom during deploy");
195
+ }
196
+ return origDeploy(r);
197
+ };
198
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
199
+
200
+ try {
201
+ await assert.rejects(() => app.start(), /boom during deploy/);
202
+ // state reset: not wedged, no leaked port/describe
203
+ const s = app.inspect() as Record<string, unknown>;
204
+ assert.equal(s.httpPort, undefined);
205
+ assert.equal(s.workers, undefined);
206
+ // a subsequent start() succeeds rather than throwing "app already started"
207
+ await app.start();
208
+ assert.ok(app.httpPort! > 0);
209
+ } finally {
210
+ await app.stop();
211
+ await rm(dir, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ test("task-inbox /api/complete returns 400 on a malformed JSON body", async () => {
216
+ const dir = await makeFixture();
217
+ const host = createNodeHost({ cwd: dir, log: () => {} });
218
+ const engine = new FakeEngine();
219
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
220
+ await app.start();
221
+ try {
222
+ const res = await fetch(`http://localhost:${app.httpPort!}/tasks/api/complete`, {
223
+ method: "POST",
224
+ headers: { "content-type": "application/json" },
225
+ body: "{not json",
226
+ });
227
+ assert.equal(res.status, 400);
228
+ assert.equal(engine.completedTasks.length, 0);
229
+ } finally {
230
+ await app.stop();
231
+ await rm(dir, { recursive: true, force: true });
232
+ }
233
+ });
234
+
235
+ test("resolvePort prefers explicit, then $PORT, then 8090; rejects bad $PORT", () => {
236
+ assert.equal(resolvePort(3000, "9999"), 3000);
237
+ assert.equal(resolvePort(undefined, "9999"), 9999);
238
+ assert.equal(resolvePort(undefined, undefined), 8090);
239
+ assert.equal(resolvePort(undefined, ""), 8090);
240
+ assert.throws(() => resolvePort(undefined, "abc"), /invalid PORT/);
241
+ assert.throws(() => resolvePort(undefined, "70000"), /invalid PORT/);
242
+ });
243
+
244
+ test("runFromEnv anchors the host at a non-'.' root without double-prefixing paths", async () => {
245
+ const dir = await makeFixture();
246
+ const engine = new FakeEngine();
247
+ // No host passed → runFromEnv selects a host anchored at `dir`. The regression
248
+ // guarded here: it must NOT also prefix `dir` inside createUrbanApp (which
249
+ // would look for "<dir>/<dir>/nano.app.json" and fail).
250
+ const app = await runFromEnv({ root: dir, engine, port: 0, handleSignals: false });
251
+ try {
252
+ assert.equal(app.manifest.id, "fixture-app");
253
+ assert.equal(engine.deployed, 3, "models deployed from the correct root");
254
+ } finally {
255
+ await app.stop();
256
+ await rm(dir, { recursive: true, force: true });
257
+ }
258
+ });
@@ -0,0 +1,213 @@
1
+ // The runtime orchestrator: createUrbanApp() takes a manifest + a host + an engine and
2
+ // materializes the app by mounting each module. Hosts (CLI/IDE/console/bare process) call
3
+ // this the same way; a host may mount a subset via `mount` flags (e.g. the console mounts
4
+ // its own surfaces but reuses deploy + workers + datasource).
5
+
6
+ import type { AppApi, Mounted } from "./context.ts";
7
+ import type { EngineClient, HostContext, HttpServer } from "./host.ts";
8
+ import { loadManifest, type AppManifest } from "./manifest.ts";
9
+ import { validateManifest } from "./validate.ts";
10
+ import { makeRouter, type Route } from "./router.ts";
11
+ import { deployModels } from "./modules/deploy.ts";
12
+ import { provisionData, DataLayer } from "./modules/datasource.ts";
13
+ import { mountWorkers } from "./modules/workers.ts";
14
+ import { mountSurfaces } from "./modules/surfaces.ts";
15
+ import { mountTriggers } from "./modules/triggers.ts";
16
+ import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
17
+
18
+ /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
19
+ * error when $PORT is set but not a valid integer in 0..65535. */
20
+ export function resolvePort(explicit: number | undefined, envPort: string | undefined): number {
21
+ if (explicit !== undefined) return explicit;
22
+ if (envPort === undefined || envPort === "") return 8090;
23
+ const n = Number(envPort);
24
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
25
+ throw new Error(`invalid PORT "${envPort}": expected an integer in 0..65535`);
26
+ }
27
+ return n;
28
+ }
29
+
30
+ export interface MountFlags {
31
+ deploy?: boolean;
32
+ data?: boolean;
33
+ workers?: boolean;
34
+ surfaces?: boolean;
35
+ triggers?: boolean;
36
+ security?: boolean;
37
+ }
38
+
39
+ export interface CreateUrbanAppOptions {
40
+ host: HostContext;
41
+ engine: EngineClient;
42
+ /** App root directory; manifest paths resolve relative to it. Default ".". */
43
+ root?: string;
44
+ /** Provide a manifest object directly, or… */
45
+ manifest?: AppManifest;
46
+ /** …a path to load it from (relative to root). Default "nano.app.json". */
47
+ manifestPath?: string;
48
+ /** HTTP port for surfaces/triggers. Default from PORT env or 8090. */
49
+ port?: number;
50
+ /** Which modules to mount (all true by default). */
51
+ mount?: MountFlags;
52
+ }
53
+
54
+ export interface UrbanApp {
55
+ readonly manifest: AppManifest;
56
+ readonly root: string;
57
+ /** Materialize the app (deploy → data → workers → surfaces/triggers). */
58
+ start(): Promise<void>;
59
+ /** Tear everything down cleanly: unsubscribe workers, close the DB, stop the
60
+ * HTTP server, and close the engine client (the app owns the engine's
61
+ * connection lifecycle once started). */
62
+ stop(): Promise<void>;
63
+ /** A structured snapshot of what is mounted. */
64
+ inspect(): Record<string, unknown>;
65
+ /** The provisioned data layer (available after start when `data` is mounted). */
66
+ readonly data: DataLayer | undefined;
67
+ readonly security: SecurityPolicy | undefined;
68
+ readonly httpPort: number | undefined;
69
+ }
70
+
71
+ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<UrbanApp> {
72
+ const host = opts.host;
73
+ const root = opts.root ?? ".";
74
+ const manifest = validateManifest(
75
+ opts.manifest ?? (await loadManifest(host, `${root.replace(/\/+$/, "")}/${opts.manifestPath ?? "nano.app.json"}`)),
76
+ );
77
+ const engine = opts.engine;
78
+ const flags: Required<MountFlags> = {
79
+ deploy: opts.mount?.deploy ?? true,
80
+ data: opts.mount?.data ?? true,
81
+ workers: opts.mount?.workers ?? true,
82
+ surfaces: opts.mount?.surfaces ?? true,
83
+ triggers: opts.mount?.triggers ?? true,
84
+ security: opts.mount?.security ?? true,
85
+ };
86
+ const ctx = { manifest, host, engine, root };
87
+
88
+ let data: DataLayer | undefined;
89
+ let security: SecurityPolicy | undefined;
90
+ let server: HttpServer | undefined;
91
+ const mounted: Mounted[] = [];
92
+ const describe: Record<string, unknown> = {};
93
+ let started = false;
94
+ let httpPort: number | undefined;
95
+
96
+ const port = resolvePort(opts.port, host.env("PORT"));
97
+
98
+ // Release every mounted resource and reset internal state so a subsequent
99
+ // start() begins clean. Used by stop() and by start()'s failure path.
100
+ const teardown = async () => {
101
+ if (server) {
102
+ try {
103
+ await server.stop();
104
+ } catch (e) {
105
+ host.log("warn", "error stopping http server", { error: String(e) });
106
+ }
107
+ }
108
+ for (const m of mounted) {
109
+ try {
110
+ await m.stop();
111
+ } catch (e) {
112
+ host.log("warn", "error stopping module", { error: String(e) });
113
+ }
114
+ }
115
+ if (data) data.closeAll();
116
+ try {
117
+ await engine.close();
118
+ } catch (e) {
119
+ host.log("warn", "error closing engine", { error: String(e) });
120
+ }
121
+ mounted.length = 0;
122
+ for (const k of Object.keys(describe)) delete describe[k];
123
+ server = undefined;
124
+ httpPort = undefined;
125
+ data = undefined;
126
+ security = undefined;
127
+ started = false;
128
+ };
129
+
130
+ const app: UrbanApp = {
131
+ manifest,
132
+ root,
133
+ get data() {
134
+ return data;
135
+ },
136
+ get security() {
137
+ return security;
138
+ },
139
+ get httpPort() {
140
+ return httpPort;
141
+ },
142
+
143
+ async start() {
144
+ if (started) throw new Error("app already started");
145
+ started = true;
146
+ try {
147
+ if (flags.security) security = mountSecurity(ctx);
148
+ if (flags.deploy) describe.deploy = await deployModels(ctx);
149
+
150
+ data = flags.data ? await provisionData(ctx) : new DataLayer(new Map(), undefined, {});
151
+ const api: AppApi = {
152
+ manifest,
153
+ data,
154
+ engine,
155
+ env: (n) => host.env(n),
156
+ log: (l, m, f) => host.log(l, m, f),
157
+ };
158
+
159
+ if (flags.workers) {
160
+ const w = await mountWorkers(ctx, api);
161
+ mounted.push(w);
162
+ describe.workers = w.describe?.();
163
+ }
164
+
165
+ const routes: Route[] = [];
166
+ if (flags.surfaces) {
167
+ const s = mountSurfaces(ctx, api);
168
+ routes.push(...s.routes);
169
+ describe.surfaces = s.describe();
170
+ }
171
+ if (flags.triggers) {
172
+ const t = mountTriggers(ctx, api);
173
+ routes.push(...t.routes);
174
+ describe.triggers = t.describe();
175
+ }
176
+ if (security) describe.security = security.describe();
177
+ if (data) describe.data = data.describe();
178
+
179
+ if (routes.length > 0) {
180
+ // A tiny liveness route.
181
+ routes.push({
182
+ method: "GET",
183
+ path: "/healthz",
184
+ handler: () => ({
185
+ status: 200,
186
+ headers: { "content-type": "application/json" },
187
+ body: JSON.stringify({ ok: true, app: manifest.id }),
188
+ }),
189
+ });
190
+ server = await host.serveHttp(port, makeRouter(routes));
191
+ httpPort = server.port;
192
+ host.log("info", "urban app serving surfaces/triggers", { port: httpPort, routes: routes.length });
193
+ }
194
+ host.log("info", `urban app "${manifest.id}" started`, {});
195
+ } catch (err) {
196
+ // A failed start must not leave the app half-mounted (leaked workers,
197
+ // a bound port) or wedged in the "already started" state.
198
+ await teardown();
199
+ throw err;
200
+ }
201
+ },
202
+
203
+ async stop() {
204
+ await teardown();
205
+ },
206
+
207
+ inspect() {
208
+ return { app: manifest.id, name: manifest.name, root, httpPort, ...describe };
209
+ },
210
+ };
211
+
212
+ return app;
213
+ }
@@ -0,0 +1,73 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { collectManifestIssues, validateManifest, ManifestValidationError } from "./validate.ts";
4
+
5
+ const valid = {
6
+ schemaVersion: 1,
7
+ id: "nano-workforce",
8
+ name: "Nano Workforce",
9
+ data: { default: "app", sources: { app: { driver: "sqlite", url: "file:./x.db" } } },
10
+ types: { task: { table: "crew_tasks", fields: { title: { type: "string" } } } },
11
+ workers: [{ taskType: "a.b", handler: "workers/x.ts" }],
12
+ triggers: [{ id: "t", type: "webhook", path: "/h" }],
13
+ };
14
+
15
+ test("a well-formed manifest has no issues", () => {
16
+ assert.deepEqual(collectManifestIssues(valid), []);
17
+ assert.equal(validateManifest(valid).id, "nano-workforce");
18
+ });
19
+
20
+ test("missing required keys are reported (driven by the schema)", () => {
21
+ const issues = collectManifestIssues({ schemaVersion: 1 });
22
+ const paths = issues.map((i) => i.path);
23
+ assert.ok(paths.includes("id"));
24
+ assert.ok(paths.includes("name"));
25
+ });
26
+
27
+ test("unknown top-level keys are rejected (additionalProperties:false)", () => {
28
+ const issues = collectManifestIssues({ ...valid, bogus: 1 });
29
+ assert.ok(issues.some((i) => i.path === "bogus"));
30
+ });
31
+
32
+ test("bad schemaVersion and bad slug id are reported", () => {
33
+ const issues = collectManifestIssues({ ...valid, schemaVersion: 2, id: "Not A Slug" });
34
+ assert.ok(issues.some((i) => i.path === "schemaVersion"));
35
+ assert.ok(issues.some((i) => i.path === "id"));
36
+ });
37
+
38
+ test("binding rules: worker needs handler-or-llm, source needs driver+url", () => {
39
+ const issues = collectManifestIssues({
40
+ ...valid,
41
+ workers: [{ taskType: "a" }],
42
+ types: { t: { fields: { x: { type: "string" } } } },
43
+ data: { default: "app", sources: { app: { driver: "sqlite" } } },
44
+ });
45
+ assert.ok(issues.some((i) => i.path === "workers[0]" && /handler.*llm/.test(i.message)));
46
+ // A type with `fields` and no `table` is a valid transient type — no issue.
47
+ assert.ok(!issues.some((i) => i.path.startsWith("types.t")));
48
+ assert.ok(issues.some((i) => i.path === "data.sources.app.url"));
49
+ });
50
+
51
+ test("an llm-backed worker (no handler) is accepted", () => {
52
+ const issues = collectManifestIssues({
53
+ ...valid,
54
+ workers: [{ taskType: "summarize", llm: "gpt" }],
55
+ });
56
+ assert.ok(!issues.some((i) => i.path.startsWith("workers[0]")));
57
+ });
58
+
59
+ test("data.default must reference an existing source", () => {
60
+ const issues = collectManifestIssues({
61
+ ...valid,
62
+ data: { default: "nope", sources: { app: { driver: "sqlite", url: "file:./x" } } },
63
+ });
64
+ assert.ok(issues.some((i) => i.path === "data.default"));
65
+ });
66
+
67
+ test("validateManifest throws ManifestValidationError with issues", () => {
68
+ assert.throws(() => validateManifest({ schemaVersion: 1 }), (e: unknown) => {
69
+ assert.ok(e instanceof ManifestValidationError);
70
+ assert.ok(e.issues.length >= 2);
71
+ return true;
72
+ });
73
+ });
@@ -0,0 +1,129 @@
1
+ // Manifest validation. We deliberately avoid a heavyweight JSON-Schema engine here to
2
+ // keep core dependency-free and runtime-agnostic. Instead we drive validation *from* the
3
+ // vendored nano-app.schema.json (the ADR 0027 source of truth) for the top-level envelope
4
+ // — required keys, allowed keys (additionalProperties:false), the schemaVersion const, and
5
+ // the id slug pattern — and then layer the runtime's binding rules (a worker needs a
6
+ // handler, a data source needs driver+url, a typed table needs fields, a trigger needs
7
+ // id+type). Reading the schema means the envelope check tracks the schema and can't drift
8
+ // from it silently.
9
+
10
+ import schema from "../schema/nano-app.schema.json" with { type: "json" };
11
+ import { workerJobType, type AppManifest } from "./manifest.ts";
12
+
13
+ export interface ValidationIssue {
14
+ path: string;
15
+ message: string;
16
+ }
17
+
18
+ export class ManifestValidationError extends Error {
19
+ readonly issues: ValidationIssue[];
20
+ constructor(issues: ValidationIssue[]) {
21
+ super(
22
+ `Invalid Urban manifest (${issues.length} issue${issues.length === 1 ? "" : "s"}):\n` +
23
+ issues.map((i) => ` • ${i.path}: ${i.message}`).join("\n"),
24
+ );
25
+ this.name = "ManifestValidationError";
26
+ this.issues = issues;
27
+ }
28
+ }
29
+
30
+ interface JsonSchema {
31
+ required?: string[];
32
+ properties?: Record<string, unknown>;
33
+ additionalProperties?: boolean;
34
+ $defs?: { slug?: { pattern?: string } };
35
+ }
36
+
37
+ const S = schema as unknown as JsonSchema;
38
+
39
+ /** Validate a parsed manifest. Returns the list of issues (empty === valid). */
40
+ export function collectManifestIssues(m: unknown): ValidationIssue[] {
41
+ const issues: ValidationIssue[] = [];
42
+ if (m === null || typeof m !== "object" || Array.isArray(m)) {
43
+ return [{ path: "$", message: "manifest must be a JSON object" }];
44
+ }
45
+ const obj = m as Record<string, unknown>;
46
+
47
+ // — Envelope, driven by the schema —
48
+ for (const req of S.required ?? []) {
49
+ if (!(req in obj)) issues.push({ path: req, message: "required by nano-app.schema.json" });
50
+ }
51
+ const allowed = new Set(Object.keys(S.properties ?? {}));
52
+ if (S.additionalProperties === false) {
53
+ for (const k of Object.keys(obj)) {
54
+ if (!allowed.has(k)) {
55
+ issues.push({ path: k, message: "unknown top-level key (additionalProperties: false)" });
56
+ }
57
+ }
58
+ }
59
+ if ("schemaVersion" in obj && obj.schemaVersion !== 1) {
60
+ issues.push({ path: "schemaVersion", message: "must be 1" });
61
+ }
62
+ const slugPattern = S.$defs?.slug?.pattern;
63
+ if (slugPattern && typeof obj.id === "string" && !new RegExp(slugPattern).test(obj.id)) {
64
+ issues.push({ path: "id", message: `must match slug pattern ${slugPattern}` });
65
+ }
66
+ if ("name" in obj && (typeof obj.name !== "string" || obj.name.length === 0)) {
67
+ issues.push({ path: "name", message: "must be a non-empty string" });
68
+ }
69
+
70
+ // — Runtime binding rules —
71
+ const man = obj as unknown as AppManifest;
72
+
73
+ if (Array.isArray(man.workers)) {
74
+ man.workers.forEach((w, i) => {
75
+ if (!workerJobType(w)) {
76
+ issues.push({ path: `workers[${i}]`, message: "missing taskType/type" });
77
+ }
78
+ // The schema allows a worker backed by either a `handler` file or an `llm`
79
+ // binding (oneOf). Require exactly one — don't force `handler`.
80
+ const hasHandler = typeof w?.handler === "string" && w.handler.length > 0;
81
+ const hasLlm = typeof w?.llm === "string" && w.llm.length > 0;
82
+ if (!hasHandler && !hasLlm) {
83
+ issues.push({ path: `workers[${i}]`, message: "worker requires a `handler` or `llm`" });
84
+ } else if (hasHandler && hasLlm) {
85
+ issues.push({ path: `workers[${i}]`, message: "worker declares both `handler` and `llm` (mutually exclusive)" });
86
+ }
87
+ });
88
+ }
89
+
90
+ if (man.data?.sources) {
91
+ for (const [name, src] of Object.entries(man.data.sources)) {
92
+ if (!src || typeof src.driver !== "string") {
93
+ issues.push({ path: `data.sources.${name}.driver`, message: "missing driver" });
94
+ }
95
+ if (!src || typeof src.url !== "string") {
96
+ issues.push({ path: `data.sources.${name}.url`, message: "missing url" });
97
+ }
98
+ }
99
+ if (man.data.default && !man.data.sources[man.data.default]) {
100
+ issues.push({ path: "data.default", message: `no such source "${man.data.default}"` });
101
+ }
102
+ }
103
+
104
+ if (man.types) {
105
+ for (const [name, t] of Object.entries(man.types)) {
106
+ // `table` is optional in the schema: a type may declare `fields` without a
107
+ // `table` (a transient / non-persisted domain type). Don't require it.
108
+ if (t && typeof t !== "object") {
109
+ issues.push({ path: `types.${name}`, message: "must be an object" });
110
+ }
111
+ }
112
+ }
113
+
114
+ if (Array.isArray(man.triggers)) {
115
+ man.triggers.forEach((t, i) => {
116
+ if (!t?.id) issues.push({ path: `triggers[${i}].id`, message: "missing id" });
117
+ if (!t?.type) issues.push({ path: `triggers[${i}].type`, message: "missing type" });
118
+ });
119
+ }
120
+
121
+ return issues;
122
+ }
123
+
124
+ /** Throw ManifestValidationError if the manifest is invalid; otherwise return it typed. */
125
+ export function validateManifest(m: unknown): AppManifest {
126
+ const issues = collectManifestIssues(m);
127
+ if (issues.length > 0) throw new ManifestValidationError(issues);
128
+ return m as AppManifest;
129
+ }