Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `// <auto-generated/>` marker (#460).**
`DecimalWire[.<Namespace>].g.cs` and `MetaNetValidation[.<Namespace>].g.cs` were the only
emitted files without it, so a runner with no `GenStateDir` wrote them once and then logged
Expand Down
3 changes: 2 additions & 1 deletion docs/features/own-your-codegen.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion examples/advanced-modeling/codegen/generators/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion examples/showcase/codegen/generators/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion server/typescript/packages/codegen-ts/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion server/typescript/packages/codegen-ts/src/routes-expose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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". */
Expand Down
3 changes: 2 additions & 1 deletion server/typescript/packages/codegen-ts/src/runner.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -709,6 +709,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
(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.
Expand Down
63 changes: 49 additions & 14 deletions server/typescript/packages/codegen-ts/src/ui-tier-gate.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -83,29 +84,63 @@ 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));
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<typeof m> => m !== undefined);
if (mounts.length === 0 || mounts.some((m) => m.expose === undefined)) return undefined;
return (entity) => {
const union = new Set<CrudVerb>();
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));
if (mounts.length === 0 || mounts.some((m) => m.expose === undefined && m.filter === undefined)) return undefined;
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<CrudVerb>();
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));
},
};
}

/**
* 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'.`,
);
}
Loading
Loading