@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
|
@@ -141,7 +141,7 @@ test("Table.insert throws when the driver reports no lastInsertId", async () =>
|
|
|
141
141
|
table: (name, pk) => new Table(fake, name, pk),
|
|
142
142
|
};
|
|
143
143
|
const t = fake.table("orders");
|
|
144
|
-
await assert.rejects(t.insert({ status: "x" }
|
|
144
|
+
await assert.rejects(t.insert({ status: "x" }), /no lastInsertId/);
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
test("Table.all ignores a non-finite limit", async () => {
|
|
@@ -158,8 +158,8 @@ test("query/exec run raw parameterised SQL", async () => {
|
|
|
158
158
|
await withGateway(async (src) => {
|
|
159
159
|
const r = await src.exec("INSERT INTO orders (status, total) VALUES (?, ?)", ["raw", 42]);
|
|
160
160
|
assert.equal(r.changed, 1);
|
|
161
|
-
const rows = await src.query("SELECT total FROM orders WHERE status = ?", ["raw"]);
|
|
162
|
-
assert.equal(Number(
|
|
161
|
+
const rows = await src.query<{ total: number }>("SELECT total FROM orders WHERE status = ?", ["raw"]);
|
|
162
|
+
assert.equal(Number(rows[0]?.total), 42);
|
|
163
163
|
});
|
|
164
164
|
});
|
|
165
165
|
|
|
@@ -216,6 +216,7 @@ test("insert throws when every provided value is undefined", async () => {
|
|
|
216
216
|
test("insert throws a distinct message for a genuinely empty row", async () => {
|
|
217
217
|
await withGateway(async (src) => {
|
|
218
218
|
const orders = src.table<Order>("orders");
|
|
219
|
-
|
|
219
|
+
const empty: Partial<Order> = {};
|
|
220
|
+
await assert.rejects(orders.insert(empty), /empty row/);
|
|
220
221
|
});
|
|
221
222
|
});
|
|
@@ -55,7 +55,7 @@ export interface TableMeta {
|
|
|
55
55
|
* interchangeable. */
|
|
56
56
|
export interface DataSource {
|
|
57
57
|
/** Run a SELECT (or any row-returning statement) and collect the rows. */
|
|
58
|
-
query(sql: string, params?: unknown[]): Promise<
|
|
58
|
+
query<T extends object = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
59
59
|
/** Run a non-row statement (INSERT/UPDATE/DELETE/DDL). */
|
|
60
60
|
exec(sql: string, params?: unknown[]): Promise<ExecResult>;
|
|
61
61
|
/** Run `fn` inside a transaction, committing on success and rolling back on throw. The
|
|
@@ -77,11 +77,10 @@ export function quoteIdent(name: string): string {
|
|
|
77
77
|
* yields an empty clause (matches all rows). Takes `object` (not `Row`) so a `Partial<T>` for a
|
|
78
78
|
* generated `interface` row type (which lacks a string index signature) is accepted. */
|
|
79
79
|
function whereClause(where: object): { clause: string; params: unknown[] } {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return { clause, params: keys.map((k) => w[k]) };
|
|
80
|
+
const entries = Object.entries(where);
|
|
81
|
+
if (entries.length === 0) return { clause: "", params: [] };
|
|
82
|
+
const clause = " WHERE " + entries.map(([k]) => `${quoteIdent(k)} = ?`).join(" AND ");
|
|
83
|
+
return { clause, params: entries.map(([, v]) => v) };
|
|
85
84
|
}
|
|
86
85
|
|
|
87
86
|
/** A typed gateway over a single table — the record-oriented data object a handler binds to
|
|
@@ -105,20 +104,20 @@ export class Table<T extends object = Row> {
|
|
|
105
104
|
* `DEFAULT`/`NULL` governs — `undefined` means "not provided, let the schema decide", never
|
|
106
105
|
* a bound value. An explicit `null` is preserved (it stores `NULL`). */
|
|
107
106
|
async insert(row: Partial<T>): Promise<number | bigint> {
|
|
108
|
-
const provided = Object.
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
107
|
+
const provided = Object.entries(row);
|
|
108
|
+
const entries = provided.filter(([, v]) => v !== undefined);
|
|
109
|
+
if (entries.length === 0) {
|
|
111
110
|
throw new Error(
|
|
112
111
|
provided.length === 0
|
|
113
112
|
? `Table(${this.name}).insert: no columns to insert (empty row)`
|
|
114
113
|
: `Table(${this.name}).insert: no columns to insert (all values were undefined)`,
|
|
115
114
|
);
|
|
116
115
|
}
|
|
117
|
-
const cols =
|
|
118
|
-
const ph =
|
|
116
|
+
const cols = entries.map(([k]) => quoteIdent(k)).join(", ");
|
|
117
|
+
const ph = entries.map(() => "?").join(", ");
|
|
119
118
|
const r = await this.#src.exec(
|
|
120
119
|
`INSERT INTO ${quoteIdent(this.name)} (${cols}) VALUES (${ph})`,
|
|
121
|
-
|
|
120
|
+
entries.map(([, v]) => v),
|
|
122
121
|
);
|
|
123
122
|
if (r.lastInsertId == null) {
|
|
124
123
|
// The driver reported no rowid — treat as a failed/ambiguous insert rather than
|
|
@@ -130,11 +129,11 @@ export class Table<T extends object = Row> {
|
|
|
130
129
|
|
|
131
130
|
/** Fetch the row with the given primary key, or `undefined`. */
|
|
132
131
|
async get(id: unknown): Promise<T | undefined> {
|
|
133
|
-
const rows = await this.#src.query(
|
|
132
|
+
const rows = await this.#src.query<T>(
|
|
134
133
|
`SELECT * FROM ${quoteIdent(this.name)} WHERE ${quoteIdent(this.pk)} = ? LIMIT 1`,
|
|
135
134
|
[id],
|
|
136
135
|
);
|
|
137
|
-
return rows[0]
|
|
136
|
+
return rows[0];
|
|
138
137
|
}
|
|
139
138
|
|
|
140
139
|
/** Every row (optionally capped at `limit`). */
|
|
@@ -143,38 +142,38 @@ export class Table<T extends object = Row> {
|
|
|
143
142
|
typeof limit === "number" && Number.isFinite(limit)
|
|
144
143
|
? ` LIMIT ${Math.max(0, Math.floor(limit))}`
|
|
145
144
|
: "";
|
|
146
|
-
return
|
|
145
|
+
return this.#src.query<T>(`SELECT * FROM ${quoteIdent(this.name)}${lim}`);
|
|
147
146
|
}
|
|
148
147
|
|
|
149
148
|
/** Rows matching an equality filter (keys ANDed). An empty filter matches all rows. */
|
|
150
149
|
async find(where: Partial<T> = {}): Promise<T[]> {
|
|
151
|
-
const { clause, params } = whereClause(where
|
|
152
|
-
return
|
|
150
|
+
const { clause, params } = whereClause(where);
|
|
151
|
+
return this.#src.query<T>(
|
|
153
152
|
`SELECT * FROM ${quoteIdent(this.name)}${clause}`,
|
|
154
153
|
params,
|
|
155
|
-
)
|
|
154
|
+
);
|
|
156
155
|
}
|
|
157
156
|
|
|
158
157
|
/** The first row matching an equality filter, or `undefined`. */
|
|
159
158
|
async findOne(where: Partial<T> = {}): Promise<T | undefined> {
|
|
160
|
-
const { clause, params } = whereClause(where
|
|
161
|
-
const rows = await this.#src.query(
|
|
159
|
+
const { clause, params } = whereClause(where);
|
|
160
|
+
const rows = await this.#src.query<T>(
|
|
162
161
|
`SELECT * FROM ${quoteIdent(this.name)}${clause} LIMIT 1`,
|
|
163
162
|
params,
|
|
164
163
|
);
|
|
165
|
-
return rows[0]
|
|
164
|
+
return rows[0];
|
|
166
165
|
}
|
|
167
166
|
|
|
168
167
|
/** Patch the row with the given primary key; returns rows changed. Keys whose value is
|
|
169
168
|
* `undefined` are skipped (that column is left unchanged); an explicit `null` clears the
|
|
170
169
|
* column to `NULL`. A patch with no defined keys is a no-op. */
|
|
171
170
|
async update(id: unknown, patch: Partial<T>): Promise<number> {
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
const set =
|
|
171
|
+
const entries = Object.entries(patch).filter(([, v]) => v !== undefined);
|
|
172
|
+
if (entries.length === 0) return 0;
|
|
173
|
+
const set = entries.map(([k]) => `${quoteIdent(k)} = ?`).join(", ");
|
|
175
174
|
const r = await this.#src.exec(
|
|
176
175
|
`UPDATE ${quoteIdent(this.name)} SET ${set} WHERE ${quoteIdent(this.pk)} = ?`,
|
|
177
|
-
[...
|
|
176
|
+
[...entries.map(([, v]) => v), id],
|
|
178
177
|
);
|
|
179
178
|
return r.changed;
|
|
180
179
|
}
|
|
@@ -190,12 +189,12 @@ export class Table<T extends object = Row> {
|
|
|
190
189
|
|
|
191
190
|
/** Count rows matching an equality filter (all rows when omitted). */
|
|
192
191
|
async count(where: Partial<T> = {}): Promise<number> {
|
|
193
|
-
const { clause, params } = whereClause(where
|
|
194
|
-
const rows = await this.#src.query(
|
|
192
|
+
const { clause, params } = whereClause(where);
|
|
193
|
+
const rows = await this.#src.query<{ n?: unknown }>(
|
|
195
194
|
`SELECT COUNT(*) AS n FROM ${quoteIdent(this.name)}${clause}`,
|
|
196
195
|
params,
|
|
197
196
|
);
|
|
198
|
-
return Number(
|
|
197
|
+
return Number(rows[0]?.n ?? 0);
|
|
199
198
|
}
|
|
200
199
|
}
|
|
201
200
|
|
|
@@ -207,8 +206,8 @@ class SqliteGateway implements DataSource {
|
|
|
207
206
|
this.#db = db;
|
|
208
207
|
}
|
|
209
208
|
|
|
210
|
-
async query(sql: string, params: unknown[] = []): Promise<
|
|
211
|
-
return this.#db.all<
|
|
209
|
+
async query<T extends object = Row>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
210
|
+
return this.#db.all<T>(sql, params);
|
|
212
211
|
}
|
|
213
212
|
|
|
214
213
|
async exec(sql: string, params: unknown[] = []): Promise<ExecResult> {
|
|
@@ -21,15 +21,13 @@ function fakeFetch(
|
|
|
21
21
|
content: string,
|
|
22
22
|
opts: { ok?: boolean; status?: number; capture?: (url: string, init: RequestInit) => void } = {},
|
|
23
23
|
): typeof fetch {
|
|
24
|
-
return
|
|
25
|
-
opts.capture?.(String(url), init);
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
status: opts.status ?? 200,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
};
|
|
32
|
-
}) as unknown as typeof fetch;
|
|
24
|
+
return async (url: string | URL | Request, init?: RequestInit) => {
|
|
25
|
+
opts.capture?.(String(url), init ?? {});
|
|
26
|
+
return new Response(
|
|
27
|
+
JSON.stringify({ choices: [{ message: { content } }] }),
|
|
28
|
+
{ status: opts.status ?? 200 },
|
|
29
|
+
);
|
|
30
|
+
};
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
// --- resolveProvider -------------------------------------------------------
|
|
@@ -113,8 +111,8 @@ test("callLlm posts to /chat/completions and returns the message content", async
|
|
|
113
111
|
assert.equal(out, "hello world");
|
|
114
112
|
assert.equal(seen?.url, "http://h/v1/chat/completions");
|
|
115
113
|
assert.equal(seen?.init.method, "POST");
|
|
116
|
-
const headers = seen?.init.headers
|
|
117
|
-
assert.equal(headers.authorization, "Bearer k");
|
|
114
|
+
const headers = new Headers(seen?.init.headers);
|
|
115
|
+
assert.equal(headers.get("authorization"), "Bearer k");
|
|
118
116
|
const body = JSON.parse(String(seen?.init.body));
|
|
119
117
|
assert.equal(body.model, "m");
|
|
120
118
|
assert.equal(body.response_format, undefined);
|
|
@@ -139,7 +137,7 @@ test("callLlm throws on a non-ok provider response", async () => {
|
|
|
139
137
|
});
|
|
140
138
|
|
|
141
139
|
test("callLlm throws when the provider returns no content", async () => {
|
|
142
|
-
const f
|
|
140
|
+
const f: typeof fetch = async () => new Response(JSON.stringify({ choices: [] }));
|
|
143
141
|
await assert.rejects(
|
|
144
142
|
callLlm({ baseUrl: "http://h/v1", model: "m" }, [{ role: "user", content: "hi" }], { fetch: f }),
|
|
145
143
|
/no message content/,
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { LlmBinding } from "../manifest.ts";
|
|
24
24
|
import { expandEnvString } from "../manifest.ts";
|
|
25
|
+
import { isRecord } from "../guards.ts";
|
|
25
26
|
|
|
26
27
|
/** Job variables/headers default to untyped JSON. */
|
|
27
28
|
export type LlmVars = Record<string, unknown>;
|
|
@@ -102,8 +103,8 @@ export function buildMessages(vars: LlmVars): ChatMessage[] {
|
|
|
102
103
|
if (vars.messages.length === 0) {
|
|
103
104
|
throw new Error("llm worker: 'messages' must not be an empty array");
|
|
104
105
|
}
|
|
105
|
-
msgs =
|
|
106
|
-
const o = m
|
|
106
|
+
msgs = vars.messages.map((m, i) => {
|
|
107
|
+
const o = isRecord(m) ? m : {};
|
|
107
108
|
if (
|
|
108
109
|
typeof o.content !== "string" ||
|
|
109
110
|
(o.role !== "user" && o.role !== "assistant" && o.role !== "system")
|
|
@@ -150,8 +151,11 @@ export async function callLlm(
|
|
|
150
151
|
const detail = await res.text().catch(() => "");
|
|
151
152
|
throw new Error(`llm provider returned ${res.status}: ${detail.slice(0, 500)}`);
|
|
152
153
|
}
|
|
153
|
-
const data =
|
|
154
|
-
const
|
|
154
|
+
const data: unknown = await res.json();
|
|
155
|
+
const choices = isRecord(data) && Array.isArray(data.choices) ? data.choices : [];
|
|
156
|
+
const firstChoice = choices[0];
|
|
157
|
+
const message = isRecord(firstChoice) && isRecord(firstChoice.message) ? firstChoice.message : undefined;
|
|
158
|
+
const content = message?.content;
|
|
155
159
|
if (typeof content !== "string") {
|
|
156
160
|
throw new Error("llm provider returned no message content");
|
|
157
161
|
}
|
|
@@ -187,10 +191,10 @@ export async function runLlmJob(
|
|
|
187
191
|
}
|
|
188
192
|
// response_format asks for a JSON object, but a provider can still return a scalar, array,
|
|
189
193
|
// or null — none of which is a valid variable map (nor valid decision-rails input).
|
|
190
|
-
if (
|
|
194
|
+
if (!isRecord(parsed)) {
|
|
191
195
|
throw new Error(`llm worker: expected a JSON object output but got: ${text.slice(0, 300)}`);
|
|
192
196
|
}
|
|
193
|
-
const obj = parsed
|
|
197
|
+
const obj = parsed;
|
|
194
198
|
|
|
195
199
|
const decisionId = binding.output?.decision;
|
|
196
200
|
if (decisionId) {
|
|
@@ -201,9 +205,7 @@ export async function runLlmJob(
|
|
|
201
205
|
);
|
|
202
206
|
}
|
|
203
207
|
const out = await rt.evaluateDecision(decisionId, obj);
|
|
204
|
-
return out
|
|
205
|
-
? (out as Record<string, unknown>)
|
|
206
|
-
: { result: out };
|
|
208
|
+
return isRecord(out) ? out : { result: out };
|
|
207
209
|
}
|
|
208
210
|
return obj;
|
|
209
211
|
}
|
|
@@ -27,7 +27,8 @@ interface FakeEngineCalls {
|
|
|
27
27
|
|
|
28
28
|
function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
|
|
29
29
|
const calls: FakeEngineCalls = { created: [], canceled: [], messages: [] };
|
|
30
|
-
const engine = {
|
|
30
|
+
const engine: EngineClient = {
|
|
31
|
+
deployResources: async () => ({ deployed: 0 }),
|
|
31
32
|
async createInstance(input: { processDefinitionId: string; variables?: Record<string, unknown> }) {
|
|
32
33
|
calls.created.push(input);
|
|
33
34
|
return { processInstanceKey: "pi-42" };
|
|
@@ -38,7 +39,11 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
|
|
|
38
39
|
async publishMessage(input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) {
|
|
39
40
|
calls.messages.push(input);
|
|
40
41
|
},
|
|
41
|
-
|
|
42
|
+
async searchUserTasks() { return []; },
|
|
43
|
+
async completeUserTask() {},
|
|
44
|
+
async registerWorker(jobType) { return { jobType, unsubscribe: async () => {} }; },
|
|
45
|
+
async close() {},
|
|
46
|
+
};
|
|
42
47
|
return { engine, calls };
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -89,6 +94,22 @@ test("GET /app/runtime.js serves the renderer module", async () => {
|
|
|
89
94
|
assert.match(res.body ?? "", /pc:refresh/);
|
|
90
95
|
});
|
|
91
96
|
|
|
97
|
+
test("renderer preserves grid row-detail expansion across refreshes", async () => {
|
|
98
|
+
// Regression: the 5s poll rebuilds the whole tbody, which used to collapse an open
|
|
99
|
+
// detail row (e.g. the escalation-answer form). The client now tracks open rowKeys
|
|
100
|
+
// and reuses their built detail node so expansion (and a half-typed answer) survives.
|
|
101
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
102
|
+
const js = res.body ?? "";
|
|
103
|
+
assert.match(js, /const expanded = new Set\(\)/);
|
|
104
|
+
assert.match(js, /const detailNodes = new Map\(\)/);
|
|
105
|
+
assert.match(js, /expanded\.add\(key\)/);
|
|
106
|
+
assert.match(js, /expanded\.delete\(key\)/);
|
|
107
|
+
// A missing/null rowKey must be treated as keyless (null) rather than coerced to
|
|
108
|
+
// the string "undefined"/"null", which would collide unrelated rows in the caches.
|
|
109
|
+
assert.match(js, /rowKeyOf/);
|
|
110
|
+
assert.match(js, /v == null \? null : String\(v\)/);
|
|
111
|
+
});
|
|
112
|
+
|
|
92
113
|
test("renderer wires a column's linkField to a new-tab anchor", async () => {
|
|
93
114
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
94
115
|
const js = res.body ?? "";
|
|
@@ -102,6 +123,21 @@ test("renderer wires a column's linkField to a new-tab anchor", async () => {
|
|
|
102
123
|
assert.match(js, /rel: "noopener noreferrer"/);
|
|
103
124
|
});
|
|
104
125
|
|
|
126
|
+
test("the renderer honours numeric actionForm fields", async () => {
|
|
127
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
128
|
+
const js = res.body ?? "";
|
|
129
|
+
// Fields declared `type: "number"` render a numeric <input> and their submitted
|
|
130
|
+
// value is coerced to a real number (blank omitted so the action's default applies).
|
|
131
|
+
assert.match(js, /f\.type === "number"/);
|
|
132
|
+
assert.match(js, /Number\.isFinite\(num\)/);
|
|
133
|
+
// Numeric branch sets inputmode="numeric" so mobile keyboards show a number pad.
|
|
134
|
+
assert.match(js, /attrs\.inputmode = "numeric"/);
|
|
135
|
+
// Blank and non-finite numeric values are omitted (continue) rather than sent,
|
|
136
|
+
// so the action-side default applies and no raw string leaks through as a number.
|
|
137
|
+
assert.match(js, /if \(t === ""\) continue;/);
|
|
138
|
+
assert.match(js, /if \(!Number\.isFinite\(num\)\) continue;/);
|
|
139
|
+
});
|
|
140
|
+
|
|
105
141
|
test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
|
|
106
142
|
const ok = await dispatch("GET", "/app/pages/home");
|
|
107
143
|
assert.equal(ok.status, 200);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// POST /app/actions/message → engine.publishMessage
|
|
17
17
|
|
|
18
18
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
19
|
+
import { errorMessage, isRecord } from "../guards.ts";
|
|
19
20
|
import type { EngineClient } from "../host.ts";
|
|
20
21
|
import { html, json, type Route } from "../router.ts";
|
|
21
22
|
import { quoteIdent } from "./gateway.ts";
|
|
@@ -197,7 +198,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
197
198
|
);
|
|
198
199
|
return json({ rows });
|
|
199
200
|
} catch (e) {
|
|
200
|
-
return json({ error:
|
|
201
|
+
return json({ error: errorMessage(e) }, 500);
|
|
201
202
|
}
|
|
202
203
|
},
|
|
203
204
|
});
|
|
@@ -215,12 +216,12 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
215
216
|
let variables: Record<string, unknown> = {};
|
|
216
217
|
try {
|
|
217
218
|
const body = JSON.parse((await req.text()) || "{}");
|
|
218
|
-
if (body
|
|
219
|
-
const v =
|
|
219
|
+
if (isRecord(body)) {
|
|
220
|
+
const v = body.variables;
|
|
220
221
|
// Only a plain object is a valid variable map — reject arrays/scalars/null so a
|
|
221
222
|
// malformed body can't reach the engine as bad `variables`.
|
|
222
|
-
if (
|
|
223
|
-
variables = v
|
|
223
|
+
if (isRecord(v)) {
|
|
224
|
+
variables = v;
|
|
224
225
|
}
|
|
225
226
|
}
|
|
226
227
|
} catch {
|
|
@@ -230,7 +231,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
230
231
|
const res = await engine.createInstance({ processDefinitionId: process, variables });
|
|
231
232
|
return json({ processInstanceKey: res.processInstanceKey ?? null });
|
|
232
233
|
} catch (e) {
|
|
233
|
-
return json({ error:
|
|
234
|
+
return json({ error: errorMessage(e) }, 502);
|
|
234
235
|
}
|
|
235
236
|
},
|
|
236
237
|
});
|
|
@@ -244,7 +245,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
244
245
|
let key: string | number | undefined;
|
|
245
246
|
try {
|
|
246
247
|
const body = JSON.parse((await req.text()) || "{}");
|
|
247
|
-
const k = (body
|
|
248
|
+
const k = isRecord(body) ? body.processInstanceKey : undefined;
|
|
248
249
|
if (typeof k === "string" || typeof k === "number") key = k;
|
|
249
250
|
} catch {
|
|
250
251
|
return json({ error: "body must be JSON" }, 400);
|
|
@@ -254,7 +255,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
254
255
|
await engine.cancelInstance({ processInstanceKey: String(key) });
|
|
255
256
|
return json({ ok: true });
|
|
256
257
|
} catch (e) {
|
|
257
|
-
return json({ error:
|
|
258
|
+
return json({ error: errorMessage(e) }, 502);
|
|
258
259
|
}
|
|
259
260
|
},
|
|
260
261
|
});
|
|
@@ -269,18 +270,15 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
269
270
|
let correlationKey = "";
|
|
270
271
|
let variables: Record<string, unknown> = {};
|
|
271
272
|
try {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
correlationKey?: unknown;
|
|
275
|
-
variables?: unknown;
|
|
276
|
-
};
|
|
273
|
+
const parsed: unknown = JSON.parse((await req.text()) || "{}");
|
|
274
|
+
const body = isRecord(parsed) ? parsed : {};
|
|
277
275
|
if (typeof body?.name === "string") name = body.name;
|
|
278
276
|
if (typeof body?.correlationKey === "string" || typeof body?.correlationKey === "number") {
|
|
279
277
|
correlationKey = String(body.correlationKey);
|
|
280
278
|
}
|
|
281
279
|
const v = body?.variables;
|
|
282
|
-
if (
|
|
283
|
-
variables = v
|
|
280
|
+
if (isRecord(v)) {
|
|
281
|
+
variables = v;
|
|
284
282
|
}
|
|
285
283
|
} catch {
|
|
286
284
|
return json({ error: "body must be JSON" }, 400);
|
|
@@ -291,7 +289,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
291
289
|
await engine.publishMessage({ name, correlationKey, variables });
|
|
292
290
|
return json({ ok: true });
|
|
293
291
|
} catch (e) {
|
|
294
|
-
return json({ error:
|
|
292
|
+
return json({ error: errorMessage(e) }, 502);
|
|
295
293
|
}
|
|
296
294
|
},
|
|
297
295
|
});
|
|
@@ -452,24 +450,49 @@ function renderActionForm(node) {
|
|
|
452
450
|
const p = node.props;
|
|
453
451
|
const card = el("section", { class: "pc-card" });
|
|
454
452
|
if (p.title) card.append(el("h2", {}, p.title));
|
|
455
|
-
|
|
453
|
+
// Map/null-proto stores so a schema field keyed __proto__/constructor can't
|
|
454
|
+
// pollute Object.prototype or shadow inherited props (prototype-pollution class).
|
|
455
|
+
const inputs = new Map();
|
|
456
|
+
const fieldTypes = new Map();
|
|
456
457
|
for (const f of p.fields || []) {
|
|
457
|
-
const
|
|
458
|
-
|
|
458
|
+
const kind = f.type === "number" ? "number" : "text";
|
|
459
|
+
fieldTypes.set(f.key, kind);
|
|
460
|
+
const attrs = { type: kind, placeholder: f.label || f.key };
|
|
461
|
+
if (kind === "number") {
|
|
462
|
+
attrs.inputmode = "numeric";
|
|
463
|
+
if (f.min != null) attrs.min = String(f.min);
|
|
464
|
+
if (f.max != null) attrs.max = String(f.max);
|
|
465
|
+
attrs.step = f.step != null ? String(f.step) : "1";
|
|
466
|
+
}
|
|
467
|
+
const input = el("input", attrs);
|
|
468
|
+
inputs.set(f.key, input);
|
|
459
469
|
card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
|
|
460
470
|
}
|
|
461
471
|
const msg = el("p", { class: "pc-msg" });
|
|
462
472
|
const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
|
|
463
473
|
btn.addEventListener("click", async () => {
|
|
464
|
-
const variables =
|
|
465
|
-
for (const [k, input] of
|
|
474
|
+
const variables = Object.create(null);
|
|
475
|
+
for (const [k, input] of inputs) {
|
|
476
|
+
if (fieldTypes.get(k) === "number") {
|
|
477
|
+
const t = String(input.value).trim();
|
|
478
|
+
// Blank → omit so the action-side default applies; a non-finite parse
|
|
479
|
+
// (NaN/Infinity) is also omitted rather than smuggling a raw string
|
|
480
|
+
// process variable through, which would defeat the numeric coercion.
|
|
481
|
+
if (t === "") continue;
|
|
482
|
+
const num = Number(t);
|
|
483
|
+
if (!Number.isFinite(num)) continue;
|
|
484
|
+
variables[k] = num;
|
|
485
|
+
} else {
|
|
486
|
+
variables[k] = input.value;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
466
489
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
|
|
467
490
|
try {
|
|
468
491
|
const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
|
|
469
492
|
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
|
|
470
493
|
msg.className = "pc-msg ok";
|
|
471
494
|
msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
|
|
472
|
-
for (const input of
|
|
495
|
+
for (const input of inputs.values()) input.value = "";
|
|
473
496
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
474
497
|
} catch (e) {
|
|
475
498
|
msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -514,6 +537,23 @@ function renderDataGrid(node) {
|
|
|
514
537
|
card.append(table);
|
|
515
538
|
const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
|
|
516
539
|
|
|
540
|
+
// Expansion state has to outlive the poll: refresh() rebuilds the whole tbody
|
|
541
|
+
// every refreshMs, so without this an open detail row (where you answer an
|
|
542
|
+
// escalation) would collapse on the next tick. We remember which rowKeys are
|
|
543
|
+
// open, and reuse the already-built detail <tr> for them across refreshes so a
|
|
544
|
+
// half-typed answer survives too. Keyed by p.rowKey; grids without one keep the
|
|
545
|
+
// old (collapse-on-refresh) behavior.
|
|
546
|
+
const expanded = new Set();
|
|
547
|
+
const detailNodes = new Map();
|
|
548
|
+
// A row only participates in expansion/detail-node caching if it has a real
|
|
549
|
+
// (non-null) rowKey. Coercing a missing key through String() would yield the
|
|
550
|
+
// literal "undefined"/"null" and collide unrelated rows, so treat it as keyless.
|
|
551
|
+
const rowKeyOf = (row) => {
|
|
552
|
+
if (!p.rowKey) return null;
|
|
553
|
+
const v = row[p.rowKey];
|
|
554
|
+
return v == null ? null : String(v);
|
|
555
|
+
};
|
|
556
|
+
|
|
517
557
|
function dataUrl(source, tbl, filters, order) {
|
|
518
558
|
let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
|
|
519
559
|
const qs = [];
|
|
@@ -663,6 +703,7 @@ function renderDataGrid(node) {
|
|
|
663
703
|
|
|
664
704
|
function renderRow(row) {
|
|
665
705
|
const cells = cols.map((c) => gridCell(c, row));
|
|
706
|
+
const key = rowKeyOf(row);
|
|
666
707
|
let toggle = null;
|
|
667
708
|
if (hasExtra) {
|
|
668
709
|
const actionCell = el("td", { class: "pc-row-actions" });
|
|
@@ -679,16 +720,29 @@ function renderDataGrid(node) {
|
|
|
679
720
|
const tr = el("tr", {}, ...cells);
|
|
680
721
|
tbody.append(tr);
|
|
681
722
|
if (detail && toggle) {
|
|
682
|
-
const
|
|
683
|
-
|
|
723
|
+
const isOpen = key != null && expanded.has(key);
|
|
724
|
+
// Reuse an already-built detail row for an open PR across refreshes so its
|
|
725
|
+
// expanded state — and any half-typed escalation answer in the form — survives
|
|
726
|
+
// the poll. A closed (or keyless) row always gets a fresh, lazily-built panel.
|
|
727
|
+
let entry = key != null ? detailNodes.get(key) : null;
|
|
728
|
+
if (!(isOpen && entry && entry.built)) {
|
|
729
|
+
const dtr = el("tr", { hidden: isOpen ? null : "" }, el("td", { colspan: span }));
|
|
730
|
+
entry = { dtr, built: false };
|
|
731
|
+
if (key != null) detailNodes.set(key, entry);
|
|
732
|
+
if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
|
|
733
|
+
}
|
|
734
|
+
const dtr = entry.dtr;
|
|
735
|
+
toggle.textContent = dtr.hidden ? "▸" : "▾";
|
|
684
736
|
toggle.addEventListener("click", (ev) => {
|
|
685
737
|
ev.stopPropagation();
|
|
686
738
|
const open = dtr.hidden;
|
|
687
739
|
dtr.hidden = !open;
|
|
688
740
|
toggle.textContent = open ? "▾" : "▸";
|
|
689
|
-
if (open
|
|
690
|
-
|
|
691
|
-
dtr.firstChild.append(detailPanel(row));
|
|
741
|
+
if (open) {
|
|
742
|
+
if (key != null) expanded.add(key);
|
|
743
|
+
if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
|
|
744
|
+
} else if (key != null) {
|
|
745
|
+
expanded.delete(key);
|
|
692
746
|
}
|
|
693
747
|
});
|
|
694
748
|
tbody.append(dtr);
|
|
@@ -698,6 +752,14 @@ function renderDataGrid(node) {
|
|
|
698
752
|
async function refresh() {
|
|
699
753
|
try {
|
|
700
754
|
const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
755
|
+
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
756
|
+
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
757
|
+
if (p.rowKey) {
|
|
758
|
+
const live = new Set();
|
|
759
|
+
for (const r of rows) { const k = rowKeyOf(r); if (k != null) live.add(k); }
|
|
760
|
+
for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
|
|
761
|
+
for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
|
|
762
|
+
}
|
|
701
763
|
tbody.replaceChildren();
|
|
702
764
|
for (const row of rows) renderRow(row);
|
|
703
765
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
|
@@ -27,19 +27,11 @@ function matchRule(rules: Record<string, string[]> | undefined, key: string): st
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function mountSecurity(ctx: RuntimeContext): SecurityPolicy {
|
|
30
|
-
const sec =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
surfaces?: Record<string, string[]>;
|
|
36
|
-
data?: Record<string, unknown>;
|
|
37
|
-
};
|
|
38
|
-
};
|
|
39
|
-
const mode = sec.mode ?? "local";
|
|
40
|
-
const roles = sec.roles ?? [];
|
|
41
|
-
const surfaces = sec.rules?.surfaces;
|
|
42
|
-
const actions = sec.rules?.actions;
|
|
30
|
+
const sec = ctx.manifest.security;
|
|
31
|
+
const mode = typeof sec?.mode === "string" ? sec.mode : "local";
|
|
32
|
+
const roles = Array.isArray(sec?.roles) ? sec.roles.filter((role) => typeof role === "string") : [];
|
|
33
|
+
const surfaces = sec?.rules?.surfaces;
|
|
34
|
+
const actions = sec?.rules?.actions;
|
|
43
35
|
|
|
44
36
|
ctx.host.log("info", "security policy loaded", { mode, roles });
|
|
45
37
|
|