@nanobpm/urban 0.6.0 → 0.7.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/dist/runtime/core/host.d.ts +4 -0
- package/dist/runtime/core/manifest.d.ts +8 -0
- package/dist/runtime/core/modules/gateway.d.ts +2 -0
- package/dist/runtime/core/modules/gateway.js +2 -1
- package/dist/runtime/core/modules/pages.d.ts +42 -0
- package/dist/runtime/core/modules/pages.js +670 -0
- package/dist/runtime/core/modules/surfaces.js +7 -0
- package/dist/runtime/engine/nanosdk.d.ts +6 -0
- package/dist/runtime/engine/nanosdk.js +3 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/package.json +1 -1
- package/src/runtime/core/host.ts +2 -0
- package/src/runtime/core/manifest.ts +8 -0
- package/src/runtime/core/modules/gateway.ts +2 -1
- package/src/runtime/core/modules/pages.test.ts +224 -0
- package/src/runtime/core/modules/pages.ts +705 -0
- package/src/runtime/core/modules/surfaces.ts +8 -0
- package/src/runtime/core/runtime.test.ts +4 -0
- package/src/runtime/engine/nanosdk.test.ts +18 -0
- package/src/runtime/engine/nanosdk.ts +8 -0
- package/src/runtime/index.ts +2 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// linked `.form` and posts completion back. The `chat` surface is stubbed as a mount point
|
|
4
4
|
// (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
|
|
5
5
|
import { html, json, normalizeRoutePath } from "../router.js";
|
|
6
|
+
import { mountPages } from "./pages.js";
|
|
6
7
|
/** Escape HTML-significant characters before embedding a value in markup. */
|
|
7
8
|
function escapeHtml(s) {
|
|
8
9
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
@@ -83,6 +84,12 @@ export function mountSurfaces(ctx, app) {
|
|
|
83
84
|
<p>Chat surface mount point (agent: <code>${escapeHtml(chat.agent ?? "?")}</code>). LLM wiring pending.</p>`),
|
|
84
85
|
});
|
|
85
86
|
}
|
|
87
|
+
// The pages surface (the schema-driven page runtime) mounts its own routes.
|
|
88
|
+
const pages = mountPages(ctx, app);
|
|
89
|
+
if (pages.routes.length > 0) {
|
|
90
|
+
routes.push(...pages.routes);
|
|
91
|
+
enabled.push("pages@/");
|
|
92
|
+
}
|
|
86
93
|
ctx.host.log("info", "surfaces mounted", { enabled });
|
|
87
94
|
return { name: "surfaces", routes, describe: () => ({ enabled }) };
|
|
88
95
|
}
|
|
@@ -53,6 +53,9 @@ export interface NanoSdkClient {
|
|
|
53
53
|
variables?: Record<string, unknown>;
|
|
54
54
|
awaitCompletion?: boolean;
|
|
55
55
|
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
56
|
+
cancelProcessInstance(input: {
|
|
57
|
+
processInstanceKey: string | number;
|
|
58
|
+
}, options?: unknown): Promise<unknown>;
|
|
56
59
|
publishMessage(input: {
|
|
57
60
|
name: string;
|
|
58
61
|
correlationKey?: string;
|
|
@@ -101,6 +104,9 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
101
104
|
processInstanceKey: string;
|
|
102
105
|
variables?: Record<string, unknown>;
|
|
103
106
|
}>;
|
|
107
|
+
cancelInstance(input: {
|
|
108
|
+
processInstanceKey: string;
|
|
109
|
+
}): Promise<void>;
|
|
104
110
|
publishMessage(input: {
|
|
105
111
|
name: string;
|
|
106
112
|
correlationKey?: string;
|
|
@@ -66,6 +66,9 @@ export class SdkEngineClient {
|
|
|
66
66
|
variables: body.variables ?? undefined,
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
|
+
async cancelInstance(input) {
|
|
70
|
+
await this.client.cancelProcessInstance({ processInstanceKey: input.processInstanceKey });
|
|
71
|
+
}
|
|
69
72
|
async publishMessage(input) {
|
|
70
73
|
await this.client.publishMessage({
|
|
71
74
|
name: input.name,
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
|
|
|
10
10
|
export type { ProvisionedSource } from "./core/modules/datasource.ts";
|
|
11
11
|
export { makeGateway, Table } from "./core/modules/gateway.ts";
|
|
12
12
|
export type { ColumnMeta, DataSource as GatewayDataSource, ExecResult, ForeignKeyMeta, Row, TableMeta, } from "./core/modules/gateway.ts";
|
|
13
|
+
export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
|
|
14
|
+
export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./core/modules/pages.ts";
|
|
13
15
|
export type { AppJobHandler } from "./core/modules/workers.ts";
|
|
14
16
|
export { resolveHandler } from "./core/modules/workers.ts";
|
|
15
17
|
export { evalCorrelation } from "./core/modules/triggers.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
|
|
|
8
8
|
// Data layer
|
|
9
9
|
export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
|
|
10
10
|
export { makeGateway, Table } from "./core/modules/gateway.js";
|
|
11
|
+
export { createPagesRoutes, mountPages } from "./core/modules/pages.js";
|
|
11
12
|
export { resolveHandler } from "./core/modules/workers.js";
|
|
12
13
|
export { evalCorrelation } from "./core/modules/triggers.js";
|
|
13
14
|
// Adapters + engine + run entrypoint
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface EngineClient {
|
|
|
105
105
|
variables?: Record<string, unknown>;
|
|
106
106
|
awaitCompletion?: boolean;
|
|
107
107
|
}): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }>;
|
|
108
|
+
/** Cancel a running process instance (the pages surface's row-cancel action). */
|
|
109
|
+
cancelInstance(input: { processInstanceKey: string }): Promise<void>;
|
|
108
110
|
/** Publish a message for correlation. */
|
|
109
111
|
publishMessage(input: {
|
|
110
112
|
name: string;
|
|
@@ -44,6 +44,14 @@ export interface SurfaceDecl {
|
|
|
44
44
|
enabled?: boolean;
|
|
45
45
|
path?: string;
|
|
46
46
|
agent?: string;
|
|
47
|
+
/** pages surface: directory of `*.page.json` (relative to app root). Default `pages`. */
|
|
48
|
+
pagesDir?: string;
|
|
49
|
+
/** pages surface: the page served at `/`. Default `home`. */
|
|
50
|
+
homePage?: string;
|
|
51
|
+
/** pages surface: max rows a `dataGrid` fetch returns. Default 200. */
|
|
52
|
+
rowLimit?: number;
|
|
53
|
+
/** pages surface: the injected default datasource name. Default `app`. */
|
|
54
|
+
sourceName?: string;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
export interface AppManifest {
|
|
@@ -68,7 +68,8 @@ export interface DataSource {
|
|
|
68
68
|
table<T extends object = Row>(name: string, pk?: string): Table<T>;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** Double-quote a SQL identifier (table/column), escaping embedded quotes. */
|
|
72
|
+
export function quoteIdent(name: string): string {
|
|
72
73
|
return `"${name.replaceAll('"', '""')}"`;
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { EngineClient, HttpRequest, HttpResponse } from "../host.ts";
|
|
4
|
+
import { makeRouter } from "../router.ts";
|
|
5
|
+
import { createPagesRoutes, type PagesDataSource } from "./pages.ts";
|
|
6
|
+
|
|
7
|
+
function req(
|
|
8
|
+
method: string,
|
|
9
|
+
path: string,
|
|
10
|
+
opts: { query?: string; body?: unknown } = {},
|
|
11
|
+
): HttpRequest {
|
|
12
|
+
const bodyText = opts.body === undefined ? "" : JSON.stringify(opts.body);
|
|
13
|
+
return {
|
|
14
|
+
method,
|
|
15
|
+
path,
|
|
16
|
+
query: new URLSearchParams(opts.query ?? ""),
|
|
17
|
+
headers: new Headers(),
|
|
18
|
+
text: () => Promise.resolve(bodyText),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FakeEngineCalls {
|
|
23
|
+
created: { processDefinitionId: string; variables?: Record<string, unknown> }[];
|
|
24
|
+
canceled: string[];
|
|
25
|
+
messages: { name: string; correlationKey?: string; variables?: Record<string, unknown> }[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
|
|
29
|
+
const calls: FakeEngineCalls = { created: [], canceled: [], messages: [] };
|
|
30
|
+
const engine = {
|
|
31
|
+
async createInstance(input: { processDefinitionId: string; variables?: Record<string, unknown> }) {
|
|
32
|
+
calls.created.push(input);
|
|
33
|
+
return { processInstanceKey: "pi-42" };
|
|
34
|
+
},
|
|
35
|
+
async cancelInstance(input: { processInstanceKey: string }) {
|
|
36
|
+
calls.canceled.push(input.processInstanceKey);
|
|
37
|
+
},
|
|
38
|
+
async publishMessage(input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) {
|
|
39
|
+
calls.messages.push(input);
|
|
40
|
+
},
|
|
41
|
+
} as unknown as EngineClient;
|
|
42
|
+
return { engine, calls };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fakeDb(overrides: Partial<PagesDataSource> = {}): PagesDataSource {
|
|
46
|
+
return {
|
|
47
|
+
schema: async () => [{ name: "orders" }],
|
|
48
|
+
query: async (sql: string) => {
|
|
49
|
+
if (/PRAGMA table_info/i.test(sql)) {
|
|
50
|
+
return [{ name: "id" }, { name: "status" }, { name: "total" }];
|
|
51
|
+
}
|
|
52
|
+
return [{ id: 1, status: "new", total: 10 }];
|
|
53
|
+
},
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function build(dbOverrides: Partial<PagesDataSource> = {}) {
|
|
59
|
+
const { engine, calls } = fakeEngine();
|
|
60
|
+
const db = fakeDb(dbOverrides);
|
|
61
|
+
const readPage = async (path: string) => {
|
|
62
|
+
if (path === "pages/home.page.json") return JSON.stringify({ title: "Home", nodes: [] });
|
|
63
|
+
throw new Error("not found");
|
|
64
|
+
};
|
|
65
|
+
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
|
|
66
|
+
db,
|
|
67
|
+
engine,
|
|
68
|
+
readPage,
|
|
69
|
+
});
|
|
70
|
+
const router = makeRouter(routes);
|
|
71
|
+
return { router, calls };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function dispatch(method: string, path: string, opts?: { query?: string; body?: unknown }): Promise<HttpResponse> {
|
|
75
|
+
const { router } = build();
|
|
76
|
+
return router(req(method, path, opts));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("GET / serves the renderer shell with the home marker", async () => {
|
|
80
|
+
const res = await dispatch("GET", "/");
|
|
81
|
+
assert.equal(res.status, 200);
|
|
82
|
+
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
83
|
+
assert.match(res.body ?? "", /data-home="home"/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("GET /app/runtime.js serves the renderer module", async () => {
|
|
87
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
88
|
+
assert.match(res.headers?.["content-type"] ?? "", /javascript/);
|
|
89
|
+
assert.match(res.body ?? "", /pc:refresh/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
|
|
93
|
+
const ok = await dispatch("GET", "/app/pages/home");
|
|
94
|
+
assert.equal(ok.status, 200);
|
|
95
|
+
assert.deepEqual(JSON.parse(ok.body ?? "{}").title, "Home");
|
|
96
|
+
const miss = await dispatch("GET", "/app/pages/nope");
|
|
97
|
+
assert.equal(miss.status, 404);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("GET /app/data/<source>/<table> returns rows", async () => {
|
|
101
|
+
const res = await dispatch("GET", "/app/data/app/orders");
|
|
102
|
+
assert.equal(res.status, 200);
|
|
103
|
+
const body = JSON.parse(res.body ?? "{}");
|
|
104
|
+
assert.equal(body.rows.length, 1);
|
|
105
|
+
assert.equal(body.rows[0].status, "new");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("GET /app/data rejects unknown source, invalid + unknown table", async () => {
|
|
109
|
+
assert.equal((await dispatch("GET", "/app/data/other/orders")).status, 404);
|
|
110
|
+
const { router } = build();
|
|
111
|
+
const unknownTable = await router(req("GET", "/app/data/app/customers"));
|
|
112
|
+
assert.equal(unknownTable.status, 404);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("GET /app/data whitelists filter columns", async () => {
|
|
116
|
+
const { router } = build();
|
|
117
|
+
const good = await router(req("GET", "/app/data/app/orders", { query: "where=status:new" }));
|
|
118
|
+
assert.equal(good.status, 200);
|
|
119
|
+
const bad = await router(req("GET", "/app/data/app/orders", { query: "where=evil:1" }));
|
|
120
|
+
assert.equal(bad.status, 400);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("POST /app/actions/start/<process> creates an instance", async () => {
|
|
124
|
+
const { router, calls } = build();
|
|
125
|
+
const res = await router(req("POST", "/app/actions/start/my-proc", { body: { variables: { a: 1 } } }));
|
|
126
|
+
assert.equal(res.status, 200);
|
|
127
|
+
assert.equal(JSON.parse(res.body ?? "{}").processInstanceKey, "pi-42");
|
|
128
|
+
assert.equal(calls.created.length, 1);
|
|
129
|
+
assert.equal(calls.created[0].processDefinitionId, "my-proc");
|
|
130
|
+
assert.deepEqual(calls.created[0].variables, { a: 1 });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("POST /app/actions/cancel cancels, 400 without a key", async () => {
|
|
134
|
+
const { router, calls } = build();
|
|
135
|
+
const ok = await router(req("POST", "/app/actions/cancel", { body: { processInstanceKey: "pi-9" } }));
|
|
136
|
+
assert.equal(ok.status, 200);
|
|
137
|
+
assert.deepEqual(calls.canceled, ["pi-9"]);
|
|
138
|
+
const bad = await router(req("POST", "/app/actions/cancel", { body: {} }));
|
|
139
|
+
assert.equal(bad.status, 400);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("POST /app/actions/message publishes, 400 on missing fields", async () => {
|
|
143
|
+
const { router, calls } = build();
|
|
144
|
+
const ok = await router(req("POST", "/app/actions/message", {
|
|
145
|
+
body: { name: "answered", correlationKey: "k1", variables: { answer: "yes" } },
|
|
146
|
+
}));
|
|
147
|
+
assert.equal(ok.status, 200);
|
|
148
|
+
assert.equal(calls.messages.length, 1);
|
|
149
|
+
assert.equal(calls.messages[0].name, "answered");
|
|
150
|
+
const noName = await router(req("POST", "/app/actions/message", { body: { correlationKey: "k" } }));
|
|
151
|
+
assert.equal(noName.status, 400);
|
|
152
|
+
const noKey = await router(req("POST", "/app/actions/message", { body: { name: "m" } }));
|
|
153
|
+
assert.equal(noKey.status, 400);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("GET /app/data quotes table/column identifiers in the emitted SQL", async () => {
|
|
157
|
+
const seen: string[] = [];
|
|
158
|
+
const db = fakeDb({
|
|
159
|
+
query: async (sql: string) => {
|
|
160
|
+
seen.push(sql);
|
|
161
|
+
if (/PRAGMA table_info/i.test(sql)) {
|
|
162
|
+
return [{ name: "id" }, { name: "status" }, { name: "total" }];
|
|
163
|
+
}
|
|
164
|
+
return [{ id: 1 }];
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
|
|
168
|
+
db,
|
|
169
|
+
engine: fakeEngine().engine,
|
|
170
|
+
readPage: async () => "{}",
|
|
171
|
+
});
|
|
172
|
+
const router = makeRouter(routes);
|
|
173
|
+
const res = await router(req("GET", "/app/data/app/orders", {
|
|
174
|
+
query: "where=status:new&order=total:desc",
|
|
175
|
+
}));
|
|
176
|
+
assert.equal(res.status, 200);
|
|
177
|
+
const pragma = seen.find((s) => /PRAGMA table_info/i.test(s))!;
|
|
178
|
+
assert.match(pragma, /table_info\("orders"\)/);
|
|
179
|
+
const select = seen.find((s) => /^SELECT/.test(s))!;
|
|
180
|
+
assert.match(select, /FROM "orders"/);
|
|
181
|
+
assert.match(select, /WHERE "status" = \?/);
|
|
182
|
+
assert.match(select, /ORDER BY "total" DESC/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("GET /app/data returns a structured 500 when the SELECT query throws", async () => {
|
|
186
|
+
const db = fakeDb({
|
|
187
|
+
query: async (sql: string) => {
|
|
188
|
+
if (/PRAGMA table_info/i.test(sql)) return [{ name: "id" }, { name: "status" }];
|
|
189
|
+
throw new Error("disk gone");
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
|
|
193
|
+
db,
|
|
194
|
+
engine: fakeEngine().engine,
|
|
195
|
+
readPage: async () => "{}",
|
|
196
|
+
});
|
|
197
|
+
const res = await makeRouter(routes)(req("GET", "/app/data/app/orders"));
|
|
198
|
+
assert.equal(res.status, 500);
|
|
199
|
+
assert.match(JSON.parse(res.body ?? "{}").error, /disk gone/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("rowLimit respects an explicit 0 and falls back for non-finite values", async () => {
|
|
203
|
+
const limits: string[] = [];
|
|
204
|
+
const mkDb = () => fakeDb({
|
|
205
|
+
query: async (sql: string) => {
|
|
206
|
+
if (/PRAGMA table_info/i.test(sql)) return [{ name: "id" }];
|
|
207
|
+
const m = sql.match(/LIMIT (\S+)/);
|
|
208
|
+
if (m) limits.push(m[1]);
|
|
209
|
+
return [];
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const run = async (rowLimit: number | undefined) => {
|
|
213
|
+
const routes = createPagesRoutes({ pagesDir: "p", homePage: "h", sourceName: "app", rowLimit }, {
|
|
214
|
+
db: mkDb(),
|
|
215
|
+
engine: fakeEngine().engine,
|
|
216
|
+
readPage: async () => "{}",
|
|
217
|
+
});
|
|
218
|
+
return makeRouter(routes)(req("GET", "/app/data/app/orders"));
|
|
219
|
+
};
|
|
220
|
+
await run(0);
|
|
221
|
+
await run(Infinity);
|
|
222
|
+
await run(undefined);
|
|
223
|
+
assert.deepEqual(limits, ["0", "200", "200"]);
|
|
224
|
+
});
|