@nanobpm/urban 0.79.0 → 0.81.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 (52) hide show
  1. package/dist/runtime/core/manifest.d.ts +44 -11
  2. package/dist/runtime/core/modules/cancel.d.ts +13 -8
  3. package/dist/runtime/core/modules/cancel.js +40 -21
  4. package/dist/runtime/core/modules/datasource.js +7 -4
  5. package/dist/runtime/core/modules/formjs.gen.d.ts +1 -0
  6. package/dist/runtime/core/modules/formjs.gen.js +5 -0
  7. package/dist/runtime/core/modules/forms.d.ts +22 -0
  8. package/dist/runtime/core/modules/forms.js +59 -0
  9. package/dist/runtime/core/modules/gateway.js +5 -4
  10. package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
  11. package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
  12. package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
  13. package/dist/runtime/core/modules/instance-tracking.js +451 -123
  14. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
  15. package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
  16. package/dist/runtime/core/modules/pages.js +42 -4
  17. package/dist/runtime/core/modules/runtime.gen.js +1 -1
  18. package/dist/runtime/core/modules/surfaces.js +18 -124
  19. package/dist/runtime/core/read-model.d.ts +28 -0
  20. package/dist/runtime/core/read-model.js +44 -4
  21. package/dist/runtime/core/validate.js +192 -0
  22. package/dist/runtime/index.d.ts +2 -1
  23. package/dist/runtime/index.js +5 -0
  24. package/package.json +1 -1
  25. package/src/runtime/browser/fake-dom.test-utils.ts +114 -0
  26. package/src/runtime/browser/formjs.browser.js +192 -0
  27. package/src/runtime/browser/formjs.browser.test.ts +136 -0
  28. package/src/runtime/browser/runtime.browser.js +101 -1
  29. package/src/runtime/browser/runtime.engineform.test.ts +158 -0
  30. package/src/runtime/core/manifest.ts +44 -11
  31. package/src/runtime/core/modules/cancel.test.ts +112 -38
  32. package/src/runtime/core/modules/cancel.ts +42 -22
  33. package/src/runtime/core/modules/datasource.ts +7 -4
  34. package/src/runtime/core/modules/formjs.gen.ts +6 -0
  35. package/src/runtime/core/modules/forms.test.ts +122 -0
  36. package/src/runtime/core/modules/forms.ts +67 -0
  37. package/src/runtime/core/modules/gateway.ts +7 -4
  38. package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
  39. package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
  40. package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
  41. package/src/runtime/core/modules/instance-tracking.ts +469 -126
  42. package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
  43. package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
  44. package/src/runtime/core/modules/pages.test.ts +99 -0
  45. package/src/runtime/core/modules/pages.ts +44 -5
  46. package/src/runtime/core/modules/runtime.gen.ts +1 -1
  47. package/src/runtime/core/modules/surfaces.ts +19 -121
  48. package/src/runtime/core/read-model.test.ts +69 -0
  49. package/src/runtime/core/read-model.ts +43 -4
  50. package/src/runtime/core/validate.test.ts +341 -0
  51. package/src/runtime/core/validate.ts +188 -0
  52. package/src/runtime/index.ts +15 -0
@@ -8,8 +8,9 @@
8
8
  // Surfaces contribute routes to the shared server.
9
9
 
10
10
  import type { AppApi, RuntimeContext } from "../context.ts";
11
- import { presentFormIdentifier } from "../host.ts";
12
- import { html, json, noContent, normalizeRoutePath, type Route } from "../router.ts";
11
+ import { html, json, normalizeRoutePath, type Route } from "../router.ts";
12
+ import { FORMJS_JS } from "./formjs.gen.ts";
13
+ import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
13
14
  import { mountActions } from "./actions.ts";
14
15
  import { mountAgent } from "./agent.ts";
15
16
  import { mountApi } from "./api.ts";
