@nanobpm/urban 0.94.2 → 0.96.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 (37) hide show
  1. package/dist/runtime/adapters/deno.js +30 -0
  2. package/dist/runtime/adapters/node.js +31 -1
  3. package/dist/runtime/core/host.d.ts +54 -11
  4. package/dist/runtime/core/modules/mcp.js +5 -1
  5. package/dist/runtime/core/modules/pages.d.ts +17 -0
  6. package/dist/runtime/core/modules/pages.js +54 -10
  7. package/dist/runtime/core/process-identity.d.ts +21 -0
  8. package/dist/runtime/core/process-identity.js +56 -0
  9. package/dist/runtime/engine/nanosdk.d.ts +3 -5
  10. package/dist/runtime/engine/nanosdk.js +12 -16
  11. package/dist/runtime/index.d.ts +2 -1
  12. package/dist/runtime/index.js +5 -0
  13. package/package.json +2 -2
  14. package/src/runtime/adapters/deno.ts +32 -1
  15. package/src/runtime/adapters/node.test.ts +153 -0
  16. package/src/runtime/adapters/node.ts +27 -1
  17. package/src/runtime/core/glob.test.ts +6 -0
  18. package/src/runtime/core/host.ts +54 -8
  19. package/src/runtime/core/modules/actions.test.ts +2 -0
  20. package/src/runtime/core/modules/agent.test.ts +1 -0
  21. package/src/runtime/core/modules/api.test.ts +2 -0
  22. package/src/runtime/core/modules/connectors.test.ts +1 -0
  23. package/src/runtime/core/modules/datasource.test.ts +1 -0
  24. package/src/runtime/core/modules/mcp.test.ts +25 -0
  25. package/src/runtime/core/modules/mcp.ts +7 -2
  26. package/src/runtime/core/modules/pages.test.ts +136 -1
  27. package/src/runtime/core/modules/pages.ts +75 -11
  28. package/src/runtime/core/modules/surfaces.test.ts +1 -0
  29. package/src/runtime/core/modules/triggers.test.ts +1 -0
  30. package/src/runtime/core/modules/workers.test.ts +1 -0
  31. package/src/runtime/core/process-identity.test.ts +72 -0
  32. package/src/runtime/core/process-identity.ts +66 -0
  33. package/src/runtime/core/runtime.test.ts +27 -0
  34. package/src/runtime/devserver.test.ts +1 -0
  35. package/src/runtime/engine/nanosdk.test.ts +82 -0
  36. package/src/runtime/engine/nanosdk.ts +18 -16
  37. package/src/runtime/index.ts +11 -0
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import type { EngineClient, HttpRequest, HttpResponse } from "../host.ts";
8
8
  import { makeRouter } from "../router.ts";
9
- import { createPagesRoutes, type GridLayout, type PagesDataSource, type PagesDeps, parseGridLayout } from "./pages.ts";
9
+ import { createPagesRoutes, type GridLayout, type PagesDataSource, type PagesDeps, parseGridLayout, RUNTIME_JS_PATH } from "./pages.ts";
10
10
 
11
11
  function req(
12
12
  method: string,
@@ -2123,3 +2123,138 @@ test("#572: parseGridLayout accepts only the declared layouts (anything else is
2123
2123
  assert.equal(parseGridLayout("grid"), undefined);
2124
2124
  assert.equal(parseGridLayout(1), undefined);
2125
2125
  });
