@nanobpm/urban 0.21.0 → 0.22.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 (97) hide show
  1. package/README.md +35 -0
  2. package/dist/cli.d.ts +26 -0
  3. package/dist/cli.js +80 -16
  4. package/dist/runtime/adapters/deno.js +19 -3
  5. package/dist/runtime/adapters/detect.js +2 -2
  6. package/dist/runtime/adapters/globals.d.ts +19 -0
  7. package/dist/runtime/adapters/globals.js +9 -0
  8. package/dist/runtime/adapters/node.js +25 -5
  9. package/dist/runtime/core/context.d.ts +4 -0
  10. package/dist/runtime/core/guards.d.ts +2 -0
  11. package/dist/runtime/core/guards.js +10 -0
  12. package/dist/runtime/core/host.js +5 -4
  13. package/dist/runtime/core/manifest.d.ts +8 -0
  14. package/dist/runtime/core/manifest.js +0 -1
  15. package/dist/runtime/core/modules/actions.js +9 -5
  16. package/dist/runtime/core/modules/connectors.js +6 -4
  17. package/dist/runtime/core/modules/dataops.d.ts +36 -0
  18. package/dist/runtime/core/modules/dataops.js +231 -0
  19. package/dist/runtime/core/modules/datasource.d.ts +51 -1
  20. package/dist/runtime/core/modules/datasource.js +98 -21
  21. package/dist/runtime/core/modules/deploy.d.ts +3 -1
  22. package/dist/runtime/core/modules/deploy.js +29 -7
  23. package/dist/runtime/core/modules/gateway.d.ts +1 -1
  24. package/dist/runtime/core/modules/gateway.js +16 -17
  25. package/dist/runtime/core/modules/llm.js +9 -7
  26. package/dist/runtime/core/modules/pages.js +42 -15
  27. package/dist/runtime/core/modules/security.js +5 -5
  28. package/dist/runtime/core/modules/templates.d.ts +18 -0
  29. package/dist/runtime/core/modules/templates.js +133 -0
  30. package/dist/runtime/core/modules/triggers.js +14 -5
  31. package/dist/runtime/core/modules/workers.js +15 -9
  32. package/dist/runtime/core/runtime.d.ts +5 -0
  33. package/dist/runtime/core/runtime.js +5 -5
  34. package/dist/runtime/core/validate.d.ts +2 -1
  35. package/dist/runtime/core/validate.js +38 -28
  36. package/dist/runtime/devserver.js +2 -1
  37. package/dist/runtime/engine/nanosdk.d.ts +1 -1
  38. package/dist/runtime/engine/nanosdk.js +15 -9
  39. package/dist/runtime/index.d.ts +5 -0
  40. package/dist/runtime/index.js +4 -0
  41. package/dist/runtime/run.js +10 -7
  42. package/dist/toolkit/addConnector.js +4 -3
  43. package/dist/toolkit/fsio.js +2 -1
  44. package/dist/toolkit/models.js +3 -5
  45. package/package.json +1 -1
  46. package/src/cli.test.ts +44 -0
  47. package/src/cli.ts +82 -16
  48. package/src/runtime/adapters/deno.ts +23 -3
  49. package/src/runtime/adapters/detect.ts +2 -2
  50. package/src/runtime/adapters/globals.ts +27 -0
  51. package/src/runtime/adapters/node.ts +27 -4
  52. package/src/runtime/connector-worker-sdk.test.ts +2 -1
  53. package/src/runtime/core/context.ts +4 -0
  54. package/src/runtime/core/guards.ts +10 -0
  55. package/src/runtime/core/host.ts +4 -4
  56. package/src/runtime/core/manifest.ts +19 -5
  57. package/src/runtime/core/modules/actions.test.ts +82 -31
  58. package/src/runtime/core/modules/actions.ts +11 -7
  59. package/src/runtime/core/modules/connectors.test.ts +29 -8
  60. package/src/runtime/core/modules/connectors.ts +6 -4
  61. package/src/runtime/core/modules/dataops.test.ts +306 -0
  62. package/src/runtime/core/modules/dataops.ts +292 -0
  63. package/src/runtime/core/modules/datasource.migrations.test.ts +12 -3
  64. package/src/runtime/core/modules/datasource.test.ts +129 -0
  65. package/src/runtime/core/modules/datasource.ts +105 -23
  66. package/src/runtime/core/modules/deploy.test.ts +258 -0
  67. package/src/runtime/core/modules/deploy.ts +29 -7
  68. package/src/runtime/core/modules/gateway.test.ts +5 -4
  69. package/src/runtime/core/modules/gateway.ts +29 -30
  70. package/src/runtime/core/modules/llm.test.ts +10 -12
  71. package/src/runtime/core/modules/llm.ts +11 -9
  72. package/src/runtime/core/modules/pages.test.ts +22 -2
  73. package/src/runtime/core/modules/pages.ts +45 -22
  74. package/src/runtime/core/modules/security.ts +5 -13
  75. package/src/runtime/core/modules/surfaces.test.ts +53 -10
  76. package/src/runtime/core/modules/templates.ts +153 -0
  77. package/src/runtime/core/modules/triggers.test.ts +51 -19
  78. package/src/runtime/core/modules/triggers.ts +14 -6
  79. package/src/runtime/core/modules/workers.test.ts +43 -20
  80. package/src/runtime/core/modules/workers.ts +18 -10
  81. package/src/runtime/core/runtime.test.ts +23 -10
  82. package/src/runtime/core/runtime.ts +15 -2
  83. package/src/runtime/core/validate.ts +43 -32
  84. package/src/runtime/devserver.test.ts +18 -5
  85. package/src/runtime/devserver.ts +2 -1
  86. package/src/runtime/engine/nanosdk.test.ts +16 -11
  87. package/src/runtime/engine/nanosdk.ts +22 -15
  88. package/src/runtime/index.ts +7 -0
  89. package/src/runtime/run.ts +10 -10
  90. package/src/toolkit/addConnector.ts +6 -5
  91. package/src/toolkit/derivers/messages.test.ts +4 -2
  92. package/src/toolkit/derivers/worker-io.test.ts +4 -2
  93. package/src/toolkit/fsio.ts +2 -1
  94. package/src/toolkit/gen.ts +4 -4
  95. package/src/toolkit/models.test.ts +1 -1
  96. package/src/toolkit/models.ts +3 -4
  97. package/src/toolkit/scaffold.ts +1 -1
