@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
@@ -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" } as Record<string, unknown>), /no lastInsertId/);
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((rows[0] as { total: number }).total), 42);
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
- await assert.rejects(orders.insert({} as Partial<Order>), /empty row/);
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<Row[]>;
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 w = where as Row;
81
- const keys = Object.keys(w);
82
- if (keys.length === 0) return { clause: "", params: [] };
83
- const clause = " WHERE " + keys.map((k) => `${quoteIdent(k)} = ?`).join(" AND ");
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.keys(row);
109
- const keys = provided.filter((k) => (row as Row)[k] !== undefined);
110
- if (keys.length === 0) {
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 = keys.map(quoteIdent).join(", ");
118
- const ph = keys.map(() => "?").join(", ");
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
- keys.map((k) => (row as Row)[k]),
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] as T | undefined;
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 (await this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${lim}`)) as T[];
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 as Row);
152
- return (await this.#src.query(
150
+ const { clause, params } = whereClause(where);
151
+ return this.#src.query<T>(
153
152
  `SELECT * FROM ${quoteIdent(this.name)}${clause}`,
154
153
  params,
155
- )) as T[];
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 as Row);
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] as T | undefined;
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 keys = Object.keys(patch).filter((k) => (patch as Row)[k] !== undefined);
173
- if (keys.length === 0) return 0;
174
- const set = keys.map((k) => `${quoteIdent(k)} = ?`).join(", ");
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
- [...keys.map((k) => (patch as Row)[k]), id],
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 as Row);
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((rows[0] as Row)?.n ?? 0);
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<Row[]> {
211
- return this.#db.all<Row>(sql, params);
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 (async (url: string, init: RequestInit) => {
25
- opts.capture?.(String(url), init);
26
- return {
27
- ok: opts.ok ?? true,
28
- status: opts.status ?? 200,
29
- json: async () => ({ choices: [{ message: { content } }] }),
30
- text: async () => content,
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 as Record<string, string>;
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 = (async () => ({ ok: true, status: 200, json: async () => ({ choices: [] }) })) as unknown as typeof fetch;
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 = (vars.messages as unknown[]).map((m, i) => {
106
- const o = m as { role?: unknown; content?: unknown };
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 = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
154
- const content = data.choices?.[0]?.message?.content;
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 (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
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 as Record<string, unknown>;
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 !== null && typeof out === "object" && !Array.isArray(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
- } as unknown as EngineClient;
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: String((e as Error)?.message ?? e) }, 500);
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 && typeof body === "object") {
219
- const v = (body as { variables?: unknown }).variables;
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 (v && typeof v === "object" && !Array.isArray(v)) {
223
- variables = v as Record<string, unknown>;
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: String((e as Error)?.message ?? e) }, 502);
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 as { processInstanceKey?: unknown })?.processInstanceKey;
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: String((e as Error)?.message ?? e) }, 502);
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 body = JSON.parse((await req.text()) || "{}") as {
273
- name?: unknown;
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 (v && typeof v === "object" && !Array.isArray(v)) {
283
- variables = v as Record<string, unknown>;
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: String((e as Error)?.message ?? e) }, 502);
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
- const inputs = {};
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 input = el("input", { type: "text", placeholder: f.label || f.key });
458
- inputs[f.key] = input;
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 Object.entries(inputs)) variables[k] = input.value;
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 Object.values(inputs)) input.value = "";
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 dtr = el("tr", { hidden: "" }, el("td", { colspan: span }));
683
- let built = false;
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 && !built) {
690
- built = true;
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 = (ctx.manifest.security ?? {}) as {
31
- mode?: string;
32
- roles?: string[];
33
- rules?: {
34
- actions?: Record<string, string[]>;
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