@nanobpm/urban 0.78.0 → 0.80.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 (38) hide show
  1. package/dist/runtime/core/modules/canonical-projections.d.ts +13 -0
  2. package/dist/runtime/core/modules/canonical-projections.js +30 -0
  3. package/dist/runtime/core/modules/formjs.gen.d.ts +1 -0
  4. package/dist/runtime/core/modules/formjs.gen.js +5 -0
  5. package/dist/runtime/core/modules/forms.d.ts +22 -0
  6. package/dist/runtime/core/modules/forms.js +59 -0
  7. package/dist/runtime/core/modules/instance-state-store.d.ts +50 -0
  8. package/dist/runtime/core/modules/instance-state-store.js +101 -0
  9. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +63 -0
  10. package/dist/runtime/core/modules/open-user-tasks-store.js +135 -0
  11. package/dist/runtime/core/modules/pages.js +36 -0
  12. package/dist/runtime/core/modules/runtime.gen.js +1 -1
  13. package/dist/runtime/core/modules/surfaces.js +18 -124
  14. package/dist/runtime/core/modules/workers.js +33 -0
  15. package/dist/runtime/index.d.ts +5 -0
  16. package/dist/runtime/index.js +8 -1
  17. package/package.json +1 -1
  18. package/src/runtime/browser/fake-dom.test-utils.ts +114 -0
  19. package/src/runtime/browser/formjs.browser.js +192 -0
  20. package/src/runtime/browser/formjs.browser.test.ts +136 -0
  21. package/src/runtime/browser/runtime.browser.js +101 -1
  22. package/src/runtime/browser/runtime.engineform.test.ts +158 -0
  23. package/src/runtime/core/modules/canonical-projections.test.ts +190 -0
  24. package/src/runtime/core/modules/canonical-projections.ts +38 -0
  25. package/src/runtime/core/modules/formjs.gen.ts +6 -0
  26. package/src/runtime/core/modules/forms.test.ts +122 -0
  27. package/src/runtime/core/modules/forms.ts +67 -0
  28. package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
  29. package/src/runtime/core/modules/instance-state-store.test.ts +143 -0
  30. package/src/runtime/core/modules/instance-state-store.ts +135 -0
  31. package/src/runtime/core/modules/open-user-tasks-store.test.ts +156 -0
  32. package/src/runtime/core/modules/open-user-tasks-store.ts +172 -0
  33. package/src/runtime/core/modules/pages.test.ts +99 -0
  34. package/src/runtime/core/modules/pages.ts +38 -0
  35. package/src/runtime/core/modules/runtime.gen.ts +1 -1
  36. package/src/runtime/core/modules/surfaces.ts +19 -121
  37. package/src/runtime/core/modules/workers.ts +33 -0
  38. package/src/runtime/index.ts +29 -1
