From c74eec4f9b8eb3eb7cd48cbcb28c4d29024ff6e2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 15:05:43 -0400 Subject: [PATCH 1/5] fix(codegen-ts): api reference follows a routes generator's filter, warns on unmarked owned copies Entities a routes generator's own filter rejects are documented with no routes; a pre-mountsRoutes owned copy is named in a gen warning. Adds an end-to-end regression for expose omitting create/update/delete. --- CHANGELOG.md | 11 ++++ .../packages/codegen-ts/src/generator.ts | 7 ++- .../src/generators/routes-file-hono.ts | 5 +- .../codegen-ts/src/generators/routes-file.ts | 5 +- .../codegen-ts/src/reference/routes-hono.ts | 5 +- .../codegen-ts/src/reference/routes.ts | 5 +- .../packages/codegen-ts/src/runner.ts | 3 +- .../packages/codegen-ts/src/ui-tier-gate.ts | 44 +++++++++++--- .../codegen-ts/test/api-docs-expose.test.ts | 60 +++++++++++++++++++ .../test-generators/src/generators/routes.ts | 5 +- 10 files changed, 136 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a47f6b071..5142264b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,17 @@ here.** ### Fixed +- **The api reference now follows a routes generator's `filter` as well as its `expose`.** + `routesFile({ filter })` and `routesFileHono({ filter })` mount nothing for an entity the filter + rejects, but the API pages still listed all five verbs for it. The pages now document the + routes the generators mount and nothing else, from the one resolved answer the run reads off + them (`runRoutesExpose`). Output with neither option is byte-identical. A routes generator + ejected before the `mountsRoutes` marker existed is now named in a `meta gen` warning when the run + writes an api reference: its `expose` and `filter` are invisible to the pages until it carries + the marker (or is re-ejected with `meta eject routes --force`). TypeScript only: no other port + has an `expose` option or a routes-generator `filter`, and the ports' api references document + routes from the model alone. + - **C#: the two shared helpers now carry the `// ` marker (#460).** `DecimalWire[.].g.cs` and `MetaNetValidation[.].g.cs` were the only emitted files without it, so a runner with no `GenStateDir` wrote them once and then logged diff --git a/server/typescript/packages/codegen-ts/src/generator.ts b/server/typescript/packages/codegen-ts/src/generator.ts index 64bcdec18..c3fcc0dc5 100644 --- a/server/typescript/packages/codegen-ts/src/generator.ts +++ b/server/typescript/packages/codegen-ts/src/generator.ts @@ -164,7 +164,12 @@ export interface Generator { * (`runRoutesExpose`), per surface (a `emitsHonoRoutes` generator is the Hono one), so the API docs list the verbs each routes file actually mounts, not all five. * Absent `expose` means every verb. A copy ejected before this marker existed needs * `meta eject routes --force` (or the same one-line addition) to be honoured. */ - mountsRoutes?: { readonly expose?: ExposeOption }; + mountsRoutes?: { + readonly expose?: ExposeOption; + /** The generator's own `filter` option (not its built-in gates): an entity it rejects is + * mounted nowhere, so the API docs list none of its routes. */ + readonly filter?: (entity: MetaObject) => boolean; + }; /** Marks a generator that writes the per-object model pages and their index (`docsFile()`). * The runner reads it, per target, to tell an api reference generator in the same target * that there are model pages to link back to (`ctx.docs.modelPages`). It changes nothing diff --git a/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts b/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts index 0221c1c62..a237fe875 100644 --- a/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts +++ b/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts @@ -63,7 +63,10 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts) const generator: Generator = { name: "routes-file-hono", // Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts. - mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, + mountsRoutes: { + ...(opts?.expose !== undefined && { expose: opts.expose }), + ...(opts?.filter !== undefined && { filter: opts.filter }), + }, // Marks this as the Hono routes generator so the runner can aggregate // `ctx.config.includeHonoRoutes` and api-docs auto-documents the Hono surface. emitsHonoRoutes: true, diff --git a/server/typescript/packages/codegen-ts/src/generators/routes-file.ts b/server/typescript/packages/codegen-ts/src/generators/routes-file.ts index 6b3f38323..e47667d82 100644 --- a/server/typescript/packages/codegen-ts/src/generators/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/routes-file.ts @@ -60,7 +60,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator const generator: Generator = { name: "routes-file", // Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts. - mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, + mountsRoutes: { + ...(opts?.expose !== undefined && { expose: opts.expose }), + ...(opts?.filter !== undefined && { filter: opts.filter }), + }, // Always set: AND-composes the built-in gates with the optional user filter. filter: (e: MetaObject) => servesReadApi(e) && !isTphSubtype(e) && userFilter(e), diff --git a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts index ad7b81d60..1101d71b0 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts @@ -281,7 +281,10 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts) // `ctx.config.includeHonoRoutes` and api-docs auto-documents the Hono surface. emitsHonoRoutes: true, // Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts. - mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, + mountsRoutes: { + ...(opts?.expose !== undefined && { expose: opts.expose }), + ...(opts?.filter !== undefined && { filter: opts.filter }), + }, // TPH subtypes are EXCLUDED, matching the Fastify generator. A TPH subtype shares // its base's table, so mounting vanilla CRUD for it produced routes with no // discriminator scoping at all: the list returned EVERY subtype's rows, and diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index 69e2521b8..934358a69 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -735,7 +735,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator name: "routes-file", // Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts, so an // `expose` you pass here is honoured there. Keep this line if you change what the file mounts. - mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, + mountsRoutes: { + ...(opts?.expose !== undefined && { expose: opts.expose }), + ...(opts?.filter !== undefined && { filter: opts.filter }), + }, // TPH subtypes get no standalone routes file (their routes live in the discriminator // base's); AND-composed with your filter. // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index 51389c5a2..2712a3d27 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -1,6 +1,6 @@ import { join, relative, resolve, isAbsolute, dirname } from "node:path"; import { warnMissingPromptGenerators } from "./prompt-generator-gate.js"; -import { runEmitsHonoRoutes, runEmitsUiTier, runRoutesExpose, warnUnmarkedUiGenerators } from "./ui-tier-gate.js"; +import { runEmitsHonoRoutes, runEmitsUiTier, runRoutesExpose, warnUnmarkedRoutesGenerators, warnUnmarkedUiGenerators } from "./ui-tier-gate.js"; import { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -709,6 +709,7 @@ export async function runGen(opts: RunGenOpts): Promise { (m) => warnings.push(m), ); warnUnmarkedUiGenerators(config.generators, (m) => warnings.push(m)); + warnUnmarkedRoutesGenerators(config.generators, (m) => warnings.push(m)); // Codegen is opt-in, so `meta gen` is the post-selection audit: the two ways a legal // selection still surprises you. Warnings only — see catalog-gates.ts. diff --git a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts index e2e28b829..e43cb3568 100644 --- a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts +++ b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts @@ -1,4 +1,5 @@ import type { Generator } from "./generator.js"; +import { tphDiscriminatorBase } from "./tph.js"; import { CRUD_VERBS, resolveExpose, type CrudVerb, type ExposeOption, type RoutesExpose } from "./routes-expose.js"; /** @@ -83,9 +84,10 @@ export function runEmitsHonoRoutes(generators: readonly Generator[]): boolean { * page that lists DELETE for a route the adopter does not mount is a lie). * * Per surface, because the Fastify and Hono routes files are separate generators with their - * own option: a surface is narrowed only when EVERY generator mounting it carries an `expose` - * (several on one surface union their verbs), and a surface with none, or one that mounts all - * five, documents all five. Undefined when no surface is narrowed. + * own option: several generators on one surface union their verbs, and a generator with no + * `expose` mounts all five. A generator's own `filter` removes the entities it rejects, so an + * entity no generator on the surface accepts is documented with no routes. Undefined when no + * generator on any surface carries an `expose` or a `filter`. */ export function runRoutesExpose(generators: readonly Generator[]): RoutesExpose | undefined { const rest = surfaceExpose(generators.filter((g) => g.emitsHonoRoutes !== true)); @@ -98,14 +100,42 @@ function surfaceExpose(generators: readonly Generator[]): ExposeOption | undefin const mounts = generators .map((g) => g.mountsRoutes) .filter((m): m is NonNullable => m !== undefined); - if (mounts.length === 0 || mounts.some((m) => m.expose === undefined)) return undefined; + if (mounts.length === 0 || mounts.some((m) => m.expose === undefined && m.filter === undefined)) return undefined; return (entity) => { + // A TPH subtype has no routes file of its own: its mounts live in the base's file, so the + // base is the entity the generator's `filter` and `expose` were applied to. + const mounted = tphDiscriminatorBase(entity) ?? entity; const union = new Set(); for (const m of mounts) { - const verbs = resolveExpose(entity, m.expose); - if (verbs === undefined) return undefined; - for (const v of verbs) union.add(v); + // An entity the generator's own `filter` rejects is mounted by that generator nowhere. + if (m.filter !== undefined && !m.filter(mounted)) continue; + for (const v of resolveExpose(mounted, m.expose) ?? CRUD_VERBS) union.add(v); } return CRUD_VERBS.filter((v) => union.has(v)); }; } + +/** + * A routes generator ejected before `mountsRoutes` existed still mounts its routes, but the api + * reference cannot see its `expose` or `filter` and documents all five verbs for every entity. + * Warned about when the run writes an api reference, by name for the same reason as + * {@link warnUnmarkedUiGenerators}; adding the marker silences it. + */ +export function warnUnmarkedRoutesGenerators( + generators: readonly Generator[], + warn: (msg: string) => void, +): void { + if (!generators.some((g) => g.emitsApiDocs !== undefined)) return; + const stale = generators.filter( + (g) => (g.name === "routes-file" || g.name === "routes-file-hono") && g.mountsRoutes === undefined, + ); + if (stale.length === 0) return; + const names = stale.map((g) => `'${g.name}'`).join(", "); + warn( + `${names} mount${stale.length === 1 ? "s" : ""} routes but carr${stale.length === 1 ? "ies" : "y"} no ` + + `'mountsRoutes' marker — an owned copy ejected before the marker existed. The api reference ` + + `lists every verb for every entity whatever its 'expose' or 'filter' says: add ` + + `'mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}' beside 'name:' in your ` + + `copy (and 'filter' likewise), or re-eject it with 'meta eject routes --force'.`, + ); +} diff --git a/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts index d66863d94..6d4cf5375 100644 --- a/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts +++ b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts @@ -8,6 +8,12 @@ import { buildApiModel } from "../src/generators/api-model.js"; import { routesFile } from "../src/generators/routes-file.js"; import { routesFileHono } from "../src/generators/routes-file-hono.js"; import { runRoutesExpose } from "../src/ui-tier-gate.js"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runGen } from "../src/runner.js"; +import { defineConfig } from "../src/metaobjects-config.js"; +import { apiDocsFile } from "../src/generators/api-docs-file.js"; const ENTITY = (name: string, table: string) => ({ "object.entity": { @@ -116,3 +122,57 @@ describe("runRoutesExpose reads the option off the configured generators", () => expect(runRoutesExpose([routesFile({ expose: ["list"] }), routesFile()])?.rest).toBeUndefined(); }); }); + +describe("a routes generator's own filter removes the entities it does not mount", () => { + test("the rejected entity is documented with no routes; the rest are unchanged", async () => { + const root = await loadRoot(); + const expose = runRoutesExpose([routesFile({ filter: (e) => e.name !== "AuditEntry" })]); + const model = buildApiModel(root, { loadedRoot: root, ...(expose !== undefined && { expose }) }); + expect(sigs(model, "AuditEntry")).toEqual([]); + expect(sigs(model, "Product")).toHaveLength(6); + }); + + test("filter and expose compose; a second unfiltered generator on the surface mounts everything", async () => { + const root = await loadRoot(); + const audit = root.objects().find((o) => o.name === "AuditEntry")!; + const product = root.objects().find((o) => o.name === "Product")!; + type Fn = (e: typeof audit) => readonly string[] | undefined; + const both = runRoutesExpose([routesFile({ filter: (e) => e.name !== "AuditEntry", expose: ["list"] })]); + expect((both?.rest as Fn)(audit)).toEqual([]); + expect((both?.rest as Fn)(product)).toEqual(["list"]); + expect(runRoutesExpose([routesFile({ filter: () => false }), routesFile()])?.rest).toBeUndefined(); + }); +}); + +describe("through meta gen: the written api pages list only the mounted verbs", () => { + async function pages(generators: Parameters[0]["generators"]) { + const root = await loadRoot(); + const tmp = mkdtempSync(join(tmpdir(), "api-docs-expose-")); + try { + const { warnings } = await runGen({ + config: defineConfig({ + outDir: join(tmp, "src"), extStyle: "none", dbImport: "../db", dialect: "sqlite", + targets: { docs: { outDir: join(tmp, "docs") } }, generators, + }), + metadata: root, projectRoot: tmp, + }); + return { product: readFileSync(join(tmp, "docs", "api", "Product.md"), "utf8"), warnings }; + } finally { rmSync(tmp, { recursive: true, force: true }); } + } + + test("expose omitting create/update/delete: write routes are not documented; default is unchanged", async () => { + const narrowed = await pages([routesFile({ expose: ["list", "get"] }), apiDocsFile()]); + const page = narrowed.product; + expect(page).toContain("GET /products"); + expect(page).not.toMatch(/\b(POST|PATCH|PUT|DELETE) \/products/); + const full = await pages([routesFile(), apiDocsFile()]); + expect(full.product).toMatch(/POST \/products/); + expect(full.product).toMatch(/DELETE \/products\/:id/); + }); + + test("an owned routes copy with no mountsRoutes marker is warned about", async () => { + const { mountsRoutes: _marker, ...stale } = routesFile(); + const { warnings } = await pages([stale, apiDocsFile()]); + expect(warnings.join("\n")).toContain("mountsRoutes"); + }); +}); diff --git a/server/typescript/packages/test-generators/src/generators/routes.ts b/server/typescript/packages/test-generators/src/generators/routes.ts index 69e2521b8..934358a69 100644 --- a/server/typescript/packages/test-generators/src/generators/routes.ts +++ b/server/typescript/packages/test-generators/src/generators/routes.ts @@ -735,7 +735,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator name: "routes-file", // Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts, so an // `expose` you pass here is honoured there. Keep this line if you change what the file mounts. - mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, + mountsRoutes: { + ...(opts?.expose !== undefined && { expose: opts.expose }), + ...(opts?.filter !== undefined && { filter: opts.filter }), + }, // TPH subtypes get no standalone routes file (their routes live in the discriminator // base's); AND-composed with your filter. // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't From 3f83bd778ea7eb689b6bc0666609550781559cb5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 15:09:52 -0400 Subject: [PATCH 2/5] no-mistakes(review): Filtered read-only surfaces drop from API docs --- .../codegen-ts/src/generators/api-model.ts | 9 ++-- .../packages/codegen-ts/src/ui-tier-gate.ts | 8 +--- .../codegen-ts/test/api-docs-expose.test.ts | 47 +++++++++++++++++++ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/generators/api-model.ts b/server/typescript/packages/codegen-ts/src/generators/api-model.ts index 7b17c7b0c..d0034168e 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts @@ -726,17 +726,16 @@ function restSymbols( /** * The CRUD verbs the routes generator mounts for `obj`, as `routes-file.ts` decides them. * - * A read-only surface (a projection or a report) mounts `list` and `get` and `expose` cannot - * narrow it. A TPH discriminator base is read-only by construction and an author's `expose` - * INTERSECTS with that set. Any other entity mounts all five, narrowed by `expose`: the + * A read-only surface (a projection or a report) and a TPH discriminator base are read-only by + * construction: they mount `list` and `get`, and an author's `expose` INTERSECTS with that set + * (an empty result, as a routes generator's `filter` yields, mounts nothing). Any other entity mounts all five, narrowed by `expose`: the * option the run reads off the configured routes generator, so a page never lists a * verb the adopter did not mount (and overrides by hand). */ function mountedVerbs( obj: MetaObject, tphBase: boolean, expose: ExposeOption | undefined, ): ReadonlySet { - if (isReadOnlySurface(obj)) return new Set(["list", "get"]); - const fixed = tphBase ? TPH_POLYMORPHIC_VERBS : CRUD_VERBS; + const fixed = isReadOnlySurface(obj) || tphBase ? TPH_POLYMORPHIC_VERBS : CRUD_VERBS; return new Set(intersectExpose(fixed, resolveExpose(obj, expose))); } diff --git a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts index e43cb3568..342d9544b 100644 --- a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts +++ b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts @@ -1,5 +1,4 @@ import type { Generator } from "./generator.js"; -import { tphDiscriminatorBase } from "./tph.js"; import { CRUD_VERBS, resolveExpose, type CrudVerb, type ExposeOption, type RoutesExpose } from "./routes-expose.js"; /** @@ -102,14 +101,11 @@ function surfaceExpose(generators: readonly Generator[]): ExposeOption | undefin .filter((m): m is NonNullable => m !== undefined); if (mounts.length === 0 || mounts.some((m) => m.expose === undefined && m.filter === undefined)) return undefined; return (entity) => { - // A TPH subtype has no routes file of its own: its mounts live in the base's file, so the - // base is the entity the generator's `filter` and `expose` were applied to. - const mounted = tphDiscriminatorBase(entity) ?? entity; const union = new Set(); for (const m of mounts) { // An entity the generator's own `filter` rejects is mounted by that generator nowhere. - if (m.filter !== undefined && !m.filter(mounted)) continue; - for (const v of resolveExpose(mounted, m.expose) ?? CRUD_VERBS) union.add(v); + if (m.filter !== undefined && !m.filter(entity)) continue; + for (const v of resolveExpose(entity, m.expose) ?? CRUD_VERBS) union.add(v); } return CRUD_VERBS.filter((v) => union.has(v)); }; diff --git a/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts index 6d4cf5375..4880c126a 100644 --- a/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts +++ b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts @@ -144,6 +144,53 @@ describe("a routes generator's own filter removes the entities it does not mount }); }); +describe("a routes generator's filter also removes a read-only surface it does not mount", () => { + async function loadWithProjection() { + const res = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ + "metadata.root": { + package: "acme::shop", + children: [ + ENTITY("Product", "products"), + { + "object.projection": { + name: "ProductSummary", + children: [ + { "source.rdb": { "@kind": "view", "@view": "v_product_summary" } }, + { "field.long": { name: "id", extends: "Product.id" } }, + { "field.string": { name: "title", extends: "Product.title" } }, + { "identity.primary": { name: "pk", extends: "Product.id" } }, + ], + }, + }, + ], + }, + }), + { id: "meta.json", format: "json" }, + ), + ]); + expect(res.errors).toEqual([]); + return res.root; + } + + test("an unfiltered projection keeps GET list and GET by id; a filtered one is documented with no routes, Fastify and Hono", async () => { + const root = await loadWithProjection(); + const docs = (expose: ReturnType) => + buildApiModel(root, { loadedRoot: root, ...(expose !== undefined && { expose }) }); + + const open = docs(undefined); + expect(sigs(open, "ProductSummary")).toHaveLength(2); + + const fastify = docs(runRoutesExpose([routesFile({ filter: (e) => e.name !== "ProductSummary" })])); + expect(sigs(fastify, "ProductSummary")).toEqual([]); + expect(sigs(fastify, "Product")).toHaveLength(6); + + const hono = docs(runRoutesExpose([routesFileHono({ filter: (e) => e.name !== "ProductSummary" })])); + expect(sigs(hono, "ProductSummary", "rest-hono")).toEqual([]); + }); +}); + describe("through meta gen: the written api pages list only the mounted verbs", () => { async function pages(generators: Parameters[0]["generators"]) { const root = await loadRoot(); From 8ec32b8e21f1926fd54e102063bc347e971c3641 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 15:11:31 -0400 Subject: [PATCH 3/5] no-mistakes(review): Read-only API docs follow filter, ignore expose --- .../codegen-ts/src/generators/api-model.ts | 15 ++++++---- .../packages/codegen-ts/src/routes-expose.ts | 7 ++++- .../packages/codegen-ts/src/ui-tier-gate.ts | 29 ++++++++++++------- .../codegen-ts/test/api-docs-expose.test.ts | 15 ++++++++++ 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/generators/api-model.ts b/server/typescript/packages/codegen-ts/src/generators/api-model.ts index d0034168e..8cda79f56 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts @@ -469,10 +469,12 @@ function buildEntityUnit( // object gets routes. (Its `filter` option can narrow that further; this builder // reads the model, not the wired generator set, and cannot see it. See the module // header.) - symbols.push(...restSymbols(obj, layout, root, apiPrefix, expose.rest)); + if (expose.restMounted?.(obj) !== false) symbols.push(...restSymbols(obj, layout, root, apiPrefix, expose.rest)); // The OPT-IN Hono variant mounts the SAME CRUD verbs under the same rules — // documented only when the adopter wired it. - if (includeHono) symbols.push(...restHonoSymbols(obj, layout, apiPrefix, expose.restHono)); + if (includeHono && expose.restHonoMounted?.(obj) !== false) { + symbols.push(...restHonoSymbols(obj, layout, apiPrefix, expose.restHono)); + } } // --- relation: the drizzle relations() export, when the resolver files entries @@ -726,16 +728,17 @@ function restSymbols( /** * The CRUD verbs the routes generator mounts for `obj`, as `routes-file.ts` decides them. * - * A read-only surface (a projection or a report) and a TPH discriminator base are read-only by - * construction: they mount `list` and `get`, and an author's `expose` INTERSECTS with that set - * (an empty result, as a routes generator's `filter` yields, mounts nothing). Any other entity mounts all five, narrowed by `expose`: the + * A read-only surface (a projection or a report) mounts `list` and `get` and `expose` cannot + * narrow it. A TPH discriminator base is read-only by construction and an author's `expose` + * INTERSECTS with that set. Any other entity mounts all five, narrowed by `expose`: the * option the run reads off the configured routes generator, so a page never lists a * verb the adopter did not mount (and overrides by hand). */ function mountedVerbs( obj: MetaObject, tphBase: boolean, expose: ExposeOption | undefined, ): ReadonlySet { - const fixed = isReadOnlySurface(obj) || tphBase ? TPH_POLYMORPHIC_VERBS : CRUD_VERBS; + if (isReadOnlySurface(obj)) return new Set(["list", "get"]); + const fixed = tphBase ? TPH_POLYMORPHIC_VERBS : CRUD_VERBS; return new Set(intersectExpose(fixed, resolveExpose(obj, expose))); } diff --git a/server/typescript/packages/codegen-ts/src/routes-expose.ts b/server/typescript/packages/codegen-ts/src/routes-expose.ts index 04e5e50aa..8c89f9511 100644 --- a/server/typescript/packages/codegen-ts/src/routes-expose.ts +++ b/server/typescript/packages/codegen-ts/src/routes-expose.ts @@ -50,11 +50,16 @@ export type ExposeOption = /** * The `expose` option of each routes SURFACE, as the api reference documents them: the Fastify * routes file and the opt-in Hono one are separate generators and each mounts its own verbs. - * A surface left out mounts every verb. + * A surface left out mounts every verb. `restMounted` / `restHonoMounted` say whether any routes + * generator on that surface accepts the entity at all (its `filter`): an entity none accepts has no + * routes, however read-only its surface is. They are separate from `expose` because a read-only + * surface ignores `expose` but not `filter`. */ export interface RoutesExpose { readonly rest?: ExposeOption; readonly restHono?: ExposeOption; + readonly restMounted?: (entity: MetaObject) => boolean; + readonly restHonoMounted?: (entity: MetaObject) => boolean; } /** Resolve the option for one entity. `undefined` means "mount all five". */ diff --git a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts index 342d9544b..52658d7b0 100644 --- a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts +++ b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts @@ -1,3 +1,4 @@ +import type { MetaObject } from "@metaobjectsdev/metadata"; import type { Generator } from "./generator.js"; import { CRUD_VERBS, resolveExpose, type CrudVerb, type ExposeOption, type RoutesExpose } from "./routes-expose.js"; @@ -92,22 +93,30 @@ export function runRoutesExpose(generators: readonly Generator[]): RoutesExpose const rest = surfaceExpose(generators.filter((g) => g.emitsHonoRoutes !== true)); const restHono = surfaceExpose(generators.filter((g) => g.emitsHonoRoutes === true)); if (rest === undefined && restHono === undefined) return undefined; - return { ...(rest !== undefined && { rest }), ...(restHono !== undefined && { restHono }) }; + return { + ...(rest !== undefined && { rest: rest.expose, restMounted: rest.mounted }), + ...(restHono !== undefined && { restHono: restHono.expose, restHonoMounted: restHono.mounted }), + }; } -function surfaceExpose(generators: readonly Generator[]): ExposeOption | undefined { +function surfaceExpose( + generators: readonly Generator[], +): { expose: ExposeOption; mounted: (entity: MetaObject) => boolean } | undefined { const mounts = generators .map((g) => g.mountsRoutes) .filter((m): m is NonNullable => m !== undefined); if (mounts.length === 0 || mounts.some((m) => m.expose === undefined && m.filter === undefined)) return undefined; - return (entity) => { - const union = new Set(); - for (const m of mounts) { - // An entity the generator's own `filter` rejects is mounted by that generator nowhere. - if (m.filter !== undefined && !m.filter(entity)) continue; - for (const v of resolveExpose(entity, m.expose) ?? CRUD_VERBS) union.add(v); - } - return CRUD_VERBS.filter((v) => union.has(v)); + const accepts = (m: (typeof mounts)[number], entity: MetaObject): boolean => m.filter === undefined || m.filter(entity); + return { + mounted: (entity) => mounts.some((m) => accepts(m, entity)), + expose: (entity) => { + const union = new Set(); + for (const m of mounts) { + if (!accepts(m, entity)) continue; + for (const v of resolveExpose(entity, m.expose) ?? CRUD_VERBS) union.add(v); + } + return CRUD_VERBS.filter((v) => union.has(v)); + }, }; } diff --git a/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts index 4880c126a..fcf42328d 100644 --- a/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts +++ b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts @@ -189,6 +189,21 @@ describe("a routes generator's filter also removes a read-only surface it does n const hono = docs(runRoutesExpose([routesFileHono({ filter: (e) => e.name !== "ProductSummary" })])); expect(sigs(hono, "ProductSummary", "rest-hono")).toEqual([]); }); + + test("expose cannot narrow a projection: the routes generators mount GET list and GET by id regardless", async () => { + const root = await loadWithProjection(); + for (const gens of [ + [routesFile({ expose: ["list"] })], + [routesFile({ expose: () => [] })], + ]) { + const expose = runRoutesExpose(gens); + const model = buildApiModel(root, { loadedRoot: root, ...(expose !== undefined && { expose }) }); + expect(sigs(model, "ProductSummary")).toHaveLength(2); + } + const hono = runRoutesExpose([routesFileHono({ expose: ["list"] })]); + const honoModel = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, ...(hono !== undefined && { expose: hono }) }); + expect(sigs(honoModel, "ProductSummary", "rest-hono")).toHaveLength(2); + }); }); describe("through meta gen: the written api pages list only the mounted verbs", () => { From 43dcdf6ab2dfe2e83d0b5987a754b683be29d7aa Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 16:36:42 -0400 Subject: [PATCH 4/5] no-mistakes(test): Sync stale example routes.ts owned copies --- examples/advanced-modeling/codegen/generators/routes.ts | 5 ++++- examples/showcase/codegen/generators/routes.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 69e2521b8..934358a69 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -735,7 +735,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator name: "routes-file", // Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts, so an // `expose` you pass here is honoured there. Keep this line if you change what the file mounts. - mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, + mountsRoutes: { + ...(opts?.expose !== undefined && { expose: opts.expose }), + ...(opts?.filter !== undefined && { filter: opts.filter }), + }, // TPH subtypes get no standalone routes file (their routes live in the discriminator // base's); AND-composed with your filter. // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index 69e2521b8..934358a69 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -735,7 +735,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator name: "routes-file", // Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts, so an // `expose` you pass here is honoured there. Keep this line if you change what the file mounts. - mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, + mountsRoutes: { + ...(opts?.expose !== undefined && { expose: opts.expose }), + ...(opts?.filter !== undefined && { filter: opts.filter }), + }, // TPH subtypes get no standalone routes file (their routes live in the discriminator // base's); AND-composed with your filter. // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't From e086e7f569ff8e80fd32ccc029aaa47cacff9827 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 17:08:46 -0400 Subject: [PATCH 5/5] no-mistakes(document): Updated docs/features/own-your-codegen.md: apiDocsFile now also honours routesFile filter. CHANGELOG Unreleased entry already covered the change. No other stale docs found. --- docs/features/own-your-codegen.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 77922d8ac..33fcf4e91 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -741,7 +741,8 @@ rather than off this page; its own JSDoc carries the recipe with the right one.) Both are executed as tests (`runtime-ts/test/route-auth-seam.test.ts`) rather than asserted in prose. For mounting *fewer* endpoints, narrow the generator: `routesFile({ expose: ["list", "get"] })` (`apiDocsFile()` reads the option per routes surface, Fastify -and Hono apart, and documents only the verbs that surface mounts; a copy ejected before 1.1.0 +and Hono apart, and documents only the verbs that surface mounts; `routesFile({ filter })` removes +the entities it rejects from the pages altogether; a copy ejected before 1.1.0 needs `meta eject routes --force` to be honoured). To register every entity's routes in one call, pass `registerAll: true`: the generator also emits `routes.index.ts` (or `routes.index.hono.ts`) with a `registerAllRoutes(...)`, so adding an entity needs no edit