2126
+
2127
+ // nano-ide#578 — an app MAY ship `pages/app.css` / `pages/app.js`; the shell links them only when present.
2128
+ function appAssetRoutes(files: Record<string, string>) {
2129
+ return makeRouter(
2130
+ createPagesRoutes(
2131
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
2132
+ {
2133
+ db: fakeDb(),
2134
+ engine: fakeEngine().engine,
2135
+ readPage: async () => "{}",
2136
+ readAsset: async (p) => {
2137
+ const body = files[p];
2138
+ if (body === undefined) throw new Error(`ENOENT ${p}`);
2139
+ return body;
2140
+ },
2141
+ },
2142
+ ),
2143
+ );
2144
+ }
2145
+
2146
+ test("#578: no app.css/app.js → the shell references neither (no 404 noise)", async () => {
2147
+ const body = (await appAssetRoutes({})(req("GET", "/"))).body ?? "";
2148
+ assert.doesNotMatch(body, /app\.css/);
2149
+ assert.doesNotMatch(body, /app\.js/);
2150
+ });
2151
+
2152
+ test("#578: app.css is linked AFTER the built-in CSS, and app.js loads after the runtime", async () => {
2153
+ const router = appAssetRoutes({ "pages/app.css": "body{color:red}", "pages/app.js": "export {};" });
2154
+ const body = (await router(req("GET", "/"))).body ?? "";
2155
+ const css = body.indexOf('<link rel="stylesheet" href="./app.css" />');
2156
+ assert.ok(css > body.indexOf("</style>"), "app.css must follow the renderer <style> so its rules win");
2157
+ // Compare against the actual fingerprinted runtime <script> tag — the bare word "runtime"
2158
+ // also appears in the embedded renderer CSS, so a substring match would pass even if the
2159
+ // app script were moved before the runtime module.
2160
+ const runtimeTag = `<script type="module" src=".${RUNTIME_JS_PATH}"></script>`;
2161
+ const runtimeAt = body.indexOf(runtimeTag);
2162
+ assert.ok(runtimeAt >= 0, "the shell references the fingerprinted runtime module");
2163
+ const js = body.indexOf('<script type="module" src="./app.js"></script>');
2164
+ assert.ok(js > runtimeAt, "app.js must load after the runtime module");
2165
+ const cssRes = await router(req("GET", "/app.css"));
2166
+ assert.equal(cssRes.status, 200);
2167
+ assert.equal(cssRes.body, "body{color:red}");
2168
+ assert.match(String(cssRes.headers?.["content-type"]), /text\/css/);
2169
+ // Mutable at a stable URL: the browser must revalidate on every load so an edit (or a
2170
+ // remove/re-add) takes effect without a restart — same policy as `/app/runtime.js`.
2171
+ assert.equal(cssRes.headers?.["cache-control"], "no-cache");
2172
+ const jsRes = await router(req("GET", "/app.js"));
2173
+ assert.equal(jsRes.status, 200);
2174
+ assert.match(String(jsRes.headers?.["content-type"]), /javascript/);
2175
+ assert.equal(jsRes.headers?.["cache-control"], "no-cache");
2176
+ // A missing app asset 404s no-cache too, so a cached 404 can't mask a later re-add.
2177
+ const missing = await appAssetRoutes({})(req("GET", "/app.css"));
2178
+ assert.equal(missing.status, 404);
2179
+ assert.equal(missing.headers?.["cache-control"], "no-cache");
2180
+ });
2181
+
2182
+ test("#578: only the present asset is referenced", async () => {
2183
+ const body = (await appAssetRoutes({ "pages/app.css": "x" })(req("GET", "/"))).body ?? "";
2184
+ assert.match(body, /href="\.\/app\.css"/);
2185
+ assert.doesNotMatch(body, /src="\.\/app\.js"/);
2186
+ });
2187
+
2188
+ test("#578: the shell probes existence WITHOUT reading the assets when the host offers an exists seam", async () => {
2189
+ const files: Record<string, string> = { "pages/app.js": "export {};" };
2190
+ const reads: string[] = [];
2191
+ const probes: string[] = [];
2192
+ const router = makeRouter(
2193
+ createPagesRoutes(
2194
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
2195
+ {
2196
+ db: fakeDb(),
2197
+ engine: fakeEngine().engine,
2198
+ readPage: async () => "{}",
2199
+ readAsset: async (p) => {
2200
+ reads.push(p);
2201
+ const body = files[p];
2202
+ if (body === undefined) throw new Error(`ENOENT ${p}`);
2203
+ return body;
2204
+ },
2205
+ existsAsset: async (p) => {
2206
+ probes.push(p);
2207
+ return p in files;
2208
+ },
2209
+ },
2210
+ ),
2211
+ );
2212
+ const body = (await router(req("GET", "/"))).body ?? "";
2213
+ assert.match(body, /src="\.\/app\.js"/, "a present app.js is linked");
2214
+ assert.doesNotMatch(body, /href="\.\/app\.css"/, "an absent app.css is not");
2215
+ assert.deepEqual([...probes].sort(), ["pages/app.css", "pages/app.js"], "both assets are probed per shell request");
2216
+ assert.deepEqual(reads, [], "no asset bytes are read to build the shell");
2217
+
2218
+ // Per-request, NOT memoized (#578 no-restart): swap which asset exists and re-request. A shell
2219
+ // that cached the first probe result would keep linking app.js (and omit app.css); a genuine
2220
+ // per-request probe must reflect the removal/addition AND re-probe BOTH paths on the next load.
2221
+ probes.length = 0;
2222
+ delete files["pages/app.js"];
2223
+ files["pages/app.css"] = "body{color:red}";
2224
+ const body2 = (await router(req("GET", "/"))).body ?? "";
2225
+ assert.match(body2, /href="\.\/app\.css"/, "a newly-added app.css is linked on the next request");
2226
+ assert.doesNotMatch(body2, /src="\.\/app\.js"/, "a removed app.js is dropped on the next request");
2227
+ assert.deepEqual(
2228
+ [...probes].sort(),
2229
+ ["pages/app.css", "pages/app.js"],
2230
+ "both assets are re-probed on the second request (no memoization)",
2231
+ );
2232
+ assert.deepEqual(reads, [], "still no asset bytes are read to build the shell");
2233
+ });
2234
+
2235
+ test("#578: a DIRECTORY at an asset path is not linked (the probe is file-only, like serving)", async () => {
2236
+ // `HostContext.exists` is true for files AND directories; the probe contract must answer
2237
+ // "would serving this path succeed?" — a `pages/app.css/` directory 404s when read, so the
2238
+ // shell must not link it. The injected probe here is exactly what `mountPages` builds from
2239
+ // `HostContext.statFile` (`(await statFile(p))?.isFile === true`).
2240
+ const files: Record<string, string> = { "pages/app.js": "export {};" };
2241
+ const dirs = new Set(["pages/app.css"]);
2242
+ const router = makeRouter(
2243
+ createPagesRoutes(
2244
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
2245
+ {
2246
+ db: fakeDb(),
2247
+ engine: fakeEngine().engine,
2248
+ readPage: async () => "{}",
2249
+ readAsset: async (p) => {
2250
+ if (dirs.has(p)) throw new Error(`EISDIR ${p}`);
2251
+ throw new Error(`ENOENT ${p}`);
2252
+ },
2253
+ existsAsset: async (p) => !dirs.has(p) && p in files,
2254
+ },
2255
+ ),
2256
+ );
2257
+ const body = (await router(req("GET", "/"))).body ?? "";
2258
+ assert.doesNotMatch(body, /href="\.\/app\.css"/, "a directory named app.css is not linked");
2259
+ assert.match(body, /src="\.\/app\.js"/, "a real app.js file still is");
2260
+ });
@@ -27,7 +27,7 @@
27
27
 
