@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
|
@@ -34,7 +34,7 @@ export function createPagesRoutes(opts, deps) {
|
|
|
34
34
|
const rowLimitRaw = opts.rowLimit ?? 200;
|
|
35
35
|
const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
|
|
36
36
|
const sourceName = opts.sourceName ?? "app";
|
|
37
|
-
const { db, engine, readPage } = deps;
|
|
37
|
+
const { db, engine, readPage, listPages } = deps;
|
|
38
38
|
// The table-name whitelist is memoised: an Urban app runs its migrations at boot
|
|
39
39
|
// (before serving), so the schema is stable for the process lifetime, and the renderer
|
|
40
40
|
// refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
|
|
@@ -75,6 +75,36 @@ export function createPagesRoutes(opts, deps) {
|
|
|
75
75
|
source: "surface:pages",
|
|
76
76
|
handler: () => javascript(RENDERER_JS),
|
|
77
77
|
});
|
|
78
|
+
// ── GET /app/pages (index of available pages) ───────────────────────────
|
|
79
|
+
// Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
|
|
80
|
+
// page here, and it lets any client enumerate the app's screens. Each entry
|
|
81
|
+
// carries the page's own `title` (best-effort — an unreadable/!JSON page still
|
|
82
|
+
// appears by id) and a `home` flag. Registered as an exact path so it never
|
|
83
|
+
// shadows the `/app/pages/<id>` prefix route below.
|
|
84
|
+
routes.push({
|
|
85
|
+
method: "GET",
|
|
86
|
+
path: "/app/pages",
|
|
87
|
+
source: "surface:pages",
|
|
88
|
+
handler: async () => {
|
|
89
|
+
const ids = listPages ? await listPages().catch(() => []) : [];
|
|
90
|
+
const pages = [];
|
|
91
|
+
for (const id of ids) {
|
|
92
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id))
|
|
93
|
+
continue;
|
|
94
|
+
let title = id;
|
|
95
|
+
try {
|
|
96
|
+
const doc = JSON.parse(await readPage(`${pagesDir}/${id}.page.json`));
|
|
97
|
+
if (isRecord(doc) && typeof doc.title === "string" && doc.title)
|
|
98
|
+
title = doc.title;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Unreadable or non-JSON page: still list it by id.
|
|
102
|
+
}
|
|
103
|
+
pages.push({ id, title, home: id === homePage });
|
|
104
|
+
}
|
|
105
|
+
return json({ pages, home: homePage });
|
|
106
|
+
},
|
|
107
|
+
});
|
|
78
108
|
// ── GET /app/pages/<id> ─────────────────────────────────────────────────
|
|
79
109
|
routes.push({
|
|
80
110
|
method: "GET",
|
|
@@ -297,6 +327,13 @@ export function mountPages(ctx, app) {
|
|
|
297
327
|
db: app.data.open(sourceName),
|
|
298
328
|
engine: app.engine,
|
|
299
329
|
readPage: (p) => ctx.host.readTextFile(p),
|
|
330
|
+
listPages: async () => {
|
|
331
|
+
const dir = opts.pagesDir ?? "pages";
|
|
332
|
+
const names = await ctx.host.listDir(dir).catch(() => []);
|
|
333
|
+
return names
|
|
334
|
+
.filter((n) => n.endsWith(".page.json"))
|
|
335
|
+
.map((n) => n.slice(0, -".page.json".length));
|
|
336
|
+
},
|
|
300
337
|
});
|
|
301
338
|
ctx.host.log("info", "pages surface mounted", { source: sourceName, home: opts.homePage ?? "home" });
|
|
302
339
|
return {
|
|
@@ -400,6 +437,22 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
400
437
|
.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; }
|
|
401
438
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
402
439
|
.pc-card-body[hidden] { display:none; }
|
|
440
|
+
/* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
|
|
441
|
+
.pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
|
|
442
|
+
.pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
|
|
443
|
+
.pc-main-col { flex:1 1 auto; min-width:0; }
|
|
444
|
+
.pc-nav-title { font-weight:650; }
|
|
445
|
+
.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; }
|
|
446
|
+
.pc-bar .pc-nav-title { margin-right:.5rem; }
|
|
447
|
+
.pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
|
|
448
|
+
.pc-rail { display:flex; flex-direction:column; gap:.5rem; }
|
|
449
|
+
.pc-rail .pc-nav-title { margin-bottom:.25rem; padding:0 .7rem; }
|
|
450
|
+
.pc-rail .pc-nav-items { display:flex; flex-direction:column; gap:.15rem; }
|
|
451
|
+
.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; }
|
|
452
|
+
.pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
|
|
453
|
+
.pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
|
|
454
|
+
.pc-nav-icon { line-height:1; }
|
|
455
|
+
.pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
|
|
403
456
|
`;
|
|
404
457
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
405
458
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
@@ -409,6 +462,37 @@ const RENDERER_JS = String.raw `
|
|
|
409
462
|
const root = document.getElementById("page");
|
|
410
463
|
const HOME = root.dataset.home || "home";
|
|
411
464
|
|
|
465
|
+
// ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
|
|
466
|
+
// Pages are selected by the URL fragment (#/<page>) so navigation never hits
|
|
467
|
+
// the server and works identically at the origin root and under the Nano
|
|
468
|
+
// console's /console/app-view/<name>/ path-prefixed proxy (the hash is never
|
|
469
|
+
// sent upstream). An empty/invalid hash falls back to the home page. Only a
|
|
470
|
+
// safe id charset is accepted so the fragment can't smuggle a path/URL.
|
|
471
|
+
const PAGE_ID = /^[A-Za-z0-9_-]+$/;
|
|
472
|
+
function safePageId(value) {
|
|
473
|
+
return typeof value === "string" && PAGE_ID.test(value);
|
|
474
|
+
}
|
|
475
|
+
function currentPage() {
|
|
476
|
+
try {
|
|
477
|
+
const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
|
|
478
|
+
return safePageId(raw) ? raw : HOME;
|
|
479
|
+
} catch (e) {
|
|
480
|
+
return HOME;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
let CURRENT = currentPage();
|
|
484
|
+
|
|
485
|
+
// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
|
|
486
|
+
// listener here; navigating to another page runs every disposer before the new
|
|
487
|
+
// page renders, so a switched-away grid stops polling (otherwise every visited
|
|
488
|
+
// page would leave a live setInterval fetching forever).
|
|
489
|
+
const disposers = [];
|
|
490
|
+
function teardown() {
|
|
491
|
+
while (disposers.length) {
|
|
492
|
+
try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
412
496
|
// Theme bridge for the Nano console embed. The app runs in a sandboxed,
|
|
413
497
|
// same-origin iframe, so the console's --nano-* custom properties and its
|
|
414
498
|
// data-appearance never cascade in. Instead the console postMessages its
|
|
@@ -857,15 +941,71 @@ function renderDataGrid(node) {
|
|
|
857
941
|
}
|
|
858
942
|
}
|
|
859
943
|
document.addEventListener("pc:refresh", refresh);
|
|
944
|
+
disposers.push(() => document.removeEventListener("pc:refresh", refresh));
|
|
860
945
|
// Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
|
|
861
946
|
// (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
|
|
862
|
-
// answer and the detail input keeps focus across ticks.
|
|
863
|
-
|
|
947
|
+
// answer and the detail input keeps focus across ticks. The interval is registered as a
|
|
948
|
+
// disposer so a page switch (hashchange → renderPage → teardown) stops it.
|
|
949
|
+
if (p.refreshMs && p.refreshMs > 0) {
|
|
950
|
+
const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
|
|
951
|
+
disposers.push(() => clearInterval(timer));
|
|
952
|
+
}
|
|
864
953
|
refresh();
|
|
865
954
|
return card;
|
|
866
955
|
}
|
|
867
956
|
|
|
868
|
-
|
|
957
|
+
// A navigation node — a horizontal menu bar (variant "bar", default) or a
|
|
958
|
+
// vertical side rail (variant "rail"). Each item links either to another page
|
|
959
|
+
// ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
|
|
960
|
+
// current page) or to an external URL ({ label, href } -> a hardened new-tab
|
|
961
|
+
// link; only http(s) is honoured). With items:"auto" (or omitted) the nav
|
|
962
|
+
// lists every page from the /app/pages index, using each page's title.
|
|
963
|
+
function navLink(item) {
|
|
964
|
+
const isPage = safePageId(item.page);
|
|
965
|
+
const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
|
|
966
|
+
if (!isPage && !isExt) return null;
|
|
967
|
+
const attrs = { class: "pc-nav-link" };
|
|
968
|
+
if (isPage) {
|
|
969
|
+
attrs.href = "#/" + encodeURIComponent(item.page);
|
|
970
|
+
if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
|
|
971
|
+
} else {
|
|
972
|
+
attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
|
|
973
|
+
}
|
|
974
|
+
const kids = [];
|
|
975
|
+
if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
|
|
976
|
+
kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
|
|
977
|
+
return el("a", attrs, ...kids);
|
|
978
|
+
}
|
|
979
|
+
function fillNav(list, items) {
|
|
980
|
+
list.replaceChildren();
|
|
981
|
+
let n = 0;
|
|
982
|
+
for (const item of items || []) {
|
|
983
|
+
if (!item || typeof item !== "object") continue;
|
|
984
|
+
const a = navLink(item);
|
|
985
|
+
if (a) { list.append(a); n++; }
|
|
986
|
+
}
|
|
987
|
+
if (!n) list.append(el("span", { class: "pc-nav-empty" }, "No pages"));
|
|
988
|
+
}
|
|
989
|
+
function renderNav(node) {
|
|
990
|
+
const p = node.props || {};
|
|
991
|
+
const rail = p.variant === "rail";
|
|
992
|
+
const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
|
|
993
|
+
if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
|
|
994
|
+
const list = el("div", { class: "pc-nav-items" });
|
|
995
|
+
nav.append(list);
|
|
996
|
+
const items = p.items;
|
|
997
|
+
if (Array.isArray(items)) {
|
|
998
|
+
fillNav(list, items);
|
|
999
|
+
} else {
|
|
1000
|
+
// "auto" (or unspecified): enumerate every page and link to each.
|
|
1001
|
+
getJSON("/app/pages")
|
|
1002
|
+
.then((res) => fillNav(list, (res.pages || []).map((pg) => ({ label: pg.title || pg.id, page: pg.id }))))
|
|
1003
|
+
.catch(() => list.append(el("span", { class: "pc-nav-empty" }, "No pages")));
|
|
1004
|
+
}
|
|
1005
|
+
return nav;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
|
|
869
1009
|
|
|
870
1010
|
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
871
1011
|
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
@@ -920,7 +1060,7 @@ function makeCollapsible(node, card) {
|
|
|
920
1060
|
chevron,
|
|
921
1061
|
el("span", { class: "pc-collapse-title" }, titleText),
|
|
922
1062
|
);
|
|
923
|
-
const storageKey = "pc:collapsed:" +
|
|
1063
|
+
const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
|
|
924
1064
|
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
925
1065
|
function apply() {
|
|
926
1066
|
body.hidden = collapsed;
|
|
@@ -937,16 +1077,30 @@ function makeCollapsible(node, card) {
|
|
|
937
1077
|
return container;
|
|
938
1078
|
}
|
|
939
1079
|
|
|
940
|
-
|
|
1080
|
+
// Render the page named by the current hash route. A rail nav is lifted into a
|
|
1081
|
+
// left <aside> and the rest of the nodes flow into a content column; otherwise
|
|
1082
|
+
// (bar nav or none) nodes render in document order. Re-runs on every hashchange,
|
|
1083
|
+
// tearing down the previous page's grid polls first.
|
|
1084
|
+
async function renderPage() {
|
|
1085
|
+
teardown();
|
|
1086
|
+
CURRENT = currentPage();
|
|
941
1087
|
try {
|
|
942
|
-
const doc = await getJSON("/app/pages/" + encodeURIComponent(
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
);
|
|
1088
|
+
const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
|
|
1089
|
+
document.title = (doc && doc.title) || "Urban App";
|
|
1090
|
+
const nodes = (doc && doc.nodes) || [];
|
|
1091
|
+
const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
|
|
1092
|
+
const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
|
|
1093
|
+
if (rail) {
|
|
1094
|
+
const col = el("div", { class: "pc-main-col" });
|
|
1095
|
+
for (const b of built) if (b !== rail) col.append(b.node);
|
|
1096
|
+
root.replaceChildren(el("div", { class: "pc-layout" }, el("aside", { class: "pc-rail-wrap" }, rail.node), col));
|
|
1097
|
+
} else {
|
|
1098
|
+
root.replaceChildren(...built.map((b) => b.node));
|
|
1099
|
+
}
|
|
947
1100
|
} catch (e) {
|
|
948
|
-
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
1101
|
+
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
|
|
949
1102
|
}
|
|
950
1103
|
}
|
|
951
|
-
|
|
1104
|
+
window.addEventListener("hashchange", renderPage);
|
|
1105
|
+
renderPage();
|
|
952
1106
|
`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Injectable timer + clock seam so background scheduling is deterministic under test. */
|
|
2
|
+
export interface SchedulerDeps {
|
|
3
|
+
setTimer: (fn: () => void, delayMs: number) => unknown;
|
|
4
|
+
clearTimer: (handle: unknown) => void;
|
|
5
|
+
/** Current wall-clock time in ms since epoch. */
|
|
6
|
+
now: () => number;
|
|
7
|
+
}
|
|
8
|
+
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
9
|
+
export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
10
|
+
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
11
|
+
export declare function defaultScheduler(): SchedulerDeps;
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
7
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
8
|
+
function isTimerHandle(handle) {
|
|
9
|
+
return typeof handle === "number" || (typeof handle === "object" && handle !== null);
|
|
10
|
+
}
|
|
11
|
+
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
12
|
+
export function defaultScheduler() {
|
|
13
|
+
return {
|
|
14
|
+
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
15
|
+
clearTimer: (h) => {
|
|
16
|
+
if (isTimerHandle(h))
|
|
17
|
+
globalThis.clearTimeout(h);
|
|
18
|
+
},
|
|
19
|
+
now: () => Date.now(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
2
2
|
import { type Route } from "../router.ts";
|
|
3
|
+
import { type SchedulerDeps } from "./scheduler.ts";
|
|
4
|
+
export type { SchedulerDeps };
|
|
3
5
|
export interface TriggersHandle extends Mounted {
|
|
4
6
|
routes: Route[];
|
|
5
7
|
}
|
|
6
|
-
/** Injectable timer + clock seam so cron scheduling is deterministic under test. */
|
|
7
|
-
export interface SchedulerDeps {
|
|
8
|
-
setTimer: (fn: () => void, delayMs: number) => unknown;
|
|
9
|
-
clearTimer: (handle: unknown) => void;
|
|
10
|
-
/** Current wall-clock time in ms since epoch. */
|
|
11
|
-
now: () => number;
|
|
12
|
-
}
|
|
13
8
|
type Scope = {
|
|
14
9
|
body: unknown;
|
|
15
10
|
headers: Record<string, string>;
|
|
@@ -58,4 +53,3 @@ export declare function runTriggerAction(app: AppApi, action: RuntimeTriggerActi
|
|
|
58
53
|
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
59
54
|
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
60
55
|
export declare function mountTriggers(ctx: RuntimeContext, app: AppApi, sched?: SchedulerDeps): TriggersHandle;
|
|
61
|
-
export {};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { isRecord } from "../guards.js";
|
|
6
6
|
import { json, normalizeRoutePath } from "../router.js";
|
|
7
7
|
import { nextCronFire, parseCron } from "./cron.js";
|
|
8
|
+
import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
|
|
8
9
|
/**
|
|
9
10
|
* Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
|
|
10
11
|
* returns the raw value at that path (or `undefined` if any segment is missing); a literal
|
|
@@ -92,22 +93,6 @@ export async function runTriggerAction(app, action, scope) {
|
|
|
92
93
|
return { kind: "none" };
|
|
93
94
|
}
|
|
94
95
|
const emptyScope = { body: {}, headers: {}, query: {} };
|
|
95
|
-
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
96
|
-
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
97
|
-
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
98
|
-
function defaultScheduler() {
|
|
99
|
-
return {
|
|
100
|
-
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
101
|
-
clearTimer: (h) => {
|
|
102
|
-
if (isTimerHandle(h))
|
|
103
|
-
globalThis.clearTimeout(h);
|
|
104
|
-
},
|
|
105
|
-
now: () => Date.now(),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
function isTimerHandle(handle) {
|
|
109
|
-
return typeof handle === "number" || (typeof handle === "object" && handle !== null);
|
|
110
|
-
}
|
|
111
96
|
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
112
97
|
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
113
98
|
export function mountTriggers(ctx, app, sched = defaultScheduler()) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { isRecord } from "../guards.js";
|
|
5
5
|
import { workerJobType } from "../manifest.js";
|
|
6
6
|
import { runLlmJob } from "./llm.js";
|
|
7
|
+
import { runInJobContext } from "../execContext.js";
|
|
7
8
|
/** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
|
|
8
9
|
* decision's JSON-string `output` into the value fed back to the LLM job. Validates the
|
|
9
10
|
* SDK shape and adds decision context to a parse failure rather than surfacing a raw
|
|
@@ -98,7 +99,7 @@ export async function mountWorkers(ctx, app) {
|
|
|
98
99
|
env: (n) => app.env(n),
|
|
99
100
|
evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
|
|
100
101
|
};
|
|
101
|
-
const wrapped = (job) => runLlmJob(job.variables, binding, rt);
|
|
102
|
+
const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => runLlmJob(job.variables, binding, rt));
|
|
102
103
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
103
104
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
104
105
|
});
|
|
@@ -113,7 +114,7 @@ export async function mountWorkers(ctx, app) {
|
|
|
113
114
|
throw new Error(`worker "${jobType}": ${decl.handler} exports no handler for it ` +
|
|
114
115
|
`(expected handlers["${jobType}"], a named export, or a default function)`);
|
|
115
116
|
}
|
|
116
|
-
const wrapped = (job) => handler(job, app);
|
|
117
|
+
const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, app));
|
|
117
118
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
118
119
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
119
120
|
});
|
|
@@ -13,6 +13,9 @@ export interface MountFlags {
|
|
|
13
13
|
surfaces?: boolean;
|
|
14
14
|
triggers?: boolean;
|
|
15
15
|
security?: boolean;
|
|
16
|
+
/** The `instanceTracking` reconciler poll loop. Shares no HTTP surface; safe to disable
|
|
17
|
+
* in hosts that only render surfaces (e.g. the console preview). */
|
|
18
|
+
instanceTracking?: boolean;
|
|
16
19
|
}
|
|
17
20
|
export interface CreateUrbanAppOptions {
|
|
18
21
|
host: HostContext;
|
|
@@ -7,10 +7,12 @@ import { validateManifest } from "./validate.js";
|
|
|
7
7
|
import { makeRouter } from "./router.js";
|
|
8
8
|
import { deployModels } from "./modules/deploy.js";
|
|
9
9
|
import { provisionData, DataLayer } from "./modules/datasource.js";
|
|
10
|
+
import { installExecStore } from "./execContext.js";
|
|
10
11
|
import { mountWorkers } from "./modules/workers.js";
|
|
11
12
|
import { mountConnectors } from "./modules/connectors.js";
|
|
12
13
|
import { mountSurfaces } from "./modules/surfaces.js";
|
|
13
14
|
import { mountTriggers } from "./modules/triggers.js";
|
|
15
|
+
import { mountInstanceTracking } from "./modules/instance-tracking.js";
|
|
14
16
|
import { mountSecurity } from "./modules/security.js";
|
|
15
17
|
/** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
|
|
16
18
|
* error when $PORT is set but not a valid integer in 0..65535. */
|
|
@@ -43,8 +45,13 @@ export async function createUrbanApp(opts) {
|
|
|
43
45
|
surfaces: opts.mount?.surfaces ?? true,
|
|
44
46
|
triggers: opts.mount?.triggers ?? true,
|
|
45
47
|
security: opts.mount?.security ?? true,
|
|
48
|
+
instanceTracking: opts.mount?.instanceTracking ?? true,
|
|
46
49
|
};
|
|
47
50
|
const ctx = { manifest, host, engine, root, templates: opts.templates };
|
|
51
|
+
// Install the ambient job-execution store once per process (idempotent) so worker dispatch can
|
|
52
|
+
// stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
|
|
53
|
+
// leaves provenance capture disabled.
|
|
54
|
+
installExecStore(() => host.createAsyncStore?.());
|
|
48
55
|
let data;
|
|
49
56
|
let security;
|
|
50
57
|
let server;
|
|
@@ -144,6 +151,11 @@ export async function createUrbanApp(opts) {
|
|
|
144
151
|
mounted.push(t);
|
|
145
152
|
describe.triggers = t.describe?.();
|
|
146
153
|
}
|
|
154
|
+
if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
|
|
155
|
+
const it = mountInstanceTracking(ctx, api);
|
|
156
|
+
mounted.push(it);
|
|
157
|
+
describe.instanceTracking = it.describe?.();
|
|
158
|
+
}
|
|
147
159
|
if (security)
|
|
148
160
|
describe.security = security.describe();
|
|
149
161
|
if (data)
|
|
@@ -131,6 +131,44 @@ export function collectManifestIssues(m) {
|
|
|
131
131
|
issues.push({ path: `triggers[${i}].type`, message: "missing type" });
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
|
+
// instanceTracking: each binding must name the table + key column it tracks and a
|
|
135
|
+
// non-empty `onTerminated.set` patch — a reconciler with nothing to write is inert
|
|
136
|
+
// and almost certainly a mistake. `activeStatuses` without `statusField` is incoherent
|
|
137
|
+
// (no column to read the statuses from), so the runtime would silently poll every row.
|
|
138
|
+
const tracking = Array.isArray(obj.instanceTracking) ? obj.instanceTracking : undefined;
|
|
139
|
+
if (tracking) {
|
|
140
|
+
tracking.forEach((t, i) => {
|
|
141
|
+
const b = isRecord(t) ? t : undefined;
|
|
142
|
+
if (typeof b?.table !== "string" || b.table.length === 0) {
|
|
143
|
+
issues.push({ path: `instanceTracking[${i}].table`, message: "missing table" });
|
|
144
|
+
}
|
|
145
|
+
if (typeof b?.keyField !== "string" || b.keyField.length === 0) {
|
|
146
|
+
issues.push({ path: `instanceTracking[${i}].keyField`, message: "missing keyField" });
|
|
147
|
+
}
|
|
148
|
+
const onTerminated = isRecord(b?.onTerminated) ? b.onTerminated : undefined;
|
|
149
|
+
const set = isRecord(onTerminated?.set) ? onTerminated.set : undefined;
|
|
150
|
+
if (!set || Object.keys(set).length === 0) {
|
|
151
|
+
issues.push({
|
|
152
|
+
path: `instanceTracking[${i}].onTerminated.set`,
|
|
153
|
+
message: "missing onTerminated.set patch (a non-empty column → value map)",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
157
|
+
issues.push({
|
|
158
|
+
path: `instanceTracking[${i}].activeStatuses`,
|
|
159
|
+
message: "activeStatuses requires statusField",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
|
|
163
|
+
// value would become a 0-delay hot loop at runtime, so reject it at author time.
|
|
164
|
+
if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
|
|
165
|
+
issues.push({
|
|
166
|
+
path: `instanceTracking[${i}].pollMs`,
|
|
167
|
+
message: "pollMs must be a finite positive number of milliseconds",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
134
172
|
return issues;
|
|
135
173
|
}
|
|
136
174
|
export function assertValidManifest(m) {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type { EngineClient, JobHandler, WorkerSubscription } from "../core/host.ts";
|
|
1
|
+
import type { EngineClient, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, WorkerSubscription } from "../core/host.ts";
|
|
2
2
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
3
3
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
4
4
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
5
5
|
export declare function requireProcessInstanceKey(key: string | number | null | undefined): string;
|
|
6
|
+
/** Map the engine's process-instance `state` onto the transport-agnostic
|
|
7
|
+
* {@link ProcessInstanceState} union, or `undefined` for an unrecognized value
|
|
8
|
+
* (which the caller skips rather than mis-reconciling). Case-insensitive so a
|
|
9
|
+
* future casing tweak on the engine side doesn't silently drop instances. */
|
|
10
|
+
export declare function normalizeProcessInstanceState(raw: unknown): ProcessInstanceState | undefined;
|
|
6
11
|
/** A job as delivered to a nano-sdk job handler: the frame fields plus the
|
|
7
12
|
* acknowledgement actions the handler must call. */
|
|
8
13
|
export interface NanoSdkActivatedJob {
|
|
@@ -76,6 +81,10 @@ export interface NanoSdkClient {
|
|
|
76
81
|
userTaskKey: string;
|
|
77
82
|
variables?: Record<string, unknown>;
|
|
78
83
|
}, options?: unknown): Promise<unknown>;
|
|
84
|
+
searchProcessInstances(input: {
|
|
85
|
+
filter?: Record<string, unknown>;
|
|
86
|
+
page?: Record<string, unknown>;
|
|
87
|
+
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
79
88
|
createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
|
|
80
89
|
/** Stop every worker created on this client. Used on teardown to also drain a
|
|
81
90
|
* REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
|
|
@@ -134,6 +143,10 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
134
143
|
variables?: Record<string, unknown>;
|
|
135
144
|
}[]>;
|
|
136
145
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
146
|
+
searchProcessInstances(filter?: {
|
|
147
|
+
processInstanceKeys?: string[];
|
|
148
|
+
state?: ProcessInstanceState;
|
|
149
|
+
}): Promise<ProcessInstanceSnapshot[]>;
|
|
137
150
|
registerWorker(jobType: string, handler: JobHandler, options?: {
|
|
138
151
|
workerName?: string;
|
|
139
152
|
maxParallelJobs?: number;
|
|
@@ -28,6 +28,24 @@ export function requireProcessInstanceKey(key) {
|
|
|
28
28
|
}
|
|
29
29
|
return String(key);
|
|
30
30
|
}
|
|
31
|
+
/** Map the engine's process-instance `state` onto the transport-agnostic
|
|
32
|
+
* {@link ProcessInstanceState} union, or `undefined` for an unrecognized value
|
|
33
|
+
* (which the caller skips rather than mis-reconciling). Case-insensitive so a
|
|
34
|
+
* future casing tweak on the engine side doesn't silently drop instances. */
|
|
35
|
+
export function normalizeProcessInstanceState(raw) {
|
|
36
|
+
if (typeof raw !== "string")
|
|
37
|
+
return undefined;
|
|
38
|
+
switch (raw.toUpperCase()) {
|
|
39
|
+
case "ACTIVE":
|
|
40
|
+
return "ACTIVE";
|
|
41
|
+
case "COMPLETED":
|
|
42
|
+
return "COMPLETED";
|
|
43
|
+
case "TERMINATED":
|
|
44
|
+
return "TERMINATED";
|
|
45
|
+
default:
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
31
49
|
/**
|
|
32
50
|
* An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
|
|
33
51
|
* message, and user-task calls map one-to-one onto the SDK's client methods; workers
|
|
@@ -101,6 +119,37 @@ export class SdkEngineClient {
|
|
|
101
119
|
async completeUserTask(userTaskKey, variables) {
|
|
102
120
|
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
|
103
121
|
}
|
|
122
|
+
async searchProcessInstances(filter) {
|
|
123
|
+
const f = {};
|
|
124
|
+
if (filter?.state)
|
|
125
|
+
f.state = filter.state;
|
|
126
|
+
const keys = filter?.processInstanceKeys?.filter((k) => k != null && k !== "");
|
|
127
|
+
if (keys && keys.length > 0)
|
|
128
|
+
f.processInstanceKey = { $in: keys };
|
|
129
|
+
// A process-instance search is an eventually consistent read; ask for zero-wait
|
|
130
|
+
// consistency so it reflects what is currently visible without blocking. Cap the page
|
|
131
|
+
// to the number of keys asked for (each key matches at most one instance), so a bounded
|
|
132
|
+
// reconcile query never silently truncates on the API's default page size.
|
|
133
|
+
const page = keys && keys.length > 0 ? { limit: keys.length } : undefined;
|
|
134
|
+
const body = await this.client.searchProcessInstances({ filter: f, ...(page ? { page } : {}) }, { consistency: { waitUpToMs: 0 } });
|
|
135
|
+
const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
|
|
136
|
+
return items.flatMap((it) => {
|
|
137
|
+
const key = it.processInstanceKey;
|
|
138
|
+
if (key == null || key === "") {
|
|
139
|
+
this.log("warn", "skipping process instance with no key in engine response");
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const state = normalizeProcessInstanceState(it.state);
|
|
143
|
+
if (!state) {
|
|
144
|
+
this.log("warn", "skipping process instance with unrecognized state", {
|
|
145
|
+
processInstanceKey: String(key),
|
|
146
|
+
state: String(it.state),
|
|
147
|
+
});
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
return [{ processInstanceKey: String(key), state }];
|
|
151
|
+
});
|
|
152
|
+
}
|
|
104
153
|
async registerWorker(jobType, handler, options) {
|
|
105
154
|
const worker = this.client.createJobWorker({
|
|
106
155
|
jobType,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"test:deno": "deno test -A --no-check src/"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@nanobpm/nano-app-schema": "^0.
|
|
65
|
+
"@nanobpm/nano-app-schema": "^0.4.0",
|
|
66
66
|
"@nanobpm/nano-sdk": "^1.2.3",
|
|
67
67
|
"@nanobpm/workflow": "^0.8.0",
|
|
68
68
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import { isAbsolute, resolve } from "node:path";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
10
|
import type {
|
|
11
|
+
AsyncStore,
|
|
10
12
|
HostContext,
|
|
11
13
|
HttpHandler,
|
|
12
14
|
HttpRequest,
|
|
@@ -134,6 +136,10 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
|
134
136
|
};
|
|
135
137
|
},
|
|
136
138
|
now: () => Date.now(),
|
|
139
|
+
createAsyncStore<T>(): AsyncStore<T> {
|
|
140
|
+
const als = new AsyncLocalStorage<T>();
|
|
141
|
+
return { run: <R>(value: T, fn: () => R): R => als.run(value, fn), current: () => als.getStore() };
|
|
142
|
+
},
|
|
137
143
|
log,
|
|
138
144
|
};
|
|
139
145
|
}
|
|
@@ -9,7 +9,9 @@ import { register } from "node:module";
|
|
|
9
9
|
import { isAbsolute, resolve } from "node:path";
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
11
|
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
13
|
import type {
|
|
14
|
+
AsyncStore,
|
|
13
15
|
HostContext,
|
|
14
16
|
HttpHandler,
|
|
15
17
|
HttpRequest,
|
|
@@ -176,6 +178,10 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
176
178
|
return { close: () => w.close() } satisfies WatchHandle;
|
|
177
179
|
},
|
|
178
180
|
now: () => Date.now(),
|
|
181
|
+
createAsyncStore<T>(): AsyncStore<T> {
|
|
182
|
+
const als = new AsyncLocalStorage<T>();
|
|
183
|
+
return { run: <R>(value: T, fn: () => R): R => als.run(value, fn), current: () => als.getStore() };
|
|
184
|
+
},
|
|
179
185
|
log,
|
|
180
186
|
};
|
|
181
187
|
}
|