@nanobpm/urban 0.15.0 → 0.16.1

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 CHANGED
@@ -46,6 +46,14 @@ function parse(argv) {
46
46
  f.write = true;
47
47
  else if (a === "--deno")
48
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
+ }
49
57
  else if (a === "--")
50
58
  continue; // tolerate a bare "--" some runners inject (not an option terminator here)
51
59
  else if (a === "--root")
@@ -70,7 +78,8 @@ function parse(argv) {
70
78
  const USAGE = `urban — build and run Urban apps (nano.app.json)
71
79
 
72
80
  Usage:
73
- 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)
74
83
  urban check validate the manifest
75
84
  urban gen [--check] derive artifacts (migrations, worker-io)
76
85
  urban stubs [--write] scaffold write-once handler stubs from the model
@@ -183,14 +192,16 @@ async function cmdDeploy(f) {
183
192
  async function cmdNew(f) {
184
193
  const name = f._[1];
185
194
  if (!name) {
186
- 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)");
187
196
  return 1;
188
197
  }
189
198
  const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
190
- const res = await scaffold({ name, dir, deno: f.deno });
199
+ const res = await scaffold({ name, dir, deno: f.deno, style: f.style });
191
200
  console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
192
201
  console.log(` cd ${res.dir}`);
193
202
  console.log(` npm install && npm start`);
203
+ if (f.style === "code")
204
+ console.log(` npm run greet -- Adam # start a code-first instance`);
194
205
  if (f.deno)
195
206
  console.log(` # or on Deno: deno task start`);
196
207
  return 0;
@@ -48,8 +48,17 @@ export class TypeRepo {
48
48
  }
49
49
  insert(row) {
50
50
  this.assertFields(row);
51
- const keys = Object.keys(row);
51
+ // Omit keys whose value is `undefined` so the column's own `DEFAULT`/`NULL` governs;
52
+ // `undefined` means "not provided", never a bound value. An explicit `null` is preserved.
53
+ const provided = Object.keys(row);
54
+ const keys = provided.filter((k) => row[k] !== undefined);
52
55
  if (keys.length === 0) {
56
+ // A present-but-all-undefined payload throws (parity with Table.insert): the caller
57
+ // meant to write those values, so silently inserting a full DEFAULT row would create
58
+ // an unintended record. A genuinely empty `insert({})` is the explicit "default row".
59
+ if (provided.length > 0) {
60
+ throw new Error(`insert into type "${this.typeName}": no columns to insert (all values were undefined)`);
61
+ }
53
62
  return this.db.run(`INSERT INTO ${this.table()} DEFAULT VALUES`, []);
54
63
  }
55
64
  const cols = keys.join(", ");
@@ -59,8 +59,10 @@ export declare class Table<T extends object = Row> {
59
59
  readonly name: string;
60
60
  readonly pk: string;
61
61
  constructor(src: DataSource, name: string, pk?: string);
62
- /** Insert one row (only the present keys are written); returns the new primary-key value
63
- * (the inserted rowid for an INTEGER PRIMARY KEY). */
62
+ /** Insert one row; returns the new primary-key value (the inserted rowid for an INTEGER
63
+ * PRIMARY KEY). Keys whose value is `undefined` are omitted, so the table's own column
64
+ * `DEFAULT`/`NULL` governs — `undefined` means "not provided, let the schema decide", never
65
+ * a bound value. An explicit `null` is preserved (it stores `NULL`). */
64
66
  insert(row: Partial<T>): Promise<number | bigint>;
65
67
  /** Fetch the row with the given primary key, or `undefined`. */
66
68
  get(id: unknown): Promise<T | undefined>;
@@ -70,7 +72,9 @@ export declare class Table<T extends object = Row> {
70
72
  find(where?: Partial<T>): Promise<T[]>;
71
73
  /** The first row matching an equality filter, or `undefined`. */
72
74
  findOne(where?: Partial<T>): Promise<T | undefined>;
73
- /** Patch the row with the given primary key; returns rows changed. */
75
+ /** Patch the row with the given primary key; returns rows changed. Keys whose value is
76
+ * `undefined` are skipped (that column is left unchanged); an explicit `null` clears the
77
+ * column to `NULL`. A patch with no defined keys is a no-op. */
74
78
  update(id: unknown, patch: Partial<T>): Promise<number>;
75
79
  /** Delete the row with the given primary key; returns rows changed. */
76
80
  delete(id: unknown): Promise<number>;
@@ -40,12 +40,17 @@ export class Table {
40
40
  this.name = name;
41
41
  this.pk = pk;
42
42
  }
43
- /** Insert one row (only the present keys are written); returns the new primary-key value
44
- * (the inserted rowid for an INTEGER PRIMARY KEY). */
43
+ /** Insert one row; returns the new primary-key value (the inserted rowid for an INTEGER
44
+ * PRIMARY KEY). Keys whose value is `undefined` are omitted, so the table's own column
45
+ * `DEFAULT`/`NULL` governs — `undefined` means "not provided, let the schema decide", never
46
+ * a bound value. An explicit `null` is preserved (it stores `NULL`). */
45
47
  async insert(row) {
46
- const keys = Object.keys(row);
48
+ const provided = Object.keys(row);
49
+ const keys = provided.filter((k) => row[k] !== undefined);
47
50
  if (keys.length === 0) {
48
- throw new Error(`Table(${this.name}).insert: no columns to insert`);
51
+ throw new Error(provided.length === 0
52
+ ? `Table(${this.name}).insert: no columns to insert (empty row)`
53
+ : `Table(${this.name}).insert: no columns to insert (all values were undefined)`);
49
54
  }
50
55
  const cols = keys.map(quoteIdent).join(", ");
51
56
  const ph = keys.map(() => "?").join(", ");
@@ -80,9 +85,11 @@ export class Table {
80
85
  const rows = await this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${clause} LIMIT 1`, params);
81
86
  return rows[0];
82
87
  }
83
- /** Patch the row with the given primary key; returns rows changed. */
88
+ /** Patch the row with the given primary key; returns rows changed. Keys whose value is
89
+ * `undefined` are skipped (that column is left unchanged); an explicit `null` clears the
90
+ * column to `NULL`. A patch with no defined keys is a no-op. */
84
91
  async update(id, patch) {
85
- const keys = Object.keys(patch);
92
+ const keys = Object.keys(patch).filter((k) => patch[k] !== undefined);
86
93
  if (keys.length === 0)
87
94
  return 0;
88
95
  const set = keys.map((k) => `${quoteIdent(k)} = ?`).join(", ");
@@ -43,7 +43,7 @@ export function renderWorkerStub(taskType, typedIn, typedOut) {
43
43
  return (`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
44
44
  `// This file is yours to edit — \`urban stubs\` will never overwrite it. Implement the body\n` +
45
45
  `// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
46
- `import type { AppJobHandler } from "@nanobpm/urban";\n` +
46
+ `import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
47
47
  generatedImport +
48
48
  `\n` +
49
49
  `const handler: AppJobHandler${generic} = async (job, app) => {\n` +
@@ -0,0 +1,3 @@
1
+ export type { AppJobHandler } from "./runtime/core/modules/workers.ts";
2
+ export type { EngineJob, JobHandler } from "./runtime/core/host.ts";
3
+ export type { AppApi } from "./runtime/core/context.ts";
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.15.0",
3
+ "version": "0.16.1",
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.0",
65
+ "@nanobpm/nano-sdk": "^1.2.3",
62
66
  "@nanobpm/workflow": "^0.7.0",
63
67
  "bpmn-auto-layout": "^2.0.0-alpha.2",
64
- "create-urban-app": "^0.4.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
@@ -44,6 +44,7 @@ interface Flags {
44
44
  check: boolean;
45
45
  write: boolean;
46
46
  deno: boolean;
47
+ style?: "model" | "code";
47
48
  help: boolean;
48
49
  version: boolean;
49
50
  _: string[];
@@ -63,6 +64,12 @@ function parse(argv: string[]): Flags {
63
64
  else if (a === "--check") f.check = true;
64
65
  else if (a === "--write") f.write = true;
65
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
+ }
66
73
  else if (a === "--") continue; // tolerate a bare "--" some runners inject (not an option terminator here)
67
74
  else if (a === "--root") f.root = need(++i, a);
68
75
  else if (a === "--manifest") f.manifest = need(++i, a);
@@ -82,7 +89,8 @@ function parse(argv: string[]): Flags {
82
89
  const USAGE = `urban — build and run Urban apps (nano.app.json)
83
90
 
84
91
  Usage:
85
- 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)
86
94
  urban check validate the manifest
87
95
  urban gen [--check] derive artifacts (migrations, worker-io)
88
96
  urban stubs [--write] scaffold write-once handler stubs from the model
@@ -197,14 +205,15 @@ async function cmdDeploy(f: Flags): Promise<number> {
197
205
  async function cmdNew(f: Flags): Promise<number> {
198
206
  const name = f._[1];
199
207
  if (!name) {
200
- 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)");
201
209
  return 1;
202
210
  }
203
211
  const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
204
- const res = await scaffold({ name, dir, deno: f.deno });
212
+ const res = await scaffold({ name, dir, deno: f.deno, style: f.style });
205
213
  console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
206
214
  console.log(` cd ${res.dir}`);
207
215
  console.log(` npm install && npm start`);
216
+ if (f.style === "code") console.log(` npm run greet -- Adam # start a code-first instance`);
208
217
  if (f.deno) console.log(` # or on Deno: deno task start`);
209
218
  return 0;
210
219
  }
@@ -60,8 +60,19 @@ export class TypeRepo {
60
60
 
61
61
  insert(row: Record<string, unknown>): { changes: number; lastInsertRowid: number | bigint } {
62
62
  this.assertFields(row);
63
- const keys = Object.keys(row);
63
+ // Omit keys whose value is `undefined` so the column's own `DEFAULT`/`NULL` governs;
64
+ // `undefined` means "not provided", never a bound value. An explicit `null` is preserved.
65
+ const provided = Object.keys(row);
66
+ const keys = provided.filter((k) => row[k] !== undefined);
64
67
  if (keys.length === 0) {
68
+ // A present-but-all-undefined payload throws (parity with Table.insert): the caller
69
+ // meant to write those values, so silently inserting a full DEFAULT row would create
70
+ // an unintended record. A genuinely empty `insert({})` is the explicit "default row".
71
+ if (provided.length > 0) {
72
+ throw new Error(
73
+ `insert into type "${this.typeName}": no columns to insert (all values were undefined)`,
74
+ );
75
+ }
65
76
  return this.db.run(`INSERT INTO ${this.table()} DEFAULT VALUES`, []);
66
77
  }
67
78
  const cols = keys.join(", ");
@@ -162,3 +162,60 @@ test("query/exec run raw parameterised SQL", async () => {
162
162
  assert.equal(Number((rows[0] as { total: number }).total), 42);
163
163
  });
164
164
  });
165
+
166
+ test("insert omits undefined-valued keys so the column DEFAULT applies", async () => {
167
+ await withGateway(async (src) => {
168
+ const orders = src.table<Order>("orders");
169
+ // `total` is `NOT NULL DEFAULT 0`; passing it as undefined must omit the column
170
+ // (not bind NULL, which would violate NOT NULL), letting the DEFAULT fill it.
171
+ const id = await orders.insert({ status: "new", total: undefined });
172
+ assert.equal((await orders.get(id))?.total, 0);
173
+ });
174
+ });
175
+
176
+ test("insert preserves an explicit null; omits undefined", async () => {
177
+ await withGateway(async (src) => {
178
+ await src.exec("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT, tag TEXT DEFAULT 'none')");
179
+ const notes = src.table("notes");
180
+ const id = await notes.insert({ body: null, tag: undefined });
181
+ const row = await notes.get(id);
182
+ assert.equal(row?.body, null); // explicit null is stored as NULL
183
+ assert.equal(row?.tag, "none"); // undefined omitted → column DEFAULT
184
+ });
185
+ });
186
+
187
+ test("update skips undefined keys and clears on explicit null", async () => {
188
+ await withGateway(async (src) => {
189
+ await src.exec("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT, tag TEXT DEFAULT 'none')");
190
+ const notes = src.table("notes");
191
+ const id = await notes.insert({ body: "hello", tag: "x" });
192
+ // tag:undefined must be left unchanged; body updated.
193
+ await notes.update(id, { tag: undefined, body: "world" });
194
+ let row = await notes.get(id);
195
+ assert.equal(row?.body, "world");
196
+ assert.equal(row?.tag, "x");
197
+ // explicit null clears the column.
198
+ await notes.update(id, { body: null });
199
+ row = await notes.get(id);
200
+ assert.equal(row?.body, null);
201
+ // a patch of only-undefined keys is a no-op.
202
+ assert.equal(await notes.update(id, { tag: undefined }), 0);
203
+ });
204
+ });
205
+
206
+ test("insert throws when every provided value is undefined", async () => {
207
+ await withGateway(async (src) => {
208
+ const orders = src.table<Order>("orders");
209
+ await assert.rejects(
210
+ orders.insert({ status: undefined, total: undefined }),
211
+ /all values were undefined/,
212
+ );
213
+ });
214
+ });
215
+
216
+ test("insert throws a distinct message for a genuinely empty row", async () => {
217
+ await withGateway(async (src) => {
218
+ const orders = src.table<Order>("orders");
219
+ await assert.rejects(orders.insert({} as Partial<Order>), /empty row/);
220
+ });
221
+ });
@@ -100,12 +100,19 @@ export class Table<T extends object = Row> {
100
100
  this.pk = pk;
101
101
  }
102
102
 
103
- /** Insert one row (only the present keys are written); returns the new primary-key value
104
- * (the inserted rowid for an INTEGER PRIMARY KEY). */
103
+ /** Insert one row; returns the new primary-key value (the inserted rowid for an INTEGER
104
+ * PRIMARY KEY). Keys whose value is `undefined` are omitted, so the table's own column
105
+ * `DEFAULT`/`NULL` governs — `undefined` means "not provided, let the schema decide", never
106
+ * a bound value. An explicit `null` is preserved (it stores `NULL`). */
105
107
  async insert(row: Partial<T>): Promise<number | bigint> {
106
- const keys = Object.keys(row);
108
+ const provided = Object.keys(row);
109
+ const keys = provided.filter((k) => (row as Row)[k] !== undefined);
107
110
  if (keys.length === 0) {
108
- throw new Error(`Table(${this.name}).insert: no columns to insert`);
111
+ throw new Error(
112
+ provided.length === 0
113
+ ? `Table(${this.name}).insert: no columns to insert (empty row)`
114
+ : `Table(${this.name}).insert: no columns to insert (all values were undefined)`,
115
+ );
109
116
  }
110
117
  const cols = keys.map(quoteIdent).join(", ");
111
118
  const ph = keys.map(() => "?").join(", ");
@@ -158,9 +165,11 @@ export class Table<T extends object = Row> {
158
165
  return rows[0] as T | undefined;
159
166
  }
160
167
 
161
- /** Patch the row with the given primary key; returns rows changed. */
168
+ /** Patch the row with the given primary key; returns rows changed. Keys whose value is
169
+ * `undefined` are skipped (that column is left unchanged); an explicit `null` clears the
170
+ * column to `NULL`. A patch with no defined keys is a no-op. */
162
171
  async update(id: unknown, patch: Partial<T>): Promise<number> {
163
- const keys = Object.keys(patch);
172
+ const keys = Object.keys(patch).filter((k) => (patch as Row)[k] !== undefined);
164
173
  if (keys.length === 0) return 0;
165
174
  const set = keys.map((k) => `${quoteIdent(k)} = ?`).join(", ");
166
175
  const r = await this.#src.exec(
@@ -152,6 +152,12 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
152
152
 
153
153
  // field-drift guard
154
154
  assert.throws(() => app.data!.repo("task").insert({ bogus: 1 }));
155
+ // all-declared-but-undefined payload throws (parity with Table.insert): the caller
156
+ // meant to write those columns, so we don't silently insert a DEFAULT VALUES row.
157
+ assert.throws(
158
+ () => app.data!.repo("task").insert({ title: undefined, status: undefined }),
159
+ /all values were undefined/,
160
+ );
155
161
  } finally {
156
162
  await app.stop();
157
163
  await rm(dir, { recursive: true, force: true });
@@ -40,6 +40,7 @@ test("slugifyTaskType makes a filesystem-safe slug", () => {
40
40
 
41
41
  test("typed in+out stub imports both symbols and parameterises the handler", () => {
42
42
  const stub = renderWorkerStub("pr.finalize", true, true);
43
+ assert.match(stub, /import type \{ AppJobHandler \} from "@nanobpm\/urban\/worker";/);
43
44
  assert.match(stub, /import type \{ WorkerInputs, WorkerOutputs \} from "\.\.\/\.\.\/nano-generated\/worker-io\.d\.ts";/);
44
45
  assert.match(stub, /AppJobHandler<WorkerInputs\["pr\.finalize"\], WorkerOutputs\["pr\.finalize"\]>/);
45
46
  assert.match(stub, /export default handler;/);
@@ -94,7 +94,7 @@ export function renderWorkerStub(
94
94
  `// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
95
95
  `// This file is yours to edit — \`urban stubs\` will never overwrite it. Implement the body\n` +
96
96
  `// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
97
- `import type { AppJobHandler } from "@nanobpm/urban";\n` +
97
+ `import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
98
98
  generatedImport +
99
99
  `\n` +
100
100
  `const handler: AppJobHandler${generic} = async (job, app) => {\n` +
@@ -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";