@nanobpm/urban 0.40.1 → 0.41.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/cli.js +7 -0
- package/dist/openapi/spec.d.ts +13 -0
- package/dist/openapi/spec.js +97 -5
- package/dist/runtime/core/modules/pages.js +63 -6
- package/dist/toolkit/gen.d.ts +3 -0
- package/dist/toolkit/gen.js +14 -5
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/openapi/spec.test.ts +94 -0
- package/src/openapi/spec.ts +95 -5
- package/src/runtime/core/modules/pages.test.ts +49 -6
- package/src/runtime/core/modules/pages.ts +69 -6
- package/src/toolkit/gen.test.ts +32 -0
- package/src/toolkit/gen.ts +20 -6
package/dist/cli.js
CHANGED
|
@@ -142,6 +142,13 @@ async function cmdGen(f) {
|
|
|
142
142
|
console.error(` • ${e.path}: ${e.message}`);
|
|
143
143
|
return 1;
|
|
144
144
|
}
|
|
145
|
+
// Non-fatal spec-hygiene advisories (e.g. a requestBody schema shared by >1 operation). Printed
|
|
146
|
+
// in both gen and check; never change the exit code.
|
|
147
|
+
if (res.warnings.length > 0) {
|
|
148
|
+
console.warn(`⚠ ${res.warnings.length} spec warning(s):`);
|
|
149
|
+
for (const w of res.warnings)
|
|
150
|
+
console.warn(` • ${w}`);
|
|
151
|
+
}
|
|
145
152
|
if (f.check) {
|
|
146
153
|
if (res.drift.length === 0 && res.missingStubs.length === 0) {
|
|
147
154
|
console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
|
package/dist/openapi/spec.d.ts
CHANGED
|
@@ -152,6 +152,19 @@ export declare function collectOperations(doc: OpenApiDoc): OperationInfo[];
|
|
|
152
152
|
* fails loudly on an incoherent spec instead of shipping a mis-dispatching registry.
|
|
153
153
|
*/
|
|
154
154
|
export declare function assertUniqueOperationIds(doc: OpenApiDoc): void;
|
|
155
|
+
/** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
|
|
156
|
+
* keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
|
|
157
|
+
* request-body schema reused verbatim by multiple operations is the structural fingerprint of the
|
|
158
|
+
* "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
|
|
159
|
+
* operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
|
|
160
|
+
* `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
|
|
161
|
+
* tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
|
|
162
|
+
* split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
|
|
163
|
+
* only (pure, total) — the message is built by the caller. */
|
|
164
|
+
export declare function sharedRequestBodySchemas(doc: OpenApiDoc): {
|
|
165
|
+
ref: string;
|
|
166
|
+
operationIds: string[];
|
|
167
|
+
}[];
|
|
155
168
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
156
169
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
157
170
|
export declare function operationsWithoutId(doc: OpenApiDoc): string[];
|
package/dist/openapi/spec.js
CHANGED
|
@@ -200,6 +200,33 @@ export function assertUniqueOperationIds(doc) {
|
|
|
200
200
|
"it names the delegate module and the generated controller registry key.");
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
+
/** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
|
|
204
|
+
* keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
|
|
205
|
+
* request-body schema reused verbatim by multiple operations is the structural fingerprint of the
|
|
206
|
+
* "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
|
|
207
|
+
* operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
|
|
208
|
+
* `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
|
|
209
|
+
* tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
|
|
210
|
+
* split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
|
|
211
|
+
* only (pure, total) — the message is built by the caller. */
|
|
212
|
+
export function sharedRequestBodySchemas(doc) {
|
|
213
|
+
const byRef = new Map();
|
|
214
|
+
for (const op of collectOperations(doc)) {
|
|
215
|
+
const ref = op.requestBodySchema?.$ref;
|
|
216
|
+
if (typeof ref !== "string" || ref.length === 0)
|
|
217
|
+
continue;
|
|
218
|
+
const ids = byRef.get(ref) ?? [];
|
|
219
|
+
ids.push(op.operationId);
|
|
220
|
+
byRef.set(ref, ids);
|
|
221
|
+
}
|
|
222
|
+
const out = [];
|
|
223
|
+
for (const [ref, ids] of byRef) {
|
|
224
|
+
if (ids.length > 1)
|
|
225
|
+
out.push({ ref, operationIds: [...ids].sort() });
|
|
226
|
+
}
|
|
227
|
+
out.sort((a, b) => a.ref.localeCompare(b.ref));
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
203
230
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
204
231
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
205
232
|
export function operationsWithoutId(doc) {
|
|
@@ -532,6 +559,36 @@ function matchesType(declared, value) {
|
|
|
532
559
|
function matchesAnyType(declared, value) {
|
|
533
560
|
return declared.some((t) => matchesType(t, value));
|
|
534
561
|
}
|
|
562
|
+
/** A short, human-readable label for one `anyOf`/`oneOf` variant, so a composition failure can name
|
|
563
|
+
* the allowed shapes instead of an opaque "matched 0". Prefers the variant's discriminant — its
|
|
564
|
+
* `required` fields (e.g. `{pr}` vs `{url}`) — since that is exactly what a caller must choose
|
|
565
|
+
* between; falls back to the declared type(s), else "object". */
|
|
566
|
+
function variantLabel(doc, variant) {
|
|
567
|
+
const s = resolveSchema(doc, variant);
|
|
568
|
+
if (!s)
|
|
569
|
+
return "?";
|
|
570
|
+
const req = Array.isArray(s.required) ? s.required : [];
|
|
571
|
+
if (req.length > 0)
|
|
572
|
+
return `{${req.join(", ")}}`;
|
|
573
|
+
const types = schemaTypeList(s);
|
|
574
|
+
return types.length > 0 ? types.join("|") : "object";
|
|
575
|
+
}
|
|
576
|
+
/** The `(allowed: {pr} | {url})` suffix for a composition-failure summary message. */
|
|
577
|
+
function variantSummary(doc, variants) {
|
|
578
|
+
return ` (allowed: ${variants.map((v) => variantLabel(doc, v)).join(" | ")})`;
|
|
579
|
+
}
|
|
580
|
+
/** When a `oneOf`/`anyOf` matched no variant, surface actionable detail: the issues of the variant
|
|
581
|
+
* the value was CLOSEST to (fewest problems). For the discriminant case this is the shape the
|
|
582
|
+
* caller meant, so its issues read like `pr: is required` / `url: is not an allowed property`
|
|
583
|
+
* instead of a bare "matched 0". Ties resolve to the earliest variant (declaration order). */
|
|
584
|
+
function closestVariantIssues(perVariant) {
|
|
585
|
+
let best;
|
|
586
|
+
for (const iss of perVariant) {
|
|
587
|
+
if (best === undefined || iss.length < best.length)
|
|
588
|
+
best = iss;
|
|
589
|
+
}
|
|
590
|
+
return best ?? [];
|
|
591
|
+
}
|
|
535
592
|
/** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
|
|
536
593
|
* found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
|
|
537
594
|
export function validateValue(doc, schemaIn, value, path = "") {
|
|
@@ -578,13 +635,48 @@ export function validateValue(doc, schemaIn, value, path = "") {
|
|
|
578
635
|
for (const sub of schema.allOf)
|
|
579
636
|
issues.push(...validateValue(doc, sub, value, path));
|
|
580
637
|
}
|
|
581
|
-
if (schema.anyOf
|
|
582
|
-
|
|
638
|
+
if (schema.anyOf) {
|
|
639
|
+
// `anyOf` needs only ONE match, so short-circuit on the first clean variant — `validateValue`
|
|
640
|
+
// runs on every request (params/query/body/response) and evaluating every variant for a large
|
|
641
|
+
// schema is needless work once we have a hit. Only when NO variant matches do we build the full
|
|
642
|
+
// per-variant issue list to name the allowed shapes and surface the closest variant's issues.
|
|
643
|
+
let anyMatched = false;
|
|
644
|
+
const perVariant = [];
|
|
645
|
+
for (const sub of schema.anyOf) {
|
|
646
|
+
const subIssues = validateValue(doc, sub, value, path);
|
|
647
|
+
if (subIssues.length === 0) {
|
|
648
|
+
anyMatched = true;
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
perVariant.push(subIssues);
|
|
652
|
+
}
|
|
653
|
+
if (!anyMatched) {
|
|
654
|
+
issues.push({
|
|
655
|
+
path: at,
|
|
656
|
+
message: `does not match any of the ${schema.anyOf.length} allowed shapes${variantSummary(doc, schema.anyOf)}`,
|
|
657
|
+
});
|
|
658
|
+
issues.push(...closestVariantIssues(perVariant));
|
|
659
|
+
}
|
|
583
660
|
}
|
|
584
661
|
if (schema.oneOf) {
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
662
|
+
const perVariant = schema.oneOf.map((sub) => validateValue(doc, sub, value, path));
|
|
663
|
+
const matched = perVariant.filter((v) => v.length === 0).length;
|
|
664
|
+
if (matched === 0) {
|
|
665
|
+
// No variant matched: name the allowed shapes AND surface the closest variant's issues, so a
|
|
666
|
+
// discriminated body (e.g. exactly one of pr|url) fails with actionable detail, not "matched 0".
|
|
667
|
+
issues.push({
|
|
668
|
+
path: at,
|
|
669
|
+
message: `does not match any of the ${schema.oneOf.length} allowed shapes${variantSummary(doc, schema.oneOf)}`,
|
|
670
|
+
});
|
|
671
|
+
issues.push(...closestVariantIssues(perVariant));
|
|
672
|
+
}
|
|
673
|
+
else if (matched > 1) {
|
|
674
|
+
// Matched more than one: the shapes are not mutually exclusive for this value (usually a body
|
|
675
|
+
// that supplies two discriminants, or under-constrained variants). Report the ambiguity.
|
|
676
|
+
issues.push({
|
|
677
|
+
path: at,
|
|
678
|
+
message: `must match exactly one of the ${schema.oneOf.length} allowed shapes, but matched ${matched}${variantSummary(doc, schema.oneOf)}`,
|
|
679
|
+
});
|
|
588
680
|
}
|
|
589
681
|
}
|
|
590
682
|
// `value` is non-null here (the null branch returned above), so match against the declared value
|
|
@@ -26,9 +26,14 @@ import { html, json } from "../router.js";
|
|
|
26
26
|
import { cancelInstanceReconciling } from "./cancel.js";
|
|
27
27
|
import { apiDocsPath } from "./api.js";
|
|
28
28
|
import { quoteIdent } from "./gateway.js";
|
|
29
|
-
/** A JavaScript-body response (the renderer module served at
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/** A JavaScript-body response (the renderer module served at the fingerprinted
|
|
30
|
+
* `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
|
|
31
|
+
function javascript(body, status = 200, extraHeaders) {
|
|
32
|
+
return {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "content-type": "text/javascript; charset=utf-8", ...extraHeaders },
|
|
35
|
+
body,
|
|
36
|
+
};
|
|
32
37
|
}
|
|
33
38
|
/** A SQL identifier guard — a table name must be a bare identifier *and* a known table
|
|
34
39
|
* (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
|
|
@@ -75,14 +80,29 @@ export function createPagesRoutes(opts, deps) {
|
|
|
75
80
|
};
|
|
76
81
|
const routes = [];
|
|
77
82
|
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
83
|
+
// The shell must never be pinned by the browser: it carries the *current* fingerprinted
|
|
84
|
+
// runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
|
|
85
|
+
// a revalidation on every load; the shell body is tiny, and the expensive module it references
|
|
86
|
+
// is what gets the long-lived immutable caching (via its content-hashed URL).
|
|
87
|
+
shell.headers = { ...shell.headers, "cache-control": "no-cache" };
|
|
78
88
|
// ── the renderer shell + module ─────────────────────────────────────────
|
|
79
89
|
routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
|
|
80
90
|
routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
|
|
91
|
+
// The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
|
|
92
|
+
routes.push({
|
|
93
|
+
method: "GET",
|
|
94
|
+
path: RUNTIME_JS_PATH,
|
|
95
|
+
source: "surface:pages",
|
|
96
|
+
handler: () => javascript(RENDERER_JS, 200, { "cache-control": IMMUTABLE_CACHE }),
|
|
97
|
+
});
|
|
98
|
+
// Back-compat / defence in depth: the legacy unhashed URL keeps working, but is served
|
|
99
|
+
// `no-cache` so any client (or cached shell) that still requests it always revalidates and
|
|
100
|
+
// gets the current bytes instead of replaying a heuristically-cached older copy.
|
|
81
101
|
routes.push({
|
|
82
102
|
method: "GET",
|
|
83
103
|
path: "/app/runtime.js",
|
|
84
104
|
source: "surface:pages",
|
|
85
|
-
handler: () => javascript(RENDERER_JS),
|
|
105
|
+
handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
|
|
86
106
|
});
|
|
87
107
|
// ── GET /app/pages (index of available pages) ───────────────────────────
|
|
88
108
|
// Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
|
|
@@ -377,7 +397,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
377
397
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
378
398
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
379
399
|
//
|
|
380
|
-
// The href must be DOCUMENT-relative, exactly like the `./app/runtime
|
|
400
|
+
// The href must be DOCUMENT-relative, exactly like the `./app/runtime.<hash>.js` script tag below:
|
|
381
401
|
// the shell is served at the app's mount root (always a trailing "/"), which is the origin
|
|
382
402
|
// root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
|
|
383
403
|
// proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
|
|
@@ -400,7 +420,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
400
420
|
</head>
|
|
401
421
|
<body>${apiDocsBadge}
|
|
402
422
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
403
|
-
<script type="module" src="
|
|
423
|
+
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
404
424
|
</body>
|
|
405
425
|
</html>`;
|
|
406
426
|
}
|
|
@@ -1084,6 +1104,19 @@ function renderDataGrid(node) {
|
|
|
1084
1104
|
try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
|
|
1085
1105
|
}
|
|
1086
1106
|
}
|
|
1107
|
+
// Capture scroll offsets of scrolled descendants before the swap. An open detail node is
|
|
1108
|
+
// reused (same element instance), but replaceChildren() detaches it, which resets the
|
|
1109
|
+
// scrollTop/scrollLeft of any inner scroll container (e.g. a scrolled-into .pc-transcript,
|
|
1110
|
+
// max-height + overflow:auto) back to 0. Only built (open) detail subtrees are re-attached
|
|
1111
|
+
// by the rebuild, so walk just those — not the whole grid — to keep this O(open panels)
|
|
1112
|
+
// rather than O(grid size). Restoration below skips any node that isn't re-attached.
|
|
1113
|
+
const scrollSaved = [];
|
|
1114
|
+
for (const entry of detailNodes.values()) {
|
|
1115
|
+
if (!entry.built) continue;
|
|
1116
|
+
for (const sc of entry.dtr.querySelectorAll("*")) {
|
|
1117
|
+
if (sc.scrollTop || sc.scrollLeft) scrollSaved.push([sc, sc.scrollTop, sc.scrollLeft]);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1087
1120
|
tbody.replaceChildren();
|
|
1088
1121
|
for (const row of rows) renderRow(row);
|
|
1089
1122
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
|
@@ -1095,6 +1128,10 @@ function renderDataGrid(node) {
|
|
|
1095
1128
|
try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
|
|
1096
1129
|
}
|
|
1097
1130
|
}
|
|
1131
|
+
// Restore scroll depth on the reused nodes that survived the swap (still connected).
|
|
1132
|
+
for (const [sc, top, left] of scrollSaved) {
|
|
1133
|
+
if (sc.isConnected) { sc.scrollTop = top; sc.scrollLeft = left; }
|
|
1134
|
+
}
|
|
1098
1135
|
} catch (e) {
|
|
1099
1136
|
tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
|
|
1100
1137
|
}
|
|
@@ -1263,3 +1300,23 @@ async function renderPage() {
|
|
|
1263
1300
|
window.addEventListener("hashchange", renderPage);
|
|
1264
1301
|
renderPage();
|
|
1265
1302
|
`;
|
|
1303
|
+
/** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
|
|
1304
|
+
* module's URL so a content change busts the browser cache. Environment-agnostic (no
|
|
1305
|
+
* `crypto` import) so the runtime stays loadable under Node, Deno, and the embedded host. */
|
|
1306
|
+
function fnv1aHex(s) {
|
|
1307
|
+
let h = 0x811c9dc5;
|
|
1308
|
+
for (let i = 0; i < s.length; i++) {
|
|
1309
|
+
h ^= s.charCodeAt(i);
|
|
1310
|
+
h = Math.imul(h, 0x01000193);
|
|
1311
|
+
}
|
|
1312
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1313
|
+
}
|
|
1314
|
+
// The renderer module is served at a *content-fingerprinted* URL. The shell references this exact
|
|
1315
|
+
// path, so whenever `RENDERER_JS` changes (e.g. an Urban upgrade in a marketplace/Studio update)
|
|
1316
|
+
// the URL changes with it and the browser fetches the new module instead of silently replaying a
|
|
1317
|
+
// heuristically-cached copy of the old one — the failure that made a "Cancel" button (and every
|
|
1318
|
+
// other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
|
|
1319
|
+
// per content, the response itself is served `immutable` with a one-year max-age.
|
|
1320
|
+
const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
|
|
1321
|
+
const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
|
|
1322
|
+
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
package/dist/toolkit/gen.d.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface GenResult {
|
|
|
42
42
|
incomplete: boolean;
|
|
43
43
|
/** Per-file model-derivation errors (empty on success). */
|
|
44
44
|
modelErrors: ModelError[];
|
|
45
|
+
/** Non-fatal spec-hygiene warnings (e.g. a requestBody schema shared by >1 operation). Empty on
|
|
46
|
+
* a clean spec. Surfaced by the CLI but never fail gen/check — advisory only. */
|
|
47
|
+
warnings: string[];
|
|
45
48
|
}
|
|
46
49
|
declare function join(root: string, rel: string): string;
|
|
47
50
|
declare function dirOf(p: string): string;
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -9,7 +9,7 @@ import { deriveWorkerBindings } from "./derivers/worker-io.js";
|
|
|
9
9
|
import { deriveMeta } from "./derivers/meta.js";
|
|
10
10
|
import { deriveMessageBindings } from "./derivers/messages.js";
|
|
11
11
|
import { deriveApi } from "./derivers/api.js";
|
|
12
|
-
import { parseSpec } from "../openapi/spec.js";
|
|
12
|
+
import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.js";
|
|
13
13
|
import { deriveModels, MODEL_PROVENANCE } from "./models.js";
|
|
14
14
|
function join(root, rel) {
|
|
15
15
|
// Normalize Windows-style separators to forward slashes (GenIO uses forward slashes on all
|
|
@@ -68,6 +68,7 @@ async function collectAll(opts) {
|
|
|
68
68
|
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
69
69
|
const manifest = JSON.parse(await io.readText(manifestPath));
|
|
70
70
|
const artifacts = [];
|
|
71
|
+
const warnings = [];
|
|
71
72
|
// 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
|
|
72
73
|
if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
|
|
73
74
|
artifacts.push(...deriveMigrations(manifest));
|
|
@@ -101,13 +102,21 @@ async function collectAll(opts) {
|
|
|
101
102
|
const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
|
|
102
103
|
? manifest.api.dir.trim()
|
|
103
104
|
: undefined;
|
|
104
|
-
|
|
105
|
+
const doc = parseSpec(specText);
|
|
106
|
+
artifacts.push(...deriveApi(doc, opsDir, manifest.api?.eject === true));
|
|
107
|
+
// Non-fatal spec-hygiene: a requestBody schema reused by >1 operation can't be tightened into
|
|
108
|
+
// per-operation discriminated variants — flag it so the author can split it (never fail gen).
|
|
109
|
+
for (const { ref, operationIds } of sharedRequestBodySchemas(doc)) {
|
|
110
|
+
warnings.push(`requestBody schema ${ref} is shared by ${operationIds.length} operations ` +
|
|
111
|
+
`(${operationIds.join(", ")}). A single schema can't model per-operation discriminants — ` +
|
|
112
|
+
"consider splitting it into named variants + oneOf (additionalProperties:false) per operation.");
|
|
113
|
+
}
|
|
105
114
|
}
|
|
106
|
-
return { artifacts: sortArtifacts(artifacts), derived };
|
|
115
|
+
return { artifacts: sortArtifacts(artifacts), derived, warnings };
|
|
107
116
|
}
|
|
108
117
|
/** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
|
|
109
118
|
export async function runGen(opts) {
|
|
110
|
-
const { artifacts, derived } = await collectAll(opts);
|
|
119
|
+
const { artifacts, derived, warnings } = await collectAll(opts);
|
|
111
120
|
const { root, io } = opts;
|
|
112
121
|
const emitModels = opts.emitModels ?? true;
|
|
113
122
|
const drift = [];
|
|
@@ -160,7 +169,7 @@ export async function runGen(opts) {
|
|
|
160
169
|
swept.push(rel);
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
|
-
return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
|
|
172
|
+
return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, warnings };
|
|
164
173
|
}
|
|
165
174
|
export { dirOf, join as joinPath };
|
|
166
175
|
/** Derive code-first models in-memory (no writes) — backs `urban derive --stdout`, the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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",
|
package/src/cli.ts
CHANGED
|
@@ -153,6 +153,12 @@ async function cmdGen(f: Flags): Promise<number> {
|
|
|
153
153
|
for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
|
|
154
154
|
return 1;
|
|
155
155
|
}
|
|
156
|
+
// Non-fatal spec-hygiene advisories (e.g. a requestBody schema shared by >1 operation). Printed
|
|
157
|
+
// in both gen and check; never change the exit code.
|
|
158
|
+
if (res.warnings.length > 0) {
|
|
159
|
+
console.warn(`⚠ ${res.warnings.length} spec warning(s):`);
|
|
160
|
+
for (const w of res.warnings) console.warn(` • ${w}`);
|
|
161
|
+
}
|
|
156
162
|
if (f.check) {
|
|
157
163
|
if (res.drift.length === 0 && res.missingStubs.length === 0) {
|
|
158
164
|
console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
|
package/src/openapi/spec.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
parseSpec,
|
|
13
13
|
resolveSchema,
|
|
14
14
|
responseSchemaForStatus,
|
|
15
|
+
sharedRequestBodySchemas,
|
|
15
16
|
toRouteMatcher,
|
|
16
17
|
undeclaredPathParams,
|
|
17
18
|
undeclaredSecuritySchemes,
|
|
@@ -333,6 +334,72 @@ test("validateValue enforces allOf (intersection), anyOf/oneOf (union) — match
|
|
|
333
334
|
assert.ok(validateValue(doc, one, "x").length > 0); // matches NEITHER
|
|
334
335
|
});
|
|
335
336
|
|
|
337
|
+
test("validateValue: a failed oneOf discriminant names the shapes and surfaces the closest variant", () => {
|
|
338
|
+
// The Camunda-style discriminated request body: exactly one of two mutually-exclusive variants
|
|
339
|
+
// (mutual exclusion enforced by additionalProperties:false + a distinct required discriminant).
|
|
340
|
+
const byPr: OpenApiSchema = {
|
|
341
|
+
type: "object",
|
|
342
|
+
additionalProperties: false,
|
|
343
|
+
required: ["pr"],
|
|
344
|
+
properties: { pr: { type: "string" }, maxRounds: { type: "integer" } },
|
|
345
|
+
};
|
|
346
|
+
const byUrl: OpenApiSchema = {
|
|
347
|
+
type: "object",
|
|
348
|
+
additionalProperties: false,
|
|
349
|
+
required: ["url"],
|
|
350
|
+
properties: { url: { type: "string" }, maxRounds: { type: "integer" } },
|
|
351
|
+
};
|
|
352
|
+
const start: OpenApiSchema = { oneOf: [byPr, byUrl] };
|
|
353
|
+
|
|
354
|
+
// Neither discriminant supplied → matched 0. The summary must NAME the allowed shapes, and the
|
|
355
|
+
// closest variant's own issue must be surfaced (not just the opaque "matched 0").
|
|
356
|
+
const none = validateValue(doc, start, { maxRounds: 3 }, "body");
|
|
357
|
+
assert.ok(none.length > 0, "an empty discriminant is rejected");
|
|
358
|
+
assert.ok(
|
|
359
|
+
none.some((i) => /allowed:.*\{pr\}.*\|.*\{url\}/.test(i.message)),
|
|
360
|
+
`summary names the allowed shapes: ${JSON.stringify(none)}`,
|
|
361
|
+
);
|
|
362
|
+
assert.ok(
|
|
363
|
+
none.some((i) => i.path === "body/pr" && /required/.test(i.message)),
|
|
364
|
+
`closest variant's field issue is surfaced: ${JSON.stringify(none)}`,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Both discriminants supplied → with additionalProperties:false, EACH variant rejects the other's
|
|
368
|
+
// field, so this is also "matches none" (the desirable strictness — a body can't smuggle both).
|
|
369
|
+
const both = validateValue(doc, start, { pr: "a/b#1", url: "http://x" }, "body");
|
|
370
|
+
assert.ok(
|
|
371
|
+
both.some((i) => /does not match any of the 2 allowed shapes/.test(i.message)),
|
|
372
|
+
`supplying both discriminants is rejected by the strict variants: ${JSON.stringify(both)}`,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Exactly one discriminant → valid.
|
|
376
|
+
assert.equal(validateValue(doc, start, { pr: "a/b#1" }, "body").length, 0);
|
|
377
|
+
|
|
378
|
+
// `anyOf` no-match reporting mirrors `oneOf`: name the allowed shapes AND surface the closest
|
|
379
|
+
// variant's issues, so the improved diagnostics are guarded for the union branch too. Here a value
|
|
380
|
+
// matches NEITHER discriminated variant, and `{pr}` is the closest (its own field is the issue).
|
|
381
|
+
const anyStart: OpenApiSchema = { anyOf: [byPr, byUrl] };
|
|
382
|
+
const anyNone = validateValue(doc, anyStart, { maxRounds: 3 }, "body");
|
|
383
|
+
assert.ok(anyNone.length > 0, "a value matching no anyOf variant is rejected");
|
|
384
|
+
assert.ok(
|
|
385
|
+
anyNone.some((i) => /allowed:.*\{pr\}.*\|.*\{url\}/.test(i.message)),
|
|
386
|
+
`anyOf summary names the allowed shapes: ${JSON.stringify(anyNone)}`,
|
|
387
|
+
);
|
|
388
|
+
assert.ok(
|
|
389
|
+
anyNone.some((i) => i.path === "body/pr" && /required/.test(i.message)),
|
|
390
|
+
`anyOf surfaces the closest variant's field issue: ${JSON.stringify(anyNone)}`,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// The "matched more than one" branch: genuinely overlapping (non-exclusive) variants. A value that
|
|
394
|
+
// satisfies two shapes is ambiguous and must be reported as such.
|
|
395
|
+
const overlapping: OpenApiSchema = { oneOf: [{ type: "integer" }, { type: "integer", minimum: 10 }] };
|
|
396
|
+
const ambiguous = validateValue(doc, overlapping, 20, "n");
|
|
397
|
+
assert.ok(
|
|
398
|
+
ambiguous.some((i) => /must match exactly one of the 2 allowed shapes, but matched 2/.test(i.message)),
|
|
399
|
+
`ambiguous (multi-match) is reported: ${JSON.stringify(ambiguous)}`,
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
336
403
|
test("undeclaredPathParams flags path-template params with no declared parameter", () => {
|
|
337
404
|
const drift: OpenApiDoc = {
|
|
338
405
|
openapi: "3.0.0",
|
|
@@ -608,3 +675,30 @@ test("responseSchemaForStatus: a status-range ('2XX') covers concrete statuses i
|
|
|
608
675
|
assert.equal(responseSchemaForStatus(entries, 200)?.type, "string"); // 2XX class → range schema
|
|
609
676
|
assert.equal(responseSchemaForStatus(entries, 404)?.type, "object"); // outside 2XX → default
|
|
610
677
|
});
|
|
678
|
+
|
|
679
|
+
test("sharedRequestBodySchemas: flags a requestBody $ref reused across operations, ignores single-use", () => {
|
|
680
|
+
const jsonRef = (ref: string) => ({ requestBody: { content: { "application/json": { schema: { $ref: ref } } } } });
|
|
681
|
+
const doc: OpenApiDoc = {
|
|
682
|
+
openapi: "3.1.0",
|
|
683
|
+
components: {
|
|
684
|
+
schemas: {
|
|
685
|
+
StartVariables: { type: "object" },
|
|
686
|
+
FeatureAnswer: { type: "object" },
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
paths: {
|
|
690
|
+
// Two operations share the SAME loose body schema — the defect fingerprint.
|
|
691
|
+
"/convergence": { post: { operationId: "startConvergenceLoop", ...jsonRef("#/components/schemas/StartVariables") } },
|
|
692
|
+
"/fanout": { post: { operationId: "startPlanFanout", ...jsonRef("#/components/schemas/StartVariables") } },
|
|
693
|
+
// A distinct, single-use body — must NOT be flagged.
|
|
694
|
+
"/feature": { post: { operationId: "answerFeature", ...jsonRef("#/components/schemas/FeatureAnswer") } },
|
|
695
|
+
// An inline (non-$ref) body — no shared identity to compare, must NOT be flagged.
|
|
696
|
+
"/inline": { post: { operationId: "inlineBody", requestBody: { content: { "application/json": { schema: { type: "object" } } } } } },
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const shared = sharedRequestBodySchemas(doc);
|
|
701
|
+
assert.equal(shared.length, 1, `only the reused ref is flagged: ${JSON.stringify(shared)}`);
|
|
702
|
+
assert.equal(shared[0].ref, "#/components/schemas/StartVariables");
|
|
703
|
+
assert.deepEqual(shared[0].operationIds, ["startConvergenceLoop", "startPlanFanout"]); // sorted
|
|
704
|
+
});
|
package/src/openapi/spec.ts
CHANGED
|
@@ -338,6 +338,32 @@ export function assertUniqueOperationIds(doc: OpenApiDoc): void {
|
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
|
|
342
|
+
* keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
|
|
343
|
+
* request-body schema reused verbatim by multiple operations is the structural fingerprint of the
|
|
344
|
+
* "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
|
|
345
|
+
* operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
|
|
346
|
+
* `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
|
|
347
|
+
* tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
|
|
348
|
+
* split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
|
|
349
|
+
* only (pure, total) — the message is built by the caller. */
|
|
350
|
+
export function sharedRequestBodySchemas(doc: OpenApiDoc): { ref: string; operationIds: string[] }[] {
|
|
351
|
+
const byRef = new Map<string, string[]>();
|
|
352
|
+
for (const op of collectOperations(doc)) {
|
|
353
|
+
const ref = op.requestBodySchema?.$ref;
|
|
354
|
+
if (typeof ref !== "string" || ref.length === 0) continue;
|
|
355
|
+
const ids = byRef.get(ref) ?? [];
|
|
356
|
+
ids.push(op.operationId);
|
|
357
|
+
byRef.set(ref, ids);
|
|
358
|
+
}
|
|
359
|
+
const out: { ref: string; operationIds: string[] }[] = [];
|
|
360
|
+
for (const [ref, ids] of byRef) {
|
|
361
|
+
if (ids.length > 1) out.push({ ref, operationIds: [...ids].sort() });
|
|
362
|
+
}
|
|
363
|
+
out.sort((a, b) => a.ref.localeCompare(b.ref));
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
341
367
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
342
368
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
343
369
|
export function operationsWithoutId(doc: OpenApiDoc): string[] {
|
|
@@ -697,6 +723,36 @@ function matchesAnyType(declared: string[], value: unknown): boolean {
|
|
|
697
723
|
return declared.some((t) => matchesType(t, value));
|
|
698
724
|
}
|
|
699
725
|
|
|
726
|
+
/** A short, human-readable label for one `anyOf`/`oneOf` variant, so a composition failure can name
|
|
727
|
+
* the allowed shapes instead of an opaque "matched 0". Prefers the variant's discriminant — its
|
|
728
|
+
* `required` fields (e.g. `{pr}` vs `{url}`) — since that is exactly what a caller must choose
|
|
729
|
+
* between; falls back to the declared type(s), else "object". */
|
|
730
|
+
function variantLabel(doc: OpenApiDoc, variant: OpenApiSchema): string {
|
|
731
|
+
const s = resolveSchema(doc, variant);
|
|
732
|
+
if (!s) return "?";
|
|
733
|
+
const req = Array.isArray(s.required) ? s.required : [];
|
|
734
|
+
if (req.length > 0) return `{${req.join(", ")}}`;
|
|
735
|
+
const types = schemaTypeList(s);
|
|
736
|
+
return types.length > 0 ? types.join("|") : "object";
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** The `(allowed: {pr} | {url})` suffix for a composition-failure summary message. */
|
|
740
|
+
function variantSummary(doc: OpenApiDoc, variants: OpenApiSchema[]): string {
|
|
741
|
+
return ` (allowed: ${variants.map((v) => variantLabel(doc, v)).join(" | ")})`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** When a `oneOf`/`anyOf` matched no variant, surface actionable detail: the issues of the variant
|
|
745
|
+
* the value was CLOSEST to (fewest problems). For the discriminant case this is the shape the
|
|
746
|
+
* caller meant, so its issues read like `pr: is required` / `url: is not an allowed property`
|
|
747
|
+
* instead of a bare "matched 0". Ties resolve to the earliest variant (declaration order). */
|
|
748
|
+
function closestVariantIssues(perVariant: ValidationIssue[][]): ValidationIssue[] {
|
|
749
|
+
let best: ValidationIssue[] | undefined;
|
|
750
|
+
for (const iss of perVariant) {
|
|
751
|
+
if (best === undefined || iss.length < best.length) best = iss;
|
|
752
|
+
}
|
|
753
|
+
return best ?? [];
|
|
754
|
+
}
|
|
755
|
+
|
|
700
756
|
/** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
|
|
701
757
|
* found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
|
|
702
758
|
export function validateValue(
|
|
@@ -743,13 +799,47 @@ export function validateValue(
|
|
|
743
799
|
if (schema.allOf) {
|
|
744
800
|
for (const sub of schema.allOf) issues.push(...validateValue(doc, sub, value, path));
|
|
745
801
|
}
|
|
746
|
-
if (schema.anyOf
|
|
747
|
-
|
|
802
|
+
if (schema.anyOf) {
|
|
803
|
+
// `anyOf` needs only ONE match, so short-circuit on the first clean variant — `validateValue`
|
|
804
|
+
// runs on every request (params/query/body/response) and evaluating every variant for a large
|
|
805
|
+
// schema is needless work once we have a hit. Only when NO variant matches do we build the full
|
|
806
|
+
// per-variant issue list to name the allowed shapes and surface the closest variant's issues.
|
|
807
|
+
let anyMatched = false;
|
|
808
|
+
const perVariant: ValidationIssue[][] = [];
|
|
809
|
+
for (const sub of schema.anyOf) {
|
|
810
|
+
const subIssues = validateValue(doc, sub, value, path);
|
|
811
|
+
if (subIssues.length === 0) {
|
|
812
|
+
anyMatched = true;
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
perVariant.push(subIssues);
|
|
816
|
+
}
|
|
817
|
+
if (!anyMatched) {
|
|
818
|
+
issues.push({
|
|
819
|
+
path: at,
|
|
820
|
+
message: `does not match any of the ${schema.anyOf.length} allowed shapes${variantSummary(doc, schema.anyOf)}`,
|
|
821
|
+
});
|
|
822
|
+
issues.push(...closestVariantIssues(perVariant));
|
|
823
|
+
}
|
|
748
824
|
}
|
|
749
825
|
if (schema.oneOf) {
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
826
|
+
const perVariant = schema.oneOf.map((sub) => validateValue(doc, sub, value, path));
|
|
827
|
+
const matched = perVariant.filter((v) => v.length === 0).length;
|
|
828
|
+
if (matched === 0) {
|
|
829
|
+
// No variant matched: name the allowed shapes AND surface the closest variant's issues, so a
|
|
830
|
+
// discriminated body (e.g. exactly one of pr|url) fails with actionable detail, not "matched 0".
|
|
831
|
+
issues.push({
|
|
832
|
+
path: at,
|
|
833
|
+
message: `does not match any of the ${schema.oneOf.length} allowed shapes${variantSummary(doc, schema.oneOf)}`,
|
|
834
|
+
});
|
|
835
|
+
issues.push(...closestVariantIssues(perVariant));
|
|
836
|
+
} else if (matched > 1) {
|
|
837
|
+
// Matched more than one: the shapes are not mutually exclusive for this value (usually a body
|
|
838
|
+
// that supplies two discriminants, or under-constrained variants). Report the ambiguity.
|
|
839
|
+
issues.push({
|
|
840
|
+
path: at,
|
|
841
|
+
message: `must match exactly one of the ${schema.oneOf.length} allowed shapes, but matched ${matched}${variantSummary(doc, schema.oneOf)}`,
|
|
842
|
+
});
|
|
753
843
|
}
|
|
754
844
|
}
|
|
755
845
|
|
|
@@ -90,12 +90,15 @@ test("GET / serves the renderer shell with the home marker", async () => {
|
|
|
90
90
|
assert.equal(res.status, 200);
|
|
91
91
|
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
92
92
|
assert.match(res.body ?? "", /data-home="home"/);
|
|
93
|
-
// The runtime module is loaded by a *relative* src so it resolves
|
|
94
|
-
// document's mount path — works both at the origin root and under the Nano
|
|
95
|
-
// console's /console/app-view/<name>/ reverse proxy. A root-absolute
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
assert.
|
|
93
|
+
// The runtime module is loaded by a *relative*, *content-fingerprinted* src so it resolves
|
|
94
|
+
// against the document's mount path — works both at the origin root and under the Nano
|
|
95
|
+
// console's /console/app-view/<name>/ reverse proxy. A root-absolute path would 404 against
|
|
96
|
+
// the console origin; a non-fingerprinted URL would let a stale browser cache replay an old
|
|
97
|
+
// module after an upgrade (silently breaking every page action).
|
|
98
|
+
assert.match(res.body ?? "", /src="\.\/app\/runtime\.[0-9a-f]{8}\.js"/);
|
|
99
|
+
assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\./);
|
|
100
|
+
// The shell itself must never be pinned by the browser (it carries the current module hash).
|
|
101
|
+
assert.match(res.headers?.["cache-control"] ?? "", /no-cache/);
|
|
99
102
|
});
|
|
100
103
|
|
|
101
104
|
test("the API docs badge links via a document-relative href (proxy-safe)", async () => {
|
|
@@ -145,6 +148,31 @@ test("GET /app/runtime.js serves the renderer module", async () => {
|
|
|
145
148
|
assert.match(res.body ?? "", /function apiUrl\(u\)/);
|
|
146
149
|
});
|
|
147
150
|
|
|
151
|
+
test("the fingerprinted runtime URL from the shell is served immutable; the shell references it", async () => {
|
|
152
|
+
// The shell's <script src> must point at a route the router actually serves, and that route
|
|
153
|
+
// must carry long-lived immutable caching (the URL is unique per content, so it can never go
|
|
154
|
+
// stale). This is the cache-bust that stops an upgraded app from replaying an old module.
|
|
155
|
+
const shell = await dispatch("GET", "/");
|
|
156
|
+
const m = (shell.body ?? "").match(/src="\.(\/app\/runtime\.[0-9a-f]{8}\.js)"/);
|
|
157
|
+
assert.ok(m, "shell must reference a fingerprinted runtime URL");
|
|
158
|
+
const res = await dispatch("GET", m![1]);
|
|
159
|
+
assert.equal(res.status, 200);
|
|
160
|
+
assert.match(res.headers?.["content-type"] ?? "", /javascript/);
|
|
161
|
+
assert.match(res.body ?? "", /pc:refresh/);
|
|
162
|
+
assert.match(res.headers?.["cache-control"] ?? "", /immutable/);
|
|
163
|
+
assert.match(res.headers?.["cache-control"] ?? "", /max-age=31536000/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the legacy /app/runtime.js URL still serves the module, but no-cache", async () => {
|
|
167
|
+
// Back-compat + defence in depth: an old cached shell (or any client) hitting the unhashed URL
|
|
168
|
+
// still gets the current bytes, and `no-cache` forces revalidation instead of a stale replay.
|
|
169
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
170
|
+
assert.equal(res.status, 200);
|
|
171
|
+
assert.match(res.headers?.["content-type"] ?? "", /javascript/);
|
|
172
|
+
assert.match(res.body ?? "", /pc:refresh/);
|
|
173
|
+
assert.match(res.headers?.["cache-control"] ?? "", /no-cache/);
|
|
174
|
+
});
|
|
175
|
+
|
|
148
176
|
test("the shell renders an API-docs badge only when an apiDocsPath is provided", async () => {
|
|
149
177
|
const routes = createPagesRoutes(
|
|
150
178
|
{ pagesDir: "pages", homePage: "home", sourceName: "app", apiDocsPath: "/app/api-docs" },
|
|
@@ -234,6 +262,21 @@ test("renderer keeps the detail input focused across the refresh poll", async ()
|
|
|
234
262
|
assert.match(js, /s\.addRange\(savedRange\)/);
|
|
235
263
|
});
|
|
236
264
|
|
|
265
|
+
test("renderer preserves inner scroll depth across the refresh poll", async () => {
|
|
266
|
+
// Regression: an open detail row is reused across the 5s poll, but tbody.replaceChildren()
|
|
267
|
+
// detaches it, which resets the scrollTop of any inner overflow:auto container — so a
|
|
268
|
+
// scrolled-into transcript (.pc-transcript, max-height + overflow:auto) jumped back to the
|
|
269
|
+
// top every refreshMs. The client now captures scroll offsets of scrolled descendants
|
|
270
|
+
// before the swap and restores them on the nodes that stay connected afterwards.
|
|
271
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
272
|
+
const js = res.body ?? "";
|
|
273
|
+
// Offsets are captured from scrolled descendants before replaceChildren()…
|
|
274
|
+
assert.match(js, /const scrollSaved = \[\]/);
|
|
275
|
+
assert.match(js, /if\s*\(\s*sc\.scrollTop\s*\|\|\s*sc\.scrollLeft\s*\)\s*scrollSaved\.push/);
|
|
276
|
+
// …and restored only on nodes re-attached by the rebuild (still connected).
|
|
277
|
+
assert.match(js, /if\s*\(\s*sc\.isConnected\s*\)\s*\{\s*sc\.scrollTop\s*=\s*top\s*;\s*sc\.scrollLeft\s*=\s*left\s*;\s*\}/);
|
|
278
|
+
});
|
|
279
|
+
|
|
237
280
|
test("renderer wires a column's linkField to a new-tab anchor", async () => {
|
|
238
281
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
239
282
|
const js = res.body ?? "";
|
|
@@ -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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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="
|
|
473
|
+
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
450
474
|
</body>
|
|
451
475
|
</html>`;
|
|
452
476
|
}
|
|
@@ -1132,6 +1156,19 @@ function renderDataGrid(node) {
|
|
|
1132
1156
|
try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
|
|
1133
1157
|
}
|
|
1134
1158
|
}
|
|
1159
|
+
// Capture scroll offsets of scrolled descendants before the swap. An open detail node is
|
|
1160
|
+
// reused (same element instance), but replaceChildren() detaches it, which resets the
|
|
1161
|
+
// scrollTop/scrollLeft of any inner scroll container (e.g. a scrolled-into .pc-transcript,
|
|
1162
|
+
// max-height + overflow:auto) back to 0. Only built (open) detail subtrees are re-attached
|
|
1163
|
+
// by the rebuild, so walk just those — not the whole grid — to keep this O(open panels)
|
|
1164
|
+
// rather than O(grid size). Restoration below skips any node that isn't re-attached.
|
|
1165
|
+
const scrollSaved = [];
|
|
1166
|
+
for (const entry of detailNodes.values()) {
|
|
1167
|
+
if (!entry.built) continue;
|
|
1168
|
+
for (const sc of entry.dtr.querySelectorAll("*")) {
|
|
1169
|
+
if (sc.scrollTop || sc.scrollLeft) scrollSaved.push([sc, sc.scrollTop, sc.scrollLeft]);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1135
1172
|
tbody.replaceChildren();
|
|
1136
1173
|
for (const row of rows) renderRow(row);
|
|
1137
1174
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
|
@@ -1143,6 +1180,10 @@ function renderDataGrid(node) {
|
|
|
1143
1180
|
try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
|
|
1144
1181
|
}
|
|
1145
1182
|
}
|
|
1183
|
+
// Restore scroll depth on the reused nodes that survived the swap (still connected).
|
|
1184
|
+
for (const [sc, top, left] of scrollSaved) {
|
|
1185
|
+
if (sc.isConnected) { sc.scrollTop = top; sc.scrollLeft = left; }
|
|
1186
|
+
}
|
|
1146
1187
|
} catch (e) {
|
|
1147
1188
|
tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
|
|
1148
1189
|
}
|
|
@@ -1311,3 +1352,25 @@ async function renderPage() {
|
|
|
1311
1352
|
window.addEventListener("hashchange", renderPage);
|
|
1312
1353
|
renderPage();
|
|
1313
1354
|
`;
|
|
1355
|
+
|
|
1356
|
+
/** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
|
|
1357
|
+
* module's URL so a content change busts the browser cache. Environment-agnostic (no
|
|
1358
|
+
* `crypto` import) so the runtime stays loadable under Node, Deno, and the embedded host. */
|
|
1359
|
+
function fnv1aHex(s: string): string {
|
|
1360
|
+
let h = 0x811c9dc5;
|
|
1361
|
+
for (let i = 0; i < s.length; i++) {
|
|
1362
|
+
h ^= s.charCodeAt(i);
|
|
1363
|
+
h = Math.imul(h, 0x01000193);
|
|
1364
|
+
}
|
|
1365
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// The renderer module is served at a *content-fingerprinted* URL. The shell references this exact
|
|
1369
|
+
// path, so whenever `RENDERER_JS` changes (e.g. an Urban upgrade in a marketplace/Studio update)
|
|
1370
|
+
// the URL changes with it and the browser fetches the new module instead of silently replaying a
|
|
1371
|
+
// heuristically-cached copy of the old one — the failure that made a "Cancel" button (and every
|
|
1372
|
+
// other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
|
|
1373
|
+
// per content, the response itself is served `immutable` with a one-year max-age.
|
|
1374
|
+
const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
|
|
1375
|
+
const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
|
|
1376
|
+
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|