@@ -42,13 +43,15 @@ function inboxPage(): string {
42
43
  ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
43
44
  code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
44
45
  button{font:inherit;padding:.2rem .6rem;cursor:pointer}
45
- form label{display:block;margin:.6rem 0 .2rem;font-weight:600}
46
- form .field input,form .field textarea,form .field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
47
- form .field input[type=checkbox],form .field input[type=radio]{width:auto}
46
+ .njf-field label{display:block;margin:.6rem 0 .2rem;font-weight:600}
47
+ .njf-field input,.njf-field textarea,.njf-field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
48
+ .njf-field input[type=checkbox],.njf-field input[type=radio]{width:auto}
49
+ .njf-actions{margin-top:.6rem}
48
50
  #form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
49
51
  .muted{color:#666}</style>
50
52
  <h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
51
53
  <div id="form"></div>
54
+ <script>${FORMJS_JS}</script>
52
55
  <script>
53
56
  const BASE=location.pathname.replace(/[/]+$/,'');
54
57
  const tasksEl=document.getElementById('tasks');
@@ -83,7 +86,14 @@ function openForm(t){
83
86
  api('/api/form?'+q).then(f=>{
84
87
  formEl.replaceChildren();
85
88
  if(!f||!f.schema){renderNoForm(t);return;}
86
- renderForm(t,f.schema);
89
+ // Reuse the ONE shared form-js renderer (NanoFormJs) — no fork with the pages surface.
90
+ formEl.appendChild(NanoFormJs.renderForm(f.schema,{
91
+ heading:'Task '+(t.elementId||t.userTaskKey),
92
+ submitLabel:'Submit',
93
+ cancelLabel:'Cancel',
94
+ onSubmit:(variables)=>complete(t.userTaskKey,variables),
95
+ onCancel:loadTasks,
96
+ }));
87
97
  }).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
88
98
  }
89
99
 
@@ -95,93 +105,6 @@ function renderNoForm(t){
95
105
  formEl.appendChild(bar);
96
106
  }
97
107
 
98
- function renderForm(t,schema){
99
- const form=document.createElement('form');
100
- form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
101
- const components=Array.isArray(schema.components)?schema.components:[];
102
- const inputs=[];
103
- for(const c of components){
104
- const built=buildField(c);
105
- if(!built)continue;
106
- form.appendChild(built.field);
107
- if(built.read)inputs.push(built.read);
108
- }
109
- const submit=btn('Submit',null);submit.type='submit';
110
- const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
111
- form.appendChild(bar);
112
- form.addEventListener('submit',e=>{
113
- e.preventDefault();
114
- // Null-prototype: field keys come from the engine-supplied form schema. A component
115
- // keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
116
- // through JSON), never mutate a prototype — no prototype pollution in the page runtime.
117
- const variables=Object.create(null);
118
- for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
119
- submit.disabled=true;
120
- complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
121
- });
122
- formEl.appendChild(form);
123
- }
124
-
125
- function buildField(c){
126
- const type=c&&c.type;
127
- // Static (keyless) components: show text as a paragraph, ignore layout-only ones.
128
- if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
129
- const key=c&&c.key;
130
- if(!key)return null;
131
- const wrap=el('div',{class:'field'});
132
- const label=typeof c.label==='string'&&c.label?c.label:key;
133
- if(type==='checkbox'){
134
- const input=document.createElement('input');input.type='checkbox';
135
- const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
136
- wrap.appendChild(lab);
137
- return {field:wrap,read:()=>({key,value:input.checked})};
138
- }
139
- const labelEl=el('label',{},label);
140
- wrap.appendChild(labelEl);
141
- let input;
142
- if(type==='textarea'){input=document.createElement('textarea');}
143
- else if(type==='select'){
144
- input=document.createElement('select');
145
- input.appendChild(el('option',{value:''},'—'));
146
- for(const o of (Array.isArray(c.values)?c.values:[])){
147
- input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
148
- }
149
- }
150
- else if(type==='radio'){
151
- const name='r'+Math.random().toString(36).slice(2);
152
- const group=el('div',{});
153
- for(const o of (Array.isArray(c.values)?c.values:[])){
154
- const rlab=el('label',{});
155
- const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
156
- rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
157
- group.appendChild(rlab);
158
- }
159
- wrap.appendChild(group);
160
- return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
161
- }
162
- else{
163
- input=document.createElement('input');
164
- input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
165
- }
166
- // Associate the label with the input (for/id) so screen readers announce it on focus.
167
- const fieldId='f'+Math.random().toString(36).slice(2);
168
- input.id=fieldId;labelEl.setAttribute('for',fieldId);
169
- wrap.appendChild(input);
170
- const isNumber=type==='number';
171
- return {field:wrap,read:()=>{
172
- const raw=input.value;
173
- if(raw===''||raw==null)return null;
174
- if(isNumber){
175
- const n=Number(raw);
176
- // A non-numeric value in a number field is only reachable via tampering (the browser
177
- // blanks invalid type=number input). Treat it as absent rather than silently submitting
178
- // NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
179
- return Number.isFinite(n)?{key,value:n}:null;
180
- }
181
- return {key,value:raw};
182
- }};
183
- }
184
-
185
108
  function complete(userTaskKey,variables){
186
109
  return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
187
110
  body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
@@ -236,39 +159,14 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
236
159
  method: "GET",
237
160
  path: `${base}/api/form`,
238
161
  source: "surface:taskInbox",
239
- handler: async (req) => {
240
- const formKey = req.query.get("formKey") ?? undefined;
241
- const formId = req.query.get("formId") ?? undefined;
242
- // Reject only when *neither* identifier is present. Presence follows getForm's
243
- // canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
244
- // whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
245
- // truthiness check and returning a spurious 204. The raw values are still passed
246
- // through unchanged — resolving a blank key to its `formId` fallback is getForm's
247
- // single responsibility.
248
- if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
249
- return json({ error: "formKey or formId required" }, 400);
250
- const form = await app.engine.getForm({ formKey, formId });
251
- // A task whose form can't be resolved returns 204: the client renders the
252
- // no-form fallback rather than erroring.
253
- if (!form) return noContent();
254
- return json(form);
255
- },
162
+ handler: async (req) =>
163
+ resolveFormResponse(app.engine, req.query.get("formKey") ?? undefined, req.query.get("formId") ?? undefined),
256
164
  });
257
165
  routes.push({
258
166
  method: "POST",
259
167
  path: `${base}/api/complete`,
260
168
  source: "surface:taskInbox",
261
- handler: async (req) => {
262
- let body: { userTaskKey?: string; variables?: Record<string, unknown> };
263
- try {
264
- body = JSON.parse((await req.text()) || "{}");
265
- } catch {
266
- return json({ error: "invalid JSON body" }, 400);
267
- }
268
- if (!body.userTaskKey) return json({ error: "userTaskKey required" }, 400);
269
- await app.engine.completeUserTask(body.userTaskKey, body.variables);
270
- return json({ ok: true });
271
- },
169
+ handler: async (req) => completeUserTaskResponse(app.engine, await req.text()),
272
170
  });
273
171
  }
