@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
@@ -14,6 +14,7 @@ import {
14
14
  } from "./gateway.ts";
15
15
  import { currentJobContext } from "../execContext.ts";
16
16
  import { isAbsolutePath } from "../../../toolkit/artifact.ts";
17
+ import { SQL_IDENT, isReservedObjectName } from "../read-model.ts";
17
18
 
18
19
  export function sqlitePathFromUrl(url: string): string {
19
20
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
@@ -95,7 +96,7 @@ export function makeProvenanceRecorder(
95
96
  source: string,
96
97
  ): InsertObserver {
97
98
  return (table, pk) => {
98
- if (table.startsWith("_urban_") || table.startsWith("_nano_") || table.startsWith("sqlite_")) {
99
+ if (isReservedObjectName(table)) {
99
100
  return;
100
101
  }
101
102
  const ctx = currentJobContext();
@@ -153,9 +154,11 @@ export async function openSqliteSource(
153
154
  return db;
154
155
  }
155
156
 
156
- /** A safe unquoted SQL identifier. Table/column names are interpolated directly into SQL,
157
- * so reject anything that isn't a plain identifier to prevent invalid SQL / injection. */
158
- const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
157
+ /** A safe unquoted SQL identifier check. Table/column names are interpolated directly into SQL, so
158
+ * reject anything that isn't a plain identifier to prevent invalid SQL / injection. The regex itself
159
+ * is the runtime-wide single source of truth in `read-model.ts` (No Drift Surfaces — a divergent copy
160
+ * could let table validation and read-model/manifest validation disagree); we reuse it here rather than
161
+ * re-declaring it, keeping this module's own error message. */
159
162
  function assertSqlIdent(kind: string, name: string): string {
160
163
  if (!SQL_IDENT.test(name)) {
161
164
  throw new Error(`invalid ${kind} "${name}": must match ${SQL_IDENT.source}`);
@@ -0,0 +1,6 @@
1
+ // @generated by scripts/gen-runtime.mjs — DO NOT EDIT.
2
+ // Source: src/runtime/browser/formjs.browser.js (the shared, type-checked/linted/tested
3
+ // form-js renderer). Regenerate with `npm run gen:runtime`; CI enforces this file
4
+ // is byte-identical to a fresh generation via `git diff --exit-code` (npm run check:runtime).
5
+
6
+ export const FORMJS_JS: string = "// @ts-check\n// ── Shared, XSS-safe form-js schema renderer (ADR 0026, extended by #457) ─────\n// This is the ONE client implementation of \"render a deployed `.form`'s form-js\n// schema and collect its field values\", shared verbatim by BOTH:\n// • the `taskInbox` surface (surfaces.ts `inboxPage`), and\n// • the pages `dataGrid` engine-form detail (runtime.browser.js).\n// It used to live inline in surfaces.ts; factoring it here means the two surfaces\n// can never fork the renderer or drift on how a form-js component maps to an input\n// and its submitted value.\n//\n// Like the rest of the browser runtime it is authored/type-checked/linted/tested\n// as real source and served as a generated string artifact (formjs.gen.ts, emitted\n// by scripts/gen-runtime.mjs). It performs NO DOM access at load — every DOM touch\n// is inside a function — so it is import-safe under Node/Deno for unit testing.\n//\n// SECURITY: every value the engine returns is placed via textContent / setAttribute\n// (never innerHTML), so nothing in the form schema can inject markup. The submitted\n// `variables` bag is null-prototype so an engine-supplied component key of\n// `__proto__`/`constructor` lands as a plain own property and can never mutate a\n// prototype (prototype-pollution class).\n\n/** Create an element, setting attributes via setAttribute and text via textContent only.\n * @param {string} tag\n * @param {Record<string, string>} [attrs]\n * @param {string} [text]\n * @returns {any}\n */\nfunction fjEl(tag, attrs, text) {\n const e = document.createElement(tag);\n if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]);\n if (text != null) e.textContent = text;\n return e;\n}\n\n/** A plain type=\"button\" with an optional click handler.\n * @param {string} label\n * @param {(() => void) | null} [onClick]\n * @returns {any}\n */\nfunction fjBtn(label, onClick) {\n const b = document.createElement(\"button\");\n b.type = \"button\";\n b.textContent = label;\n if (onClick) b.addEventListener(\"click\", onClick);\n return b;\n}\n\n/** Build one form-js component into its field element and (for keyed inputs) a reader\n * that returns the entered `{ key, value }` or null when blank. Static (keyless)\n * components render as inert text; layout-only components are skipped (null).\n * @param {any} c\n * @returns {{ field: any, read?: () => ({ key: string, value: any } | null) } | null}\n */\nfunction buildField(c) {\n const type = c && c.type;\n // Static (keyless) components: show text as a paragraph, ignore layout-only ones.\n if (type === \"text\") {\n return { field: fjEl(\"p\", { class: \"njf-text\" }, typeof c.text === \"string\" ? c.text : \"\") };\n }\n const key = c && c.key;\n if (!key) return null;\n const wrap = fjEl(\"div\", { class: \"njf-field\" });\n const label = typeof c.label === \"string\" && c.label ? c.label : key;\n if (type === \"checkbox\") {\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n const lab = fjEl(\"label\", {});\n lab.appendChild(input);\n lab.appendChild(document.createTextNode(\" \" + label));\n wrap.appendChild(lab);\n return { field: wrap, read: () => ({ key, value: input.checked }) };\n }\n const labelEl = fjEl(\"label\", {}, label);\n wrap.appendChild(labelEl);\n let input;\n if (type === \"textarea\") {\n input = document.createElement(\"textarea\");\n } else if (type === \"select\") {\n input = document.createElement(\"select\");\n input.appendChild(fjEl(\"option\", { value: \"\" }, \"\\u2014\"));\n for (const o of Array.isArray(c.values) ? c.values : []) {\n input.appendChild(fjEl(\"option\", { value: String(o.value) }, String(o.label != null ? o.label : o.value)));\n }\n } else if (type === \"radio\") {\n const name = \"r\" + Math.random().toString(36).slice(2);\n const group = fjEl(\"div\", {});\n for (const o of Array.isArray(c.values) ? c.values : []) {\n const rlab = fjEl(\"label\", {});\n const r = document.createElement(\"input\");\n r.type = \"radio\";\n r.name = name;\n r.value = String(o.value);\n rlab.appendChild(r);\n rlab.appendChild(document.createTextNode(\" \" + String(o.label != null ? o.label : o.value)));\n group.appendChild(rlab);\n }\n wrap.appendChild(group);\n return {\n field: wrap,\n read: () => {\n const sel = group.querySelector(\"input:checked\");\n return sel ? { key, value: sel.value } : null;\n },\n };\n } else {\n input = document.createElement(\"input\");\n input.type = type === \"number\" ? \"number\" : type === \"datetime\" ? \"datetime-local\" : \"text\";\n }\n // Associate the label with the input (for/id) so screen readers announce it on focus.\n const fieldId = \"f\" + Math.random().toString(36).slice(2);\n input.id = fieldId;\n labelEl.setAttribute(\"for\", fieldId);\n wrap.appendChild(input);\n const isNumber = type === \"number\";\n return {\n field: wrap,\n read: () => {\n const raw = input.value;\n if (raw === \"\" || raw == null) return null;\n if (isNumber) {\n const n = Number(raw);\n // A non-numeric value in a number field is only reachable via tampering (the browser\n // blanks invalid type=number input). Treat it as absent rather than silently submitting\n // NaN, which JSON.stringify serializes as null — quietly changing the submitted value.\n return Number.isFinite(n) ? { key, value: n } : null;\n }\n return { key, value: raw };\n },\n };\n}\n\n/**\n * Render a form-js schema into a `<form>` element with a submit + optional cancel.\n * On submit it assembles every keyed field's value into one null-prototype\n * `variables` bag and calls `opts.onSubmit(variables)`; a rejected submit re-enables\n * the button so the operator can retry. `opts.onCancel`, when supplied, wires a\n * cancel button.\n * @param {any} schema the parsed form-js document (`{ components: [...] }`)\n * @param {{ heading?: string, submitLabel?: string, cancelLabel?: string,\n * onSubmit: (variables: Record<string, any>) => (void | Promise<void>),\n * onCancel?: () => void }} opts\n * @returns {any} the built <form> element\n */\nfunction renderForm(schema, opts) {\n const form = document.createElement(\"form\");\n form.className = \"njf-form\";\n if (opts.heading) form.appendChild(fjEl(\"h2\", { class: \"njf-heading\" }, opts.heading));\n const components = schema && Array.isArray(schema.components) ? schema.components : [];\n /** @type {Array<() => ({ key: string, value: any } | null)>} */\n const inputs = [];\n for (const c of components) {\n const built = buildField(c);\n if (!built) continue;\n form.appendChild(built.field);\n if (built.read) inputs.push(built.read);\n }\n const submit = fjBtn(opts.submitLabel || \"Submit\", null);\n submit.type = \"submit\";\n const bar = fjEl(\"div\", { class: \"njf-actions\" });\n bar.appendChild(submit);\n if (opts.onCancel) bar.appendChild(fjBtn(opts.cancelLabel || \"Cancel\", opts.onCancel));\n form.appendChild(bar);\n form.addEventListener(\"submit\", (e) => {\n e.preventDefault();\n // Null-prototype: field keys come from the engine-supplied form schema. A component\n // keyed '__proto__'/'constructor' must land as a plain own property (and round-trip\n // through JSON), never mutate a prototype — no prototype pollution in the page runtime.\n const variables = Object.create(null);\n for (const read of inputs) {\n const kv = read();\n if (kv) variables[kv.key] = kv.value;\n }\n submit.disabled = true;\n Promise.resolve()\n .then(() => opts.onSubmit(variables))\n .catch(() => {\n submit.disabled = false;\n });\n });\n return form;\n}\n\n\n// Expose the renderer as a browser global so both self-contained page bundles can\n// reach the ONE implementation without importing it: the taskInbox page embeds this\n// module inline and the pages shell does the same before loading the ES-module\n// runtime, which reads `globalThis.NanoFormJs` lazily when it renders an engine form.\n// Guarded so importing under Node/Deno for tests is a harmless no-op-ish assignment.\nif (typeof globalThis !== \"undefined\") {\n /** @type {any} */ (globalThis).NanoFormJs = { buildField, renderForm };\n}\n";
@@ -0,0 +1,122 @@
1
+ // The shared engine-form server seams (ADR 0026, #457): the /api/form resolution
2
+ // gate and the user-task completion, factored out of surfaces.ts so the taskInbox
3
+ // surface and the pages grid detail share ONE implementation. These tests pin the
4
+ // gate's contract directly (both callers depend on it identically).
5
+
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import type { EngineClient, FormSchema } from "../host.ts";
9
+ import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
10
+
11
+ function formEngine(getForm: EngineClient["getForm"]): Pick<EngineClient, "getForm"> {
12
+ return { getForm };
13
+ }
14
+
15
+ test("resolveFormResponse 400s when neither identifier is present (empty/whitespace = absent)", async () => {
16
+ const engine = formEngine(async () => {
17
+ throw new Error("getForm must not be called when no identifier is present");
18
+ });
19
+ for (const [formKey, formId] of [
20
+ [undefined, undefined],
21
+ ["", ""],
22
+ [" ", undefined],
23
+ [undefined, "\t"],
24
+ ] as const) {
25
+ const res = await resolveFormResponse(engine, formKey, formId);
26
+ assert.equal(res.status, 400);
27
+ }
28
+ });
29
+
30
+ test("resolveFormResponse passes raw identifiers through to getForm and returns its schema", async () => {
31
+ const seen: { formKey?: string; formId?: string }[] = [];
32
+ const schema: FormSchema = { formKey: "form-1", schema: { components: [] } };
33
+ const engine = formEngine(async (input) => {
34
+ seen.push(input);
35
+ return schema;
36
+ });
37
+ const res = await resolveFormResponse(engine, "form-1", undefined);
38
+ assert.equal(res.status, 200);
39
+ assert.ok(res.body);
40
+ assert.deepEqual(JSON.parse(res.body), schema);
41
+ // A blank formKey is passed through unchanged so getForm can fall back to formId
42
+ // (that fallback is getForm's single responsibility, not the gate's).
43
+ await resolveFormResponse(engine, "", "the-form-id");
44
+ assert.deepEqual(seen, [
45
+ { formKey: "form-1", formId: undefined },
46
+ { formKey: "", formId: "the-form-id" },
47
+ ]);
48
+ });
49
+
50
+ test("resolveFormResponse 204s (no body) when getForm can't resolve a form", async () => {
51
+ const res = await resolveFormResponse(formEngine(async () => null), "missing", undefined);
52
+ assert.equal(res.status, 204);
53
+ assert.equal(res.body, "");
54
+ });
55
+
56
+ test("completeUserTaskResponse completes the task with the submitted variables", async () => {
57
+ const calls: { key: string; variables?: Record<string, unknown> }[] = [];
58
+ const engine: Pick<EngineClient, "completeUserTask"> = {
59
+ async completeUserTask(key, variables) {
60
+ calls.push({ key, variables });
61
+ },
62
+ };
63
+ const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-7", variables: { a: 1 } }));
64
+ assert.equal(res.status, 200);
65
+ assert.ok(res.body);
66
+ assert.deepEqual(JSON.parse(res.body), { ok: true });
67
+ assert.deepEqual(calls, [{ key: "ut-7", variables: { a: 1 } }]);
68
+ });
69
+
70
+ test("completeUserTaskResponse 400s on a missing userTaskKey or malformed body — no completion attempted", async () => {
71
+ let called = false;
72
+ const engine: Pick<EngineClient, "completeUserTask"> = {
73
+ async completeUserTask() {
74
+ called = true;
75
+ },
76
+ };
77
+ assert.equal((await completeUserTaskResponse(engine, JSON.stringify({ variables: {} }))).status, 400);
78
+ assert.equal((await completeUserTaskResponse(engine, "{ not json")).status, 400);
79
+ assert.equal(called, false);
80
+ });
81
+
82
+ test("completeUserTaskResponse 400s on a non-object JSON body — no crash, no completion attempted", async () => {
83
+ let called = false;
84
+ const engine: Pick<EngineClient, "completeUserTask"> = {
85
+ async completeUserTask() {
86
+ called = true;
87
+ },
88
+ };
89
+ // `JSON.parse("null")` returns `null`, and a bare scalar/array parses to a
90
+ // non-record — reading `.userTaskKey` off those must 400, never throw a 500.
91
+ for (const bodyText of ["null", "123", '"a string"', "true", "[]"]) {
92
+ const res = await completeUserTaskResponse(engine, bodyText);
93
+ assert.equal(res.status, 400, `body=${bodyText} should 400`);
94
+ }
95
+ assert.equal(called, false);
96
+ });
97
+
98
+ test("completeUserTaskResponse 400s on non-object variables — no completion attempted", async () => {
99
+ let called = false;
100
+ const engine: Pick<EngineClient, "completeUserTask"> = {
101
+ async completeUserTask() {
102
+ called = true;
103
+ },
104
+ };
105
+ for (const variables of [1, "x", true, [], null]) {
106
+ const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-1", variables }));
107
+ assert.equal(res.status, 400, `variables=${JSON.stringify(variables)} should 400`);
108
+ }
109
+ assert.equal(called, false);
110
+ });
111
+
112
+ test("completeUserTaskResponse allows an omitted variables (undefined)", async () => {
113
+ const calls: { key: string; variables?: Record<string, unknown> }[] = [];
114
+ const engine: Pick<EngineClient, "completeUserTask"> = {
115
+ async completeUserTask(key, variables) {
116
+ calls.push({ key, variables });
117
+ },
118
+ };
119
+ const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-2" }));
120
+ assert.equal(res.status, 200);
121
+ assert.deepEqual(calls, [{ key: "ut-2", variables: undefined }]);
122
+ });
@@ -0,0 +1,67 @@
1
+ // Shared engine-form plumbing (ADR 0026, extended to the pages surface).
2
+ //
3
+ // The `taskInbox` surface pioneered rendering a user task's *engine-declared*
4
+ // form: resolve the task's `formKey`/`formId` via `EngineClient.getForm(...)`,
5
+ // render the returned form-js schema client-side, and post the entered values
6
+ // as the completion `variables`. The pages `dataGrid` engine-form detail (#457)
7
+ // needs the SAME two server seams — the `/api/form` resolution gate and the
8
+ // task-completion endpoint — so they live here ONCE and are wired by both
9
+ // `surfaces.ts` (taskInbox) and `pages.ts` (the grid detail). No fork: a single
10
+ // resolution gate, and the `host.ts` `presentFormIdentifier` presence rule stays
11
+ // the single source of truth for "was an identifier provided".
12
+
13
+ import type { EngineClient, HttpResponse } from "../host.ts";
14
+ import { presentFormIdentifier } from "../host.ts";
15
+ import { isRecord } from "../guards.ts";
16
+ import { json, noContent } from "../router.ts";
17
+
18
+ /**
19
+ * The shared `/api/form` resolution gate. Rejects only when *neither* identifier
20
+ * is present — presence follows getForm's canonical rule (`presentFormIdentifier`:
21
+ * empty/whitespace = absent) so a whitespace-only `?formKey= ` 400s here instead
22
+ * of slipping past a raw truthiness check and returning a spurious 204. The raw
23
+ * values are passed through unchanged — resolving a blank key to its `formId`
24
+ * fallback is getForm's single responsibility. A form that can't be resolved
25
+ * returns 204 so the client renders the no-form fallback rather than erroring.
26
+ */
27
+ export async function resolveFormResponse(
28
+ engine: Pick<EngineClient, "getForm">,
29
+ formKey: string | undefined,
30
+ formId: string | undefined,
31
+ ): Promise<HttpResponse> {
32
+ if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
33
+ return json({ error: "formKey or formId required" }, 400);
34
+ const form = await engine.getForm({ formKey, formId });
35
+ if (!form) return noContent();
36
+ return json(form);
37
+ }
38
+
39
+ /**
40
+ * The shared user-task completion endpoint. Parses `{ userTaskKey, variables }`
41
+ * from the request body and completes the task via `EngineClient.completeUserTask`.
42
+ * A body that isn't a JSON object (`null`, a scalar, an array) 400s instead of
43
+ * throwing a 500; a missing/blank `userTaskKey` 400s; a malformed body 400s; a
44
+ * present but non-object `variables` (number, string, array, `null`) 400s so the
45
+ * completion contract stays well-defined at this shared seam. Used by both the
46
+ * taskInbox surface and the pages grid's engine-form detail so the completion
47
+ * contract can't drift between them.
48
+ */
49
+ export async function completeUserTaskResponse(
50
+ engine: Pick<EngineClient, "completeUserTask">,
51
+ bodyText: string,
52
+ ): Promise<HttpResponse> {
53
+ let parsed: unknown;
54
+ try {
55
+ parsed = JSON.parse(bodyText || "{}");
56
+ } catch {
57
+ return json({ error: "invalid JSON body" }, 400);
58
+ }
59
+ if (!isRecord(parsed)) return json({ error: "invalid JSON body" }, 400);
60
+ const { userTaskKey, variables } = parsed;
61
+ if (typeof userTaskKey !== "string" || !userTaskKey)
62
+ return json({ error: "userTaskKey required" }, 400);
63
+ if (variables !== undefined && !isRecord(variables))
64
+ return json({ error: "variables must be a JSON object" }, 400);
65
+ await engine.completeUserTask(userTaskKey, variables);
66
+ return json({ ok: true });
67
+ }
@@ -13,6 +13,7 @@
13
13
  // const o = await orders.get(id);
14
14
 
15
15
  import type { SqliteDb } from "../host.ts";
16
+ import { RESERVED_OBJECT_PREFIXES } from "../read-model.ts";
16
17
 
17
18
  export type Row = Record<string, unknown>;
18
19
 
@@ -264,11 +265,13 @@ class SqliteGateway implements DataSource {
264
265
  // Exclude SQLite internals (`sqlite_%`) and Nano's own bookkeeping tables (`_urban_%` /
265
266
  // `_nano_%`, e.g. the migrations ledger): neither is a user/domain object, so they must
266
267
  // never surface in the domain model, DB Manager, or forms. The same exclusions apply to
267
- // views.
268
+ // views. The prefix set is derived from RESERVED_OBJECT_PREFIXES (No Drift Surfaces) so this
269
+ // read-side filter cannot diverge from the write-side skip / manifest-validation rejection.
270
+ const reservedClause = RESERVED_OBJECT_PREFIXES.map(
271
+ (p) => `AND name NOT LIKE '${p.replace(/_/g, "\\_")}%' ESCAPE '\\'`,
272
+ ).join(" ");
268
273
  const tables = this.#db.all<{ name: string; type: string }>(
269
- "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') " +
270
- "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_urban\\_%' ESCAPE '\\' " +
271
- "AND name NOT LIKE '\\_nano\\_%' ESCAPE '\\' ORDER BY name",
274
+ `SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ${reservedClause} ORDER BY name`,
272
275
  );
273
276
  const out: TableMeta[] = [];
274
277
  for (const t of tables) {
@@ -0,0 +1,41 @@
1
+ // Drift gate for the generated browser-runtime artifacts, enforced INSIDE the test
2
+ // suite (which CI already runs) rather than in a workflow step. The browser runtime
3
+ // and the shared form-js renderer are authored as real, type-checked/linted/tested
4
+ // source (runtime.browser.js, formjs.browser.js) and emitted to committed string
5
+ // artifacts (runtime.gen.ts, formjs.gen.ts) by scripts/gen-runtime.mjs. If either
6
+ // checked-in artifact drifts from a fresh generation, the served bundle no longer
7
+ // matches its source and a stale renderer could ship — so we regenerate-and-compare
8
+ // here and fail on any drift, the same guarantee `npm run check:runtime` gives.
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { execFileSync } from "node:child_process";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ // Resolve the generator relative to THIS file (cwd varies: `npm test` runs from the
16
+ // package root, but a runner may invoke us from elsewhere).
17
+ const GEN_SCRIPT = fileURLToPath(new URL("../../../../scripts/gen-runtime.mjs", import.meta.url));
18
+
19
+ function hasStderr(e: unknown): e is { stderr?: unknown; stdout?: unknown } {
20
+ return typeof e === "object" && e !== null && ("stderr" in e || "stdout" in e);
21
+ }
22
+
23
+ test("the committed runtime + formjs artifacts are byte-identical to a fresh generation (no drift)", () => {
24
+ // `--check` regenerates both artifacts in-memory and diffs them against the
25
+ // committed files, exiting non-zero (with a diagnostic) on any drift. Spawn the real
26
+ // `node` binary explicitly: under `deno test`, `process.execPath` is the `deno`
27
+ // binary, which cannot run this Node script — mirrors the pages.test.ts node --check
28
+ // guard. Regenerate + commit locally with `npm run gen:runtime -w packages/urban`.
29
+ let ok = true;
30
+ let diagnostic = "";
31
+ try {
32
+ execFileSync("node", [GEN_SCRIPT, "--check"], { stdio: "pipe" });
33
+ } catch (err) {
34
+ ok = false;
35
+ diagnostic = hasStderr(err) ? `${String(err.stdout ?? "")}${String(err.stderr ?? "")}` : String(err);
36
+ }
37
+ assert.ok(
38
+ ok,
39
+ `a committed generated artifact is stale — run \`npm run gen:runtime -w packages/urban\` and commit the result.\n${diagnostic}`,
40
+ );
41
+ });
@@ -0,0 +1,156 @@
1
+ // instance-status-read-model — the DERIVED half of the `instanceTracking` writer→source inversion
2
+ // (ADR 0065, surface #1; #439-L1 / #318). The reconciler used to WRITE the derivable status edges
3
+ // (`onTerminated.set` / `onWaitingHuman.set` applied as `table.update` patches); that stored-derived
4
+ // value tore from its inputs (nano-workforce#422: an answered escalation still showed a stale ⚠).
5
+ //
6
+ // After the inversion, the reconciler is a SOURCE — it feeds engine truth into the canonical
7
+ // projections (`urban_instance_state`, `urban_open_user_tasks`, ADR 0065 #1) — and the two derivable
8
+ // status edges are DERIVED here, once, in the closed `defineReadModel` expression DSL:
9
+ //
10
+ // terminal edge: EXISTS(urban_instance_state WHERE process_instance_key = base.<keyField>
11
+ // AND state = 'TERMINATED') -> onTerminated value
12
+ // wait-on-human edge: EXISTS(urban_open_user_tasks WHERE process_instance_key = base.<keyField>)
13
+ // -> onWaitingHuman value
14
+ //
15
+ // authored ONCE and compiled to BOTH the SQLite VIEW and the in-process TS function (surface #2 is
16
+ // closed by the two-backend compiler). Because a derived edge is recomputed on every read over the
17
+ // LIVE projections, a re-escalation after an answer re-flips by construction and #422 cannot recur:
18
+ // once `OpenUserTasksStore.syncInstance` retires the answered task, the `EXISTS` goes false on the
19
+ // next read — no stored column to leave stale.
20
+
21
+ import type { InstanceTracking } from "../manifest.ts";
22
+ import {
23
+ and,
24
+ caseWhen,
25
+ col,
26
+ defineReadModel,
27
+ eq,
28
+ type Expr,
29
+ exists,
30
+ lit,
31
+ not,
32
+ pcol,
33
+ type ReadModel,
34
+ type WhenClause,
35
+ when,
36
+ } from "../read-model.ts";
37
+ import { INSTANCE_STATE_PROJECTION } from "./instance-state-store.ts";
38
+ import { OPEN_USER_TASKS_PROJECTION } from "./open-user-tasks-store.ts";
39
+
40
+ /** The engine-truth terminal lifecycle state the terminal edge keys on (mirrors
41
+ * `ProcessInstanceState`'s `TERMINATED`). Reconciled only on `TERMINATED` — never `COMPLETED`, whose
42
+ * terminal row-write the app's own finalize worker owns — exactly as the pre-inversion reconciler was. */
43
+ export const TERMINATED_STATE = "TERMINATED";
44
+
45
+ /** The default derived-status column name a binding's read-model VIEW exposes. Distinct from the base
46
+ * `statusField` on purpose: the managed VIEW re-exports `base.*` (so pages keep every base column),
47
+ * and SQLite lets the base column win over a same-named derived alias — so the EFFECTIVE (derived)
48
+ * status is surfaced under its own column, which the operator page reads instead of the stored one.
49
+ * A binding may override it via `readModel.statusColumn`. */
50
+ export const DEFAULT_DERIVED_STATUS_COLUMN = "derived_status";
51
+
52
+ /** The default managed-VIEW name for a binding: `<table>__tracking`. Distinct from the base table
53
+ * (SQLite forbids a VIEW and a table sharing a name). An app with more than one binding on the same
54
+ * base table must give each a distinct `readModel.view`. */
55
+ export function defaultInstanceTrackingViewName(table: string): string {
56
+ return `${table}__tracking`;
57
+ }
58
+
59
+ /** `EXISTS(urban_instance_state WHERE process_instance_key = base.<keyField> AND state = 'TERMINATED')`
60
+ * — the terminal edge, derived over the canonical instance-state projection. Authored once; compiles
61
+ * to both the SQLite VIEW `EXISTS` and the TS `projections["urban_instance_state"].some(...)`. */
62
+ export function terminatedEdgeExpr(keyField: string): Expr {
63
+ return exists(
64
+ INSTANCE_STATE_PROJECTION,
65
+ and(eq(pcol("process_instance_key"), col(keyField)), eq(pcol("state"), lit(TERMINATED_STATE))),
66
+ );
67
+ }
68
+
69
+ /** `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.<keyField>)` — the wait-on-human
70
+ * edge, derived over the canonical open-user-tasks projection (an instance is parked on a human iff it
71
+ * has an open user task). Authored once; compiles to both backends. */
72
+ export function waitingHumanEdgeExpr(keyField: string): Expr {
73
+ return exists(OPEN_USER_TASKS_PROJECTION, eq(pcol("process_instance_key"), col(keyField)));
74
+ }
75
+
76
+ /**
77
+ * The effective-status derivation for one binding, in the closed expression DSL:
78
+ *
79
+ * CASE
80
+ * WHEN <terminated> THEN <onTerminated.set[statusField]> -- terminal wins
81
+ * WHEN <waiting-on-human> AND NOT <terminated> THEN <onWaitingHuman.set[statusField]>
82
+ * ELSE base.<statusField> -- the worker-owned / business-outcome transient status
83
+ * END
84
+ *
85
+ * Precedence (terminated over waiting-human) is preserved: the terminal `WHEN` is emitted first, and
86
+ * the waiting `WHEN` is additionally guarded by `NOT <terminated>` so it is correct even when the two
87
+ * patches touch disjoint columns. When a binding's patch does not set the `statusField` (it reconciles
88
+ * only secondary columns), that edge contributes no `WHEN` and the derived status passes the stored
89
+ * value through — a derived read model never re-introduces a stored derivable write.
90
+ */
91
+ export function deriveInstanceStatusExpr(binding: InstanceTracking, statusField: string): Expr {
92
+ const terminated = terminatedEdgeExpr(binding.keyField);
93
+ const whens: WhenClause[] = [];
94
+
95
+ const terminalStatus = patchValueForColumn(binding.onTerminated?.set, statusField);
96
+ if (terminalStatus !== undefined) {
97
+ whens.push(when(terminated, lit(terminalStatus)));
98
+ }
99
+
100
+ const waitingStatus = patchValueForColumn(binding.onWaitingHuman?.set, statusField);
101
+ if (waitingStatus !== undefined) {
102
+ whens.push(when(and(waitingHumanEdgeExpr(binding.keyField), not(terminated)), lit(waitingStatus)));
103
+ }
104
+
105
+ // No edge touches the status column ⇒ the derived status is exactly the stored transient (no CASE).
106
+ return whens.length > 0 ? caseWhen(whens, col(statusField)) : col(statusField);
107
+ }
108
+
109
+ /** Read the patch value a `*.set` map assigns to the status COLUMN, matching the key case-insensitively.
110
+ * SQL column identifiers fold case (SQLite), but a JS `set[statusField]` lookup is case-sensitive, so a
111
+ * binding with `statusField: "Status"` and `onTerminated.set: { status: "abandoned" }` would emit no
112
+ * terminal `WHEN` and silently fall through to the base column. Fold the comparison so the derivation
113
+ * matches the column the equivalent SQL UPDATE would have addressed. */
114
+ function patchValueForColumn(set: unknown, statusField: string): string | number | boolean | null | undefined {
115
+ if (set === null || typeof set !== "object") return undefined;
116
+ const folded = statusField.toLowerCase();
117
+ for (const [k, v] of Object.entries(set)) {
118
+ if (k.toLowerCase() !== folded) continue;
119
+ if (v === undefined) return undefined;
120
+ if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
121
+ return undefined;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ /** The managed-VIEW name + derived-status column a binding's read model uses (config overrides applied). */
127
+ export function instanceTrackingReadModelTarget(binding: InstanceTracking): {
128
+ readonly view: string;
129
+ readonly statusColumn: string;
130
+ } {
131
+ return {
132
+ view: binding.readModel?.view ?? defaultInstanceTrackingViewName(binding.table),
133
+ statusColumn: binding.readModel?.statusColumn ?? DEFAULT_DERIVED_STATUS_COLUMN,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Build the derived read model for one `instanceTracking` binding: a managed VIEW over the binding's
139
+ * base table that re-exports `base.*` plus one derived `statusColumn` computed by
140
+ * {@link deriveInstanceStatusExpr} over the canonical projections. Returns `undefined` when the binding
141
+ * declares no `statusField` (there is no single status column to derive — the projections still get fed,
142
+ * but nothing well-defined can be projected as "the effective status").
143
+ *
144
+ * The two backends (SQLite VIEW select-list + in-process TS function) fall out of the one AST, so they
145
+ * cannot drift; `assertReadModelParity` verifies it in tests.
146
+ */
147
+ export function defineInstanceTrackingReadModel(binding: InstanceTracking): ReadModel | undefined {
148
+ const statusField = binding.statusField;
149
+ if (typeof statusField !== "string" || statusField.length === 0) return undefined;
150
+ const { view, statusColumn } = instanceTrackingReadModelTarget(binding);
151
+ return defineReadModel({
152
+ name: view,
153
+ baseTable: binding.table,
154
+ derive: { [statusColumn]: deriveInstanceStatusExpr(binding, statusField) },
155
+ });
156
+ }