@nanobpm/urban 0.40.2 → 0.42.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.
@@ -73,9 +73,18 @@ export interface PagesDeps {
73
73
  cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
74
74
  }
75
75
 
76
- /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
77
- function javascript(body: string, status = 200): { status: number; headers: Record<string, string>; body: string } {
78
- return { status, headers: { "content-type": "text/javascript; charset=utf-8" }, body };
76
+ /** A JavaScript-body response (the renderer module served at the fingerprinted
77
+ * `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
78
+ function javascript(
79
+ body: string,
80
+ status = 200,
81
+ extraHeaders?: Record<string, string>,
82
+ ): { status: number; headers: Record<string, string>; body: string } {
83
+ return {
84
+ status,
85
+ headers: { "content-type": "text/javascript; charset=utf-8", ...extraHeaders },
86
+ body,
87
+ };
79
88
  }
80
89
 
81
90
  /** A SQL identifier guard — a table name must be a bare identifier *and* a known table
@@ -130,15 +139,30 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
130
139
 
131
140
  const routes: Route[] = [];
132
141
  const shell = html(rendererShell(homePage, opts.apiDocsPath));
142
+ // The shell must never be pinned by the browser: it carries the *current* fingerprinted
143
+ // runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
144
+ // a revalidation on every load; the shell body is tiny, and the expensive module it references
145
+ // is what gets the long-lived immutable caching (via its content-hashed URL).
146
+ shell.headers = { ...shell.headers, "cache-control": "no-cache" };
133
147
 
134
148
  // ── the renderer shell + module ─────────────────────────────────────────
135
149
  routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
136
150
  routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
151
+ // The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
152
+ routes.push({
153
+ method: "GET",
154
+ path: RUNTIME_JS_PATH,
155
+ source: "surface:pages",
156
+ handler: () => javascript(RENDERER_JS, 200, { "cache-control": IMMUTABLE_CACHE }),
157
+ });
158
+ // Back-compat / defence in depth: the legacy unhashed URL keeps working, but is served
159
+ // `no-cache` so any client (or cached shell) that still requests it always revalidates and
160
+ // gets the current bytes instead of replaying a heuristically-cached older copy.
137
161
  routes.push({
138
162
  method: "GET",
139
163
  path: "/app/runtime.js",
140
164
  source: "surface:pages",
141
- handler: () => javascript(RENDERER_JS),
165
+ handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
142
166
  });
143
167
 
144
168
  // ── GET /app/pages (index of available pages) ───────────────────────────
@@ -422,7 +446,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
422
446
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
423
447
  // tab a handle back to this window (reverse-tabnabbing).
424
448
  //
425
- // The href must be DOCUMENT-relative, exactly like the `./app/runtime.js` script tag below:
449
+ // The href must be DOCUMENT-relative, exactly like the `./app/runtime.<hash>.js` script tag below:
426
450
  // the shell is served at the app's mount root (always a trailing "/"), which is the origin
427
451
  // root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
428
452
  // proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
@@ -446,7 +470,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
446
470
  </head>
447
471
  <body>${apiDocsBadge}
448
472
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
449
- <script type="module" src="./app/runtime.js"></script>
473
+ <script type="module" src=".${RUNTIME_JS_PATH}"></script>
450
474
  </body>
451
475
  </html>`;
452
476
  }
@@ -501,7 +525,9 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
501
525
  .pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
502
526
  .pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
503
527
  .pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
504
- .pc-field input { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
528
+ .pc-field input:not([type=checkbox]) { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
529
+ .pc-field-check label { display:flex; flex-direction:row; align-items:center; gap:.5rem; font-size:.9rem; color:var(--nano-text); cursor:pointer; }
530
+ .pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
505
531
  .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
506
532
  .pc-btn:disabled { opacity:.5; cursor:default; }
507
533
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
@@ -809,8 +835,24 @@ function renderActionForm(node) {
809
835
  const inputs = new Map();
810
836
  const fieldTypes = new Map();
811
837
  for (const f of p.fields || []) {
812
- const kind = f.type === "number" ? "number" : "text";
838
+ const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
813
839
  fieldTypes.set(f.key, kind);
840
+ if (kind === "checkbox") {
841
+ // A boolean field renders a real checkbox; its default checked state comes
842
+ // from default/checked on the field (unset -> unchecked). We record that on
843
+ // the input's defaultChecked so a post-submit reset can restore it without a
844
+ // side map that could drift from the actual input.
845
+ const checked = f.default === true || f.checked === true;
846
+ const input = el("input", { type: "checkbox" });
847
+ input.defaultChecked = checked;
848
+ input.checked = checked;
849
+ inputs.set(f.key, input);
850
+ // Input-first, wrapped in the label so the whole row is a click target.
851
+ card.append(
852
+ el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
853
+ );
854
+ continue;
855
+ }
814
856
  const attrs = { type: kind, placeholder: f.label || f.key };
815
857
  if (kind === "number") {
816
858
  attrs.inputmode = "numeric";
@@ -827,7 +869,11 @@ function renderActionForm(node) {
827
869
  btn.addEventListener("click", async () => {
828
870
  const variables = Object.create(null);
829
871
  for (const [k, input] of inputs) {
830
- if (fieldTypes.get(k) === "number") {
872
+ if (fieldTypes.get(k) === "checkbox") {
873
+ // Always emit a real boolean (checked -> true / false), never a string,
874
+ // so a strict equality check on the action side sees the intended value.
875
+ variables[k] = input.checked === true;
876
+ } else if (fieldTypes.get(k) === "number") {
831
877
  const t = String(input.value).trim();
832
878
  // Blank → omit so the action-side default applies; a non-finite parse
833
879
  // (NaN/Infinity) is also omitted rather than smuggling a raw string
@@ -850,7 +896,12 @@ function renderActionForm(node) {
850
896
  msg.className = "pc-msg ok";
851
897
  msg.textContent = (p.action && p.action.successLabel) ||
852
898
  (res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
853
- for (const input of inputs.values()) input.value = "";
899
+ for (const [k, input] of inputs) {
900
+ // Text/number inputs clear; a checkbox resets to its declared default,
901
+ // read straight off the input's defaultChecked (no side map to drift).
902
+ if (fieldTypes.get(k) === "checkbox") input.checked = input.defaultChecked;
903
+ else input.value = "";
904
+ }
854
905
  document.dispatchEvent(new CustomEvent("pc:refresh"));
855
906
  } catch (e) {
856
907
  msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
@@ -1328,3 +1379,25 @@ async function renderPage() {
1328
1379
  window.addEventListener("hashchange", renderPage);
1329
1380
  renderPage();
1330
1381
  `;
1382
+
1383
+ /** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
1384
+ * module's URL so a content change busts the browser cache. Environment-agnostic (no
1385
+ * `crypto` import) so the runtime stays loadable under Node, Deno, and the embedded host. */
1386
+ function fnv1aHex(s: string): string {
1387
+ let h = 0x811c9dc5;
1388
+ for (let i = 0; i < s.length; i++) {
1389
+ h ^= s.charCodeAt(i);
1390
+ h = Math.imul(h, 0x01000193);
1391
+ }
1392
+ return (h >>> 0).toString(16).padStart(8, "0");
1393
+ }
1394
+
1395
+ // The renderer module is served at a *content-fingerprinted* URL. The shell references this exact
1396
+ // path, so whenever `RENDERER_JS` changes (e.g. an Urban upgrade in a marketplace/Studio update)
1397
+ // the URL changes with it and the browser fetches the new module instead of silently replaying a
1398
+ // heuristically-cached copy of the old one — the failure that made a "Cancel" button (and every
1399
+ // other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
1400
+ // per content, the response itself is served `immutable` with a one-year max-age.
1401
+ const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
1402
+ const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1403
+ const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
@@ -54,8 +54,10 @@ export type {
54
54
  WorkerSubscription,
55
55
  } from "./core/host.ts";
56
56
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
57
- // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`.
58
- export type { Logger, LogLevel, LogFields } from "./core/logger.ts";
57
+ // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
58
+ // so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).
59
+ export { createLogger } from "./core/logger.ts";
60
+ export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
59
61
 
60
62
  // BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
61
63
  // error routed to an error boundary (ADR 0050), instead of a retryable failure.
@@ -257,3 +257,35 @@ test("collectArtifacts resolves an ABSOLUTE api.spec at its own path, not root-r
257
257
  const res = await collectArtifacts({ root: "/app", io });
258
258
  assert.ok(res.map((a) => a.path).includes("nano-generated/api-io.d.ts"));
259
259
  });
