@nanobpm/urban 0.20.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 +86 -20
  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 +38 -2
  73. package/src/runtime/core/modules/pages.ts +89 -27
  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
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { createNodeHost } from "../adapters/node.ts";
7
7
  import { createUrbanApp, resolvePort } from "./runtime.ts";
8
8
  import { runFromEnv } from "../run.ts";
9
+ import { isRecord } from "./guards.ts";
9
10
  import type {
10
11
  EngineClient,
11
12
  EngineJob,
@@ -54,6 +55,16 @@ class FakeEngine implements EngineClient {
54
55
  }
55
56
  }
56
57
 
58
+ function expectRecord(value: unknown): Record<string, unknown> {
59
+ if (!isRecord(value)) throw new TypeError("expected record");
60
+ return value;
61
+ }
62
+
63
+ function expectArray(value: unknown): unknown[] {
64
+ if (!Array.isArray(value)) throw new TypeError("expected array");
65
+ return value;
66
+ }
67
+
57
68
  async function makeFixture(): Promise<string> {
58
69
  const dir = await mkdtemp(join(tmpdir(), "urban-rt-"));
59
70
  await mkdir(join(dir, "processes"));
@@ -104,8 +115,10 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
104
115
  assert.equal(engine.deployed, 3, "3 model files deployed");
105
116
 
106
117
  // migrations applied
107
- const insp = app.inspect() as Record<string, any>;
108
- assert.deepEqual(insp.data.sources[0].migrations, 1);
118
+ const insp = app.inspect();
119
+ const data = expectRecord(insp.data);
120
+ const sources = expectArray(data.sources).map(expectRecord);
121
+ assert.deepEqual(sources[0].migrations, 1);
109
122
 
110
123
  // workers registered + data injected: deliver a job and see a DB row
111
124
  await engine.deliver("wf.claim", {
@@ -124,7 +137,7 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
124
137
 
125
138
  const tasksRes = await fetch(`http://localhost:${port}/tasks/api/tasks`);
126
139
  assert.equal(tasksRes.status, 200);
127
- assert.equal((await tasksRes.json() as unknown[]).length, 1);
140
+ assert.equal(expectArray(await tasksRes.json()).length, 1);
128
141
 
129
142
  const hookRes = await fetch(`http://localhost:${port}/hooks/task`, {
130
143
  method: "POST",
@@ -148,7 +161,7 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
148
161
 
149
162
  // healthz
150
163
  const health = await fetch(`http://localhost:${port}/healthz`);
151
- assert.equal((await health.json() as { ok: boolean }).ok, true);
164
+ assert.equal(expectRecord(await health.json()).ok, true);
152
165
 
153
166
  // field-drift guard
154
167
  assert.throws(() => app.data!.repo("task").insert({ bogus: 1 }));
@@ -175,11 +188,11 @@ test("threads the engine SDK client onto app.sdk for handlers (SDK-backed engine
175
188
  await app.start();
176
189
 
177
190
  try {
178
- const out = (await engine.deliver("wf.claim", {
191
+ const out = expectRecord(await engine.deliver("wf.claim", {
179
192
  jobKey: "j1",
180
193
  jobType: "wf.claim",
181
194
  variables: { title: "Fix bug" },
182
- })) as Record<string, unknown>;
195
+ }));
183
196
  assert.equal(out.hasSdk, true, "handler saw app.sdk");
184
197
  assert.equal(out.sdkMarker, "engine-sdk", "app.sdk is the engine's own client");
185
198
  } finally {
@@ -196,11 +209,11 @@ test("app.sdk is undefined when the engine exposes no SDK client", async () => {
196
209
  await app.start();
197
210
 
198
211
  try {
199
- const out = (await engine.deliver("wf.claim", {
212
+ const out = expectRecord(await engine.deliver("wf.claim", {
200
213
  jobKey: "j1",
201
214
  jobType: "wf.claim",
202
215
  variables: { title: "Fix bug" },
203
- })) as Record<string, unknown>;
216
+ }));
204
217
  assert.equal(out.hasSdk, false, "no app.sdk for a non-SDK engine");
205
218
  assert.equal(out.sdkMarker, undefined);
206
219
  } finally {
@@ -222,7 +235,7 @@ test("stop() resets state so the app can be cleanly restarted", async () => {
222
235
  await app.stop();
223
236
 
224
237
  // after stop, inspect() no longer carries stale describe data / port
225
- const stopped = app.inspect() as Record<string, unknown>;
238
+ const stopped = app.inspect();
226
239
  assert.equal(stopped.httpPort, undefined);
227
240
  assert.equal(stopped.workers, undefined);
228
241
 
@@ -255,7 +268,7 @@ test("a failed start() tears down and resets state (no 'already started' wedge)"
255
268
  try {
256
269
  await assert.rejects(() => app.start(), /boom during deploy/);
257
270
  // state reset: not wedged, no leaked port/describe
258
- const s = app.inspect() as Record<string, unknown>;
271
+ const s = app.inspect();
259
272
  assert.equal(s.httpPort, undefined);
260
273
  assert.equal(s.workers, undefined);
261
274
  // a subsequent start() succeeds rather than throwing "app already started"
@@ -10,6 +10,7 @@ import { loadManifest, type AppManifest } from "./manifest.ts";
10
10
  import { validateManifest } from "./validate.ts";
11
11
  import { makeRouter, type Route } from "./router.ts";
12
12
  import { deployModels } from "./modules/deploy.ts";
13
+ import type { TemplateSource } from "./modules/templates.ts";
13
14
  import { provisionData, DataLayer } from "./modules/datasource.ts";
14
15
  import { mountWorkers } from "./modules/workers.ts";
15
16
  import { mountConnectors } from "./modules/connectors.ts";
@@ -32,8 +33,16 @@ export function resolvePort(explicit: number | undefined, envPort: string | unde
32
33
  /** Read the underlying nano-sdk client off an engine when it exposes one (the
33
34
  * `SdkEngineClient` does). Non-SDK engines (e.g. an in-memory test double) have no
34
35
  * `sdk`, so handlers get `undefined` and fall back to the transport-agnostic seam. */
36
+ interface EngineWithSdk extends EngineClient {
37
+ sdk?: EngineSdkClient;
38
+ }
39
+
40
+ function hasEngineSdk(engine: EngineClient): engine is EngineWithSdk {
41
+ return "sdk" in engine;
42
+ }
43
+
35
44
  function engineSdk(engine: EngineClient): EngineSdkClient | undefined {
36
- return (engine as { sdk?: EngineSdkClient }).sdk;
45
+ return hasEngineSdk(engine) ? engine.sdk : undefined;
37
46
  }
38
47
 
39
48
  export interface MountFlags {
@@ -58,6 +67,10 @@ export interface CreateUrbanAppOptions {
58
67
  port?: number;
59
68
  /** Which modules to mount (all true by default). */
60
69
  mount?: MountFlags;
70
+ /** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
71
+ * `name → content` map). Merged over the manifest's `models.templates` (this wins on
72
+ * collision). See `deployModels`. */
73
+ templates?: TemplateSource;
61
74
  }
62
75
 
63
76
  export interface UrbanApp {
@@ -92,7 +105,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
92
105
  triggers: opts.mount?.triggers ?? true,
93
106
  security: opts.mount?.security ?? true,
94
107
  };
95
- const ctx = { manifest, host, engine, root };
108
+ const ctx = { manifest, host, engine, root, templates: opts.templates };
96
109
 
97
110
  let data: DataLayer | undefined;
98
111
  let security: SecurityPolicy | undefined;
@@ -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
  }