@@ -9,7 +9,8 @@
9
9
  // silently — the whole point of consuming the package rather than vendoring a copy.
10
10
 
11
11
  import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
12
- import { workerJobType, type AppManifest } from "./manifest.ts";
12
+ import { isRecord } from "./guards.ts";
13
+ import type { AppManifest } from "./manifest.ts";
13
14
 
14
15
  export interface ValidationIssue {
15
16
  path: string;
@@ -35,15 +36,15 @@ interface JsonSchema {
35
36
  $defs?: { slug?: { pattern?: string } };
36
37
  }
37
38
 
38
- const S = schema as unknown as JsonSchema;
39
+ const S: JsonSchema = schema;
39
40
 
40
41
  /** Validate a parsed manifest. Returns the list of issues (empty === valid). */
41
42
  export function collectManifestIssues(m: unknown): ValidationIssue[] {
42
43
  const issues: ValidationIssue[] = [];
43
- if (m === null || typeof m !== "object" || Array.isArray(m)) {
44
+ if (!isRecord(m)) {
44
45
  return [{ path: "$", message: "manifest must be a JSON object" }];
45
46
  }
46
- const obj = m as Record<string, unknown>;
47
+ const obj = m;
47
48
 
48
49
  // — Envelope, driven by the schema —
49
50
  for (const req of S.required ?? []) {
@@ -69,11 +70,11 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
69
70
  }
70
71
 
71
72
  // — Runtime binding rules —
72
- const man = obj as unknown as AppManifest;
73
-
74
- if (Array.isArray(man.workers)) {
75
- man.workers.forEach((w, i) => {
76
- if (!workerJobType(w)) {
73
+ const workers = Array.isArray(obj.workers) ? obj.workers : undefined;
74
+ if (workers) {
75
+ workers.forEach((w, i) => {
76
+ const worker = isRecord(w) ? w : undefined;
77
+ if (typeof worker?.taskType !== "string" || worker.taskType.length === 0) {
77
78
  issues.push({ path: `workers[${i}]`, message: "missing taskType" });
78
79
  }
79
80
  // A worker is backed by exactly one of: a `handler` file, an `llm` binding,
@@ -82,9 +83,9 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
82
83
  // `connector` actually resolves to an installed pack is a runtime seam check
83
84
  // (mountConnectors), not something this static manifest check can see.
84
85
  const backings: string[] = [];
85
- if (typeof w?.handler === "string" && w.handler.length > 0) backings.push("handler");
86
- if (typeof w?.llm === "string" && w.llm.length > 0) backings.push("llm");
87
- if (typeof w?.connector === "string" && w.connector.length > 0) backings.push("connector");
86
+ if (typeof worker?.handler === "string" && worker.handler.length > 0) backings.push("handler");
87
+ if (typeof worker?.llm === "string" && worker.llm.length > 0) backings.push("llm");
88
+ if (typeof worker?.connector === "string" && worker.connector.length > 0) backings.push("connector");
88
89
  if (backings.length === 0) {
89
90
  issues.push({
90
91
  path: `workers[${i}]`,
@@ -97,37 +98,41 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
97
98
  });
98
99
  }
99
100
  // A `connection` must reference a declared top-level `connections` entry.
100
- if (typeof w?.connection === "string" && w.connection.length > 0) {
101
+ if (typeof worker?.connection === "string" && worker.connection.length > 0) {
101
102
  const conns =
102
- man.connections && typeof man.connections === "object" && !Array.isArray(man.connections)
103
- ? (man.connections as Record<string, unknown>)
103
+ isRecord(obj.connections)
104
+ ? obj.connections
104
105
  : undefined;
105
- if (!conns || !(w.connection in conns)) {
106
+ if (!conns || !(worker.connection in conns)) {
106
107
  issues.push({
107
108
  path: `workers[${i}].connection`,
108
- message: `no such connection "${w.connection}" (add it to connections)`,
109
+ message: `no such connection "${worker.connection}" (add it to connections)`,
109
110
  });
110
111
  }
111
112
  }
112
113
  });
113
114
  }
114
115
 
115
- if (man.data?.sources) {
116
- for (const [name, src] of Object.entries(man.data.sources)) {
117
- if (!src || typeof src.driver !== "string") {
116
+ const data = isRecord(obj.data) ? obj.data : undefined;
117
+ const sources = isRecord(data?.sources) ? data.sources : undefined;
118
+ if (sources) {
119
+ for (const [name, src] of Object.entries(sources)) {
120
+ const source = isRecord(src) ? src : undefined;
121
+ if (!source || typeof source.driver !== "string") {
118
122
  issues.push({ path: `data.sources.${name}.driver`, message: "missing driver" });
119
123
  }
120
- if (!src || typeof src.url !== "string") {
124
+ if (!source || typeof source.url !== "string") {
121
125
  issues.push({ path: `data.sources.${name}.url`, message: "missing url" });
122
126
  }
123
127
  }
124
- if (man.data.default && !man.data.sources[man.data.default]) {
125
- issues.push({ path: "data.default", message: `no such source "${man.data.default}"` });
128
+ if (typeof data?.default === "string" && !sources[data.default]) {
129
+ issues.push({ path: "data.default", message: `no such source "${data.default}"` });
126
130
  }
127
131
  }
128
132
 
129
- if (man.types) {
130
- for (const [name, t] of Object.entries(man.types)) {
133
+ const types = isRecord(obj.types) ? obj.types : undefined;
134
+ if (types) {
135
+ for (const [name, t] of Object.entries(types)) {
131
136
  // `table` is optional in the schema: a type may declare `fields` without a
132
137
  // `table` (a transient / non-persisted domain type). Don't require it.
133
138
  if (t && typeof t !== "object") {
@@ -136,19 +141,25 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
136
141
  }
137
142
  }
138
143
 
139
- if (Array.isArray(man.triggers)) {
140
- man.triggers.forEach((t, i) => {
141
- if (!t?.id) issues.push({ path: `triggers[${i}].id`, message: "missing id" });
142
- if (!t?.type) issues.push({ path: `triggers[${i}].type`, message: "missing type" });
144
+ const triggers = Array.isArray(obj.triggers) ? obj.triggers : undefined;
145
+ if (triggers) {
146
+ triggers.forEach((t, i) => {
147
+ const trigger = isRecord(t) ? t : undefined;
148
+ if (!trigger?.id) issues.push({ path: `triggers[${i}].id`, message: "missing id" });
149
+ if (!trigger?.type) issues.push({ path: `triggers[${i}].type`, message: "missing type" });
143
150
  });
144
151
  }
145
152
 
146
153
  return issues;
147
154
  }
148
155
 
149
- /** Throw ManifestValidationError if the manifest is invalid; otherwise return it typed. */
150
- export function validateManifest(m: unknown): AppManifest {
156
+ export function assertValidManifest(m: unknown): asserts m is AppManifest {
151
157
  const issues = collectManifestIssues(m);
152
158
  if (issues.length > 0) throw new ManifestValidationError(issues);
153
- return m as AppManifest;
159
+ }
160
+
161
+ /** Throw ManifestValidationError if the manifest is invalid; otherwise return it typed. */
162
+ export function validateManifest(m: unknown): AppManifest {
163
+ assertValidManifest(m);
164
+ return m;
154
165
  }
@@ -92,13 +92,14 @@ test("stop() during an in-flight reload does not start a replacement app", async
92
92
  const id = `a${starts.length}`;
93
93
  starts.push(id);
94
94
  if (id === "a0") {
95
- return {
95
+ const app: UrbanApp = {
96
96
  ...fakeApp(stops, id),
97
97
  async stop() {
98
98
  await stopGate; // block the first reload inside app.stop()
99
99
  stops.push(id);
100
100
  },
101
- } as UrbanApp;
101
+ };
102
+ return app;
102
103
  }
103
104
  return fakeApp(stops, id);
104
105
  },
@@ -195,18 +196,30 @@ test("a change during an in-flight reload doesn't detach stop() from it", async
195
196
  function fakeHost(): { host: HostContext; fire: (p: string) => void; closed: () => boolean } {
196
197
  let cb: ((p: string) => void) | undefined;
197
198
  let closed = false;
198
- const host = {
199
+ const host: HostContext = {
200
+ runtime: "node",
201
+ env: () => undefined,
202
+ readTextFile: async () => "",
203
+ listDir: async () => [],
204
+ exists: async () => false,
205
+ openSqlite: () => {
206
+ throw new Error("sqlite not used in this test");
207
+ },
208
+ importModule: async () => ({}),
209
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
199
210
  watch(onChange: (p: string) => void) {
200
211
  cb = onChange;
201
212
  return { close: () => (closed = true) };
202
213
  },
203
- } as unknown as HostContext;
214
+ now: () => 0,
215
+ log: () => {},
216
+ };
204
217
  return { host, fire: (p) => cb?.(p), closed: () => closed };
205
218
  }
206
219
 
207
220
  function fakeApp(stops: string[], id: string): UrbanApp {
208
221
  return {
209
- manifest: { id } as UrbanApp["manifest"],
222
+ manifest: { schemaVersion: 1, id, name: id },
210
223
  root: ".",
211
224
  async start() {},
212
225
  async stop() {
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { runFromEnv } from "./run.ts";
15
15
  import { selectHost } from "./adapters/detect.ts";
16
+ import { errorMessage } from "./core/guards.ts";
16
17
  import type { HostContext, WatchHandle } from "./core/host.ts";
17
18
  import type { UrbanApp } from "./core/runtime.ts";
18
19
  import { createNodeGenIO, runGen } from "../toolkit/index.ts";
@@ -136,7 +137,7 @@ export async function runDev(opts: DevOptions = {}, deps?: Partial<DevDeps>): Pr
136
137
  log("✔ reloaded");
137
138
  } catch (err) {
138
139
  // Keep the dev server alive on a bad edit; the next save will retry.
139
- log(`✖ reload failed: ${String((err as Error)?.message ?? err)}`);
140
+ log(`✖ reload failed: ${errorMessage(err)}`);
140
141
  } finally {
141
142
  reloading = false;
142
143
  if (queued && !stopped) {
@@ -224,7 +224,7 @@ test("registerWorker creates a worker the SDK auto-starts, and dispatches + comp
224
224
  assert.equal(rec.started, 1);
225
225
 
226
226
  let completedWith: Record<string, unknown> | undefined;
227
- await rec.dispatch({
227
+ const job: NanoSdkActivatedJob = {
228
228
  jobKey: 12,
229
229
  processInstanceKey: 34,
230
230
  elementId: "e1",
@@ -236,7 +236,8 @@ test("registerWorker creates a worker the SDK auto-starts, and dispatches + comp
236
236
  async fail() {
237
237
  throw new Error("should not fail");
238
238
  },
239
- } as unknown as NanoSdkActivatedJob);
239
+ };
240
+ await rec.dispatch(job);
240
241
  assert.deepEqual(handled, [{ n: 5 }]);
241
242
  assert.deepEqual(completedWith, { out: 5 });
242
243
 
@@ -252,7 +253,7 @@ test("registerWorker fails the job when the handler throws", async () => {
252
253
  });
253
254
  const rec = client.workers[0];
254
255
  let failBody: { errorMessage: string; retries?: number } | undefined;
255
- await rec.dispatch({
256
+ const job: NanoSdkActivatedJob = {
256
257
  jobKey: "j1",
257
258
  variables: {},
258
259
  async complete() {
@@ -262,7 +263,8 @@ test("registerWorker fails the job when the handler throws", async () => {
262
263
  failBody = body;
263
264
  return "failed";
264
265
  },
265
- } as unknown as NanoSdkActivatedJob);
266
+ };
267
+ await rec.dispatch(job);
266
268
  assert.equal(failBody?.errorMessage, "boom");
267
269
  assert.equal(failBody?.retries, 0);
268
270
  });
@@ -275,7 +277,7 @@ test("registerWorker routes a thrown BpmnError to the engine's error()", async (
275
277
  });
276
278
  const rec = client.workers[0];
277
279
  let errorBody: { errorCode: string; errorMessage?: string } | undefined;
278
- await rec.dispatch({
280
+ const job: NanoSdkActivatedJob = {
279
281
  jobKey: "j1",
280
282
  variables: {},
281
283
  async complete() {
@@ -288,7 +290,8 @@ test("registerWorker routes a thrown BpmnError to the engine's error()", async (
288
290
  errorBody = body;
289
291
  return "raised";
290
292
  },
291
- } as unknown as NanoSdkActivatedJob);
293
+ };
294
+ await rec.dispatch(job);
292
295
  assert.equal(errorBody?.errorCode, "NOT_FOUND");
293
296
  assert.equal(errorBody?.errorMessage, "no such record");
294
297
  });
@@ -301,7 +304,7 @@ test("registerWorker falls back to fail() for a BpmnError when the SDK has no er
301
304
  });
302
305
  const rec = client.workers[0];
303
306
  let failBody: { errorMessage: string } | undefined;
304
- await rec.dispatch({
307
+ const job: NanoSdkActivatedJob = {
305
308
  jobKey: "j1",
306
309
  variables: {},
307
310
  async complete() {
@@ -312,7 +315,8 @@ test("registerWorker falls back to fail() for a BpmnError when the SDK has no er
312
315
  return "failed";
313
316
  },
314
317
  // no error() — older transport
315
- } as unknown as NanoSdkActivatedJob);
318
+ };
319
+ await rec.dispatch(job);
316
320
  assert.equal(failBody?.errorMessage, "no such record");
317
321
  });
318
322
 
@@ -324,7 +328,7 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
324
328
  });
325
329
  const rec = client.workers[0];
326
330
  let failBody: { errorMessage: string } | undefined;
327
- await rec.dispatch({
331
+ const job: NanoSdkActivatedJob = {
328
332
  jobKey: "j1",
329
333
  variables: {},
330
334
  async complete() {
@@ -337,7 +341,8 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
337
341
  failBody = body;
338
342
  return "failed";
339
343
  },
340
- } as unknown as NanoSdkActivatedJob);
344
+ };
345
+ await rec.dispatch(job);
341
346
  // The job must still be acknowledged via fail() rather than silently dropped.
342
347
  assert.equal(failBody?.errorMessage, "no such record");
343
348
  });
@@ -371,7 +376,7 @@ test("close drains the REST-fallback worker via the SDK's stopAllWorkers", async
371
376
 
372
377
  test("close tolerates a client without stopAllWorkers", async () => {
373
378
  const client = fakeSdkClient();
374
- delete (client as { stopAllWorkers?: unknown }).stopAllWorkers;
379
+ delete client.stopAllWorkers;
375
380
  const engine = new SdkEngineClient(client);
376
381
  await engine.registerWorker("a", async () => ({}));
377
382
  await engine.close();
@@ -19,6 +19,7 @@ import type {
19
19
  } from "../core/host.ts";
20
20
  import { isBpmnError } from "../core/host.ts";
21
21
  import type { EngineSdkClient } from "./sdk.ts";
22
+ import { isRecord } from "../core/guards.ts";
22
23
 
23
24
  /** Coerce an engine response's process-instance key to a non-empty string, or
24
25
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -32,7 +33,7 @@ export function requireProcessInstanceKey(key: string | number | null | undefine
32
33
  /** A job as delivered to a nano-sdk job handler: the frame fields plus the
33
34
  * acknowledgement actions the handler must call. */
34
35
  export interface NanoSdkActivatedJob {
35
- jobKey: string;
36
+ jobKey: string | number;
36
37
  type?: string;
37
38
  processInstanceKey?: string | number;
38
39
  elementId?: string;
@@ -134,7 +135,8 @@ export class SdkEngineClient implements EngineClient {
134
135
  * the same connection. Surfaced to handlers as `AppApi.sdk`.
135
136
  */
136
137
  get sdk(): EngineSdkClient {
137
- return this.client as unknown as EngineSdkClient;
138
+ // biome-ignore lint/plugin: `this.client` is the same `@nanobpm/nano-sdk` client at runtime; exposing its full EngineSdkClient surface is the adapter boundary (see EngineSdkClient in ./sdk.ts).
139
+ return this.client as NanoSdkClient & EngineSdkClient;
138
140
  }
139
141
 
140
142
  async deployResources(
@@ -155,12 +157,13 @@ export class SdkEngineClient implements EngineClient {
155
157
  variables: input.variables ?? {},
156
158
  awaitCompletion: input.awaitCompletion ?? false,
157
159
  });
160
+ const rawKey = body.processInstanceKey ?? body.key;
158
161
  const key =
159
- (body.processInstanceKey as string | number | undefined) ??
160
- (body.key as string | number | undefined);
162
+ typeof rawKey === "string" || typeof rawKey === "number" ? rawKey : undefined;
163
+ const variables = isRecord(body.variables) ? body.variables : undefined;
161
164
  return {
162
165
  processInstanceKey: requireProcessInstanceKey(key),
163
- variables: (body.variables as Record<string, unknown> | undefined) ?? undefined,
166
+ variables,
164
167
  };
165
168
  }
166
169
 
@@ -188,10 +191,10 @@ export class SdkEngineClient implements EngineClient {
188
191
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
189
192
  // the search reflects what is currently visible without blocking.
190
193
  const body = await this.client.searchUserTasks(
191
- { filter: (filter ?? {}) as Record<string, unknown> },
194
+ { filter: { ...(filter ?? {}) } },
192
195
  { consistency: { waitUpToMs: 0 } },
193
196
  );
194
- const items = (body.items as Record<string, unknown>[] | undefined) ?? [];
197
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
195
198
  return items.flatMap((it) => {
196
199
  const userTaskKey = it.userTaskKey ?? it.key;
197
200
  if (userTaskKey == null || userTaskKey === "") {
@@ -200,8 +203,8 @@ export class SdkEngineClient implements EngineClient {
200
203
  }
201
204
  return [{
202
205
  userTaskKey: String(userTaskKey),
203
- elementId: it.elementId as string | undefined,
204
- variables: it.variables as Record<string, unknown> | undefined,
206
+ elementId: typeof it.elementId === "string" ? it.elementId : undefined,
207
+ variables: isRecord(it.variables) ? it.variables : undefined,
205
208
  }];
206
209
  });
207
210
  }
@@ -236,7 +239,7 @@ export class SdkEngineClient implements EngineClient {
236
239
  };
237
240
  try {
238
241
  const out = await handler(engineJob);
239
- return await job.complete((out as Record<string, unknown> | undefined) ?? {});
242
+ return await job.complete(out ?? {});
240
243
  } catch (err) {
241
244
  // A handler-raised BPMN error is a modelled, non-retryable outcome:
242
245
  // report it as a BPMN error (routed to an error boundary/event) rather
@@ -246,7 +249,7 @@ export class SdkEngineClient implements EngineClient {
246
249
  if (isBpmnError(err) && typeof job.error === "function") {
247
250
  // `message` is typed loosely (the guard survives module duplication),
248
251
  // so coerce to a string before slicing to avoid a secondary crash.
249
- const raw = (err as { message?: unknown }).message;
252
+ const raw = err.message;
250
253
  const message = typeof raw === "string" && raw.length > 0 ? raw : err.errorCode;
251
254
  this.log("info", `handler ${jobType} raised BPMN error`, {
252
255
  errorCode: err.errorCode,
@@ -339,15 +342,19 @@ async function importNanoSdk(): Promise<
339
342
  // Indirect the specifier so the (lazily loaded) module is not resolved at
340
343
  // typecheck time and stays out of the import graph for injected-client paths.
341
344
  const spec = "@nanobpm/nano-sdk";
342
- const mod = (await import(spec)) as {
343
- createCamundaClient?: (opts: Record<string, unknown>) => NanoSdkClient;
344
- };
345
- if (typeof mod.createCamundaClient !== "function") {
345
+ const mod: unknown = await import(spec);
346
+ if (!isNanoSdkModule(mod)) {
346
347
  throw new Error("@nanobpm/nano-sdk does not export createCamundaClient");
347
348
  }
348
349
  return mod.createCamundaClient;
349
350
  }
350
351
 
352
+ function isNanoSdkModule(value: unknown): value is {
353
+ createCamundaClient: (opts: Record<string, unknown>) => NanoSdkClient;
354
+ } {
355
+ return isRecord(value) && typeof value.createCamundaClient === "function";
356
+ }
357
+
351
358
  /**
352
359
  * Build the Urban runtime's `EngineClient`, backed by a single `@nanobpm/nano-sdk`
353
360
  * client. Provide a `client`/`createClient` seam to inject a fake (tests, embedded
@@ -70,6 +70,8 @@ export type { ConnectorsHandle } from "./core/modules/connectors.ts";
70
70
  // Data layer
71
71
  export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
72
72
  export type { ProvisionedSource } from "./core/modules/datasource.ts";
73
+ export { runDataOp } from "./core/modules/dataops.ts";
74
+ export type { DataOp, DataRequest, ResolvedSource } from "./core/modules/dataops.ts";
73
75
  export { makeGateway, Table } from "./core/modules/gateway.ts";
74
76
  export type {
75
77
  ColumnMeta,
@@ -101,6 +103,11 @@ export type {
101
103
  } from "./core/modules/llm.ts";
102
104
  export { evalCorrelation } from "./core/modules/triggers.ts";
103
105
 
106
+ // Model deploy + `{{name}}` template substitution
107
+ export { deployModels } from "./core/modules/deploy.ts";
108
+ export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
109
+ export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
110
+
104
111
  // Adapters + engine + run entrypoint
105
112
  export { createNodeHost } from "./adapters/node.ts";
106
113
  export { createDenoHost } from "./adapters/deno.ts";
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { createUrbanApp, type CreateUrbanAppOptions, type UrbanApp } from "./core/runtime.ts";
6
6
  import { selectHost } from "./adapters/detect.ts";
7
+ import { denoGlobal, processGlobal } from "./adapters/globals.ts";
7
8
  import { createNanoSdkEngineClient } from "./engine/nanosdk.ts";
8
9
  import type { EngineClient } from "./core/host.ts";
9
10
 
@@ -49,6 +50,7 @@ export async function runFromEnv(opts: RunOptions = {}): Promise<UrbanApp> {
49
50
  manifestPath: opts.manifestPath,
50
51
  port: opts.port,
51
52
  mount: opts.mount,
53
+ templates: opts.templates,
52
54
  });
53
55
  await app.start();
54
56
 
@@ -74,19 +76,17 @@ export function installSignalHandlers(stop: () => Promise<void>): void {
74
76
  }
75
77
  });
76
78
  };
77
- const g = globalThis as {
78
- Deno?: { addSignalListener(sig: string, cb: () => void): void };
79
- process?: { on(sig: string, cb: () => void): void };
80
- };
81
- if (g.Deno) {
79
+ const deno = denoGlobal();
80
+ const proc = processGlobal();
81
+ if (deno?.addSignalListener) {
82
82
  try {
83
- g.Deno.addSignalListener("SIGINT", onSignal);
84
- g.Deno.addSignalListener("SIGTERM", onSignal);
83
+ deno.addSignalListener("SIGINT", onSignal);
84
+ deno.addSignalListener("SIGTERM", onSignal);
85
85
  } catch {
86
86
  /* signal listeners may be unavailable (e.g. Windows) */
87
87
  }
88
- } else if (g.process) {
89
- g.process.on("SIGINT", onSignal);
90
- g.process.on("SIGTERM", onSignal);
88
+ } else if (proc?.on) {
89
+ proc.on("SIGINT", onSignal);
90
+ proc.on("SIGTERM", onSignal);
91
91
  }
92
92
  }
@@ -13,6 +13,7 @@
13
13
  // unit-testable.
14
14
 
15
15
  import type { GenIO } from "./gen.ts";
16
+ import { errorMessage, isRecord } from "../runtime/core/guards.ts";
16
17
 
17
18
  /** A config field a pack declares (subset of ext-types `ConfigField`). */
18
19
  interface PackConfigField {
@@ -90,9 +91,9 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
90
91
  }
91
92
  let pack: PackManifest;
92
93
  try {
93
- pack = JSON.parse(await io.readText(packManifestPath)) as PackManifest;
94
+ pack = JSON.parse(await io.readText(packManifestPath));
94
95
  } catch (err) {
95
- throw new Error(`failed to parse ${packManifestPath}: ${(err as Error).message}`);
96
+ throw new Error(`failed to parse ${packManifestPath}: ${errorMessage(err)}`);
96
97
  }
97
98
  if (typeof pack.id !== "string" || !pack.id) {
98
99
  throw new Error(`${packManifestPath} has no string "id" (a pack must declare its id).`);
@@ -123,7 +124,7 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
123
124
  try {
124
125
  app = JSON.parse(await io.readText(appManifestPath));
125
126
  } catch (err) {
126
- throw new Error(`failed to parse ${appManifestPath}: ${(err as Error).message}`);
127
+ throw new Error(`failed to parse ${appManifestPath}: ${errorMessage(err)}`);
127
128
  }
128
129
  if (app === null || typeof app !== "object" || Array.isArray(app)) {
129
130
  throw new Error(`${appManifestPath} is not a JSON object.`);
@@ -168,10 +169,10 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
168
169
  // A pre-existing connection of this name must be a compatible object of the
169
170
  // same pack `type`; otherwise wiring workers to it would produce an invalid
170
171
  // or misleading manifest. Fail loudly instead of silently reusing it.
171
- if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
172
+ if (!isRecord(existing)) {
172
173
  throw new Error(`connection "${connection}" already exists and is not an object.`);
173
174
  }
174
- const conn = existing as Record<string, unknown>;
175
+ const conn = existing;
175
176
  if (conn.type !== undefined && conn.type !== pack.id) {
176
177
  throw new Error(
177
178
  `connection "${connection}" already exists with type "${String(conn.type)}", ` +
@@ -134,10 +134,12 @@ const XML = `<?xml version="1.0"?>
134
134
 
135
135
  test("scanModelMessages extracts message name + envelope; a self-closing message is name-only", () => {
136
136
  const m = scanModelMessages(XML);
137
- const placed = m.find((x) => x.messageName === "OrderPlaced") as MessageIo;
137
+ const placed = m.find((x) => x.messageName === "OrderPlaced");
138
+ assert.ok(placed);
138
139
  assert.ok(placed);
139
140
  assert.equal(placed.inputType, "Order");
140
- const ping = m.find((x) => x.messageName === "Ping") as MessageIo;
141
+ const ping = m.find((x) => x.messageName === "Ping");
142
+ assert.ok(ping);
141
143
  assert.ok(ping);
142
144
  assert.equal(ping.inputType, undefined);
143
145
  });
@@ -40,13 +40,15 @@ const XML = `<?xml version="1.0"?>
40
40
 
41
41
  test("scanModelWorkers extracts task type, element id, process, and envelope io", () => {
42
42
  const w = scanModelWorkers(XML);
43
- const charge = w.find((x) => x.taskType === "orders.charge") as WorkerIo;
43
+ const charge = w.find((x) => x.taskType === "orders.charge");
44
+ assert.ok(charge);
44
45
  assert.ok(charge);
45
46
  assert.equal(charge.elementId, "Charge");
46
47
  assert.equal(charge.process, "orders");
47
48
  assert.equal(charge.in, "Payment");
48
49
  assert.equal(charge.out, "Receipt");
49
- const notify = w.find((x) => x.taskType === "orders.notify") as WorkerIo;
50
+ const notify = w.find((x) => x.taskType === "orders.notify");
51
+ assert.ok(notify);
50
52
  assert.equal(notify.in, undefined);
51
53
  assert.equal(notify.out, undefined);
52
54
  });
@@ -50,7 +50,8 @@ export function createNodeGenIO(): GenIO {
50
50
  // stat failed (e.g. a virtual path); fall back to no cache key and let import() surface it.
51
51
  }
52
52
  const href = `${pathToFileURL(abs).href}${version}`;
53
- return import(href) as Promise<Record<string, unknown>>;
53
+ const mod: Promise<Record<string, unknown>> = import(href);
54
+ return mod;
54
55
  },
55
56
  };
56
57
  }
@@ -110,9 +110,9 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
110
110
  const { root, io } = opts;
111
111
  const emitModels = opts.emitModels ?? true;
112
112
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
113
- const manifest = JSON.parse(await io.readText(manifestPath)) as ToolkitManifest & {
113
+ const manifest: ToolkitManifest & {
114
114
  models?: { processes?: string[]; workflows?: string[] };
115
- };
115
+ } = JSON.parse(await io.readText(manifestPath));
116
116
 
117
117
  const artifacts: DerivedArtifact[] = [];
118
118
 
@@ -186,8 +186,8 @@ export { dirOf, join as joinPath };
186
186
  export async function previewModels(opts: GenOptions): Promise<DerivedModels> {
187
187
  const { root, io } = opts;
188
188
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
189
- const manifest = JSON.parse(await io.readText(manifestPath)) as {
189
+ const manifest: {
190
190
  models?: { processes?: string[]; workflows?: string[] };
191
- };
191
+ } = JSON.parse(await io.readText(manifestPath));
192
192
  return deriveModels(root, io, manifest);
193
193
  }
@@ -93,7 +93,7 @@ test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>
93
93
 
94
94
  test("deriveModels is a no-op without a module loader (pure/IDE callers)", async () => {
95
95
  const io = memIO({ "/app/workflows/greet.ts": "// source" });
96
- delete (io as { importModule?: unknown }).importModule;
96
+ delete io.importModule;
97
97
  const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
98
98
  assert.equal(d.attempted, false);
99
99
  assert.equal(d.artifacts.length, 0);
@@ -14,6 +14,7 @@ import type { DerivedArtifact } from "./artifact.ts";
14
14
  import type { GenIO } from "./gen.ts";
15
15
  import { expandPattern, joinPath } from "./gen.ts";
16
16
  import type { ModelSource } from "./derivers/worker-io.ts";
17
+ import { isRecord } from "../runtime/core/guards.ts";
17
18
 
18
19
  /** Default source glob for code-first flows (matches the code-first scaffold layout). */
19
20
  export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
@@ -34,9 +35,7 @@ export interface ModelsManifest {
34
35
 
35
36
  /** A `defineFlow` export: has a string `id` and an `imperative`/`declarative` `kind`. */
36
37
  export function isWorkflow(v: unknown): v is Workflow {
37
- if (!v || typeof v !== "object") return false;
38
- const o = v as { id?: unknown; kind?: unknown };
39
- return typeof o.id === "string" && (o.kind === "imperative" || o.kind === "declarative");
38
+ return isRecord(v) && typeof v.id === "string" && (v.kind === "imperative" || v.kind === "declarative");
40
39
  }
41
40
 
42
41
  /** Sanitize a flow id into a safe `.bpmn` filename (mirrors the host generator). */
@@ -113,7 +112,7 @@ function messageOf(e: unknown): string {
113
112
  /** Append an actionable hint when a `.ts` import fails because the runtime can't strip types. */
114
113
  function importHint(rel: string, e: unknown): string {
115
114
  const base = messageOf(e);
116
- const code = (e as { code?: string })?.code ?? "";
115
+ const code = isRecord(e) && typeof e.code === "string" ? e.code : "";
117
116
  const looksLikeNoTsLoader =
118
117
  rel.endsWith(".ts") &&
119
118
  (code === "ERR_UNKNOWN_FILE_EXTENSION" || /Unknown file extension "\.ts"/.test(base));
@@ -57,7 +57,7 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
57
57
  const manifestFile = opts.manifestFile ?? "nano.app.json";
58
58
  const manifestPath = joinPath(root, manifestFile);
59
59
 
60
- const manifest = JSON.parse(await io.readText(manifestPath)) as ScaffoldManifest;
60
+ const manifest: ScaffoldManifest = JSON.parse(await io.readText(manifestPath));
61
61
  const models = await readModels(root, io, manifest);
62
62
  const declaredTypeIds = Object.keys(manifest.types ?? {});
63
63