@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.
- package/README.md +35 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +80 -16
- package/dist/runtime/adapters/deno.js +19 -3
- package/dist/runtime/adapters/detect.js +2 -2
- package/dist/runtime/adapters/globals.d.ts +19 -0
- package/dist/runtime/adapters/globals.js +9 -0
- package/dist/runtime/adapters/node.js +25 -5
- package/dist/runtime/core/context.d.ts +4 -0
- package/dist/runtime/core/guards.d.ts +2 -0
- package/dist/runtime/core/guards.js +10 -0
- package/dist/runtime/core/host.js +5 -4
- package/dist/runtime/core/manifest.d.ts +8 -0
- package/dist/runtime/core/manifest.js +0 -1
- package/dist/runtime/core/modules/actions.js +9 -5
- package/dist/runtime/core/modules/connectors.js +6 -4
- package/dist/runtime/core/modules/dataops.d.ts +36 -0
- package/dist/runtime/core/modules/dataops.js +231 -0
- package/dist/runtime/core/modules/datasource.d.ts +51 -1
- package/dist/runtime/core/modules/datasource.js +98 -21
- package/dist/runtime/core/modules/deploy.d.ts +3 -1
- package/dist/runtime/core/modules/deploy.js +29 -7
- package/dist/runtime/core/modules/gateway.d.ts +1 -1
- package/dist/runtime/core/modules/gateway.js +16 -17
- package/dist/runtime/core/modules/llm.js +9 -7
- package/dist/runtime/core/modules/pages.js +86 -20
- package/dist/runtime/core/modules/security.js +5 -5
- package/dist/runtime/core/modules/templates.d.ts +18 -0
- package/dist/runtime/core/modules/templates.js +133 -0
- package/dist/runtime/core/modules/triggers.js +14 -5
- package/dist/runtime/core/modules/workers.js +15 -9
- package/dist/runtime/core/runtime.d.ts +5 -0
- package/dist/runtime/core/runtime.js +5 -5
- package/dist/runtime/core/validate.d.ts +2 -1
- package/dist/runtime/core/validate.js +38 -28
- package/dist/runtime/devserver.js +2 -1
- package/dist/runtime/engine/nanosdk.d.ts +1 -1
- package/dist/runtime/engine/nanosdk.js +15 -9
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/run.js +10 -7
- package/dist/toolkit/addConnector.js +4 -3
- package/dist/toolkit/fsio.js +2 -1
- package/dist/toolkit/models.js +3 -5
- package/package.json +1 -1
- package/src/cli.test.ts +44 -0
- package/src/cli.ts +82 -16
- package/src/runtime/adapters/deno.ts +23 -3
- package/src/runtime/adapters/detect.ts +2 -2
- package/src/runtime/adapters/globals.ts +27 -0
- package/src/runtime/adapters/node.ts +27 -4
- package/src/runtime/connector-worker-sdk.test.ts +2 -1
- package/src/runtime/core/context.ts +4 -0
- package/src/runtime/core/guards.ts +10 -0
- package/src/runtime/core/host.ts +4 -4
- package/src/runtime/core/manifest.ts +19 -5
- package/src/runtime/core/modules/actions.test.ts +82 -31
- package/src/runtime/core/modules/actions.ts +11 -7
- package/src/runtime/core/modules/connectors.test.ts +29 -8
- package/src/runtime/core/modules/connectors.ts +6 -4
- package/src/runtime/core/modules/dataops.test.ts +306 -0
- package/src/runtime/core/modules/dataops.ts +292 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +12 -3
- package/src/runtime/core/modules/datasource.test.ts +129 -0
- package/src/runtime/core/modules/datasource.ts +105 -23
- package/src/runtime/core/modules/deploy.test.ts +258 -0
- package/src/runtime/core/modules/deploy.ts +29 -7
- package/src/runtime/core/modules/gateway.test.ts +5 -4
- package/src/runtime/core/modules/gateway.ts +29 -30
- package/src/runtime/core/modules/llm.test.ts +10 -12
- package/src/runtime/core/modules/llm.ts +11 -9
- package/src/runtime/core/modules/pages.test.ts +38 -2
- package/src/runtime/core/modules/pages.ts +89 -27
- package/src/runtime/core/modules/security.ts +5 -13
- package/src/runtime/core/modules/surfaces.test.ts +53 -10
- package/src/runtime/core/modules/templates.ts +153 -0
- package/src/runtime/core/modules/triggers.test.ts +51 -19
- package/src/runtime/core/modules/triggers.ts +14 -6
- package/src/runtime/core/modules/workers.test.ts +43 -20
- package/src/runtime/core/modules/workers.ts +18 -10
- package/src/runtime/core/runtime.test.ts +23 -10
- package/src/runtime/core/runtime.ts +15 -2
- package/src/runtime/core/validate.ts +43 -32
- package/src/runtime/devserver.test.ts +18 -5
- package/src/runtime/devserver.ts +2 -1
- package/src/runtime/engine/nanosdk.test.ts +16 -11
- package/src/runtime/engine/nanosdk.ts +22 -15
- package/src/runtime/index.ts +7 -0
- package/src/runtime/run.ts +10 -10
- package/src/toolkit/addConnector.ts +6 -5
- package/src/toolkit/derivers/messages.test.ts +4 -2
- package/src/toolkit/derivers/worker-io.test.ts +4 -2
- package/src/toolkit/fsio.ts +2 -1
- package/src/toolkit/gen.ts +4 -4
- package/src/toolkit/models.test.ts +1 -1
- package/src/toolkit/models.ts +3 -4
- 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()
|
|
108
|
-
|
|
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()
|
|
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()
|
|
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
|
-
}))
|
|
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
|
-
}))
|
|
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()
|
|
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()
|
|
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
|
|
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 {
|
|
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
|
|
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 (
|
|
44
|
+
if (!isRecord(m)) {
|
|
44
45
|
return [{ path: "$", message: "manifest must be a JSON object" }];
|
|
45
46
|
}
|
|
46
|
-
const obj = m
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
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
|
|
86
|
-
if (typeof
|
|
87
|
-
if (typeof
|
|
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
|
|
101
|
+
if (typeof worker?.connection === "string" && worker.connection.length > 0) {
|
|
101
102
|
const conns =
|
|
102
|
-
|
|
103
|
-
?
|
|
103
|
+
isRecord(obj.connections)
|
|
104
|
+
? obj.connections
|
|
104
105
|
: undefined;
|
|
105
|
-
if (!conns || !(
|
|
106
|
+
if (!conns || !(worker.connection in conns)) {
|
|
106
107
|
issues.push({
|
|
107
108
|
path: `workers[${i}].connection`,
|
|
108
|
-
message: `no such connection "${
|
|
109
|
+
message: `no such connection "${worker.connection}" (add it to connections)`,
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
});
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 (!
|
|
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 (
|
|
125
|
-
issues.push({ path: "data.default", message: `no such source "${
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
222
|
+
manifest: { schemaVersion: 1, id, name: id },
|
|
210
223
|
root: ".",
|
|
211
224
|
async start() {},
|
|
212
225
|
async stop() {
|
package/src/runtime/devserver.ts
CHANGED
|
@@ -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: ${
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
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 ?? {})
|
|
194
|
+
{ filter: { ...(filter ?? {}) } },
|
|
192
195
|
{ consistency: { waitUpToMs: 0 } },
|
|
193
196
|
);
|
|
194
|
-
const items = (body.items
|
|
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
|
|
204
|
-
variables: it.variables
|
|
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(
|
|
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 =
|
|
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 =
|
|
343
|
-
|
|
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
|
package/src/runtime/index.ts
CHANGED
|
@@ -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";
|
package/src/runtime/run.ts
CHANGED
|
@@ -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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
};
|
|
81
|
-
if (g.Deno) {
|
|
79
|
+
const deno = denoGlobal();
|
|
80
|
+
const proc = processGlobal();
|
|
81
|
+
if (deno?.addSignalListener) {
|
|
82
82
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
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 (
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
} else if (proc?.on) {
|
|
89
|
+
proc.on("SIGINT", onSignal);
|
|
90
|
+
proc.on("SIGTERM", onSignal);
|
|
91
91
|
}
|
|
92
92
|
}
|