@nanobpm/urban 0.27.0 → 0.29.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/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +18 -1
- package/dist/runtime/core/modules/datasource.js +56 -2
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
- package/dist/runtime/core/modules/instance-tracking.js +141 -0
- package/dist/runtime/core/modules/pages.d.ts +6 -0
- package/dist/runtime/core/modules/pages.js +167 -13
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +49 -0
- package/package.json +2 -2
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +72 -2
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +158 -0
- package/src/runtime/core/modules/pages.test.ts +77 -2
- package/src/runtime/core/modules/pages.ts +171 -13
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +73 -0
- package/src/runtime/engine/nanosdk.ts +63 -0
|
@@ -43,6 +43,12 @@ export interface PagesDeps {
|
|
|
43
43
|
engine: EngineClient;
|
|
44
44
|
/** Read a page file; injectable for tests. */
|
|
45
45
|
readPage(path: string): Promise<string>;
|
|
46
|
+
/**
|
|
47
|
+
* List the available page ids (the `*.page.json` basenames under `pagesDir`),
|
|
48
|
+
* powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
|
|
49
|
+
* Optional; when absent the index is empty. Injectable for tests.
|
|
50
|
+
*/
|
|
51
|
+
listPages?(): Promise<string[]>;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
/** A JavaScript-body response (the renderer module served at /app/runtime.js). */
|
|
@@ -64,7 +70,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
64
70
|
const rowLimitRaw = opts.rowLimit ?? 200;
|
|
65
71
|
const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
|
|
66
72
|
const sourceName = opts.sourceName ?? "app";
|
|
67
|
-
const { db, engine, readPage } = deps;
|
|
73
|
+
const { db, engine, readPage, listPages } = deps;
|
|
68
74
|
|
|
69
75
|
// The table-name whitelist is memoised: an Urban app runs its migrations at boot
|
|
70
76
|
// (before serving), so the schema is stable for the process lifetime, and the renderer
|
|
@@ -113,6 +119,34 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
113
119
|
handler: () => javascript(RENDERER_JS),
|
|
114
120
|
});
|
|
115
121
|
|
|
122
|
+
// ── GET /app/pages (index of available pages) ───────────────────────────
|
|
123
|
+
// Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
|
|
124
|
+
// page here, and it lets any client enumerate the app's screens. Each entry
|
|
125
|
+
// carries the page's own `title` (best-effort — an unreadable/!JSON page still
|
|
126
|
+
// appears by id) and a `home` flag. Registered as an exact path so it never
|
|
127
|
+
// shadows the `/app/pages/<id>` prefix route below.
|
|
128
|
+
routes.push({
|
|
129
|
+
method: "GET",
|
|
130
|
+
path: "/app/pages",
|
|
131
|
+
source: "surface:pages",
|
|
132
|
+
handler: async () => {
|
|
133
|
+
const ids = listPages ? await listPages().catch(() => []) : [];
|
|
134
|
+
const pages: { id: string; title: string; home: boolean }[] = [];
|
|
135
|
+
for (const id of ids) {
|
|
136
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) continue;
|
|
137
|
+
let title = id;
|
|
138
|
+
try {
|
|
139
|
+
const doc = JSON.parse(await readPage(`${pagesDir}/${id}.page.json`));
|
|
140
|
+
if (isRecord(doc) && typeof doc.title === "string" && doc.title) title = doc.title;
|
|
141
|
+
} catch {
|
|
142
|
+
// Unreadable or non-JSON page: still list it by id.
|
|
143
|
+
}
|
|
144
|
+
pages.push({ id, title, home: id === homePage });
|
|
145
|
+
}
|
|
146
|
+
return json({ pages, home: homePage });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
116
150
|
// ── GET /app/pages/<id> ─────────────────────────────────────────────────
|
|
117
151
|
routes.push({
|
|
118
152
|
method: "GET",
|
|
@@ -324,6 +358,13 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
|
|
|
324
358
|
db: app.data.open(sourceName),
|
|
325
359
|
engine: app.engine,
|
|
326
360
|
readPage: (p) => ctx.host.readTextFile(p),
|
|
361
|
+
listPages: async () => {
|
|
362
|
+
const dir = opts.pagesDir ?? "pages";
|
|
363
|
+
const names = await ctx.host.listDir(dir).catch(() => []);
|
|
364
|
+
return names
|
|
365
|
+
.filter((n) => n.endsWith(".page.json"))
|
|
366
|
+
.map((n) => n.slice(0, -".page.json".length));
|
|
367
|
+
},
|
|
327
368
|
});
|
|
328
369
|
ctx.host.log("info", "pages surface mounted", { source: sourceName, home: opts.homePage ?? "home" });
|
|
329
370
|
return {
|
|
@@ -430,6 +471,22 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
430
471
|
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
431
472
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
432
473
|
.pc-card-body[hidden] { display:none; }
|
|
474
|
+
/* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
|
|
475
|
+
.pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
|
|
476
|
+
.pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
|
|
477
|
+
.pc-main-col { flex:1 1 auto; min-width:0; }
|
|
478
|
+
.pc-nav-title { font-weight:650; }
|
|
479
|
+
.pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
|
|
480
|
+
.pc-bar .pc-nav-title { margin-right:.5rem; }
|
|
481
|
+
.pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
|
|
482
|
+
.pc-rail { display:flex; flex-direction:column; gap:.5rem; }
|
|
483
|
+
.pc-rail .pc-nav-title { margin-bottom:.25rem; padding:0 .7rem; }
|
|
484
|
+
.pc-rail .pc-nav-items { display:flex; flex-direction:column; gap:.15rem; }
|
|
485
|
+
.pc-nav-link { display:flex; align-items:center; gap:.4rem; padding:.4rem .7rem; border-radius:.4rem; color:var(--nano-text-muted); text-decoration:none; font-size:.9rem; }
|
|
486
|
+
.pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
|
|
487
|
+
.pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
|
|
488
|
+
.pc-nav-icon { line-height:1; }
|
|
489
|
+
.pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
|
|
433
490
|
`;
|
|
434
491
|
|
|
435
492
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
@@ -440,6 +497,37 @@ const RENDERER_JS = String.raw`
|
|
|
440
497
|
const root = document.getElementById("page");
|
|
441
498
|
const HOME = root.dataset.home || "home";
|
|
442
499
|
|
|
500
|
+
// ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
|
|
501
|
+
// Pages are selected by the URL fragment (#/<page>) so navigation never hits
|
|
502
|
+
// the server and works identically at the origin root and under the Nano
|
|
503
|
+
// console's /console/app-view/<name>/ path-prefixed proxy (the hash is never
|
|
504
|
+
// sent upstream). An empty/invalid hash falls back to the home page. Only a
|
|
505
|
+
// safe id charset is accepted so the fragment can't smuggle a path/URL.
|
|
506
|
+
const PAGE_ID = /^[A-Za-z0-9_-]+$/;
|
|
507
|
+
function safePageId(value) {
|
|
508
|
+
return typeof value === "string" && PAGE_ID.test(value);
|
|
509
|
+
}
|
|
510
|
+
function currentPage() {
|
|
511
|
+
try {
|
|
512
|
+
const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
|
|
513
|
+
return safePageId(raw) ? raw : HOME;
|
|
514
|
+
} catch (e) {
|
|
515
|
+
return HOME;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
let CURRENT = currentPage();
|
|
519
|
+
|
|
520
|
+
// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
|
|
521
|
+
// listener here; navigating to another page runs every disposer before the new
|
|
522
|
+
// page renders, so a switched-away grid stops polling (otherwise every visited
|
|
523
|
+
// page would leave a live setInterval fetching forever).
|
|
524
|
+
const disposers = [];
|
|
525
|
+
function teardown() {
|
|
526
|
+
while (disposers.length) {
|
|
527
|
+
try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
443
531
|
// Theme bridge for the Nano console embed. The app runs in a sandboxed,
|
|
444
532
|
// same-origin iframe, so the console's --nano-* custom properties and its
|
|
445
533
|
// data-appearance never cascade in. Instead the console postMessages its
|
|
@@ -888,15 +976,71 @@ function renderDataGrid(node) {
|
|
|
888
976
|
}
|
|
889
977
|
}
|
|
890
978
|
document.addEventListener("pc:refresh", refresh);
|
|
979
|
+
disposers.push(() => document.removeEventListener("pc:refresh", refresh));
|
|
891
980
|
// Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
|
|
892
981
|
// (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
|
|
893
|
-
// answer and the detail input keeps focus across ticks.
|
|
894
|
-
|
|
982
|
+
// answer and the detail input keeps focus across ticks. The interval is registered as a
|
|
983
|
+
// disposer so a page switch (hashchange → renderPage → teardown) stops it.
|
|
984
|
+
if (p.refreshMs && p.refreshMs > 0) {
|
|
985
|
+
const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
|
|
986
|
+
disposers.push(() => clearInterval(timer));
|
|
987
|
+
}
|
|
895
988
|
refresh();
|
|
896
989
|
return card;
|
|
897
990
|
}
|
|
898
991
|
|
|
899
|
-
|
|
992
|
+
// A navigation node — a horizontal menu bar (variant "bar", default) or a
|
|
993
|
+
// vertical side rail (variant "rail"). Each item links either to another page
|
|
994
|
+
// ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
|
|
995
|
+
// current page) or to an external URL ({ label, href } -> a hardened new-tab
|
|
996
|
+
// link; only http(s) is honoured). With items:"auto" (or omitted) the nav
|
|
997
|
+
// lists every page from the /app/pages index, using each page's title.
|
|
998
|
+
function navLink(item) {
|
|
999
|
+
const isPage = safePageId(item.page);
|
|
1000
|
+
const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
|
|
1001
|
+
if (!isPage && !isExt) return null;
|
|
1002
|
+
const attrs = { class: "pc-nav-link" };
|
|
1003
|
+
if (isPage) {
|
|
1004
|
+
attrs.href = "#/" + encodeURIComponent(item.page);
|
|
1005
|
+
if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
|
|
1006
|
+
} else {
|
|
1007
|
+
attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
|
|
1008
|
+
}
|
|
1009
|
+
const kids = [];
|
|
1010
|
+
if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
|
|
1011
|
+
kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
|
|
1012
|
+
return el("a", attrs, ...kids);
|
|
1013
|
+
}
|
|
1014
|
+
function fillNav(list, items) {
|
|
1015
|
+
list.replaceChildren();
|
|
1016
|
+
let n = 0;
|
|
1017
|
+
for (const item of items || []) {
|
|
1018
|
+
if (!item || typeof item !== "object") continue;
|
|
1019
|
+
const a = navLink(item);
|
|
1020
|
+
if (a) { list.append(a); n++; }
|
|
1021
|
+
}
|
|
1022
|
+
if (!n) list.append(el("span", { class: "pc-nav-empty" }, "No pages"));
|
|
1023
|
+
}
|
|
1024
|
+
function renderNav(node) {
|
|
1025
|
+
const p = node.props || {};
|
|
1026
|
+
const rail = p.variant === "rail";
|
|
1027
|
+
const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
|
|
1028
|
+
if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
|
|
1029
|
+
const list = el("div", { class: "pc-nav-items" });
|
|
1030
|
+
nav.append(list);
|
|
1031
|
+
const items = p.items;
|
|
1032
|
+
if (Array.isArray(items)) {
|
|
1033
|
+
fillNav(list, items);
|
|
1034
|
+
} else {
|
|
1035
|
+
// "auto" (or unspecified): enumerate every page and link to each.
|
|
1036
|
+
getJSON("/app/pages")
|
|
1037
|
+
.then((res) => fillNav(list, (res.pages || []).map((pg) => ({ label: pg.title || pg.id, page: pg.id }))))
|
|
1038
|
+
.catch(() => list.append(el("span", { class: "pc-nav-empty" }, "No pages")));
|
|
1039
|
+
}
|
|
1040
|
+
return nav;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
|
|
900
1044
|
|
|
901
1045
|
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
902
1046
|
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
@@ -951,7 +1095,7 @@ function makeCollapsible(node, card) {
|
|
|
951
1095
|
chevron,
|
|
952
1096
|
el("span", { class: "pc-collapse-title" }, titleText),
|
|
953
1097
|
);
|
|
954
|
-
const storageKey = "pc:collapsed:" +
|
|
1098
|
+
const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
|
|
955
1099
|
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
956
1100
|
function apply() {
|
|
957
1101
|
body.hidden = collapsed;
|
|
@@ -968,16 +1112,30 @@ function makeCollapsible(node, card) {
|
|
|
968
1112
|
return container;
|
|
969
1113
|
}
|
|
970
1114
|
|
|
971
|
-
|
|
1115
|
+
// Render the page named by the current hash route. A rail nav is lifted into a
|
|
1116
|
+
// left <aside> and the rest of the nodes flow into a content column; otherwise
|
|
1117
|
+
// (bar nav or none) nodes render in document order. Re-runs on every hashchange,
|
|
1118
|
+
// tearing down the previous page's grid polls first.
|
|
1119
|
+
async function renderPage() {
|
|
1120
|
+
teardown();
|
|
1121
|
+
CURRENT = currentPage();
|
|
972
1122
|
try {
|
|
973
|
-
const doc = await getJSON("/app/pages/" + encodeURIComponent(
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
);
|
|
1123
|
+
const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
|
|
1124
|
+
document.title = (doc && doc.title) || "Urban App";
|
|
1125
|
+
const nodes = (doc && doc.nodes) || [];
|
|
1126
|
+
const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
|
|
1127
|
+
const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
|
|
1128
|
+
if (rail) {
|
|
1129
|
+
const col = el("div", { class: "pc-main-col" });
|
|
1130
|
+
for (const b of built) if (b !== rail) col.append(b.node);
|
|
1131
|
+
root.replaceChildren(el("div", { class: "pc-layout" }, el("aside", { class: "pc-rail-wrap" }, rail.node), col));
|
|
1132
|
+
} else {
|
|
1133
|
+
root.replaceChildren(...built.map((b) => b.node));
|
|
1134
|
+
}
|
|
978
1135
|
} catch (e) {
|
|
979
|
-
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
1136
|
+
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
|
|
980
1137
|
}
|
|
981
1138
|
}
|
|
982
|
-
|
|
1139
|
+
window.addEventListener("hashchange", renderPage);
|
|
1140
|
+
renderPage();
|
|
983
1141
|
`;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createNodeHost } from "../../adapters/node.ts";
|
|
7
|
+
import { makeGateway } from "./gateway.ts";
|
|
8
|
+
import {
|
|
9
|
+
ensureProvenanceTable,
|
|
10
|
+
makeProvenanceRecorder,
|
|
11
|
+
WRITE_PROVENANCE_TABLE,
|
|
12
|
+
} from "./datasource.ts";
|
|
13
|
+
import {
|
|
14
|
+
__resetExecStoreForTests,
|
|
15
|
+
currentJobContext,
|
|
16
|
+
installExecStore,
|
|
17
|
+
runInJobContext,
|
|
18
|
+
type JobExecContext,
|
|
19
|
+
} from "../execContext.ts";
|
|
20
|
+
import type { HostContext, SqliteDb } from "../host.ts";
|
|
21
|
+
|
|
22
|
+
interface Note {
|
|
23
|
+
id: number;
|
|
24
|
+
body: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ProvRow {
|
|
28
|
+
source: string;
|
|
29
|
+
table_name: string;
|
|
30
|
+
pk_value: string;
|
|
31
|
+
instance_key: string | null;
|
|
32
|
+
element_id: string | null;
|
|
33
|
+
job_type: string | null;
|
|
34
|
+
op: string;
|
|
35
|
+
at: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function withSource(
|
|
39
|
+
fn: (host: HostContext, db: SqliteDb) => Promise<void>,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-prov-"));
|
|
42
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
43
|
+
const db = host.openSqlite(join(dir, "test.db"));
|
|
44
|
+
db.exec("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL)");
|
|
45
|
+
try {
|
|
46
|
+
await fn(host, db);
|
|
47
|
+
} finally {
|
|
48
|
+
db.close();
|
|
49
|
+
await rm(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function provRows(db: SqliteDb): ProvRow[] {
|
|
54
|
+
return db.all<ProvRow>(`SELECT * FROM ${WRITE_PROVENANCE_TABLE} ORDER BY seq`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("insert inside a job context records a provenance row linking row → instance/element", async () => {
|
|
58
|
+
__resetExecStoreForTests();
|
|
59
|
+
await withSource(async (host, db) => {
|
|
60
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
61
|
+
ensureProvenanceTable(db);
|
|
62
|
+
const src = makeGateway(db, makeProvenanceRecorder(host, db, "main"));
|
|
63
|
+
const notes = src.table<Note>("notes");
|
|
64
|
+
|
|
65
|
+
let id: number | bigint = 0;
|
|
66
|
+
await runInJobContext(
|
|
67
|
+
{ instanceKey: "inst-1", elementId: "Task_persist", jobType: "notes.write" },
|
|
68
|
+
async () => {
|
|
69
|
+
id = await notes.insert({ body: "hello" });
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const rows = provRows(db);
|
|
74
|
+
assert.equal(rows.length, 1);
|
|
75
|
+
assert.equal(rows[0].source, "main");
|
|
76
|
+
assert.equal(rows[0].table_name, "notes");
|
|
77
|
+
assert.equal(rows[0].pk_value, String(id));
|
|
78
|
+
assert.equal(rows[0].instance_key, "inst-1");
|
|
79
|
+
assert.equal(rows[0].element_id, "Task_persist");
|
|
80
|
+
assert.equal(rows[0].job_type, "notes.write");
|
|
81
|
+
assert.equal(rows[0].op, "insert");
|
|
82
|
+
assert.ok(rows[0].at.length > 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("insert outside any job context records no provenance (graceful degradation)", async () => {
|
|
87
|
+
__resetExecStoreForTests();
|
|
88
|
+
await withSource(async (host, db) => {
|
|
89
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
90
|
+
ensureProvenanceTable(db);
|
|
91
|
+
const src = makeGateway(db, makeProvenanceRecorder(host, db, "main"));
|
|
92
|
+
await src.table<Note>("notes").insert({ body: "no ctx" });
|
|
93
|
+
assert.equal(provRows(db).length, 0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a job context without an instance key records no provenance", async () => {
|
|
98
|
+
__resetExecStoreForTests();
|
|
99
|
+
await withSource(async (host, db) => {
|
|
100
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
101
|
+
ensureProvenanceTable(db);
|
|
102
|
+
const src = makeGateway(db, makeProvenanceRecorder(host, db, "main"));
|
|
103
|
+
await runInJobContext({ jobType: "notes.write" }, async () => {
|
|
104
|
+
await src.table<Note>("notes").insert({ body: "no instance" });
|
|
105
|
+
});
|
|
106
|
+
assert.equal(provRows(db).length, 0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a provenance-write failure never breaks the app insert", async () => {
|
|
111
|
+
__resetExecStoreForTests();
|
|
112
|
+
await withSource(async (host, db) => {
|
|
113
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
114
|
+
// Deliberately do NOT create the provenance table, so the recorder's own INSERT throws.
|
|
115
|
+
const src = makeGateway(db, makeProvenanceRecorder(host, db, "main"));
|
|
116
|
+
let id: number | bigint = 0;
|
|
117
|
+
await runInJobContext({ instanceKey: "inst-9" }, async () => {
|
|
118
|
+
id = await src.table<Note>("notes").insert({ body: "resilient" });
|
|
119
|
+
});
|
|
120
|
+
// The app row is written despite provenance capture failing.
|
|
121
|
+
assert.equal((await src.table<Note>("notes").get(id))?.body, "resilient");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("the recorder does not record writes to urban/engine-internal tables", async () => {
|
|
126
|
+
__resetExecStoreForTests();
|
|
127
|
+
await withSource(async (host, db) => {
|
|
128
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
129
|
+
ensureProvenanceTable(db);
|
|
130
|
+
const recorder = makeProvenanceRecorder(host, db, "main");
|
|
131
|
+
runInJobContext({ instanceKey: "inst-1" }, () => {
|
|
132
|
+
recorder("_urban_migrations", 1);
|
|
133
|
+
recorder(WRITE_PROVENANCE_TABLE, 2);
|
|
134
|
+
recorder("_nano_events", 3);
|
|
135
|
+
recorder("sqlite_sequence", 4);
|
|
136
|
+
});
|
|
137
|
+
assert.equal(provRows(db).length, 0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("with no store installed, capture is a transparent no-op", async () => {
|
|
142
|
+
__resetExecStoreForTests();
|
|
143
|
+
// No installExecStore call: runInJobContext must pass through and currentJobContext be undefined.
|
|
144
|
+
let ran = false;
|
|
145
|
+
const out = runInJobContext({ instanceKey: "x" }, () => {
|
|
146
|
+
ran = true;
|
|
147
|
+
assert.equal(currentJobContext(), undefined);
|
|
148
|
+
return 42;
|
|
149
|
+
});
|
|
150
|
+
assert.equal(ran, true);
|
|
151
|
+
assert.equal(out, 42);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("installExecStore is idempotent — the first install wins", async () => {
|
|
155
|
+
__resetExecStoreForTests();
|
|
156
|
+
await withSource(async (host, db) => {
|
|
157
|
+
void db;
|
|
158
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
159
|
+
// A second install with a factory that would throw must be ignored (never invoked).
|
|
160
|
+
installExecStore(() => {
|
|
161
|
+
throw new Error("second install must not run");
|
|
162
|
+
});
|
|
163
|
+
runInJobContext({ instanceKey: "inst-2" }, () => {
|
|
164
|
+
assert.equal(currentJobContext()?.instanceKey, "inst-2");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("installExecStore can install after an incapable host returns no store", async () => {
|
|
170
|
+
__resetExecStoreForTests();
|
|
171
|
+
await withSource(async (host, db) => {
|
|
172
|
+
void db;
|
|
173
|
+
installExecStore(() => undefined);
|
|
174
|
+
assert.equal(currentJobContext(), undefined);
|
|
175
|
+
|
|
176
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
177
|
+
runInJobContext({ instanceKey: "inst-3" }, () => {
|
|
178
|
+
assert.equal(currentJobContext()?.instanceKey, "inst-3");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The injectable timer + clock seam shared by every background loop in urban (cron
|
|
2
|
+
// triggers, the instance-tracking reconciler). Keeping one definition here — rather
|
|
3
|
+
// than a copy per module — means a test's fake scheduler drives them all the same
|
|
4
|
+
// way, and the "clamp a far-future delay to setTimeout's 32-bit range" rule has a
|
|
5
|
+
// single home.
|
|
6
|
+
|
|
7
|
+
/** Injectable timer + clock seam so background scheduling is deterministic under test. */
|
|
8
|
+
export interface SchedulerDeps {
|
|
9
|
+
setTimer: (fn: () => void, delayMs: number) => unknown;
|
|
10
|
+
clearTimer: (handle: unknown) => void;
|
|
11
|
+
/** Current wall-clock time in ms since epoch. */
|
|
12
|
+
now: () => number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
16
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
17
|
+
|
|
18
|
+
function isTimerHandle(handle: unknown): handle is ReturnType<typeof setTimeout> {
|
|
19
|
+
return typeof handle === "number" || (typeof handle === "object" && handle !== null);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
23
|
+
export function defaultScheduler(): SchedulerDeps {
|
|
24
|
+
return {
|
|
25
|
+
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
26
|
+
clearTimer: (h) => {
|
|
27
|
+
if (isTimerHandle(h)) globalThis.clearTimeout(h);
|
|
28
|
+
},
|
|
29
|
+
now: () => Date.now(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -29,6 +29,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
|
|
|
29
29
|
publishMessage: async () => {},
|
|
30
30
|
searchUserTasks: async () => [],
|
|
31
31
|
completeUserTask: async () => {},
|
|
32
|
+
searchProcessInstances: async () => [],
|
|
32
33
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
33
34
|
close: async () => {},
|
|
34
35
|
};
|
|
@@ -42,6 +43,7 @@ const fakeEngine: EngineClient = {
|
|
|
42
43
|
publishMessage: async () => {},
|
|
43
44
|
searchUserTasks: async () => [],
|
|
44
45
|
completeUserTask: async () => {},
|
|
46
|
+
searchProcessInstances: async () => [],
|
|
45
47
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
46
48
|
close: async () => {},
|
|
47
49
|
};
|
|
@@ -30,6 +30,7 @@ class MiniEngine implements EngineClient {
|
|
|
30
30
|
}
|
|
31
31
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
32
32
|
async completeUserTask(): Promise<void> {}
|
|
33
|
+
async searchProcessInstances(): Promise<[]> { return []; }
|
|
33
34
|
async registerWorker(jobType: string): Promise<{ jobType: string; unsubscribe(): Promise<void> }> {
|
|
34
35
|
return { jobType, unsubscribe: async () => {} };
|
|
35
36
|
}
|
|
@@ -9,18 +9,14 @@ import type { HttpRequest } from "../host.ts";
|
|
|
9
9
|
import { json, normalizeRoutePath, type Route } from "../router.ts";
|
|
10
10
|
import type { Trigger } from "../manifest.ts";
|
|
11
11
|
import { nextCronFire, parseCron } from "./cron.ts";
|
|
12
|
+
import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
|
|
13
|
+
|
|
14
|
+
export type { SchedulerDeps };
|
|
12
15
|
|
|
13
16
|
export interface TriggersHandle extends Mounted {
|
|
14
17
|
routes: Route[];
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
/** Injectable timer + clock seam so cron scheduling is deterministic under test. */
|
|
18
|
-
export interface SchedulerDeps {
|
|
19
|
-
setTimer: (fn: () => void, delayMs: number) => unknown;
|
|
20
|
-
clearTimer: (handle: unknown) => void;
|
|
21
|
-
/** Current wall-clock time in ms since epoch. */
|
|
22
|
-
now: () => number;
|
|
23
|
-
}
|
|
24
20
|
|
|
25
21
|
type Scope = { body: unknown; headers: Record<string, string>; query: Record<string, string> };
|
|
26
22
|
|
|
@@ -149,24 +145,6 @@ export async function runTriggerAction(
|
|
|
149
145
|
|
|
150
146
|
const emptyScope: Scope = { body: {}, headers: {}, query: {} };
|
|
151
147
|
|
|
152
|
-
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
153
|
-
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
154
|
-
|
|
155
|
-
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
156
|
-
function defaultScheduler(): SchedulerDeps {
|
|
157
|
-
return {
|
|
158
|
-
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
159
|
-
clearTimer: (h) => {
|
|
160
|
-
if (isTimerHandle(h)) globalThis.clearTimeout(h);
|
|
161
|
-
},
|
|
162
|
-
now: () => Date.now(),
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function isTimerHandle(handle: unknown): handle is ReturnType<typeof setTimeout> {
|
|
167
|
-
return typeof handle === "number" || (typeof handle === "object" && handle !== null);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
148
|
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
171
149
|
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
172
150
|
export function mountTriggers(
|
|
@@ -15,6 +15,7 @@ class MiniEngine implements EngineClient {
|
|
|
15
15
|
async publishMessage(): Promise<void> {}
|
|
16
16
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
17
17
|
async completeUserTask(): Promise<void> {}
|
|
18
|
+
async searchProcessInstances(): Promise<[]> { return []; }
|
|
18
19
|
async close(): Promise<void> {}
|
|
19
20
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
20
21
|
this.workers.set(jobType, handler);
|
|
@@ -7,6 +7,7 @@ import { isRecord } from "../guards.ts";
|
|
|
7
7
|
import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
8
8
|
import { workerJobType, type Worker } from "../manifest.ts";
|
|
9
9
|
import { type DecisionEvaluator, type LlmRuntime, runLlmJob } from "./llm.ts";
|
|
10
|
+
import { runInJobContext } from "../execContext.ts";
|
|
10
11
|
|
|
11
12
|
/** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
|
|
12
13
|
* whose `output` comes back as a JSON string (the orchestration-cluster contract). */
|
|
@@ -142,7 +143,11 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
|
|
|
142
143
|
env: (n) => app.env(n),
|
|
143
144
|
evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
|
|
144
145
|
};
|
|
145
|
-
const wrapped: JobHandler = (job) =>
|
|
146
|
+
const wrapped: JobHandler = (job) =>
|
|
147
|
+
runInJobContext(
|
|
148
|
+
{ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
|
|
149
|
+
() => runLlmJob(job.variables, binding, rt),
|
|
150
|
+
);
|
|
146
151
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
147
152
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
148
153
|
});
|
|
@@ -159,7 +164,11 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
|
|
|
159
164
|
`(expected handlers["${jobType}"], a named export, or a default function)`,
|
|
160
165
|
);
|
|
161
166
|
}
|
|
162
|
-
const wrapped: JobHandler = (job) =>
|
|
167
|
+
const wrapped: JobHandler = (job) =>
|
|
168
|
+
runInJobContext(
|
|
169
|
+
{ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
|
|
170
|
+
() => handler(job, app),
|
|
171
|
+
);
|
|
163
172
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
164
173
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
165
174
|
});
|
|
@@ -42,6 +42,9 @@ class FakeEngine implements EngineClient {
|
|
|
42
42
|
async completeUserTask(key: string, variables?: Record<string, unknown>) {
|
|
43
43
|
this.completedTasks.push({ key, variables });
|
|
44
44
|
}
|
|
45
|
+
async searchProcessInstances() {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
45
48
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
46
49
|
this.workers.set(jobType, handler);
|
|
47
50
|
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
@@ -12,10 +12,12 @@ import { makeRouter, type Route } from "./router.ts";
|
|
|
12
12
|
import { deployModels } from "./modules/deploy.ts";
|
|
13
13
|
import type { TemplateSource } from "./modules/templates.ts";
|
|
14
14
|
import { provisionData, DataLayer } from "./modules/datasource.ts";
|
|
15
|
+
import { installExecStore, type JobExecContext } from "./execContext.ts";
|
|
15
16
|
import { mountWorkers } from "./modules/workers.ts";
|
|
16
17
|
import { mountConnectors } from "./modules/connectors.ts";
|
|
17
18
|
import { mountSurfaces } from "./modules/surfaces.ts";
|
|
18
19
|
import { mountTriggers } from "./modules/triggers.ts";
|
|
20
|
+
import { mountInstanceTracking } from "./modules/instance-tracking.ts";
|
|
19
21
|
import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
|
|
20
22
|
|
|
21
23
|
/** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
|
|
@@ -52,6 +54,9 @@ export interface MountFlags {
|
|
|
52
54
|
surfaces?: boolean;
|
|
53
55
|
triggers?: boolean;
|
|
54
56
|
security?: boolean;
|
|
57
|
+
/** The `instanceTracking` reconciler poll loop. Shares no HTTP surface; safe to disable
|
|
58
|
+
* in hosts that only render surfaces (e.g. the console preview). */
|
|
59
|
+
instanceTracking?: boolean;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export interface CreateUrbanAppOptions {
|
|
@@ -104,9 +109,15 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
104
109
|
surfaces: opts.mount?.surfaces ?? true,
|
|
105
110
|
triggers: opts.mount?.triggers ?? true,
|
|
106
111
|
security: opts.mount?.security ?? true,
|
|
112
|
+
instanceTracking: opts.mount?.instanceTracking ?? true,
|
|
107
113
|
};
|
|
108
114
|
const ctx = { manifest, host, engine, root, templates: opts.templates };
|
|
109
115
|
|
|
116
|
+
// Install the ambient job-execution store once per process (idempotent) so worker dispatch can
|
|
117
|
+
// stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
|
|
118
|
+
// leaves provenance capture disabled.
|
|
119
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
120
|
+
|
|
110
121
|
let data: DataLayer | undefined;
|
|
111
122
|
let security: SecurityPolicy | undefined;
|
|
112
123
|
let server: HttpServer | undefined;
|
|
@@ -204,6 +215,11 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
204
215
|
mounted.push(t);
|
|
205
216
|
describe.triggers = t.describe?.();
|
|
206
217
|
}
|
|
218
|
+
if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
|
|
219
|
+
const it = mountInstanceTracking(ctx, api);
|
|
220
|
+
mounted.push(it);
|
|
221
|
+
describe.instanceTracking = it.describe?.();
|
|
222
|
+
}
|
|
207
223
|
if (security) describe.security = security.describe();
|
|
208
224
|
if (data) describe.data = data.describe();
|
|
209
225
|
|