274
172
 
@@ -25,6 +25,8 @@ import {
25
25
  ProjectionRegistry,
26
26
  projectionRegistry,
27
27
  ReadModelRegistry,
28
+ isReservedObjectName,
29
+ RESERVED_OBJECT_PREFIXES,
28
30
  when,
29
31
  } from "./read-model.ts";
30
32
  import type { ReadModel } from "./read-model.ts";
@@ -82,6 +84,19 @@ const EXPECTED: Record<string, { display_status: string; is_high_priority: numbe
82
84
  t3: { display_status: "active", is_high_priority: 1 },
83
85
  };
84
86
 
87
+ test("isReservedObjectName flags every reserved prefix, case-insensitively, and clears domain names", () => {
88
+ // Single source of truth for the prefixes the datasource surface hides (gateway.schema()) and the
89
+ // provenance recorder skips (datasource.ts). SQLite folds ASCII object names, so a cased reserved
90
+ // name must not slip through.
91
+ assert.deepEqual([...RESERVED_OBJECT_PREFIXES], ["_urban_", "_nano_", "sqlite_"]);
92
+ for (const name of ["_urban_x", "_URBAN_x", "_nano_y", "sqlite_master", "SQLITE_stat1"]) {
93
+ assert.ok(isReservedObjectName(name), `expected "${name}" reserved`);
94
+ }
95
+ for (const name of ["orders", "plans__tracking", "urban_things", "nano", "sqlitely"]) {
96
+ assert.ok(!isReservedObjectName(name), `expected "${name}" NOT reserved`);
97
+ }
98
+ });
99
+
85
100
  test("(i) the read model with CASE + EXISTS compiles to a SQLite VIEW returning the expected values", async () => {
86
101
  await withDb((db) => {
87
102
  seedFixtures(db);
@@ -219,6 +234,60 @@ test("both backends agree on NULL inputs: SQL NULL propagation is COALESCE'd to
219
234
  });
220
235
  });
221
236
 
237
+ test("compileToFn resolves col/pcol identifiers case-insensitively (SQLite identifier folding)", () => {
238
+ // SQLite folds identifier case even for quoted identifiers, so `col("Status")` in the VIEW binds to a
239
+ // column DECLARED `status`. The TS backend must fold too — a case-sensitive `row["Status"]` returns
240
+ // null and silently diverges from the VIEW on the no-edge fallback and on keyField correlation.
241
+ assert.equal(compileToFn(col("Status"))({ status: "abandoned" }), "abandoned"); // folds to declared column
242
+ assert.equal(compileToFn(col("status"))({ status: "abandoned" }), "abandoned"); // exact match still works
243
+ assert.equal(compileToFn(col("Missing"))({ status: "x" }), null); // a truly-absent column → null (not a fold)
244
+ // pcol inside EXISTS folds too, so keyField correlation matches the VIEW's join.
245
+ const corr = compileToFn(exists("p", eq(pcol("Process_Instance_Key"), col("Key"))));
246
+ assert.equal(corr({ key: "pi-1" }, { p: [{ process_instance_key: "pi-1" }] }), true);
247
+ assert.equal(corr({ key: "pi-1" }, { p: [{ process_instance_key: "pi-2" }] }), false);
248
+ });
249
+
250
+ test("mixed-case col/pcol identifiers: the VIEW and TS backends agree (VIEW/TS parity on the no-edge fallback)", async () => {
251
+ // End-to-end guard for the case-fold fix: author a model with mixed-case identifiers over lowercase
252
+ // columns and confirm the SQLite VIEW and the TS backend derive the SAME value — including the
253
+ // parity-critical no-edge fallback that passes the stored status through.
254
+ const mixed: ReadModel = defineReadModel({
255
+ name: "mixed_case_display",
256
+ baseTable: "widgets",
257
+ derive: {
258
+ effective: caseWhen(
259
+ [
260
+ when(
261
+ exists("urban_open_user_tasks", eq(pcol("Process_Instance_Key"), col("Process_Key"))),
262
+ lit("awaiting_operator"),
263
+ ),
264
+ ],
265
+ col("Status"), // no-edge fallback → the stored status, read via a mixed-case identifier
266
+ ),
267
+ },
268
+ });
269
+ await withDb((db) => {
270
+ db.exec(`CREATE TABLE widgets (id TEXT, process_key TEXT, status TEXT);`);
271
+ db.exec(`CREATE TABLE urban_open_user_tasks (process_instance_key TEXT);`);
272
+ db.exec(mixed.viewDdl());
273
+ db.run(`INSERT INTO widgets VALUES (?, ?, ?)`, ["w1", "pi-1", "dispatched"]); // no open task → fallback
274
+ db.run(`INSERT INTO widgets VALUES (?, ?, ?)`, ["w2", "pi-2", "dispatched"]);
275
+ db.run(`INSERT INTO urban_open_user_tasks VALUES (?)`, ["pi-2"]); // w2 waiting on a human
276
+ // SQL backend: the VIEW resolves the mixed-case identifiers against the lowercase columns.
277
+ const sqlRows = db
278
+ .all<{ id: string; effective: string }>(`SELECT id, effective FROM mixed_case_display ORDER BY id`)
279
+ .map((r) => ({ ...r }));
280
+ assert.deepEqual(sqlRows, [
281
+ { id: "w1", effective: "dispatched" }, // no-edge fallback → stored status (the parity-critical path)
282
+ { id: "w2", effective: "awaiting_operator" },
283
+ ]);
284
+ // TS backend: base/projection rows arrive keyed as SQLite stored them (lowercase).
285
+ const projections = { urban_open_user_tasks: [{ process_instance_key: "pi-2" }] };
286
+ assert.equal(mixed.evaluate({ id: "w1", process_key: "pi-1", status: "dispatched" }, projections).effective, "dispatched");
287
+ assert.equal(mixed.evaluate({ id: "w2", process_key: "pi-2", status: "dispatched" }, projections).effective, "awaiting_operator");
288
+ });
289
+ });
290
+
222
291
  test("EXISTS references a projection by name; the projection registry resolves the physical table", () => {
223
292
  const reg = new ProjectionRegistry();
224
293
  reg.register({ name: "urban_instance_state", sqlTable: "_urban_instance_state" });
@@ -261,14 +261,37 @@ function resolveBaseAlias(alias: string | undefined): string {
261
261
  return assertSqlIdentifier("base alias", alias ?? DEFAULT_BASE_ALIAS);
262
262
  }
263
263
 
264
- /** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL. */
265
- const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
264
+ /** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL. This is the
265
+ * single source of truth for "is this a legal identifier"; the manifest validator imports it (rather
266
+ * than re-declaring the regex) so a name accepted at author time is accepted at VIEW provisioning too
267
+ * (No Drift Surfaces — a divergence would let a manifest validate but fail at boot). */
268
+ export const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
269
+ /** True iff `name` is a legal SQL identifier per {@link SQL_IDENT}. The boolean twin of
270
+ * {@link assertSqlIdentifier}, for callers (e.g. the manifest validator) that collect issues rather
271
+ * than throw. */
272
+ export function isSqlIdentifier(name: string): boolean {
273
+ return SQL_IDENT.test(name);
274
+ }
266
275
  export function assertSqlIdentifier(kind: string, name: string): string {
267
276
  if (!SQL_IDENT.test(name)) {
268
277
  throw new Error(`invalid ${kind} "${name}": must match ${SQL_IDENT.source}`);
269
278
  }
270
279
  return name;
271
280
  }
281
+ /** Object-name prefixes reserved for engine/runtime internals. Tables and views whose names begin
282
+ * with one of these are Nano bookkeeping (`_urban_*` / `_nano_*`) or SQLite internals (`sqlite_*`):
283
+ * the provenance recorder skips them (`modules/datasource.ts`) and the datasource `schema()` surface
284
+ * filters them out (`modules/gateway.ts`), so they are invisible to `/app/data`, DB Manager, and forms.
285
+ * This is the single source of truth for that prefix set (No Drift Surfaces — a manifest that provisions
286
+ * a `_urban_*` VIEW would otherwise validate yet be unreadable by the documented pages surface). */
287
+ export const RESERVED_OBJECT_PREFIXES = ["_urban_", "_nano_", "sqlite_"] as const;
288
+ /** True iff `name` begins with a {@link RESERVED_OBJECT_PREFIXES} prefix. Case-insensitive, matching
289
+ * SQLite's ASCII identifier folding (a `_URBAN_x` table resolves to the same reserved object as
290
+ * `_urban_x`), so a reserved name cannot slip through by casing. */
291
+ export function isReservedObjectName(name: string): boolean {
292
+ const folded = name.toLowerCase();
293
+ return RESERVED_OBJECT_PREFIXES.some((p) => folded.startsWith(p));
294
+ }
272
295
  function quoteIdent(name: string): string {
273
296
  return `"${name.replace(/"/g, '""')}"`;
274
297
  }
@@ -491,10 +514,10 @@ export function compileToFn(expr: Expr): DerivationFn {
491
514
  case "lit":
492
515
  return node.value;
493
516
  case "col":
494
- return baseRow[node.name] ?? null;
517
+ return lookupColumn(baseRow, node.name);
495
518
  case "pcol":
496
519
  if (!projRow) throw new Error(`pcol("${node.name}") evaluated outside an EXISTS projection context`);
497
- return projRow[node.name] ?? null;
520
+ return lookupColumn(projRow, node.name);
498
521
  case "compare":
499
522
  return compareValues(
500
523
  node.op,
@@ -524,6 +547,22 @@ export function compileToFn(expr: Expr): DerivationFn {
524
547
  return (baseRow, projections = {}) => evalNode(expr, baseRow, projections);
525
548
  }
526
549
 
550
+ /** Resolve a column value the way SQLite resolves an identifier: an exact-key match first, then a
551
+ * case-insensitive fallback. SQLite folds ASCII identifier case even for quoted identifiers, so the
552
+ * VIEW's `col("Status")` binds to a table declared with column `status`; a plain case-sensitive JS
553
+ * `row["Status"]` would miss it and return null, breaking VIEW/TS parity on the no-edge fallback and on
554
+ * `keyField` correlation. Fold here so both backends read the same column. (A table cannot declare two
555
+ * columns differing only in case, so the fallback is unambiguous.) Returns `null` for a missing column,
556
+ * preserving the previous `?? null` contract. */
557
+ export function lookupColumn(row: Record<string, unknown>, name: string): unknown {
558
+ if (Object.hasOwn(row, name)) return row[name] ?? null;
559
+ const folded = name.toLowerCase();
560
+ for (const key of Object.keys(row)) {
561
+ if (key.toLowerCase() === folded) return row[key] ?? null;
562
+ }
563
+ return null;
564
+ }
565
+
527
566
  // ───────────────────────────────────────── defineReadModel ───────────────────────────────────────
528
567
 
529
568
  /** The declarative read-model input passed to {@link defineReadModel}. */