@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.
- package/dist/runtime/core/manifest.d.ts +44 -11
- package/dist/runtime/core/modules/cancel.d.ts +13 -8
- package/dist/runtime/core/modules/cancel.js +40 -21
- package/dist/runtime/core/modules/datasource.js +7 -4
- package/dist/runtime/core/modules/formjs.gen.d.ts +1 -0
- package/dist/runtime/core/modules/formjs.gen.js +5 -0
- package/dist/runtime/core/modules/forms.d.ts +22 -0
- package/dist/runtime/core/modules/forms.js +59 -0
- package/dist/runtime/core/modules/gateway.js +5 -4
- package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
- package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
- package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
- package/dist/runtime/core/modules/instance-tracking.js +451 -123
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
- package/dist/runtime/core/modules/pages.js +42 -4
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/modules/surfaces.js +18 -124
- package/dist/runtime/core/read-model.d.ts +28 -0
- package/dist/runtime/core/read-model.js +44 -4
- package/dist/runtime/core/validate.js +192 -0
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +5 -0
- package/package.json +1 -1
- package/src/runtime/browser/fake-dom.test-utils.ts +114 -0
- package/src/runtime/browser/formjs.browser.js +192 -0
- package/src/runtime/browser/formjs.browser.test.ts +136 -0
- package/src/runtime/browser/runtime.browser.js +101 -1
- package/src/runtime/browser/runtime.engineform.test.ts +158 -0
- package/src/runtime/core/manifest.ts +44 -11
- package/src/runtime/core/modules/cancel.test.ts +112 -38
- package/src/runtime/core/modules/cancel.ts +42 -22
- package/src/runtime/core/modules/datasource.ts +7 -4
- package/src/runtime/core/modules/formjs.gen.ts +6 -0
- package/src/runtime/core/modules/forms.test.ts +122 -0
- package/src/runtime/core/modules/forms.ts +67 -0
- package/src/runtime/core/modules/gateway.ts +7 -4
- package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
- package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
- package/src/runtime/core/modules/instance-tracking.ts +469 -126
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
- package/src/runtime/core/modules/pages.test.ts +99 -0
- package/src/runtime/core/modules/pages.ts +44 -5
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/modules/surfaces.ts +19 -121
- package/src/runtime/core/read-model.test.ts +69 -0
- package/src/runtime/core/read-model.ts +43 -4
- package/src/runtime/core/validate.test.ts +341 -0
- package/src/runtime/core/validate.ts +188 -0
- package/src/runtime/index.ts +15 -0
|
@@ -154,3 +154,14 @@ test("syncInstance is atomic — it composes inside an existing transaction and
|
|
|
154
154
|
test("the DSL projection name is stable and unprefixed (usable in `exists(...)`)", () => {
|
|
155
155
|
assert.equal(OPEN_USER_TASKS_PROJECTION, "urban_open_user_tasks");
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
test("instanceKeys returns each distinct instance with ≥1 open task (and drops one when cleared)", async () => {
|
|
159
|
+
await withStore((store) => {
|
|
160
|
+
assert.deepEqual(store.instanceKeys(), []); // empty projection
|
|
161
|
+
store.syncInstance("pi-1", [task("ut-1"), task("ut-2")]); // two tasks, one instance
|
|
162
|
+
store.syncInstance("pi-2", [task("ut-3")]);
|
|
163
|
+
assert.deepEqual(store.instanceKeys().sort(), ["pi-1", "pi-2"]); // distinct, not per-task
|
|
164
|
+
store.clearInstance("pi-1"); // answered/terminated
|
|
165
|
+
assert.deepEqual(store.instanceKeys(), ["pi-2"]); // retired key no longer listed
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -144,6 +144,17 @@ export class OpenUserTasksStore {
|
|
|
144
144
|
this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Every distinct instance key currently projected as having ≥1 open user task. Used by the
|
|
148
|
+
* reconciler to re-probe (and, when answered, RETIRE) keys that have dropped out of a binding's
|
|
149
|
+
* `activeStatuses` allow-list — a projected row must be retired independently of the base-row
|
|
150
|
+
* selector, or an answered escalation's row would linger and the derived edge tear (ADR 0065). */
|
|
151
|
+
instanceKeys(): string[] {
|
|
152
|
+
const rows = this.#db.all<{ process_instance_key: string }>(
|
|
153
|
+
`SELECT DISTINCT process_instance_key FROM ${OPEN_USER_TASKS_TABLE}`,
|
|
154
|
+
);
|
|
155
|
+
return rows.map((r) => r.process_instance_key);
|
|
156
|
+
}
|
|
157
|
+
|
|
147
158
|
/** True iff the instance currently has any open user task — the "parked on a human" predicate. */
|
|
148
159
|
hasOpenTask(processInstanceKey: string): boolean {
|
|
149
160
|
if (!processInstanceKey) return false;
|
|
@@ -1950,3 +1950,102 @@ test("the nested serve declines (does not shadow) a same-path trigger route regi
|
|
|
1950
1950
|
assert.equal(hook.status, 202);
|
|
1951
1951
|
assert.deepEqual(JSON.parse(hook.body ?? "{}"), { queued: true });
|
|
1952
1952
|
});
|
|
1953
|
+
|
|
1954
|
+
// ── engine-declared forms in a dataGrid detail (ADR 0026 / #457) ────────────
|
|
1955
|
+
// The pages surface exposes the SAME two seams the taskInbox surface uses:
|
|
1956
|
+
// GET /app/actions/form?formKey=… → resolve a deployed .form's form-js schema
|
|
1957
|
+
// POST /app/actions/complete → complete the row's user task
|
|
1958
|
+
// so one grid can render the several different typed forms a heterogeneous task
|
|
1959
|
+
// inbox carries, instead of duplicating each into a page-local `detail.form`.
|
|
1960
|
+
|
|
1961
|
+
/** A recording engine for the form-resolution + completion seams. `resolveForm`
|
|
1962
|
+
* supplies the getForm result while the wrapper still records every call, so a test
|
|
1963
|
+
* can assert BOTH the resolved schema and the exact `{formKey, formId}` the shared
|
|
1964
|
+
* presence gate passed through. */
|
|
1965
|
+
function formEngine(
|
|
1966
|
+
resolveForm: (input: { formKey?: string; formId?: string }) => Awaited<ReturnType<EngineClient["getForm"]>> = () => null,
|
|
1967
|
+
): {
|
|
1968
|
+
engine: EngineClient;
|
|
1969
|
+
formCalls: { formKey?: string; formId?: string }[];
|
|
1970
|
+
completeCalls: { userTaskKey: string; variables?: Record<string, unknown> }[];
|
|
1971
|
+
} {
|
|
1972
|
+
const formCalls: { formKey?: string; formId?: string }[] = [];
|
|
1973
|
+
const completeCalls: { userTaskKey: string; variables?: Record<string, unknown> }[] = [];
|
|
1974
|
+
const { engine } = fakeEngine();
|
|
1975
|
+
const withSeams: EngineClient = {
|
|
1976
|
+
...engine,
|
|
1977
|
+
async getForm(input: { formKey?: string; formId?: string }) {
|
|
1978
|
+
formCalls.push(input);
|
|
1979
|
+
return resolveForm(input);
|
|
1980
|
+
},
|
|
1981
|
+
async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>) {
|
|
1982
|
+
completeCalls.push({ userTaskKey, variables });
|
|
1983
|
+
},
|
|
1984
|
+
};
|
|
1985
|
+
return { engine: withSeams, formCalls, completeCalls };
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function formRouter(engine: EngineClient) {
|
|
1989
|
+
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
|
|
1990
|
+
db: fakeDb(),
|
|
1991
|
+
engine,
|
|
1992
|
+
readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
|
|
1993
|
+
listPages: async () => ["home"],
|
|
1994
|
+
});
|
|
1995
|
+
return makeRouter(routes);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
test("GET /app/actions/form resolves a row's engine formKey to its form-js schema", async () => {
|
|
1999
|
+
const schema = { type: "default", components: [{ type: "textfield", key: "note" }] };
|
|
2000
|
+
const { engine, formCalls } = formEngine((input) => ({ formKey: input.formKey, schema }));
|
|
2001
|
+
const res = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=form-123" }));
|
|
2002
|
+
assert.equal(res.status, 200);
|
|
2003
|
+
assert.deepEqual(JSON.parse(res.body ?? "{}").schema, schema);
|
|
2004
|
+
assert.deepEqual(formCalls, [{ formKey: "form-123", formId: undefined }]);
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
test("GET /app/actions/form 400s when neither formKey nor formId is present (shared presence gate)", async () => {
|
|
2008
|
+
const { engine } = formEngine();
|
|
2009
|
+
const res = await formRouter(engine)(req("GET", "/app/actions/form"));
|
|
2010
|
+
assert.equal(res.status, 400);
|
|
2011
|
+
const blank = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=%20%20" }));
|
|
2012
|
+
assert.equal(blank.status, 400, "a whitespace-only formKey is absent per the host.ts presence rule");
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
test("GET /app/actions/form 204s when the form can't be resolved (client falls back to bare completion)", async () => {
|
|
2016
|
+
// getForm returns null (default) → 204, matching the taskInbox no-form fallback.
|
|
2017
|
+
const { engine } = formEngine();
|
|
2018
|
+
const res = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=missing" }));
|
|
2019
|
+
assert.equal(res.status, 204);
|
|
2020
|
+
assert.equal(res.body ?? "", "");
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
test("POST /app/actions/complete completes the row's user task with the submitted variables", async () => {
|
|
2024
|
+
const { engine, completeCalls } = formEngine();
|
|
2025
|
+
const res = await formRouter(engine)(
|
|
2026
|
+
req("POST", "/app/actions/complete", { body: { userTaskKey: "ut-9", variables: { note: "ok" } } }),
|
|
2027
|
+
);
|
|
2028
|
+
assert.equal(res.status, 200);
|
|
2029
|
+
assert.deepEqual(JSON.parse(res.body ?? "{}"), { ok: true });
|
|
2030
|
+
assert.deepEqual(completeCalls, [{ userTaskKey: "ut-9", variables: { note: "ok" } }]);
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
test("POST /app/actions/complete supports the no-form bare completion (empty variables)", async () => {
|
|
2034
|
+
const { engine, completeCalls } = formEngine();
|
|
2035
|
+
const res = await formRouter(engine)(req("POST", "/app/actions/complete", { body: { userTaskKey: "ut-1" } }));
|
|
2036
|
+
assert.equal(res.status, 200);
|
|
2037
|
+
assert.deepEqual(completeCalls, [{ userTaskKey: "ut-1", variables: undefined }]);
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
test("POST /app/actions/complete 400s without a userTaskKey", async () => {
|
|
2041
|
+
const { engine, completeCalls } = formEngine();
|
|
2042
|
+
const res = await formRouter(engine)(req("POST", "/app/actions/complete", { body: { variables: {} } }));
|
|
2043
|
+
assert.equal(res.status, 400);
|
|
2044
|
+
assert.equal(completeCalls.length, 0, "no completion is attempted for a keyless body");
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
test("the pages shell embeds the shared form-js renderer (NanoFormJs) for engine forms", async () => {
|
|
2048
|
+
const res = await dispatch("GET", "/");
|
|
2049
|
+
assert.match(res.body ?? "", /NanoFormJs/, "the shell exposes the shared renderer as a global");
|
|
2050
|
+
assert.match(res.body ?? "", /function renderForm/, "the shared renderer is embedded, not forked");
|
|
2051
|
+
});
|
|
@@ -31,8 +31,11 @@ import type { EngineClient } from "../host.ts";
|
|
|
31
31
|
import { html, json, type Route } from "../router.ts";
|
|
32
32
|
import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
|
|
33
33
|
import { apiDocsPath } from "./api.ts";
|
|
34
|
+
import { FORMJS_JS } from "./formjs.gen.ts";
|
|
35
|
+
import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
|
|
34
36
|
import { quoteIdent } from "./gateway.ts";
|
|
35
37
|
import { RENDERER_JS } from "./runtime.gen.ts";
|
|
38
|
+
import { isSqlIdentifier } from "../read-model.ts";
|
|
36
39
|
|
|
37
40
|
/** The subset of the datasource gateway the page runtime needs. */
|
|
38
41
|
export interface PagesDataSource {
|
|
@@ -181,10 +184,10 @@ async function serveAsset(
|
|
|
181
184
|
}
|
|
182
185
|
}
|
|
183
186
|
|
|
184
|
-
/** A
|
|
185
|
-
* (checked against `schema()`), so `/app/data/:table` can never inject
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
/** A table name must be a bare SQL identifier ({@link isSqlIdentifier}, the runtime-wide single source
|
|
188
|
+
* of truth) *and* a known table (checked against `schema()`), so `/app/data/:table` can never inject
|
|
189
|
+
* SQL. Reusing the shared guard (rather than a private regex) keeps this surface from drifting from
|
|
190
|
+
* manifest / read-model / data-source identifier validation. */
|
|
188
191
|
// A query flag (e.g. ?count=1) is "on" when present and not one of the falsy
|
|
189
192
|
// spellings. A bare `?count` (empty string) still counts as on, so a caller can
|
|
190
193
|
// opt in without inventing a value.
|
|
@@ -367,7 +370,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
367
370
|
// v1 exposes only the injected default datasource; a request naming any other
|
|
368
371
|
// source is rejected rather than silently served off the default.
|
|
369
372
|
if (source !== sourceName) return json({ error: `unknown datasource "${source}"` }, 404);
|
|
370
|
-
if (!
|
|
373
|
+
if (!isSqlIdentifier(table)) return json({ error: "invalid table name" }, 400);
|
|
371
374
|
let tables: Set<string>;
|
|
372
375
|
try {
|
|
373
376
|
tables = await knownTables();
|
|
@@ -545,6 +548,28 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
545
548
|
},
|
|
546
549
|
});
|
|
547
550
|
|
|
551
|
+
// ── GET /app/actions/form & POST /app/actions/complete (ADR 0026, #457) ───
|
|
552
|
+
// Engine-declared forms in a `dataGrid` detail drawer. A grid whose `detail`
|
|
553
|
+
// opts into `engineForm` resolves the selected row's `form_key` here and
|
|
554
|
+
// completes the row's user task here — the SAME two seams the `taskInbox`
|
|
555
|
+
// surface uses, reused (not forked) via `forms.ts` so the resolution gate and
|
|
556
|
+
// the completion contract can't drift between the two surfaces. This closes the
|
|
557
|
+
// form-duplication drift class: the grid renders the authoritative deployed
|
|
558
|
+
// `.form` rather than a hand-authored page-local `detail.form` copy.
|
|
559
|
+
routes.push({
|
|
560
|
+
method: "GET",
|
|
561
|
+
path: "/app/actions/form",
|
|
562
|
+
source: "surface:pages",
|
|
563
|
+
handler: (req) =>
|
|
564
|
+
resolveFormResponse(engine, req.query.get("formKey") ?? undefined, req.query.get("formId") ?? undefined),
|
|
565
|
+
});
|
|
566
|
+
routes.push({
|
|
567
|
+
method: "POST",
|
|
568
|
+
path: "/app/actions/complete",
|
|
569
|
+
source: "surface:pages",
|
|
570
|
+
handler: async (req) => completeUserTaskResponse(engine, await req.text()),
|
|
571
|
+
});
|
|
572
|
+
|
|
548
573
|
// ── nested (multi-appView) sidecars (#442) ──────────────────────────────
|
|
549
574
|
// An app with more than one `appView` cannot share the four FLAT sidecar names above:
|
|
550
575
|
// each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
|
|
@@ -668,6 +693,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
668
693
|
</head>
|
|
669
694
|
<body>${apiDocsBadge}
|
|
670
695
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
696
|
+
<script>${FORMJS_JS}</script>
|
|
671
697
|
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
672
698
|
</body>
|
|
673
699
|
</html>`;
|
|
@@ -736,6 +762,19 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
736
762
|
.pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
|
|
737
763
|
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
738
764
|
.pc-btn:disabled { opacity:.5; cursor:default; }
|
|
765
|
+
/* The shared engine-form renderer (NanoFormJs, ADR 0026/#457) styled to match the
|
|
766
|
+
page runtime's native detail controls. njf-* are its stable class names; both
|
|
767
|
+
this surface and the taskInbox page style the same renderer output. */
|
|
768
|
+
.njf-form { display:flex; flex-direction:column; gap:.4rem; margin-top:.5rem; }
|
|
769
|
+
.njf-heading { font-size:1rem; margin:0 0 .25rem; }
|
|
770
|
+
.njf-text { color:var(--nano-text-muted); margin:.25rem 0; }
|
|
771
|
+
.njf-field { display:flex; flex-direction:column; gap:.25rem; }
|
|
772
|
+
.njf-field label { font-size:.8rem; color:var(--nano-text-muted); }
|
|
773
|
+
.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; }
|
|
774
|
+
.njf-actions { display:flex; gap:.5rem; margin-top:.4rem; }
|
|
775
|
+
.njf-actions button { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
776
|
+
.njf-msg { font-size:.85rem; margin-top:.35rem; }
|
|
777
|
+
.njf-msg.err { color:var(--nano-danger); }
|
|
739
778
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
740
779
|
.pc-msg.err { color:var(--nano-danger); }
|
|
741
780
|
.pc-msg.ok { color:var(--nano-ok); }
|