28
28
  import type { AppApi, RuntimeContext } from "../context.ts";
29
29
  import { errorMessage, isRecord } from "../guards.ts";
30
- import type { EngineClient } from "../host.ts";
30
+ import type { EngineClient, HttpResponse } 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";
@@ -89,6 +89,21 @@ export interface PagesDeps {
89
89
  * Injectable for tests. Rejects (→ 404) when the file is missing.
90
90
  */
91
91
  readAsset?(path: string): Promise<string>;
92
+ /**
93
+ * Probe whether a static asset exists as a READABLE REGULAR FILE at an app-root-relative
94
+ * path, WITHOUT reading its bytes. The per-request shell build uses this for the app-owned
95
+ * `app.css`/`app.js` convention (#578): those files can be large and the shell is revalidated
96
+ * on every load, so probing via a full `readAsset` would put an avoidable read + allocation
97
+ * on the HTML hot path (and read the file twice — once here, once when the browser fetches
98
+ * it). Must answer the same question the `readAsset` fallback answers — "would serving this
99
+ * path succeed?" — so a directory (or unreadable entry) at the path is a NO. `mountPages`
100
+ * wires this to the optional `HostContext.statFile` (which opens the file for reading to
101
+ * confirm a readable regular file — never `HostContext.exists`, which is true for directories
102
+ * too, and a `pages/app.css/` DIRECTORY would link an asset the browser then 404s on).
103
+ * Optional; falls back to a read-based probe when absent.
104
+ * Injectable for tests.
105
+ */
106
+ existsAsset?(path: string): Promise<boolean>;
92
107
  /**
93
108
  * List the available page ids (the `*.page.json` basenames under `pagesDir`),
94
109
  * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
@@ -118,6 +133,13 @@ function javascript(
118
133
  };
119
134
  }
120
135
 
136
+ // App-owned shell customisation by convention (nano-ide#578): an app MAY ship `pagesDir/app.css`
137
+ // (linked AFTER the built-in renderer CSS, so its rules win — themes, restyling) and/or
138
+ // `pagesDir/app.js` (an ES module loaded after the runtime — e.g. a theme picker). Each is served
139
+ // as a root sidecar and referenced from the shell ONLY when the file exists, so apps without them
140
+ // see no change and no 404.
141
+ export const APP_ASSETS = ["app.css", "app.js"] as const;
142
+
121
143
  // The `appView` sidecar documents an app ships alongside its `*.page.json` under
122
144
  // `pagesDir` (ADR 0057 / #416 / #420). The renderer (#419) mounts an <iframe> whose
123
145
  // src is the node's base-relative `embed` (`./embed.html`), which — resolved against
@@ -130,7 +152,7 @@ function javascript(
130
152
  // `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
131
153
  // this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
132
154
  // import-map references is served separately under the `/dist/` prefix below.
133
- const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"] as const;
155
+ const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css", ...APP_ASSETS] as const;
134
156
 
135
157
  // The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
136
158
  // resolve `../dist/…` onto. Because the embed is served at the mount root, `../dist`
@@ -226,6 +248,9 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
226
248
  // Falls back to `readPage` when no dedicated reader is injected — both delegate to the same
227
249
  // `host.readTextFile`, so there is one source of file bytes, not two.
228
250
  const readAsset = deps.readAsset ?? readPage;
251
+ // Existence probe for the app-owned shell assets (#578): a dedicated `existsAsset` when the
252
+ // host offers one (a metadata stat — no file bytes cross the seam), else a read-based probe.
253
+ const existsAsset = deps.existsAsset ?? ((path: string) => readAsset(path).then(() => true, () => false));
229
254
 
230
255
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
231
256
  // (before serving), so the schema is stable for the process lifetime, and the renderer
@@ -262,16 +287,25 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
262
287
  };
263
288
 
264
289
  const routes: Route[] = [];
265
- const shell = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto"));
266
290
  // The shell must never be pinned by the browser: it carries the *current* fingerprinted
267
291
  // runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
268
292
  // a revalidation on every load; the shell body is tiny, and the expensive module it references
269
293
  // is what gets the long-lived immutable caching (via its content-hashed URL).
270
- shell.headers = { ...shell.headers, "cache-control": "no-cache" };
294
+ // Built per request: it probes for the app-owned `app.css`/`app.js` (#578) so adding or removing
295
+ // one takes effect without a restart.
296
+ const shell = async (): Promise<HttpResponse> => {
297
+ const [appCss, appJs] = await Promise.all([
298
+ existsAsset(`${pagesDir}/app.css`),
299
+ existsAsset(`${pagesDir}/app.js`),
300
+ ]);
301
+ const res = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto", { appCss, appJs }));
302
+ res.headers = { ...res.headers, "cache-control": "no-cache" };
303
+ return res;
304
+ };
271
305
 
272
306
  // ── the renderer shell + module ─────────────────────────────────────────
273
- routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
274
- routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
307
+ routes.push({ method: "GET", path: "/", source: "surface:pages", handler: shell });
308
+ routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: shell });
275
309
  // The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
276
310
  routes.push({
277
311
  method: "GET",
@@ -299,11 +333,21 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
299
333
  // onto `pagesDir/<name>` (exact routes — never a root catch-all, which would shadow the
300
334
  // `/healthz` liveness route the runtime appends after the pages surface).
301
335
  for (const name of SIDECAR_ASSETS) {
336
+ // The app-owned `app.css`/`app.js` are MUTABLE at stable URLs — the whole point of the
337
+ // convention is that an edit (or a remove/re-add) takes effect without a restart, and the
338
+ // shell re-probes on every load. Serve them `no-cache` (like `/app/runtime.js`) so a
339
+ // browser/intermediary must revalidate instead of replaying a heuristically-cached 200/404.
340
+ // The other sidecars are deploy-time fixtures, so they keep the generic response.
341
+ const mutable = APP_ASSETS.some((a) => a === name);
302
342
  routes.push({
303
343
  method: "GET",
304
344
  path: `/${name}`,
305
345
  source: "surface:pages",
306
- handler: () => serveAsset(readAsset, `${pagesDir}/${name}`),
346
+ handler: async () => {
347
+ const res = await serveAsset(readAsset, `${pagesDir}/${name}`);
348
+ if (mutable) res.headers = { ...res.headers, "cache-control": "no-cache" };
349
+ return res;
350
+ },
307
351
  });
308
352
  }
309
353
  // The `dist/` tree the sidecars' import-maps reference (`../dist/cockpit/index.js` →
@@ -653,12 +697,24 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
653
697
  };
654
698
  const sourceName = opts.sourceName ?? "app";
655
699
  const bindings = ctx.manifest.instanceTracking ?? [];
700
+ // Probe "would serving this app-owned asset succeed?" — a READABLE REGULAR FILE, not the
701
+ // broader `HostContext.exists` (which is true for directories too, and a `pages/app.css/`
702
+ // DIRECTORY would link an asset the browser then 404s on). `statFile` is an OPTIONAL public
703
+ // host capability (a custom host need not implement it), and it opens the file for reading so
704
+ // no bytes cross the seam on the per-request shell hot path. Only wire the optimized probe
705
+ // when the host provides it; otherwise `createPagesRoutes` falls back to a read-based probe
706
+ // that answers the identical question.
707
+ const statFile = ctx.host.statFile?.bind(ctx.host);
708
+ const existsAsset = statFile
709
+ ? async (p: string) => (await statFile(p))?.isFile === true
710
+ : undefined;
656
711
  const routes = createPagesRoutes(opts, {
657
712
  db: app.data.open(sourceName),
658
713
  engine: app.engine,
659
714
  cancel: (key) => cancelInstanceReconciling(app, bindings, key),
660
715
  readPage: (p) => ctx.host.readTextFile(p),
661
716
  readAsset: (p) => ctx.host.readTextFile(p),
717
+ existsAsset,
662
718
  listPages: async () => {
663
719
  const dir = opts.pagesDir ?? "pages";
664
720
  const names = await ctx.host.listDir(dir).catch(() => []);
@@ -679,7 +735,12 @@ function escapeAttr(s: string): string {
679
735
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
680
736
  }
681
737
 
682
- function rendererShell(homePage: string, apiDocsPath: string | undefined, gridLayout: GridLayout): string {
738
+ function rendererShell(
739
+ homePage: string,
740
+ apiDocsPath: string | undefined,
741
+ gridLayout: GridLayout,
742
+ app: { appCss: boolean; appJs: boolean } = { appCss: false, appJs: false },
743
+ ): string {
683
744
  // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
684
745
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
685
746
  // tab a handle back to this window (reverse-tabnabbing).
@@ -704,12 +765,12 @@ function rendererShell(homePage: string, apiDocsPath: string | undefined, gridLa
704
765
  <meta charset="utf-8" />
705
766
  <meta name="viewport" content="width=device-width, initial-scale=1" />
706
767
  <title>Urban App</title>
707
- <style>${RENDERER_CSS}</style>
768
+ <style>${RENDERER_CSS}</style>${app.appCss ? '\n <link rel="stylesheet" href="./app.css" />' : ""}
708
769
  </head>
709
770
  <body data-grid-layout="${gridLayout}">${apiDocsBadge}
710
771
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
711
772
  <script>${FORMJS_JS}</script>
712
- <script type="module" src=".${RUNTIME_JS_PATH}"></script>
773
+ <script type="module" src=".${RUNTIME_JS_PATH}"></script>${app.appJs ? '\n <script type="module" src="./app.js"></script>' : ""}
713
774
  </body>
714
775
  </html>`;
715
776
  }
@@ -1095,5 +1156,8 @@ function fnv1aHex(s: string): string {
1095
1156
  // other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
1096
1157
  // per content, the response itself is served `immutable` with a one-year max-age.
1097
1158
  const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
1098
- const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1159
+ // Exported so tests can locate the actual fingerprinted runtime <script> tag in the shell
1160
+ // (asserting against the bare word "runtime" is unsound — it also appears in the embedded
1161
+ // renderer CSS, so such a test passes even if the app script were moved before the runtime).
1162
+ export const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1099
1163
  const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
@@ -16,6 +16,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
16
16
  readTextFile: async () => "",
17
17
  listDir: async () => [],
18
18
  exists: async () => false,
19
+ statFile: async () => null,
19
20
  openSqlite: () => {
20
21
  throw new Error("sqlite not used in this test");
21
22
  },
@@ -80,6 +80,7 @@ function fakeCtx(triggers: Trigger[]): { ctx: RuntimeContext; hostLogs: Array<{
80
80
  readTextFile: async () => "",
81
81
  listDir: async () => [],
82
82
  exists: async () => false,
83
+ statFile: async () => null,
83
84
  openSqlite: () => {
84
85
  throw new Error("sqlite not used in this test");
85
86
  },
@@ -68,6 +68,7 @@ function makeCtx(
68
68
  readTextFile: async () => "",
69
69
  listDir: async () => [],
70
70
  exists: async () => false,
71
+ statFile: async () => null,
71
72
  openSqlite: () => {
72
73
  throw new Error("sqlite not used in this test");
73
74
  },
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ pickProcessDefinitionIdentity,
5
+ presentEngineKey,
6
+ presentString,
7
+ } from "./process-identity.ts";
8
+
9
+ // The canonical, adapter-agnostic process-definition-identity normalizer. These cases pin the
10
+ // class the shared helper exists to keep consistent (No Drift Surfaces): a padded/numeric key is
11
+ // normalized, a blank/whitespace/non-string/non-finite value is *absent* and never coerced, and a
12
+ // version is kept only when a positive integer number. Scope today: only the SDK adapter
13
+ // (`engine/nanosdk.ts`) routes through this mapping; the WASM test adapter
14
+ // (`@nanobpm/urban-testkit`) runs a deliberately mirrored local copy until #585, pinned to this
15
+ // behaviour by its own identity cases (`wasm-engine.test.ts`).
16
+
17
+ test("presentEngineKey coerces a finite numeric key, trims a string, and drops blank/non-finite/non-string", () => {
18
+ assert.equal(presentEngineKey(7), "7");
19
+ assert.equal(presentEngineKey(" order "), "order");
20
+ assert.equal(presentEngineKey(" "), undefined);
21
+ assert.equal(presentEngineKey(""), undefined);
22
+ assert.equal(presentEngineKey(Number.NaN), undefined);
23
+ assert.equal(presentEngineKey(Number.POSITIVE_INFINITY), undefined);
24
+ assert.equal(presentEngineKey({}), undefined);
25
+ assert.equal(presentEngineKey(null), undefined);
26
+ assert.equal(presentEngineKey(undefined), undefined);
27
+ });
28
+
29
+ test("presentString keeps a trimmed non-empty string and drops blank/non-string (never coerces)", () => {
30
+ assert.equal(presentString(" human "), "human");
31
+ assert.equal(presentString(" "), undefined);
32
+ assert.equal(presentString(""), undefined);
33
+ // A number id is absent — never String(...)-coerced into a garbage id.
34
+ assert.equal(presentString(5), undefined);
35
+ assert.equal(presentString({}), undefined);
36
+ assert.equal(presentString(undefined), undefined);
37
+ });
38
+
39
+ test("pickProcessDefinitionIdentity normalizes present fields and omits blank/malformed ones", () => {
40
+ assert.deepEqual(
41
+ pickProcessDefinitionIdentity({
42
+ processDefinitionKey: 7,
43
+ processDefinitionId: " order ",
44
+ processDefinitionVersion: 2,
45
+ }),
46
+ { processDefinitionKey: "7", processDefinitionId: "order", processDefinitionVersion: 2 },
47
+ );
48
+ });
49
+
50
+ test("pickProcessDefinitionIdentity drops a blank key, a non-string id, and a stringly version", () => {
51
+ assert.deepEqual(
52
+ pickProcessDefinitionIdentity({
53
+ processDefinitionKey: " ",
54
+ processDefinitionId: 5,
55
+ processDefinitionVersion: "2",
56
+ }),
57
+ {},
58
+ );
59
+ });
60
+
61
+ test("pickProcessDefinitionIdentity keeps a version only when a positive integer number", () => {
62
+ assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 1.5 }), {});
63
+ assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 0 }), {});
64
+ assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: -1 }), {});
65
+ assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 3 }), {
66
+ processDefinitionVersion: 3,
67
+ });
68
+ });
69
+
70
+ test("pickProcessDefinitionIdentity on an empty row returns an empty identity (fully absent)", () => {
71
+ assert.deepEqual(pickProcessDefinitionIdentity({}), {});
72
+ });
@@ -0,0 +1,66 @@
1
+ // The single, transport-agnostic normalizer for a {@link ProcessDefinitionIdentity}. Both engine
2
+ // adapters — the live SDK/REST adapter (`engine/nanosdk.ts`, `SdkEngineClient`) and the in-process
3
+ // WASM test adapter (`@nanobpm/urban-testkit`, `WasmEngineClient`) — report which deployed
4
+ // definition an instance runs on *both* `createInstance` and `searchProcessInstances`. Each used to
5
+ // carry its own hand-mirrored `pickProcessDefinitionIdentity` (plus a re-declared identity type), so
6
+ // the two could silently diverge the moment identity validation changed — exactly the drift class
7
+ // the shared form-contract normalizer exists to kill (`./form-contract.ts`, issue #252). Centralizing
8
+ // the mapper (and reusing the canonical {@link ProcessDefinitionIdentity}) here removes that surface:
9
+ // a created instance and its later snapshot describe the same definition identically wherever this
10
+ // normalizer runs, because they run the *same* code.
11
+ //
12
+ // Ownership today: only the SDK adapter runs this implementation. `WasmEngineClient` still defines
13
+ // and calls its own behaviourally identical copy (`urban-testkit/src/wasm-engine.ts`) — a deliberate,
14
+ // temporary fork: this module is introduced by the same change, so it exists in no published urban
15
+ // release, and a value import would force the testkit's peer floor up to an urban version that does
16
+ // not yet exist (AGENTS.md: depend on an unreleased sibling only after it is published). Follow-up
17
+ // #585 switches the WASM adapter to this canonical import once the urban release carrying it is
18
+ // published; until then the testkit's own identity cases pin its copy to this normalizer's behaviour.
19
+
20
+ import type { ProcessDefinitionIdentity } from "./host.ts";
21
+
22
+ export type { ProcessDefinitionIdentity };
23
+
24
+ /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
25
+ * whitespace-only string). Coerces a *finite* numeric key to a string (the engine may serialize a
26
+ * key either way) but never `String(...)`-coerces an arbitrary object into a garbage
27
+ * `"[object Object]"` id, nor a non-finite number into `"NaN"`/`"Infinity"`. The blank check trims,
28
+ * matching the read-path blank-key guards, so a `" "` key can never leak into a result. */
29
+ export function presentEngineKey(value: unknown): string | undefined {
30
+ if (typeof value === "number") {
31
+ return Number.isFinite(value) ? String(value) : undefined;
32
+ }
33
+ if (typeof value !== "string") return undefined;
34
+ const trimmed = value.trim();
35
+ return trimmed === "" ? undefined : trimmed;
36
+ }
37
+
38
+ /** A present string-only identifier (e.g. a BPMN process id) under the shared trim rule; a
39
+ * non-string is absent and never `String(...)`-coerced. */
40
+ export function presentString(value: unknown): string | undefined {
41
+ if (typeof value !== "string") return undefined;
42
+ const trimmed = value.trim();
43
+ return trimmed === "" ? undefined : trimmed;
44
+ }
45
+
46
+ /** The {@link ProcessDefinitionIdentity} an engine row (a create response or a process-instance
47
+ * search row) reports — the single mapping both `createInstance` and `searchProcessInstances` use,
48
+ * so a created instance and its later snapshot describe the same definition identically. Each field
49
+ * passes the shared presence rule; a version that is not a positive integer number is omitted
50
+ * (never coerced). */
51
+ export function pickProcessDefinitionIdentity(row: {
52
+ processDefinitionKey?: unknown;
53
+ processDefinitionId?: unknown;
54
+ processDefinitionVersion?: unknown;
55
+ }): ProcessDefinitionIdentity {
56
+ const processDefinitionKey = presentEngineKey(row.processDefinitionKey);
57
+ const processDefinitionId = presentString(row.processDefinitionId);
58
+ const version = row.processDefinitionVersion;
59
+ const processDefinitionVersion =
60
+ typeof version === "number" && Number.isInteger(version) && version > 0 ? version : undefined;
61
+ return {
62
+ ...(processDefinitionKey ? { processDefinitionKey } : {}),
63
+ ...(processDefinitionId ? { processDefinitionId } : {}),
64
+ ...(processDefinitionVersion !== undefined ? { processDefinitionVersion } : {}),
65
+ };
66
+ }
@@ -757,3 +757,30 @@ test('#572: an omitted surfaces.pages.gridLayout falls back to the "auto" layout
757
757
  test('#572: an invalid surfaces.pages.gridLayout falls back to the "auto" layout', async () => {
758
758
  assert.equal(await shellGridLayout({ gridLayout: "grid" }), "auto");
759
759
  });
760
+
761
+ // #578: app-owned `pages/app.css`/`pages/app.js` by convention, driven end-to-end through the real
762
+ // node host (createUrbanApp → mountPages → HostContext.statFile), not an injected `existsAsset`.
763
+ // This guards the production wiring the focused pages.test.ts probe tests can't: that `mountPages`
764
+ // forwards the host's file-only probe (so a DIRECTORY at an asset path is NOT linked — it would
765
+ // 404 on serving) and that a real regular file IS linked.
766
+ test("#578: the shell links a real pages/app.js file but not a pages/app.css DIRECTORY (real host)", async () => {
767
+ const dir = await makeFixture({ surfaces: { pages: { enabled: true } } });
768
+ // A regular file at pages/app.js (should be linked) and a DIRECTORY at pages/app.css (should not).
769
+ await mkdir(join(dir, "pages"), { recursive: true });
770
+ await writeFile(join(dir, "pages", "app.js"), "export {};");
771
+ await mkdir(join(dir, "pages", "app.css"), { recursive: true });
772
+ const host = createNodeHost({ cwd: dir, log: () => {} });
773
+ const engine = new FakeEngine();
774
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
775
+ await app.start();
776
+ try {
777
+ const res = await fetch(`http://localhost:${app.httpPort!}/`);
778
+ assert.equal(res.status, 200);
779
+ const body = await res.text();
780
+ assert.match(body, /src="\.\/app\.js"/, "a real app.js file is linked");
781
+ assert.doesNotMatch(body, /href="\.\/app\.css"/, "a DIRECTORY named app.css is not linked");
782
+ } finally {
783
+ await app.stop();
784
+ await rm(dir, { recursive: true, force: true });
785
+ }
786
+ });
@@ -205,6 +205,7 @@ function fakeHost(): { host: HostContext; fire: (p: string) => void; closed: ()
205
205
  readTextFile: async () => "",
206
206
  listDir: async () => [],
207
207
  exists: async () => false,
208
+ statFile: async () => null,
208
209
  openSqlite: () => {
209
210
  throw new Error("sqlite not used in this test");
210
211
  },
@@ -200,6 +200,43 @@ test("createInstance routes through the SDK and coerces the key", async () => {
200
200
  assert.ok(client.calls.includes("createProcessInstance"));
201
201
  });
202
202
 
203
+ test("createInstance returns the started definition's identity (key, id, version)", async () => {
204
+ // The create response names exactly which deployed definition version the instance runs; the
205
+ // seam must surface it (normalized through the same presence rule as the search snapshot) so a
206
+ // caller need not race an eventually consistent search to learn it.
207
+ const client = fakeSdkClient({
208
+ createProcessInstance: async () => ({
209
+ processInstanceKey: 99,
210
+ processDefinitionKey: 2251799813685249,
211
+ processDefinitionId: " order ",
212
+ processDefinitionVersion: 3,
213
+ }),
214
+ });
215
+ const engine = new SdkEngineClient(client);
216
+ const res = await engine.createInstance({ processDefinitionId: "order" });
217
+ assert.deepEqual(res, {
218
+ processInstanceKey: "99",
219
+ processDefinitionKey: "2251799813685249",
220
+ processDefinitionId: "order",
221
+ processDefinitionVersion: 3,
222
+ variables: undefined,
223
+ });
224
+ });
225
+
226
+ test("createInstance omits definition identity fields the engine leaves blank or malformed", async () => {
227
+ const client = fakeSdkClient({
228
+ createProcessInstance: async () => ({
229
+ processInstanceKey: "99",
230
+ processDefinitionKey: " ",
231
+ processDefinitionId: "",
232
+ processDefinitionVersion: "3", // not a number → omitted, never coerced
233
+ }),
234
+ });
235
+ const engine = new SdkEngineClient(client);
236
+ const res = await engine.createInstance({ processDefinitionId: "order" });
237
+ assert.deepEqual(res, { processInstanceKey: "99", variables: undefined });
238
+ });
239
+
203
240
  test("createInstance throws when the SDK response omits the instance key", async () => {
204
241
  const client = fakeSdkClient({
205
242
  createProcessInstance: async () => ({ variables: { ok: true } }),
@@ -622,6 +659,51 @@ test("searchProcessInstances surfaces the parent/root linkage keys and drops bla
622
659
  ]);
623
660
  });
624
661
 
662
+ test("searchProcessInstances forwards a processDefinitionId selector (trimmed, blank dropped)", async () => {
663
+ const seen: unknown[] = [];
664
+ const client = fakeSdkClient({
665
+ searchProcessInstances: async (input) => {
666
+ seen.push(input);
667
+ return { items: [] };
668
+ },
669
+ });
670
+ const engine = new SdkEngineClient(client);
671
+ await engine.searchProcessInstances({ processDefinitionId: " order ", state: "ACTIVE" });
672
+ await engine.searchProcessInstances({ processDefinitionId: " " });
673
+ assert.deepEqual(seen, [
674
+ { filter: { state: "ACTIVE", processDefinitionId: "order" } },
675
+ { filter: {} },
676
+ ]);
677
+ });
678
+
679
+ test("searchProcessInstances surfaces each instance's definition identity", async () => {
680
+ const client = fakeSdkClient({
681
+ searchProcessInstances: async () => ({
682
+ items: [
683
+ {
684
+ processInstanceKey: 10,
685
+ state: "ACTIVE",
686
+ processDefinitionKey: 5,
687
+ processDefinitionId: "order",
688
+ processDefinitionVersion: 2,
689
+ },
690
+ { processInstanceKey: 11, state: "ACTIVE", processDefinitionId: " ", processDefinitionVersion: null },
691
+ ],
692
+ }),
693
+ });
694
+ const engine = new SdkEngineClient(client);
695
+ assert.deepEqual(await engine.searchProcessInstances(), [
696
+ {
697
+ processInstanceKey: "10",
698
+ state: "ACTIVE",
699
+ processDefinitionKey: "5",
700
+ processDefinitionId: "order",
701
+ processDefinitionVersion: 2,
702
+ },
703
+ { processInstanceKey: "11", state: "ACTIVE" },
704
+ ]);
705
+ });
706
+
625
707
  test("normalizeProcessInstanceState maps the terminal set and rejects others", () => {
626
708
  assert.equal(normalizeProcessInstanceState("ACTIVE"), "ACTIVE");
627
709
  assert.equal(normalizeProcessInstanceState("completed"), "COMPLETED");