From 12503a54b06c8888fe3855aca22bbfbe579fd5d8 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 07:44:03 -0400 Subject: [PATCH 1/6] feat(docs): meta docs honours routesFile and routesFileHono expose option The API pages listed all five verbs for an entity whose routes file mounts fewer. Routes generators now carry the option in a mountsRoutes marker and meta docs documents only the verbs mounted. Output without expose is byte-identical. --- CHANGELOG.md | 10 ++ docs/features/own-your-codegen.md | 4 +- .../codegen/generators/routes.ts | 3 + .../showcase/codegen/generators/routes.ts | 3 + .../packages/cli/src/commands/docs.ts | 6 +- .../packages/cli/test/docs-expose.test.ts | 63 +++++++++++++ .../packages/codegen-ts/src/generator.ts | 7 ++ .../src/generators/api-docs-file.ts | 3 + .../codegen-ts/src/generators/api-model.ts | 77 +++++++++++----- .../src/generators/routes-file-hono.ts | 2 + .../codegen-ts/src/generators/routes-file.ts | 2 + .../packages/codegen-ts/src/index.ts | 2 +- .../codegen-ts/src/metaobjects-config.ts | 4 + .../codegen-ts/src/reference/routes-hono.ts | 2 + .../codegen-ts/src/reference/routes.ts | 3 + .../packages/codegen-ts/src/runner.ts | 10 +- .../packages/codegen-ts/src/ui-tier-gate.ts | 28 ++++++ .../codegen-ts/test/api-docs-expose.test.ts | 91 +++++++++++++++++++ .../test/metaobjects-config.test.ts | 2 +- .../packages/test-generators/src/routes.ts | 3 + 20 files changed, 298 insertions(+), 27 deletions(-) create mode 100644 server/typescript/packages/cli/test/docs-expose.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 514b4743a..9fca16e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,16 @@ here.** none shares the gap. - The `hand-rolled-aggregate` advisory now names `object.report` for grouped, served or reported figures, and says an `object.projection` with `origin.aggregate` still fits a per-row figure. +- **`meta docs` honours `routesFile({ expose })` and `routesFileHono({ expose })`.** The API + pages listed all five verbs for an entity whose routes file mounts fewer (`DELETE` for an entity + exposed as `["list", "get"]`), so the adopter overrode the pages by hand. The generators now + carry the option in a `mountsRoutes` marker, `meta docs` reads it off the resolved suite + (`runRoutesExpose`) and documents only the verbs mounted: `update` lists both `PATCH` and `PUT`, + a read-only projection or report still lists `GET`, and a TPH base is narrowed from its + read-only pair. Output without `expose` is byte-identical. **An owned (ejected) copy needs the + marker to be honoured:** run `meta eject routes --force` (and `routes-hono --force`), or add + `mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}` beside the generator's + `name:`. TypeScript only: no other port has an `expose` option. ## [1.1.0] — 2026-10-10 diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 2938e9472..91a167fb8 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -271,7 +271,9 @@ 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"] })`. To register every entity's routes in one call, +`routesFile({ expose: ["list", "get"] })` (`meta docs` reads the option off the configured +generator and lists only the verbs mounted; 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 to your host file. What neither can express is a row-ownership diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 425fcfcdb..2c6886786 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -742,6 +742,9 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list 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 } : {}, // 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 425fcfcdb..2c6886786 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -742,6 +742,9 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list 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 } : {}, // 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/cli/src/commands/docs.ts b/server/typescript/packages/cli/src/commands/docs.ts index 32920b89b..59f4490b4 100644 --- a/server/typescript/packages/cli/src/commands/docs.ts +++ b/server/typescript/packages/cli/src/commands/docs.ts @@ -44,7 +44,7 @@ import { DEFAULT_COLUMN_NAMING_STRATEGY, renderCoreMetamodelDocs, } from "@metaobjectsdev/metadata"; -import { dbEmittingObjects, DEFAULT_DIALECT, DEFAULT_DOCS_DIR, missingDialectMessage, resolveGenerators, runEmitsHonoRoutes, runEmitsUiTier } from "@metaobjectsdev/codegen-ts"; +import { dbEmittingObjects, DEFAULT_DIALECT, DEFAULT_DOCS_DIR, missingDialectMessage, resolveGenerators, runEmitsHonoRoutes, runEmitsUiTier, runRoutesExpose } from "@metaobjectsdev/codegen-ts"; import type { MetaDataTypeProvider, MetaRoot } from "@metaobjectsdev/metadata"; import { generateSite, SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "@metaobjectsdev/docs-site"; // The `agent` schema surface takes the physical schema as an ARGUMENT with its resolvers @@ -579,6 +579,7 @@ export async function docsCommand( suite = (loadedConfig?.generators ?? []).filter((g): g is Generator => typeof g !== "string"); } + const routesExpose = loadedConfig?.routesExpose ?? runRoutesExpose(suite); const ctx: GenContext = { entities: root.objects(), loadedRoot: root, @@ -594,6 +595,9 @@ export async function docsCommand( // does (a generator opts in via emitsHonoRoutes), so `meta docs` auto-detects // routesFileHono() rather than relying on a field users don't normally set. includeHonoRoutes: loadedConfig?.includeHonoRoutes ?? runEmitsHonoRoutes(suite), + // The API pages list the verbs the routes generators mount. `routesFile({ expose })` is a + // generator option, so it is read off the resolved suite, as the Hono marker above is. + ...(routesExpose !== undefined && { routesExpose }), // `agent/ui.md` describes forms, grids and the endpoints their hooks call. Whether // any of that is emitted is a GENERATOR fact — the page's own predicate is // metadata-only and answered "could a UI be generated?", so a project with diff --git a/server/typescript/packages/cli/test/docs-expose.test.ts b/server/typescript/packages/cli/test/docs-expose.test.ts new file mode 100644 index 000000000..980c1f2bf --- /dev/null +++ b/server/typescript/packages/cli/test/docs-expose.test.ts @@ -0,0 +1,63 @@ +// `meta docs` honours the `expose` option of the project's routes generator: the API pages list +// the verbs the routes file mounts and no others (adopter report). +import { describe, test, expect, afterAll } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { docsCommand } from "../src/commands/docs.js"; + +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); + +const META = { + "metadata.root": { + package: "acme::shop", + children: [{ + "object.entity": { + name: "AuditEntry", + children: [ + { "source.rdb": { "@table": "audit_entries" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "note" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + ], + }, + }], + }, +}; + +// A routes generator as an owned `routesFile({ expose })` presents itself to the docs door: the +// `mountsRoutes` marker carries the option. +const config = (marker: string) => [ + `import { defineConfig } from "@metaobjectsdev/codegen-ts";`, + `const routes = { name: "routes-file", ${marker} generate: () => [] };`, + `export default defineConfig({ outDir: "out", dialect: "sqlite", generators: [routes] });`, +].join("\n"); + +async function docsFor(marker: string): Promise { + const root = await mkdtemp(join(tmpdir(), "meta-docs-expose-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.json"), JSON.stringify(META), "utf8"); + await writeFile(join(root, "metaobjects.config.ts"), config(marker), "utf8"); + const out = join(root, "docs-out"); + expect(await docsCommand([root, "--out", out], root)).toBe(0); + return readFile(join(out, "api", "acme", "shop", "AuditEntry.md"), "utf8").catch(() => + readFile(join(out, "api", "AuditEntry.md"), "utf8")); +} + +describe("meta docs and routesFile({ expose })", () => { + test("a routes generator that exposes list and get documents no write verb", async () => { + const page = await docsFor(`mountsRoutes: { expose: ["list", "get"] },`); + expect(page).toContain("GET /audit_entries"); + expect(page).not.toMatch(/DELETE \/audit_entries/); + expect(page).not.toMatch(/POST \/audit_entries/); + expect(page).not.toMatch(/PATCH \/audit_entries/); + }); + + test("with no expose the page still lists every verb", async () => { + const page = await docsFor(`mountsRoutes: {},`); + expect(page).toContain("DELETE /audit_entries"); + expect(page).toContain("POST /audit_entries"); + }); +}); diff --git a/server/typescript/packages/codegen-ts/src/generator.ts b/server/typescript/packages/codegen-ts/src/generator.ts index f474db35c..0f9f165e3 100644 --- a/server/typescript/packages/codegen-ts/src/generator.ts +++ b/server/typescript/packages/codegen-ts/src/generator.ts @@ -1,3 +1,4 @@ +import type { ExposeOption } from "./routes-expose.js"; import type { MetaObject, MetaRoot, TypeRegistry } from "@metaobjectsdev/metadata"; import type { LibraryManifest } from "@metaobjectsdev/metadata/library"; import type { RenderContext } from "./render-context.js"; @@ -114,6 +115,12 @@ export interface Generator { * that Hono routes are actually being emitted and document them — rather than * silently omitting the Hono CRUD registrars whenever the variant is wired. */ emitsHonoRoutes?: boolean; + /** Marks a generator that mounts the CRUD routes (`routesFile`, `routesFileHono`) and carries + * the `expose` option it was built with. `meta docs` aggregates it across the suite + * (`runRoutesExpose`) so the API docs list the verbs the routes actually mount, 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 }; /** §A6 — marks the generator that emits the Names artifact. The runner * aggregates this across the suite into ResolvedGenConfig.includeNames, which the * entity generator reads to decide whether it may reference those constants. diff --git a/server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts b/server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts index dbe9f585d..163e33b69 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts @@ -99,6 +99,9 @@ export const apiDocsFile = function apiDocsFile(opts?: ApiDocsFileOpts): Generat loadedRoot: ctx.loadedRoot, outputLayout: layout, includeHonoRoutes: ctx.config.includeHonoRoutes ?? false, + // The verbs the routes generators actually mount (`expose`), so a page never lists a + // route the project does not serve. + ...(ctx.config.routesExpose !== undefined && { expose: ctx.config.routesExpose }), // The project's prefix, because every route is mounted inside it. Without // this the page documents `/authors` for routes served at `/api/authors`. apiPrefix: ctx.renderContext?.apiPrefix ?? "", 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 57b820121..566a398ba 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts @@ -102,6 +102,7 @@ // very models this builder was reading it from. The read is gone — see // constants.ts.) +import { CRUD_VERBS, TPH_POLYMORPHIC_VERBS, intersectExpose, resolveExpose, type CrudVerb, type ExposeOption } from "../routes-expose.js"; import { type MetaRoot, type MetaObject, @@ -293,6 +294,10 @@ export interface ApiModelContext { * short. Threaded explicitly rather than read off the RenderContext below, * which is a `{ pkMap }` shim and carries nothing else. Defaults to "". */ apiPrefix?: string; + /** The `expose` option the run's routes generators were built with (`runRoutesExpose`). + * The routes generator mounts only these verbs, so the documented endpoints are the same + * subset. Absent means every verb, which documents byte-identically to before. */ + expose?: ExposeOption; } // --------------------------------------------------------------------------- @@ -308,6 +313,7 @@ export function buildApiModel(root: MetaRoot, ctx: ApiModelContext): ApiModel { const apiPrefix = ctx.apiPrefix ?? ""; const relationMap = ctx.relationMap ?? buildRelationMap(root); const includeHono = ctx.includeHonoRoutes ?? false; + const expose = ctx.expose; const units: ApiUnitDoc[] = []; @@ -316,7 +322,7 @@ export function buildApiModel(root: MetaRoot, ctx: ApiModelContext): ApiModel { // every symbol documents is the derived one. `generatableObjects` is the same swap // `runGen` hands the generators, so the units are the objects code was emitted for. for (const obj of generatableObjects(root.objects(), root)) { - units.push(buildEntityUnit(obj, pkCtx, root, layout, relationMap, includeHono, apiPrefix)); + units.push(buildEntityUnit(obj, pkCtx, root, layout, relationMap, includeHono, apiPrefix, expose)); } for (const tmpl of templateOutputs(root)) { @@ -430,6 +436,7 @@ function buildEntityUnit( relationMap: RelationMap, includeHono: boolean, apiPrefix: string, + expose: ExposeOption | undefined, ): ApiUnitDoc { const name = obj.name; const symbols: ApiSymbol[] = []; @@ -460,10 +467,10 @@ 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)); + symbols.push(...restSymbols(obj, layout, root, apiPrefix, expose)); // 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)); + if (includeHono) symbols.push(...restHonoSymbols(obj, layout, apiPrefix, expose)); } // --- relation: the drizzle relations() export, when the resolver files entries @@ -667,10 +674,11 @@ function validationSymbols(obj: MetaObject, entityMod: string): ApiSymbol[] { */ function restSymbols( obj: MetaObject, layout: OutputLayout, root: MetaRoot, apiPrefix: string, + expose: ExposeOption | undefined, ): ApiSymbol[] { const name = obj.name; const path = servedPath(obj, apiPrefix); - const readOnly = isReadOnlySurface(obj) || isTphDiscriminatorBase(obj, root); + const mounted = mountedVerbs(obj, isTphDiscriminatorBase(obj, root), expose); // REST endpoints are not importable functions — to WIRE them an adopter // imports the entity's route registrar (`Routes`) from the routes @@ -688,27 +696,48 @@ function restSymbols( const ep = restEndpointFactory("rest", routesMod, registrar); - const symbols: ApiSymbol[] = [ - ep("GET", path, `List ${name} (supports filter/sort/paging query params).`, modelShape), - ]; + const symbols: ApiSymbol[] = []; + if (mounted.has("list")) { + symbols.push(ep("GET", path, `List ${name} (supports filter/sort/paging query params).`, modelShape)); + } // `/:id` is mounted only when a row can be addressed (`hasItemRoute`): not for a // keyless projection, and never for a report. - if (!lacksItemSurface(obj)) { + if (mounted.has("get") && !lacksItemSurface(obj)) { symbols.push(ep("GET", `${path}/:id`, `Fetch a single ${name} by id (404 when not found).`, modelShape)); } - - if (!readOnly) { + if (mounted.has("create")) { + symbols.push(ep("POST", path, `Create a ${name} (body validated by ${name}InsertSchema).`, createShape)); + } + if (mounted.has("update")) { symbols.push( - ep("POST", path, `Create a ${name} (body validated by ${name}InsertSchema).`, createShape), ep("PATCH", `${path}/:id`, `Partially update a ${name} by id (body validated by ${name}UpdateSchema).`, updateShape), ep("PUT", `${path}/:id`, `PUT alias of PATCH — the same handler and partial body (validated by ${name}UpdateSchema).`, updateShape), - ep("DELETE", `${path}/:id`, `Delete a ${name} by id.`), ); } + if (mounted.has("delete")) { + symbols.push(ep("DELETE", `${path}/:id`, `Delete a ${name} by id.`)); + } return symbols; } +/** + * 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 + * option a `meta docs` 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; + return new Set(intersectExpose(fixed, resolveExpose(obj, expose))); +} + /** Build a REST endpoint symbol factory bound to one route surface (kind + * route module + registrar). The Fastify and Hono REST builders share this so a * `METHOD /path` endpoint is shaped one way; only the surface-level kind/module/ @@ -883,11 +912,11 @@ function callableArgsRef(obj: MetaObject, root: MetaRoot): string | undefined { * only when the adopter opts into the Hono variant (includeHonoRoutes). */ function restHonoSymbols( - obj: MetaObject, layout: OutputLayout, apiPrefix: string, + obj: MetaObject, layout: OutputLayout, apiPrefix: string, expose: ExposeOption | undefined, ): ApiSymbol[] { const name = obj.name; const path = servedPath(obj, apiPrefix); - const readOnly = isReadOnlySurface(obj); + const mounted = mountedVerbs(obj, false, expose); const honoMod = entityModulePath(layout, obj, `${name}.routes.hono`); const registrar = `register${name}Routes`; @@ -898,22 +927,26 @@ function restHonoSymbols( const ep = restEndpointFactory("rest-hono", honoMod, registrar); - const symbols: ApiSymbol[] = [ - ep("GET", path, `[Hono] List ${name} (filter/sort/paging query params).`, modelShape), - ]; + const symbols: ApiSymbol[] = []; + if (mounted.has("list")) { + symbols.push(ep("GET", path, `[Hono] List ${name} (filter/sort/paging query params).`, modelShape)); + } // The same rule as the Fastify surface: no `/:id` without an addressable row. - if (!lacksItemSurface(obj)) { + if (mounted.has("get") && !lacksItemSurface(obj)) { symbols.push(ep("GET", `${path}/:id`, `[Hono] Fetch a single ${name} by id (404 when not found).`, modelShape)); } - - if (!readOnly) { + if (mounted.has("create")) { + symbols.push(ep("POST", path, `[Hono] Create a ${name} (body validated by ${name}InsertSchema).`, createShape)); + } + if (mounted.has("update")) { symbols.push( - ep("POST", path, `[Hono] Create a ${name} (body validated by ${name}InsertSchema).`, createShape), ep("PATCH", `${path}/:id`, `[Hono] Partially update a ${name} by id (body validated by ${name}UpdateSchema).`, updateShape), ep("PUT", `${path}/:id`, `[Hono] PUT alias of PATCH — the same handler and partial body (validated by ${name}UpdateSchema).`, updateShape), - ep("DELETE", `${path}/:id`, `[Hono] Delete a ${name} by id.`), ); } + if (mounted.has("delete")) { + symbols.push(ep("DELETE", `${path}/:id`, `[Hono] Delete a ${name} by id.`)); + } return symbols; } 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 412dc2a0e..91f07541b 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 @@ -62,6 +62,8 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts) servesReadApi(e) && userFilter(e); const generator: Generator = { name: "routes-file-hono", + // Read by `meta docs`: the API docs list the verbs this file mounts. + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // 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 df32a8f87..e8b14e8f5 100644 --- a/server/typescript/packages/codegen-ts/src/generators/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/routes-file.ts @@ -59,6 +59,8 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list the verbs this file mounts. + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // 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/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 552f44bdd..b8ac7c608 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -11,7 +11,7 @@ export { perEntity, perPackage, perModel, oncePerRun } from "./generator.js"; // The one aggregation of the UI-tier markers. Exported because `meta docs` is the // SECOND door onto `agent/ui.md` and must answer "does this run emit a UI tier?" // with the same function the gen runner uses, not a second derivation of it. -export { runEmitsUiTier, runEmitsHonoRoutes, UI_TIER_GENERATOR_NAMES } from "./ui-tier-gate.js"; +export { runEmitsUiTier, runEmitsHonoRoutes, runRoutesExpose, UI_TIER_GENERATOR_NAMES } from "./ui-tier-gate.js"; // SP-1 declarative Mustache template-codegen — scope walks, neutral data dict, // output-pattern, and the JSON template-spec the CLI ports reuse. diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 57a0a8761..bb2d8ddb1 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -1,4 +1,5 @@ import { DEFAULT_COLUMN_NAMING_STRATEGY, type ColumnNamingStrategy, type MetaDataTypeProvider } from "@metaobjectsdev/metadata"; +import type { ExposeOption } from "./routes-expose.js"; import type { Generator } from "./generator.js"; import type { ExtStyle } from "./render-context.js"; import type { OutputLayout, ResolvedTarget } from "./import-path.js"; @@ -60,6 +61,9 @@ export interface ResolvedGenConfig { * api-docs reads this to AUTO-DETECT whether to document the Hono CRUD surface * (it otherwise mirrors the default Fastify-only suite). Undefined ⇒ false. */ includeHonoRoutes?: boolean; + /** The `expose` option the run's routes generators were built with (`runRoutesExpose`), for + * `meta docs` to document only the verbs they mount. Absent documents all five. */ + routesExpose?: ExposeOption; /** §A6 — whether the OPT-IN names generator (namesFile) is active in the run, * aggregated by the runner from the suite's `emitsNames` markers. A generator that * wants to REFERENCE `Names` reads this to know the artifact will exist: 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 ef12b20c8..2d83cebe8 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts @@ -282,6 +282,8 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts) // 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, + // Read by `meta docs`: the API docs list the verbs this file mounts (see routes-file). + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // 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 425fcfcdb..2c6886786 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -742,6 +742,9 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list 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 } : {}, // 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 93856bb9a..cb5556833 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, warnUnmarkedUiGenerators } from "./ui-tier-gate.js"; +import { runEmitsHonoRoutes, runEmitsUiTier, runRoutesExpose, warnUnmarkedUiGenerators } from "./ui-tier-gate.js"; import { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -593,6 +593,12 @@ export async function runGen(opts: RunGenOpts): Promise { // hatch honoured by one of two doors is the defect these flags exist to close. const includeHonoRoutes = config.includeHonoRoutes ?? runEmitsHonoRoutes(config.generators); + // Same auto-detection for the routes `expose` option, honoured at BOTH doors: `meta docs` + // aggregates it itself, and without it here an `api-docs` PAGE generated by `meta gen` + // would document all five verbs despite `routesFile({ expose })` mounting fewer — the + // same one-door lie the includeHonoRoutes fix above closes. Explicit config key wins. + const routesExpose = config.routesExpose ?? runRoutesExpose(config.generators); + // Same auto-detection for the CLIENT UI tier. `agent/ui.md` describes forms, grids // and the endpoints their hooks call; whether any of that is emitted is a generator // fact, and the page's own gate could only see metadata. Run-scoped like @@ -749,6 +755,8 @@ export async function runGen(opts: RunGenOpts): Promise { includeHonoRoutes, includeNames: namesTargets.has(selfTarget.name), includeUiTier, + // Absent (not undefined) when the run mounts every verb — `exactOptionalPropertyTypes`. + ...(routesExpose !== undefined && { routesExpose }), }, renderContext, ...(projectRoot !== undefined && { projectRoot }), 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 309c469dc..118c662b3 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 { CRUD_VERBS, resolveExpose, type CrudVerb, type ExposeOption } from "./routes-expose.js"; /** * Generators that emit a CLIENT UI artifact — a form, a hook, a grid or its columns. @@ -77,3 +78,30 @@ export function warnUnmarkedUiGenerators( export function runEmitsHonoRoutes(generators: readonly Generator[]): boolean { return generators.some((g) => g.emitsHonoRoutes === true); } + +/** + * The `expose` option the run's routes generators were built with, for `meta docs` to apply + * (a docs page that lists DELETE for a route the adopter does not mount is a lie). + * + * Undefined when no generator mounts routes, or any of them mounts every verb: documenting + * all five is then right. Otherwise a per-entity function returning the UNION of what the + * generators mount, since the Fastify and Hono surfaces share one documented verb list. + */ +export function runRoutesExpose(generators: readonly Generator[]): ExposeOption | undefined { + // Project the markers once, so the body works over "the routes-mounting generators' + // options" — a collection, not a filtered list re-projected through `!` in a closure. + const mounts = generators + .map((g) => g.mountsRoutes) + .filter((m): m is NonNullable => m !== undefined); + if (mounts.length === 0) return undefined; + if (mounts.some((m) => m.expose === undefined)) return undefined; + return (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); + } + 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 new file mode 100644 index 000000000..ec26ea6ef --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts @@ -0,0 +1,91 @@ +// `meta docs` honours the routes generator's `expose` option (adopter report): a page listed +// DELETE for an entity whose routes file does not mount it, so the adopter overrode the docs by +// hand. The documented endpoints are the verbs `routes-file.ts` mounts. + +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +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 type { Generator } from "../src/generator.js"; + +const ENTITY = (name: string, table: string) => ({ + "object.entity": { + name, + children: [ + { "source.rdb": { "@table": table } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "title" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + ], + }, +}); + +async function loadRoot() { + const res = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ "metadata.root": { package: "acme::shop", children: [ENTITY("Product", "products"), ENTITY("AuditEntry", "audit_entries")] } }), + { id: "meta.json", format: "json" }, + ), + ]); + expect(res.errors).toEqual([]); + return res.root; +} + +const sigs = (model: ReturnType, unit: string, kind = "rest") => + model.units.find((u) => u.node === unit)!.symbols.filter((s) => s.kind === kind).map((s) => s.signature).sort(); + +describe("api docs follow the routes generator's expose option", () => { + test("no expose documents all five verbs, as before", async () => { + const root = await loadRoot(); + expect(sigs(buildApiModel(root, { loadedRoot: root }), "Product")).toEqual([ + "DELETE /products/:id", "GET /products", "GET /products/:id", "PATCH /products/:id", "POST /products", "PUT /products/:id", + ]); + }); + + test("without expose the model is identical to exposing every verb, Fastify and Hono", async () => { + const root = await loadRoot(); + const all = ["list", "get", "create", "update", "delete"] as const; + const ctx = { loadedRoot: root, includeHonoRoutes: true }; + expect(JSON.stringify(buildApiModel(root, ctx))).toBe(JSON.stringify(buildApiModel(root, { ...ctx, expose: all }))); + }); + + test("a per-entity expose lists only the verbs mounted", async () => { + const root = await loadRoot(); + const expose = (e: { name: string }) => (e.name === "AuditEntry" ? (["list", "get"] as const) : undefined); + const model = buildApiModel(root, { loadedRoot: root, expose }); + expect(sigs(model, "AuditEntry")).toEqual(["GET /audit_entries", "GET /audit_entries/:id"]); + expect(sigs(model, "Product")).toHaveLength(6); + }); + + test("update mounts both PATCH and PUT; delete and create are separate", async () => { + const root = await loadRoot(); + const model = buildApiModel(root, { loadedRoot: root, expose: ["list", "update"] }); + expect(sigs(model, "Product")).toEqual(["GET /products", "PATCH /products/:id", "PUT /products/:id"]); + }); + + test("the opt-in Hono surface follows the same option", async () => { + const root = await loadRoot(); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, expose: ["list", "get", "create"] }); + expect(sigs(model, "Product", "rest-hono")).toEqual(["GET /products", "GET /products/:id", "POST /products"]); + }); +}); + +describe("runRoutesExpose reads the option off the configured generators", () => { + test("no routes generator, or one that mounts everything, leaves docs unchanged", () => { + expect(runRoutesExpose([])).toBeUndefined(); + expect(runRoutesExpose([routesFile()])).toBeUndefined(); + expect(runRoutesExpose([routesFile({ expose: ["list"] }), routesFileHono()])).toBeUndefined(); + }); + + test("a generator with expose yields it; two generators union their verbs", async () => { + const root = await loadRoot(); + const product = root.objects().find((o) => o.name === "Product")!; + expect(runRoutesExpose([routesFile({ expose: ["list", "get"] })] as Generator[])?.constructor).toBe(Function); + const one = runRoutesExpose([routesFile({ expose: ["list", "get"] })]) as (e: typeof product) => readonly string[] | undefined; + expect(one(product)).toEqual(["list", "get"]); + const both = runRoutesExpose([routesFile({ expose: ["list"] }), routesFileHono({ expose: ["get", "delete"] })]) as (e: typeof product) => readonly string[] | undefined; + expect(both(product)).toEqual(["list", "get", "delete"]); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts index a213f14aa..e46c1f0b0 100644 --- a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts +++ b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts @@ -69,7 +69,7 @@ describe("defineConfig", () => { test("type-level: MetaobjectsGenConfig embeds ResolvedGenConfig's non-DB fields exactly (#194 — dbImport/dialect are OPTIONAL here, filled by the runner)", () => { // Everything except dbImport/dialect matches ResolvedGenConfig field-for-field. - expectTypeOf>() + expectTypeOf>() .toEqualTypeOf>(); // extStyle is OPTIONAL too: the config `meta init` scaffolds omits it and must still // typecheck; the runner fills DEFAULT_EXT_STYLE. diff --git a/server/typescript/packages/test-generators/src/routes.ts b/server/typescript/packages/test-generators/src/routes.ts index 425fcfcdb..2c6886786 100644 --- a/server/typescript/packages/test-generators/src/routes.ts +++ b/server/typescript/packages/test-generators/src/routes.ts @@ -742,6 +742,9 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list 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 } : {}, // 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 68d999ccb6b1f11deaccce8330fe80dfb07ec78c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 07:47:13 -0400 Subject: [PATCH 2/6] no-mistakes(review): Remove routesExpose config override; derive from generators only Assisted-by: no-mistakes:claude:claude-sonnet-5-5 --- server/typescript/packages/cli/src/commands/docs.ts | 2 +- .../typescript/packages/codegen-ts/src/metaobjects-config.ts | 2 +- server/typescript/packages/codegen-ts/src/runner.ts | 5 +++-- .../packages/codegen-ts/test/metaobjects-config.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/server/typescript/packages/cli/src/commands/docs.ts b/server/typescript/packages/cli/src/commands/docs.ts index 59f4490b4..a9eed1ef9 100644 --- a/server/typescript/packages/cli/src/commands/docs.ts +++ b/server/typescript/packages/cli/src/commands/docs.ts @@ -579,7 +579,7 @@ export async function docsCommand( suite = (loadedConfig?.generators ?? []).filter((g): g is Generator => typeof g !== "string"); } - const routesExpose = loadedConfig?.routesExpose ?? runRoutesExpose(suite); + const routesExpose = runRoutesExpose(suite); const ctx: GenContext = { entities: root.objects(), loadedRoot: root, diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index bb2d8ddb1..784d64706 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -104,7 +104,7 @@ export const DEFAULT_EXT_STYLE: ExtStyle = "js"; * fills inert defaults when they are absent AND the model emits no DB artifacts, and * throws a clear error when they are absent but the model DOES emit DB code (#194). */ -export interface MetaobjectsGenConfig extends Omit { +export interface MetaobjectsGenConfig extends Omit { dbImport?: string; dialect?: Dialect; /** Import-specifier suffix in generated code. Defaults to `"js"` (`./x.js`), which is diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index cb5556833..52dd4a955 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -596,8 +596,9 @@ export async function runGen(opts: RunGenOpts): Promise { // Same auto-detection for the routes `expose` option, honoured at BOTH doors: `meta docs` // aggregates it itself, and without it here an `api-docs` PAGE generated by `meta gen` // would document all five verbs despite `routesFile({ expose })` mounting fewer — the - // same one-door lie the includeHonoRoutes fix above closes. Explicit config key wins. - const routesExpose = config.routesExpose ?? runRoutesExpose(config.generators); + // same one-door lie the includeHonoRoutes fix above closes. Derived only from the + // generators, never a config key: a second source could disagree with what is mounted. + const routesExpose = runRoutesExpose(config.generators); // Same auto-detection for the CLIENT UI tier. `agent/ui.md` describes forms, grids // and the endpoints their hooks call; whether any of that is emitted is a generator diff --git a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts index e46c1f0b0..954a24b1f 100644 --- a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts +++ b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts @@ -69,8 +69,8 @@ describe("defineConfig", () => { test("type-level: MetaobjectsGenConfig embeds ResolvedGenConfig's non-DB fields exactly (#194 — dbImport/dialect are OPTIONAL here, filled by the runner)", () => { // Everything except dbImport/dialect matches ResolvedGenConfig field-for-field. - expectTypeOf>() - .toEqualTypeOf>(); + expectTypeOf>() + .toEqualTypeOf>(); // extStyle is OPTIONAL too: the config `meta init` scaffolds omits it and must still // typecheck; the runner fills DEFAULT_EXT_STYLE. expectTypeOf().toEqualTypeOf(); From 9f606135cbed14f51322a519d976d149c8d1edce Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 09:18:11 -0400 Subject: [PATCH 3/6] no-mistakes(test): Remove unreachable routesExpose threading from meta gen runner Assisted-by: no-mistakes:claude:claude-sonnet-5-5 --- server/typescript/packages/codegen-ts/src/runner.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index 52dd4a955..93856bb9a 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, warnUnmarkedUiGenerators } from "./ui-tier-gate.js"; import { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -593,13 +593,6 @@ export async function runGen(opts: RunGenOpts): Promise { // hatch honoured by one of two doors is the defect these flags exist to close. const includeHonoRoutes = config.includeHonoRoutes ?? runEmitsHonoRoutes(config.generators); - // Same auto-detection for the routes `expose` option, honoured at BOTH doors: `meta docs` - // aggregates it itself, and without it here an `api-docs` PAGE generated by `meta gen` - // would document all five verbs despite `routesFile({ expose })` mounting fewer — the - // same one-door lie the includeHonoRoutes fix above closes. Derived only from the - // generators, never a config key: a second source could disagree with what is mounted. - const routesExpose = runRoutesExpose(config.generators); - // Same auto-detection for the CLIENT UI tier. `agent/ui.md` describes forms, grids // and the endpoints their hooks call; whether any of that is emitted is a generator // fact, and the page's own gate could only see metadata. Run-scoped like @@ -756,8 +749,6 @@ export async function runGen(opts: RunGenOpts): Promise { includeHonoRoutes, includeNames: namesTargets.has(selfTarget.name), includeUiTier, - // Absent (not undefined) when the run mounts every verb — `exactOptionalPropertyTypes`. - ...(routesExpose !== undefined && { routesExpose }), }, renderContext, ...(projectRoot !== undefined && { projectRoot }), From 8a5afd65060bdfa52748726bdea4b61ee20e74b6 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 09:41:22 -0400 Subject: [PATCH 4/6] no-mistakes(document): Document routes expose option in meta docs Assisted-by: no-mistakes:claude:claude-haiku-5-5 --- 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 91a167fb8..3a7f09f34 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -272,7 +272,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"] })` (`meta docs` reads the option off the configured -generator and lists only the verbs mounted; a copy ejected before 1.1.0 needs +generators and lists only the verbs mounted, the union across them, or all five if any +generator mounts every verb; 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 From b1e2130607f486f7403d6b9875bda39fa6beca09 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 09:50:18 -0400 Subject: [PATCH 5/6] fix(codegen-ts): meta docs resolves routes expose per surface A narrowed Fastify routes file beside a default Hono one (or the reverse) made the shared verb list undefined, so the narrowed surface documented all five verbs. Each API docs surface now reads the expose of the generators that produce it. --- CHANGELOG.md | 3 +- .../packages/cli/test/docs-expose.test.ts | 25 +++++++-- .../packages/codegen-ts/src/generator.ts | 2 +- .../codegen-ts/src/generators/api-model.ts | 18 +++--- .../codegen-ts/src/metaobjects-config.ts | 8 +-- .../packages/codegen-ts/src/routes-expose.ts | 10 ++++ .../packages/codegen-ts/src/ui-tier-gate.ts | 27 +++++---- .../codegen-ts/test/api-docs-expose.test.ts | 55 ++++++++++++++----- 8 files changed, 104 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fca16e20..1c151d647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ here.** pages listed all five verbs for an entity whose routes file mounts fewer (`DELETE` for an entity exposed as `["list", "get"]`), so the adopter overrode the pages by hand. The generators now carry the option in a `mountsRoutes` marker, `meta docs` reads it off the resolved suite - (`runRoutesExpose`) and documents only the verbs mounted: `update` lists both `PATCH` and `PUT`, + (`runRoutesExpose`) per routes surface, so a narrowed Fastify routes file beside a default Hono + one (or the reverse) narrows only its own section, and documents only the verbs mounted: `update` lists both `PATCH` and `PUT`, a read-only projection or report still lists `GET`, and a TPH base is narrowed from its read-only pair. Output without `expose` is byte-identical. **An owned (ejected) copy needs the marker to be honoured:** run `meta eject routes --force` (and `routes-hono --force`), or add diff --git a/server/typescript/packages/cli/test/docs-expose.test.ts b/server/typescript/packages/cli/test/docs-expose.test.ts index 980c1f2bf..d6c9342e0 100644 --- a/server/typescript/packages/cli/test/docs-expose.test.ts +++ b/server/typescript/packages/cli/test/docs-expose.test.ts @@ -28,18 +28,20 @@ const META = { // A routes generator as an owned `routesFile({ expose })` presents itself to the docs door: the // `mountsRoutes` marker carries the option. -const config = (marker: string) => [ +// `hono` is the marker of a second, Hono routes generator in the same suite (or ""). +const config = (marker: string, hono = "") => [ `import { defineConfig } from "@metaobjectsdev/codegen-ts";`, `const routes = { name: "routes-file", ${marker} generate: () => [] };`, - `export default defineConfig({ outDir: "out", dialect: "sqlite", generators: [routes] });`, + `const honoRoutes = { name: "routes-file-hono", emitsHonoRoutes: true, ${hono} generate: () => [] };`, + `export default defineConfig({ outDir: "out", dialect: "sqlite", generators: [routes${hono ? ", honoRoutes" : ""}] });`, ].join("\n"); -async function docsFor(marker: string): Promise { +async function docsFor(marker: string, hono = ""): Promise { const root = await mkdtemp(join(tmpdir(), "meta-docs-expose-")); dirs.push(root); await mkdir(join(root, "metaobjects"), { recursive: true }); await writeFile(join(root, "metaobjects", "meta.json"), JSON.stringify(META), "utf8"); - await writeFile(join(root, "metaobjects.config.ts"), config(marker), "utf8"); + await writeFile(join(root, "metaobjects.config.ts"), config(marker, hono), "utf8"); const out = join(root, "docs-out"); expect(await docsCommand([root, "--out", out], root)).toBe(0); return readFile(join(out, "api", "acme", "shop", "AuditEntry.md"), "utf8").catch(() => @@ -60,4 +62,19 @@ describe("meta docs and routesFile({ expose })", () => { expect(page).toContain("DELETE /audit_entries"); expect(page).toContain("POST /audit_entries"); }); + + test("a narrowed Fastify routes file beside a default Hono one narrows only the Fastify section", async () => { + const page = await docsFor(`mountsRoutes: { expose: ["list", "get"] },`, `mountsRoutes: {},`); + const at = page.indexOf("[Hono]"); + expect(at).toBeGreaterThan(-1); + const fastify = page.slice(0, page.indexOf("[Hono] List")); + expect(fastify).not.toMatch(/DELETE \/audit_entries/); + expect(page.slice(at)).toContain("DELETE /audit_entries"); + }); + + test("a narrowed Hono routes file beside a default Fastify one narrows only the Hono section", async () => { + const page = await docsFor(`mountsRoutes: {},`, `mountsRoutes: { expose: ["list", "get"] },`); + expect(page).toContain("DELETE /audit_entries"); + expect(page.slice(page.indexOf("[Hono] List"))).not.toMatch(/DELETE \/audit_entries/); + }); }); diff --git a/server/typescript/packages/codegen-ts/src/generator.ts b/server/typescript/packages/codegen-ts/src/generator.ts index 0f9f165e3..b91fff542 100644 --- a/server/typescript/packages/codegen-ts/src/generator.ts +++ b/server/typescript/packages/codegen-ts/src/generator.ts @@ -117,7 +117,7 @@ export interface Generator { emitsHonoRoutes?: boolean; /** Marks a generator that mounts the CRUD routes (`routesFile`, `routesFileHono`) and carries * the `expose` option it was built with. `meta docs` aggregates it across the suite - * (`runRoutesExpose`) so the API docs list the verbs the routes actually mount, not all five. + * (`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 }; 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 566a398ba..ce1c54f12 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts @@ -102,7 +102,7 @@ // very models this builder was reading it from. The read is gone — see // constants.ts.) -import { CRUD_VERBS, TPH_POLYMORPHIC_VERBS, intersectExpose, resolveExpose, type CrudVerb, type ExposeOption } from "../routes-expose.js"; +import { CRUD_VERBS, TPH_POLYMORPHIC_VERBS, intersectExpose, resolveExpose, type CrudVerb, type ExposeOption, type RoutesExpose } from "../routes-expose.js"; import { type MetaRoot, type MetaObject, @@ -294,10 +294,10 @@ export interface ApiModelContext { * short. Threaded explicitly rather than read off the RenderContext below, * which is a `{ pkMap }` shim and carries nothing else. Defaults to "". */ apiPrefix?: string; - /** The `expose` option the run's routes generators were built with (`runRoutesExpose`). - * The routes generator mounts only these verbs, so the documented endpoints are the same - * subset. Absent means every verb, which documents byte-identically to before. */ - expose?: ExposeOption; + /** The `expose` option of each routes surface (`runRoutesExpose`). A routes generator + * mounts only its own verbs, so each surface documents the same subset. A surface left + * out means every verb, which documents byte-identically to before. */ + expose?: RoutesExpose; } // --------------------------------------------------------------------------- @@ -313,7 +313,7 @@ export function buildApiModel(root: MetaRoot, ctx: ApiModelContext): ApiModel { const apiPrefix = ctx.apiPrefix ?? ""; const relationMap = ctx.relationMap ?? buildRelationMap(root); const includeHono = ctx.includeHonoRoutes ?? false; - const expose = ctx.expose; + const expose = ctx.expose ?? {}; const units: ApiUnitDoc[] = []; @@ -436,7 +436,7 @@ function buildEntityUnit( relationMap: RelationMap, includeHono: boolean, apiPrefix: string, - expose: ExposeOption | undefined, + expose: RoutesExpose, ): ApiUnitDoc { const name = obj.name; const symbols: ApiSymbol[] = []; @@ -467,10 +467,10 @@ 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)); + 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)); + if (includeHono) symbols.push(...restHonoSymbols(obj, layout, apiPrefix, expose.restHono)); } // --- relation: the drizzle relations() export, when the resolver files entries diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 784d64706..081c02265 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -1,5 +1,5 @@ import { DEFAULT_COLUMN_NAMING_STRATEGY, type ColumnNamingStrategy, type MetaDataTypeProvider } from "@metaobjectsdev/metadata"; -import type { ExposeOption } from "./routes-expose.js"; +import type { RoutesExpose } from "./routes-expose.js"; import type { Generator } from "./generator.js"; import type { ExtStyle } from "./render-context.js"; import type { OutputLayout, ResolvedTarget } from "./import-path.js"; @@ -61,9 +61,9 @@ export interface ResolvedGenConfig { * api-docs reads this to AUTO-DETECT whether to document the Hono CRUD surface * (it otherwise mirrors the default Fastify-only suite). Undefined ⇒ false. */ includeHonoRoutes?: boolean; - /** The `expose` option the run's routes generators were built with (`runRoutesExpose`), for - * `meta docs` to document only the verbs they mount. Absent documents all five. */ - routesExpose?: ExposeOption; + /** The `expose` option the run's routes generators were built with (`runRoutesExpose`), per + * routes surface, for `meta docs` to document only the verbs they mount. Absent documents all five. */ + routesExpose?: RoutesExpose; /** §A6 — whether the OPT-IN names generator (namesFile) is active in the run, * aggregated by the runner from the suite's `emitsNames` markers. A generator that * wants to REFERENCE `Names` reads this to know the artifact will exist: diff --git a/server/typescript/packages/codegen-ts/src/routes-expose.ts b/server/typescript/packages/codegen-ts/src/routes-expose.ts index bf8d7bb05..967a98a2f 100644 --- a/server/typescript/packages/codegen-ts/src/routes-expose.ts +++ b/server/typescript/packages/codegen-ts/src/routes-expose.ts @@ -47,6 +47,16 @@ export type ExposeOption = | readonly CrudVerb[] | ((entity: MetaObject) => readonly CrudVerb[] | undefined); +/** + * The `expose` option of each routes SURFACE, as `meta docs` 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. + */ +export interface RoutesExpose { + readonly rest?: ExposeOption; + readonly restHono?: ExposeOption; +} + /** Resolve the option for one entity. `undefined` means "mount all five". */ export function resolveExpose( entity: MetaObject, 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 118c662b3..7f1cfa417 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,5 @@ import type { Generator } from "./generator.js"; -import { CRUD_VERBS, resolveExpose, type CrudVerb, type ExposeOption } from "./routes-expose.js"; +import { CRUD_VERBS, resolveExpose, type CrudVerb, type ExposeOption, type RoutesExpose } from "./routes-expose.js"; /** * Generators that emit a CLIENT UI artifact — a form, a hook, a grid or its columns. @@ -80,21 +80,26 @@ export function runEmitsHonoRoutes(generators: readonly Generator[]): boolean { } /** - * The `expose` option the run's routes generators were built with, for `meta docs` to apply - * (a docs page that lists DELETE for a route the adopter does not mount is a lie). + * The `expose` option of each routes surface the run mounts, for `meta docs` to apply (a docs + * page that lists DELETE for a route the adopter does not mount is a lie). * - * Undefined when no generator mounts routes, or any of them mounts every verb: documenting - * all five is then right. Otherwise a per-entity function returning the UNION of what the - * generators mount, since the Fastify and Hono surfaces share one documented verb list. + * 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. */ -export function runRoutesExpose(generators: readonly Generator[]): ExposeOption | undefined { - // Project the markers once, so the body works over "the routes-mounting generators' - // options" — a collection, not a filtered list re-projected through `!` in a closure. +export function runRoutesExpose(generators: readonly Generator[]): RoutesExpose | undefined { + 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 }) }; +} + +function surfaceExpose(generators: readonly Generator[]): ExposeOption | undefined { const mounts = generators .map((g) => g.mountsRoutes) .filter((m): m is NonNullable => m !== undefined); - if (mounts.length === 0) return undefined; - if (mounts.some((m) => m.expose === undefined)) return undefined; + if (mounts.length === 0 || mounts.some((m) => m.expose === undefined)) return undefined; return (entity) => { const union = new Set(); for (const m of mounts) { 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 ec26ea6ef..d66863d94 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,7 +8,6 @@ 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 type { Generator } from "../src/generator.js"; const ENTITY = (name: string, table: string) => ({ "object.entity": { @@ -48,44 +47,72 @@ describe("api docs follow the routes generator's expose option", () => { const root = await loadRoot(); const all = ["list", "get", "create", "update", "delete"] as const; const ctx = { loadedRoot: root, includeHonoRoutes: true }; - expect(JSON.stringify(buildApiModel(root, ctx))).toBe(JSON.stringify(buildApiModel(root, { ...ctx, expose: all }))); + expect(JSON.stringify(buildApiModel(root, ctx))).toBe(JSON.stringify(buildApiModel(root, { ...ctx, expose: { rest: all, restHono: all } }))); }); test("a per-entity expose lists only the verbs mounted", async () => { const root = await loadRoot(); - const expose = (e: { name: string }) => (e.name === "AuditEntry" ? (["list", "get"] as const) : undefined); - const model = buildApiModel(root, { loadedRoot: root, expose }); + const rest = (e: { name: string }) => (e.name === "AuditEntry" ? (["list", "get"] as const) : undefined); + const model = buildApiModel(root, { loadedRoot: root, expose: { rest } }); expect(sigs(model, "AuditEntry")).toEqual(["GET /audit_entries", "GET /audit_entries/:id"]); expect(sigs(model, "Product")).toHaveLength(6); }); test("update mounts both PATCH and PUT; delete and create are separate", async () => { const root = await loadRoot(); - const model = buildApiModel(root, { loadedRoot: root, expose: ["list", "update"] }); + const model = buildApiModel(root, { loadedRoot: root, expose: { rest: ["list", "update"] } }); expect(sigs(model, "Product")).toEqual(["GET /products", "PATCH /products/:id", "PUT /products/:id"]); }); test("the opt-in Hono surface follows the same option", async () => { const root = await loadRoot(); - const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, expose: ["list", "get", "create"] }); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, expose: { restHono: ["list", "get", "create"] } }); expect(sigs(model, "Product", "rest-hono")).toEqual(["GET /products", "GET /products/:id", "POST /products"]); }); }); +describe("each routes surface documents its own generator's expose", () => { + test("Fastify narrowed, Hono default: Fastify lists two verbs, Hono all", async () => { + const root = await loadRoot(); + const expose = runRoutesExpose([routesFile({ expose: ["list", "get"] }), routesFileHono()]); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, ...(expose && { expose }) }); + expect(sigs(model, "Product", "rest")).toEqual(["GET /products", "GET /products/:id"]); + expect(sigs(model, "Product", "rest-hono")).toHaveLength(6); + }); + + test("Hono narrowed, Fastify default: Hono lists two verbs, Fastify all", async () => { + const root = await loadRoot(); + const expose = runRoutesExpose([routesFile(), routesFileHono({ expose: ["list", "get"] })]); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, ...(expose && { expose }) }); + expect(sigs(model, "Product", "rest")).toHaveLength(6); + expect(sigs(model, "Product", "rest-hono")).toEqual(["GET /products", "GET /products/:id"]); + }); + + test("both narrowed differently: each surface keeps its own verbs", async () => { + const root = await loadRoot(); + const expose = runRoutesExpose([routesFile({ expose: ["list"] }), routesFileHono({ expose: ["get", "delete"] })]); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, ...(expose && { expose }) }); + expect(sigs(model, "Product", "rest")).toEqual(["GET /products"]); + expect(sigs(model, "Product", "rest-hono")).toEqual(["DELETE /products/:id", "GET /products/:id"]); + }); +}); + describe("runRoutesExpose reads the option off the configured generators", () => { - test("no routes generator, or one that mounts everything, leaves docs unchanged", () => { + test("no routes generator, or only ones that mount everything, leaves docs unchanged", () => { expect(runRoutesExpose([])).toBeUndefined(); expect(runRoutesExpose([routesFile()])).toBeUndefined(); - expect(runRoutesExpose([routesFile({ expose: ["list"] }), routesFileHono()])).toBeUndefined(); + expect(runRoutesExpose([routesFile(), routesFileHono()])).toBeUndefined(); }); - test("a generator with expose yields it; two generators union their verbs", async () => { + test("a surface is narrowed only by its own generators; two on one surface union their verbs", async () => { const root = await loadRoot(); const product = root.objects().find((o) => o.name === "Product")!; - expect(runRoutesExpose([routesFile({ expose: ["list", "get"] })] as Generator[])?.constructor).toBe(Function); - const one = runRoutesExpose([routesFile({ expose: ["list", "get"] })]) as (e: typeof product) => readonly string[] | undefined; - expect(one(product)).toEqual(["list", "get"]); - const both = runRoutesExpose([routesFile({ expose: ["list"] }), routesFileHono({ expose: ["get", "delete"] })]) as (e: typeof product) => readonly string[] | undefined; - expect(both(product)).toEqual(["list", "get", "delete"]); + type Fn = (e: typeof product) => readonly string[] | undefined; + const one = runRoutesExpose([routesFile({ expose: ["list", "get"] }), routesFileHono()]); + expect(one?.restHono).toBeUndefined(); + expect((one?.rest as Fn)(product)).toEqual(["list", "get"]); + const two = runRoutesExpose([routesFile({ expose: ["list"] }), routesFile({ expose: ["get", "delete"] })]); + expect((two?.rest as Fn)(product)).toEqual(["list", "get", "delete"]); + expect(runRoutesExpose([routesFile({ expose: ["list"] }), routesFile()])?.rest).toBeUndefined(); }); }); From e1cceb2566c649de38fce96daf75e9160e0c068a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 10:16:09 -0400 Subject: [PATCH 6/6] no-mistakes(document): Fix own-your-codegen expose paragraph to per-surface semantics Assisted-by: no-mistakes:claude:claude-haiku-5-5 --- docs/features/own-your-codegen.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 3a7f09f34..ba5b3b071 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -271,10 +271,9 @@ 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"] })` (`meta docs` reads the option off the configured -generators and lists only the verbs mounted, the union across them, or all five if any -generator mounts every verb; a copy ejected before 1.1.0 needs -`meta eject routes --force` to be honoured). To register every entity's routes in one call, +`routesFile({ expose: ["list", "get"] })` (`meta docs` 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 +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 to your host file. What neither can express is a row-ownership