260
+
261
+ test("runGen warns when a requestBody schema is shared across operations, but never fails", async () => {
262
+ const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: "openapi.json" } });
263
+ const openapi = JSON.stringify({
264
+ openapi: "3.1.0",
265
+ components: { schemas: { StartVariables: { type: "object", properties: { pr: { type: "string" }, url: { type: "string" }, issue: { type: "string" } } } } },
266
+ paths: {
267
+ "/convergence": { post: { operationId: "startConvergenceLoop", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/StartVariables" } } } }, responses: { "200": {} } } },
268
+ "/fanout": { post: { operationId: "startPlanFanout", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/StartVariables" } } } }, responses: { "200": {} } } },
269
+ },
270
+ });
271
+ const io = memIO({ "/app/nano.app.json": manifest, "/app/openapi.json": openapi });
272
+ const res = await runGen({ root: "/app", io });
273
+
274
+ assert.equal(res.incomplete, false); // advisory only — gen still succeeds
275
+ assert.equal(res.warnings.length, 1, `one shared-body warning: ${JSON.stringify(res.warnings)}`);
276
+ assert.match(res.warnings[0], /StartVariables/);
277
+ assert.match(res.warnings[0], /startConvergenceLoop, startPlanFanout/);
278
+ // A clean spec (distinct bodies) produces no warning.
279
+ const clean = memIO({
280
+ "/app/nano.app.json": manifest,
281
+ "/app/openapi.json": JSON.stringify({
282
+ openapi: "3.1.0",
283
+ components: { schemas: { A: { type: "object" }, B: { type: "object" } } },
284
+ paths: {
285
+ "/a": { post: { operationId: "opA", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/A" } } } }, responses: { "200": {} } } },
286
+ "/b": { post: { operationId: "opB", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/B" } } } }, responses: { "200": {} } } },
287
+ },
288
+ }),
289
+ });
290
+ assert.deepEqual((await runGen({ root: "/app", io: clean })).warnings, []);
291
+ });
@@ -11,7 +11,7 @@ import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts"
11
11
  import { deriveMeta } from "./derivers/meta.ts";
12
12
  import { deriveMessageBindings } from "./derivers/messages.ts";
13
13
  import { deriveApi } from "./derivers/api.ts";
14
- import { parseSpec } from "../openapi/spec.ts";
14
+ import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.ts";
15
15
  import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
16
16
 
17
17
  /** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
@@ -57,6 +57,9 @@ export interface GenResult {
57
57
  incomplete: boolean;
58
58
  /** Per-file model-derivation errors (empty on success). */
59
59
  modelErrors: ModelError[];
60
+ /** Non-fatal spec-hygiene warnings (e.g. a requestBody schema shared by >1 operation). Empty on
61
+ * a clean spec. Surfaced by the CLI but never fail gen/check — advisory only. */
62
+ warnings: string[];
60
63
  }
61
64
 
62
65
  function join(root: string, rel: string): string {
@@ -118,7 +121,7 @@ export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifac
118
121
 
119
122
  /** Internal: collect artifacts AND the model-derivation details (needed by `runGen` for the
120
123
  * incomplete signal + the provenance-scoped stale sweep). */
121
- async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifact[]; derived: DerivedModels }> {
124
+ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifact[]; derived: DerivedModels; warnings: string[] }> {
122
125
  const { root, io } = opts;
123
126
  const emitModels = opts.emitModels ?? true;
124
127
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
@@ -128,6 +131,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
128
131
  } = JSON.parse(await io.readText(manifestPath));
129
132
 
130
133
  const artifacts: DerivedArtifact[] = [];
134
+ const warnings: string[] = [];
131
135
 
132
136
  // 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
133
137
  if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
@@ -164,15 +168,25 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
164
168
  const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
165
169
  ? manifest.api.dir.trim()
166
170
  : undefined;
167
- artifacts.push(...deriveApi(parseSpec(specText), opsDir, manifest.api?.eject === true));
171
+ const doc = parseSpec(specText);
172
+ artifacts.push(...deriveApi(doc, opsDir, manifest.api?.eject === true));
173
+ // Non-fatal spec-hygiene: a requestBody schema reused by >1 operation can't be tightened into
174
+ // per-operation discriminated variants — flag it so the author can split it (never fail gen).
175
+ for (const { ref, operationIds } of sharedRequestBodySchemas(doc)) {
176
+ warnings.push(
177
+ `requestBody schema ${ref} is shared by ${operationIds.length} operations ` +
178
+ `(${operationIds.join(", ")}). A single schema can't model per-operation discriminants — ` +
179
+ "consider splitting it into named variants + oneOf (additionalProperties:false) per operation.",
180
+ );
181
+ }
168
182
  }
169
183
 
170
- return { artifacts: sortArtifacts(artifacts), derived };
184
+ return { artifacts: sortArtifacts(artifacts), derived, warnings };
171
185
  }
172
186
 
173
187
  /** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
174
188
  export async function runGen(opts: GenOptions & { check?: boolean }): Promise<GenResult> {
175
- const { artifacts, derived } = await collectAll(opts);
189
+ const { artifacts, derived, warnings } = await collectAll(opts);
176
190
  const { root, io } = opts;
177
191
  const emitModels = opts.emitModels ?? true;
178
192
  const drift: string[] = [];
@@ -224,7 +238,7 @@ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<Ge
224
238
  }
225
239
  }
226
240
 
227
- return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
241
+ return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, warnings };
228
242
  }
229
243
 
230
244
  export { dirOf, join as joinPath };