@@ -0,0 +1,13 @@
1
+ import { type ProjectionRegistry } from "../read-model.ts";
2
+ /** The canonical projection name → physical `_urban_` table mappings this task owns. */
3
+ export declare const CANONICAL_PROJECTIONS: ReadonlyArray<{
4
+ readonly name: string;
5
+ readonly sqlTable: string;
6
+ }>;
7
+ /**
8
+ * Register the canonical engine-truth projections in a {@link ProjectionRegistry} (defaults to the
9
+ * process-wide `projectionRegistry`), so a `defineReadModel(...)` authored anywhere can
10
+ * `exists("urban_open_user_tasks", …)` / `exists("urban_instance_state", …)` and have the SQL backend
11
+ * resolve them to the `_urban_`-prefixed physical tables. Idempotent.
12
+ */
13
+ export declare function registerCanonicalProjections(registry?: ProjectionRegistry): void;
@@ -0,0 +1,30 @@
1
+ // canonical-projections — register the framework's canonical engine-truth projections under their
2
+ // stable DSL names (ADR 0065, proposal point #1). This is the concrete use of the projection-name
3
+ // seam the read-model primitive established (`projectionRegistry.register({ name, sqlTable })`): it
4
+ // maps each unprefixed DSL name a `defineReadModel(...)` `exists(...)` reads
5
+ // (`urban_open_user_tasks`, `urban_instance_state`) onto its `_urban_`-prefixed physical sidecar
6
+ // table, so the SQL backend's `EXISTS` compiles against the real table while the domain model / DB
7
+ // Manager never sees it.
8
+ //
9
+ // Registration is idempotent (the registry no-ops a matching re-register), so calling this on every
10
+ // worker mount — the boot path in `workers.ts` — is safe. It is decoupled from schema provisioning:
11
+ // a read model must be able to reference these projections by name even in a process/app that has no
12
+ // datasource to provision the physical tables into.
13
+ import { INSTANCE_STATE_PROJECTION, INSTANCE_STATE_TABLE, } from "./instance-state-store.js";
14
+ import { OPEN_USER_TASKS_PROJECTION, OPEN_USER_TASKS_TABLE, } from "./open-user-tasks-store.js";
15
+ import { projectionRegistry } from "../read-model.js";
16
+ /** The canonical projection name → physical `_urban_` table mappings this task owns. */
17
+ export const CANONICAL_PROJECTIONS = [
18
+ { name: OPEN_USER_TASKS_PROJECTION, sqlTable: OPEN_USER_TASKS_TABLE },
19
+ { name: INSTANCE_STATE_PROJECTION, sqlTable: INSTANCE_STATE_TABLE },
20
+ ];
21
+ /**
22
+ * Register the canonical engine-truth projections in a {@link ProjectionRegistry} (defaults to the
23
+ * process-wide `projectionRegistry`), so a `defineReadModel(...)` authored anywhere can
24
+ * `exists("urban_open_user_tasks", …)` / `exists("urban_instance_state", …)` and have the SQL backend
25
+ * resolve them to the `_urban_`-prefixed physical tables. Idempotent.
26
+ */
27
+ export function registerCanonicalProjections(registry = projectionRegistry) {
28
+ for (const projection of CANONICAL_PROJECTIONS)
29
+ registry.register(projection);
30
+ }
@@ -0,0 +1 @@
1
+ export declare const FORMJS_JS: string;
@@ -0,0 +1,5 @@
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
+ export const FORMJS_JS = "// @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,22 @@
1
+ import type { EngineClient, HttpResponse } from "../host.ts";
2
+ /**
3
+ * The shared `/api/form` resolution gate. Rejects only when *neither* identifier
4
+ * is present — presence follows getForm's canonical rule (`presentFormIdentifier`:
5
+ * empty/whitespace = absent) so a whitespace-only `?formKey= ` 400s here instead
6
+ * of slipping past a raw truthiness check and returning a spurious 204. The raw
7
+ * values are passed through unchanged — resolving a blank key to its `formId`
8
+ * fallback is getForm's single responsibility. A form that can't be resolved
9
+ * returns 204 so the client renders the no-form fallback rather than erroring.
10
+ */
11
+ export declare function resolveFormResponse(engine: Pick<EngineClient, "getForm">, formKey: string | undefined, formId: string | undefined): Promise<HttpResponse>;
12
+ /**
13
+ * The shared user-task completion endpoint. Parses `{ userTaskKey, variables }`
14
+ * from the request body and completes the task via `EngineClient.completeUserTask`.
15
+ * A body that isn't a JSON object (`null`, a scalar, an array) 400s instead of
16
+ * throwing a 500; a missing/blank `userTaskKey` 400s; a malformed body 400s; a
17
+ * present but non-object `variables` (number, string, array, `null`) 400s so the
18
+ * completion contract stays well-defined at this shared seam. Used by both the
19
+ * taskInbox surface and the pages grid's engine-form detail so the completion
20
+ * contract can't drift between them.
21
+ */
22
+ export declare function completeUserTaskResponse(engine: Pick<EngineClient, "completeUserTask">, bodyText: string): Promise<HttpResponse>;
@@ -0,0 +1,59 @@
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
+ import { presentFormIdentifier } from "../host.js";
13
+ import { isRecord } from "../guards.js";
14
+ import { json, noContent } from "../router.js";
15
+ /**
16
+ * The shared `/api/form` resolution gate. Rejects only when *neither* identifier
17
+ * is present — presence follows getForm's canonical rule (`presentFormIdentifier`:
18
+ * empty/whitespace = absent) so a whitespace-only `?formKey= ` 400s here instead
19
+ * of slipping past a raw truthiness check and returning a spurious 204. The raw
20
+ * values are passed through unchanged — resolving a blank key to its `formId`
21
+ * fallback is getForm's single responsibility. A form that can't be resolved
22
+ * returns 204 so the client renders the no-form fallback rather than erroring.
23
+ */
24
+ export async function resolveFormResponse(engine, formKey, formId) {
25
+ if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
26
+ return json({ error: "formKey or formId required" }, 400);
27
+ const form = await engine.getForm({ formKey, formId });
28
+ if (!form)
29
+ return noContent();
30
+ return json(form);
31
+ }
32
+ /**
33
+ * The shared user-task completion endpoint. Parses `{ userTaskKey, variables }`
34
+ * from the request body and completes the task via `EngineClient.completeUserTask`.
35
+ * A body that isn't a JSON object (`null`, a scalar, an array) 400s instead of
36
+ * throwing a 500; a missing/blank `userTaskKey` 400s; a malformed body 400s; a
37
+ * present but non-object `variables` (number, string, array, `null`) 400s so the
38
+ * completion contract stays well-defined at this shared seam. Used by both the
39
+ * taskInbox surface and the pages grid's engine-form detail so the completion
40
+ * contract can't drift between them.
41
+ */
42
+ export async function completeUserTaskResponse(engine, bodyText) {
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(bodyText || "{}");
46
+ }
47
+ catch {
48
+ return json({ error: "invalid JSON body" }, 400);
49
+ }
50
+ if (!isRecord(parsed))
51
+ return json({ error: "invalid JSON body" }, 400);
52
+ const { userTaskKey, variables } = parsed;
53
+ if (typeof userTaskKey !== "string" || !userTaskKey)
54
+ return json({ error: "userTaskKey required" }, 400);
55
+ if (variables !== undefined && !isRecord(variables))
56
+ return json({ error: "variables must be a JSON object" }, 400);
57
+ await engine.completeUserTask(userTaskKey, variables);
58
+ return json({ ok: true });
59
+ }
@@ -0,0 +1,50 @@
1
+ import type { ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb } from "../host.ts";
2
+ import { type Clock } from "./lineage-store.ts";
3
+ /** The instance-state projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
4
+ * from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
5
+ export declare const INSTANCE_STATE_TABLE = "_urban_instance_state";
6
+ /** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
7
+ * `projectionRegistry`, mapped to {@link INSTANCE_STATE_TABLE}). The DSL name is unprefixed so a read
8
+ * model reads `EXISTS(urban_instance_state WHERE process_instance_key = base.process_instance_key)`. */
9
+ export declare const INSTANCE_STATE_PROJECTION = "urban_instance_state";
10
+ /**
11
+ * The canonical instance-state DDL — the single source of truth applied by
12
+ * {@link InstanceStateStore.ensureSchema}, mirrored verbatim by the boot migration
13
+ * `db/migrations/007_urban_instance_state.sql` (a drift-guard test asserts they match).
14
+ *
15
+ * Forward-only and additive. Keyed by `process_instance_key` (one canonical state per instance) so a
16
+ * re-record upserts in place and is idempotent. `waiting_on_human` is 0/1 (SQLite has no boolean).
17
+ */
18
+ export declare const INSTANCE_STATE_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS _urban_instance_state (\n process_instance_key TEXT NOT NULL,\n state TEXT NOT NULL,\n waiting_on_human INTEGER NOT NULL DEFAULT 0,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (process_instance_key)\n);";
19
+ export interface InstanceStateStoreOptions {
20
+ readonly clock?: Clock;
21
+ }
22
+ /** One projected instance-state row, as {@link InstanceStateStore.getState} returns it. */
23
+ export interface InstanceStateRow {
24
+ readonly processInstanceKey: string;
25
+ readonly state: ProcessInstanceState;
26
+ readonly waitingOnHuman: boolean;
27
+ }
28
+ /**
29
+ * The per-source sidecar behind the `urban_instance_state` projection. A thin, idempotent
30
+ * persistence + query layer over the engine's per-instance lifecycle truth.
31
+ */
32
+ export declare class InstanceStateStore {
33
+ #private;
34
+ constructor(db: SqliteDb, options?: InstanceStateStoreOptions);
35
+ /** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
36
+ * `db/migrations/007_urban_instance_state.sql` do not need this; the runtime calls it so the store
37
+ * is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
38
+ ensureSchema(): void;
39
+ /**
40
+ * Record an instance's canonical engine lifecycle state (and whether it is waiting on a human),
41
+ * upserting the single row keyed by `processInstanceKey`. Idempotent: re-recording the same
42
+ * `(state, waitingOnHuman)` is a no-op (returns `false`). A blank `processInstanceKey` is ignored.
43
+ */
44
+ recordState(processInstanceKey: string, state: ProcessInstanceState, waitingOnHuman?: boolean): boolean;
45
+ /** Record from a {@link ProcessInstanceSnapshot} (the engine's `searchProcessInstances` row),
46
+ * carrying the optional waiting-on-human flag the reconciler probes separately. */
47
+ recordFromSnapshot(snapshot: ProcessInstanceSnapshot, waitingOnHuman?: boolean): boolean;
48
+ /** The projected canonical state for an instance, or `undefined` if none recorded. */
49
+ getState(processInstanceKey: string): InstanceStateRow | undefined;
50
+ }
@@ -0,0 +1,101 @@
1
+ // instance-state-store — the canonical per-instance engine lifecycle-state projection (ADR 0065,
2
+ // proposal point #1). A framework-level, per-source sidecar (exactly like the lineage store) that
3
+ // records each tracked process instance's engine-truth lifecycle state (ACTIVE / COMPLETED /
4
+ // TERMINATED) plus whether it is waiting on a human, from the same `searchProcessInstances` /
5
+ // `openUserTasks` truth the `instanceTracking` reconciler already reads.
6
+ //
7
+ // A read model can then DERIVE an instance's status edge purely from this projection — e.g. the
8
+ // terminal edge (`onTerminated`, → `abandoned`) becomes an `EXISTS(urban_instance_state WHERE
9
+ // process_instance_key = … AND state = 'TERMINATED')` derivation, instead of a stored `status` write
10
+ // that tears from engine truth. This projection PROVIDES that engine-truth source; retiring the
11
+ // reconciler's writes is the downstream `writer-source-inversion` task.
12
+ //
13
+ // It is framework bookkeeping, so the physical table is `_urban_`-prefixed to stay hidden from the
14
+ // domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables — and NEVER
15
+ // app-written. Recording is idempotent: re-recording an unchanged state is a no-op.
16
+ import { systemClock } from "./lineage-store.js";
17
+ /** The instance-state projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
18
+ * from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
19
+ export const INSTANCE_STATE_TABLE = "_urban_instance_state";
20
+ /** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
21
+ * `projectionRegistry`, mapped to {@link INSTANCE_STATE_TABLE}). The DSL name is unprefixed so a read
22
+ * model reads `EXISTS(urban_instance_state WHERE process_instance_key = base.process_instance_key)`. */
23
+ export const INSTANCE_STATE_PROJECTION = "urban_instance_state";
24
+ /**
25
+ * The canonical instance-state DDL — the single source of truth applied by
26
+ * {@link InstanceStateStore.ensureSchema}, mirrored verbatim by the boot migration
27
+ * `db/migrations/007_urban_instance_state.sql` (a drift-guard test asserts they match).
28
+ *
29
+ * Forward-only and additive. Keyed by `process_instance_key` (one canonical state per instance) so a
30
+ * re-record upserts in place and is idempotent. `waiting_on_human` is 0/1 (SQLite has no boolean).
31
+ */
32
+ export const INSTANCE_STATE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${INSTANCE_STATE_TABLE} (
33
+ process_instance_key TEXT NOT NULL,
34
+ state TEXT NOT NULL,
35
+ waiting_on_human INTEGER NOT NULL DEFAULT 0,
36
+ updated_at TEXT NOT NULL,
37
+ PRIMARY KEY (process_instance_key)
38
+ );`;
39
+ function normalizeState(raw) {
40
+ // Validate rather than coerce: silently mapping an unknown value to "ACTIVE" would let corrupted rows
41
+ // or an unhandled new engine state masquerade as ACTIVE and produce incorrect derived edges. Surface
42
+ // the bad data loudly instead.
43
+ if (raw === "ACTIVE" || raw === "COMPLETED" || raw === "TERMINATED")
44
+ return raw;
45
+ throw new Error(`${INSTANCE_STATE_TABLE} contains an unexpected state ${JSON.stringify(raw)} — refusing to coerce to ACTIVE (data corruption or an unhandled engine state)`);
46
+ }
47
+ /**
48
+ * The per-source sidecar behind the `urban_instance_state` projection. A thin, idempotent
49
+ * persistence + query layer over the engine's per-instance lifecycle truth.
50
+ */
51
+ export class InstanceStateStore {
52
+ #db;
53
+ #clock;
54
+ constructor(db, options = {}) {
55
+ this.#db = db;
56
+ this.#clock = options.clock ?? systemClock;
57
+ }
58
+ /** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
59
+ * `db/migrations/007_urban_instance_state.sql` do not need this; the runtime calls it so the store
60
+ * is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
61
+ ensureSchema() {
62
+ this.#db.exec(INSTANCE_STATE_SCHEMA_SQL);
63
+ }
64
+ /**
65
+ * Record an instance's canonical engine lifecycle state (and whether it is waiting on a human),
66
+ * upserting the single row keyed by `processInstanceKey`. Idempotent: re-recording the same
67
+ * `(state, waitingOnHuman)` is a no-op (returns `false`). A blank `processInstanceKey` is ignored.
68
+ */
69
+ recordState(processInstanceKey, state, waitingOnHuman = false) {
70
+ if (!processInstanceKey)
71
+ return false;
72
+ const { changes } = this.#db.run(`INSERT INTO ${INSTANCE_STATE_TABLE}
73
+ (process_instance_key, state, waiting_on_human, updated_at)
74
+ VALUES (?, ?, ?, ?)
75
+ ON CONFLICT (process_instance_key) DO UPDATE SET
76
+ state = excluded.state,
77
+ waiting_on_human = excluded.waiting_on_human,
78
+ updated_at = excluded.updated_at
79
+ WHERE ${INSTANCE_STATE_TABLE}.state <> excluded.state
80
+ OR ${INSTANCE_STATE_TABLE}.waiting_on_human <> excluded.waiting_on_human`, [processInstanceKey, state, waitingOnHuman ? 1 : 0, new Date(this.#clock.now()).toISOString()]);
81
+ return changes > 0;
82
+ }
83
+ /** Record from a {@link ProcessInstanceSnapshot} (the engine's `searchProcessInstances` row),
84
+ * carrying the optional waiting-on-human flag the reconciler probes separately. */
85
+ recordFromSnapshot(snapshot, waitingOnHuman = false) {
86
+ return this.recordState(snapshot.processInstanceKey, snapshot.state, waitingOnHuman);
87
+ }
88
+ /** The projected canonical state for an instance, or `undefined` if none recorded. */
89
+ getState(processInstanceKey) {
90
+ const rows = this.#db.all(`SELECT process_instance_key, state, waiting_on_human
91
+ FROM ${INSTANCE_STATE_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
92
+ const row = rows[0];
93
+ if (!row)
94
+ return undefined;
95
+ return {
96
+ processInstanceKey: row.process_instance_key,
97
+ state: normalizeState(row.state),
98
+ waitingOnHuman: row.waiting_on_human !== 0,
99
+ };
100
+ }
101
+ }
@@ -0,0 +1,63 @@
1
+ import type { SqliteDb } from "../host.ts";
2
+ import type { UserTaskSummary } from "../host.ts";
3
+ import { type Clock } from "./lineage-store.ts";
4
+ /** The open-user-tasks projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
5
+ * from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
6
+ export declare const OPEN_USER_TASKS_TABLE = "_urban_open_user_tasks";
7
+ /** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
8
+ * `projectionRegistry`, mapped to {@link OPEN_USER_TASKS_TABLE}). The DSL name is unprefixed so a read
9
+ * model reads `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.process_instance_key)`. */
10
+ export declare const OPEN_USER_TASKS_PROJECTION = "urban_open_user_tasks";
11
+ /**
12
+ * The canonical open-user-tasks DDL — the single source of truth applied by
13
+ * {@link OpenUserTasksStore.ensureSchema}, mirrored verbatim by the boot migration
14
+ * `db/migrations/006_urban_open_user_tasks.sql` (a drift-guard test asserts they match).
15
+ *
16
+ * Forward-only and additive. Keyed by `user_task_key` so a re-record of the same open task is a no-op;
17
+ * `process_instance_key` carries an index so a read model's correlated `EXISTS(... WHERE
18
+ * process_instance_key = …)` and the reconciler's per-instance sync read only that instance's rows.
19
+ */
20
+ export declare const OPEN_USER_TASKS_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS _urban_open_user_tasks (\n process_instance_key TEXT NOT NULL,\n user_task_key TEXT NOT NULL,\n element_id TEXT,\n created_at TEXT NOT NULL,\n PRIMARY KEY (user_task_key)\n);\nCREATE INDEX IF NOT EXISTS idx__urban_open_user_tasks_instance ON _urban_open_user_tasks (process_instance_key);";
21
+ export interface OpenUserTasksStoreOptions {
22
+ readonly clock?: Clock;
23
+ }
24
+ /** One projected open-user-task row, as {@link OpenUserTasksStore.openTasks} returns it. */
25
+ export interface OpenUserTaskRow {
26
+ readonly processInstanceKey: string;
27
+ readonly userTaskKey: string;
28
+ readonly elementId?: string;
29
+ }
30
+ /**
31
+ * The per-source sidecar behind the `urban_open_user_tasks` projection. A thin, idempotent
32
+ * persistence + query layer over the engine's open-user-task truth; it interprets no app schema.
33
+ */
34
+ export declare class OpenUserTasksStore {
35
+ #private;
36
+ constructor(db: SqliteDb, options?: OpenUserTasksStoreOptions);
37
+ /** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
38
+ * `db/migrations/006_urban_open_user_tasks.sql` do not need this; the runtime calls it so the store
39
+ * is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
40
+ ensureSchema(): void;
41
+ /**
42
+ * Record one open user task for an instance, idempotently. A re-record of the same `userTaskKey` is
43
+ * a no-op. A blank `processInstanceKey` or `userTaskKey` is ignored. Prefer {@link syncInstance} to
44
+ * record an instance's WHOLE current open set (which also retires closed tasks); this single-row
45
+ * form is the idempotent building block.
46
+ */
47
+ recordOpenTask(processInstanceKey: string, task: UserTaskSummary): boolean;
48
+ /**
49
+ * Make the projection reflect an instance's CURRENT open set exactly: insert the engine's open tasks
50
+ * and delete any previously-projected task for the instance that is no longer open. This is the
51
+ * authoritative record from engine truth (`openUserTasks({ processInstanceKey })`), the operation the
52
+ * reconciler drives each poll — so an answered/closed task disappears and the derived
53
+ * "waiting-on-human" edge re-flips by construction (nano-workforce#422). Idempotent: syncing the same
54
+ * open set twice leaves the table unchanged. A blank `processInstanceKey` is ignored.
55
+ */
56
+ syncInstance(processInstanceKey: string, openTasks: readonly UserTaskSummary[]): void;
57
+ /** Retire every projected open task for an instance (e.g. it terminated). Idempotent. */
58
+ clearInstance(processInstanceKey: string): void;
59
+ /** True iff the instance currently has any open user task — the "parked on a human" predicate. */
60
+ hasOpenTask(processInstanceKey: string): boolean;
61
+ /** The projected open tasks for an instance, in insertion order. */
62
+ openTasks(processInstanceKey: string): OpenUserTaskRow[];
63
+ }
@@ -0,0 +1,135 @@
1
+ // open-user-tasks-store — the canonical engine-truth projection of "which process instances are
2
+ // parked on a human" (ADR 0065, proposal point #1). A framework-level, per-source sidecar (exactly
3
+ // like the lineage store) that materialises the set of currently-open (CREATED-state) user tasks per
4
+ // process instance from engine truth — the same `openUserTasks` / `searchUserTasks({state:"CREATED"})`
5
+ // query the `instanceTracking` reconciler already runs.
6
+ //
7
+ // This ABSORBS nano-workforce's hand-rolled `user_tasks` table: it is the ONE authoritative projection
8
+ // a read model can `EXISTS`-over to derive the "waiting-on-human" status edge, instead of an app
9
+ // hand-maintaining a stored `awaiting_operator` column (the tearing surface of nano-workforce#422).
10
+ //
11
+ // It is framework bookkeeping, so the physical table is `_urban_`-prefixed to stay hidden from the
12
+ // domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables — and it is NEVER
13
+ // app-written. Recording is idempotent and authoritative-per-instance: {@link OpenUserTasksStore.syncInstance}
14
+ // replaces an instance's rows with exactly the engine's current open set, so an answered/closed task
15
+ // disappears on the next poll and the derived edge re-flips by construction (No Drift Surfaces).
16
+ import { systemClock } from "./lineage-store.js";
17
+ /** The open-user-tasks projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
18
+ * from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
19
+ export const OPEN_USER_TASKS_TABLE = "_urban_open_user_tasks";
20
+ /** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
21
+ * `projectionRegistry`, mapped to {@link OPEN_USER_TASKS_TABLE}). The DSL name is unprefixed so a read
22
+ * model reads `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.process_instance_key)`. */
23
+ export const OPEN_USER_TASKS_PROJECTION = "urban_open_user_tasks";
24
+ /**
25
+ * The canonical open-user-tasks DDL — the single source of truth applied by
26
+ * {@link OpenUserTasksStore.ensureSchema}, mirrored verbatim by the boot migration
27
+ * `db/migrations/006_urban_open_user_tasks.sql` (a drift-guard test asserts they match).
28
+ *
29
+ * Forward-only and additive. Keyed by `user_task_key` so a re-record of the same open task is a no-op;
30
+ * `process_instance_key` carries an index so a read model's correlated `EXISTS(... WHERE
31
+ * process_instance_key = …)` and the reconciler's per-instance sync read only that instance's rows.
32
+ */
33
+ export const OPEN_USER_TASKS_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${OPEN_USER_TASKS_TABLE} (
34
+ process_instance_key TEXT NOT NULL,
35
+ user_task_key TEXT NOT NULL,
36
+ element_id TEXT,
37
+ created_at TEXT NOT NULL,
38
+ PRIMARY KEY (user_task_key)
39
+ );
40
+ CREATE INDEX IF NOT EXISTS idx_${OPEN_USER_TASKS_TABLE}_instance ON ${OPEN_USER_TASKS_TABLE} (process_instance_key);`;
41
+ /**
42
+ * The per-source sidecar behind the `urban_open_user_tasks` projection. A thin, idempotent
43
+ * persistence + query layer over the engine's open-user-task truth; it interprets no app schema.
44
+ */
45
+ export class OpenUserTasksStore {
46
+ #db;
47
+ #clock;
48
+ constructor(db, options = {}) {
49
+ this.#db = db;
50
+ this.#clock = options.clock ?? systemClock;
51
+ }
52
+ /** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
53
+ * `db/migrations/006_urban_open_user_tasks.sql` do not need this; the runtime calls it so the store
54
+ * is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
55
+ ensureSchema() {
56
+ this.#db.exec(OPEN_USER_TASKS_SCHEMA_SQL);
57
+ }
58
+ /**
59
+ * Record one open user task for an instance, idempotently. A re-record of the same `userTaskKey` is
60
+ * a no-op. A blank `processInstanceKey` or `userTaskKey` is ignored. Prefer {@link syncInstance} to
61
+ * record an instance's WHOLE current open set (which also retires closed tasks); this single-row
62
+ * form is the idempotent building block.
63
+ */
64
+ recordOpenTask(processInstanceKey, task) {
65
+ if (!processInstanceKey || !task.userTaskKey)
66
+ return false;
67
+ const { changes } = this.#db.run(`INSERT OR IGNORE INTO ${OPEN_USER_TASKS_TABLE}
68
+ (process_instance_key, user_task_key, element_id, created_at)
69
+ VALUES (?, ?, ?, ?)`, [processInstanceKey, task.userTaskKey, task.elementId ?? null, new Date(this.#clock.now()).toISOString()]);
70
+ return changes > 0;
71
+ }
72
+ /**
73
+ * Make the projection reflect an instance's CURRENT open set exactly: insert the engine's open tasks
74
+ * and delete any previously-projected task for the instance that is no longer open. This is the
75
+ * authoritative record from engine truth (`openUserTasks({ processInstanceKey })`), the operation the
76
+ * reconciler drives each poll — so an answered/closed task disappears and the derived
77
+ * "waiting-on-human" edge re-flips by construction (nano-workforce#422). Idempotent: syncing the same
78
+ * open set twice leaves the table unchanged. A blank `processInstanceKey` is ignored.
79
+ */
80
+ syncInstance(processInstanceKey, openTasks) {
81
+ if (!processInstanceKey)
82
+ return;
83
+ // Wrap the insert+delete replace in a SAVEPOINT so the whole sync is atomic: no reader observes a
84
+ // transient mixed state mid-sync, and a mid-loop failure rolls back cleanly instead of leaving the
85
+ // instance with a torn open set. SAVEPOINT (not BEGIN) so this also composes when called inside an
86
+ // existing transaction, e.g. a reconciler poll that batches many instances.
87
+ this.#db.exec("SAVEPOINT urban_sync_open_tasks");
88
+ try {
89
+ const keep = new Set();
90
+ for (const task of openTasks) {
91
+ if (!task.userTaskKey)
92
+ continue;
93
+ keep.add(task.userTaskKey);
94
+ this.recordOpenTask(processInstanceKey, task);
95
+ }
96
+ const existing = this.#db.all(`SELECT user_task_key FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
97
+ for (const row of existing) {
98
+ if (!keep.has(row.user_task_key)) {
99
+ this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE user_task_key = ?`, [row.user_task_key]);
100
+ }
101
+ }
102
+ this.#db.exec("RELEASE urban_sync_open_tasks");
103
+ }
104
+ catch (err) {
105
+ this.#db.exec("ROLLBACK TO urban_sync_open_tasks");
106
+ this.#db.exec("RELEASE urban_sync_open_tasks");
107
+ throw err;
108
+ }
109
+ }
110
+ /** Retire every projected open task for an instance (e.g. it terminated). Idempotent. */
111
+ clearInstance(processInstanceKey) {
112
+ if (!processInstanceKey)
113
+ return;
114
+ this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
115
+ }
116
+ /** True iff the instance currently has any open user task — the "parked on a human" predicate. */
117
+ hasOpenTask(processInstanceKey) {
118
+ if (!processInstanceKey)
119
+ return false;
120
+ // Existence predicate: `SELECT 1 … LIMIT 1` short-circuits on the first matching row rather than
121
+ // counting every open task for the instance.
122
+ const rows = this.#db.all(`SELECT 1 AS one FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ? LIMIT 1`, [processInstanceKey]);
123
+ return rows.length > 0;
124
+ }
125
+ /** The projected open tasks for an instance, in insertion order. */
126
+ openTasks(processInstanceKey) {
127
+ const rows = this.#db.all(`SELECT process_instance_key, user_task_key, element_id
128
+ FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ? ORDER BY rowid`, [processInstanceKey]);
129
+ return rows.map((r) => ({
130
+ processInstanceKey: r.process_instance_key,
131
+ userTaskKey: r.user_task_key,
132
+ ...(r.element_id ? { elementId: r.element_id } : {}),
133
+ }));
134
+ }
135
+ }
@@ -28,6 +28,8 @@ import { errorMessage, isRecord } from "../guards.js";
28
28
  import { html, json } from "../router.js";
29
29
  import { cancelInstanceReconciling } from "./cancel.js";
30
30
  import { apiDocsPath } from "./api.js";
31
+ import { FORMJS_JS } from "./formjs.gen.js";
32
+ import { completeUserTaskResponse, resolveFormResponse } from "./forms.js";
31
33
  import { quoteIdent } from "./gateway.js";
32
34
  import { RENDERER_JS } from "./runtime.gen.js";
33
35
  /** A JavaScript-body response (the renderer module served at the fingerprinted
@@ -489,6 +491,26 @@ export function createPagesRoutes(opts, deps) {
489
491
  }
490
492
  },
491
493
  });
494
+ // ── GET /app/actions/form & POST /app/actions/complete (ADR 0026, #457) ───
495
+ // Engine-declared forms in a `dataGrid` detail drawer. A grid whose `detail`
496
+ // opts into `engineForm` resolves the selected row's `form_key` here and
497
+ // completes the row's user task here — the SAME two seams the `taskInbox`
498
+ // surface uses, reused (not forked) via `forms.ts` so the resolution gate and
499
+ // the completion contract can't drift between the two surfaces. This closes the
500
+ // form-duplication drift class: the grid renders the authoritative deployed
501
+ // `.form` rather than a hand-authored page-local `detail.form` copy.
502
+ routes.push({
503
+ method: "GET",
504
+ path: "/app/actions/form",
505
+ source: "surface:pages",
506
+ handler: (req) => resolveFormResponse(engine, req.query.get("formKey") ?? undefined, req.query.get("formId") ?? undefined),
507
+ });
508
+ routes.push({
509
+ method: "POST",
510
+ path: "/app/actions/complete",
511
+ source: "surface:pages",
512
+ handler: async (req) => completeUserTaskResponse(engine, await req.text()),
513
+ });
492
514
  // ── nested (multi-appView) sidecars (#442) ──────────────────────────────
493
515
  // An app with more than one `appView` cannot share the four FLAT sidecar names above:
494
516
  // each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
@@ -603,6 +625,7 @@ function rendererShell(homePage, apiDocsPath) {
603
625
  </head>
604
626
  <body>${apiDocsBadge}
605
627
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
628
+ <script>${FORMJS_JS}</script>
606
629
  <script type="module" src=".${RUNTIME_JS_PATH}"></script>
607
630
  </body>
608
631
  </html>`;
@@ -669,6 +692,19 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
669
692
  .pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
670
693
  .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
671
694
  .pc-btn:disabled { opacity:.5; cursor:default; }
695
+ /* The shared engine-form renderer (NanoFormJs, ADR 0026/#457) styled to match the
696
+ page runtime's native detail controls. njf-* are its stable class names; both
697
+ this surface and the taskInbox page style the same renderer output. */
698
+ .njf-form { display:flex; flex-direction:column; gap:.4rem; margin-top:.5rem; }
699
+ .njf-heading { font-size:1rem; margin:0 0 .25rem; }
700
+ .njf-text { color:var(--nano-text-muted); margin:.25rem 0; }
701
+ .njf-field { display:flex; flex-direction:column; gap:.25rem; }
702
+ .njf-field label { font-size:.8rem; color:var(--nano-text-muted); }
703
+ .njf-field input:not([type=checkbox]):not([type=radio]), .njf-field textarea, .njf-field select { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); max-width:24rem; }
704
+ .njf-actions { display:flex; gap:.5rem; margin-top:.4rem; }
705
+ .njf-actions button { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
706
+ .njf-msg { font-size:.85rem; margin-top:.35rem; }
707
+ .njf-msg.err { color:var(--nano-danger); }
672
708
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
673
709
  .pc-msg.err { color:var(--nano-danger); }
674
710
  .pc-msg.ok { color:var(--nano-ok); }