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 @@ -20,6 +20,17 @@ 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`) 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
`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

Expand Down
4 changes: 3 additions & 1 deletion docs/features/own-your-codegen.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
3 changes: 3 additions & 0 deletions examples/advanced-modeling/codegen/generators/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/showcase/codegen/generators/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion server/typescript/packages/cli/src/commands/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -579,6 +579,7 @@ export async function docsCommand(
suite = (loadedConfig?.generators ?? []).filter((g): g is Generator => typeof g !== "string");
}

const routesExpose = runRoutesExpose(suite);
const ctx: GenContext = {
entities: root.objects(),
loadedRoot: root,
Expand All @@ -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
Expand Down
80 changes: 80 additions & 0 deletions server/typescript/packages/cli/test/docs-expose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// `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.
// `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: () => [] };`,
`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, hono = ""): Promise<string> {
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, 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(() =>
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");
});

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/);
});
});
7 changes: 7 additions & 0 deletions server/typescript/packages/codegen-ts/src/generator.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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`), 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 };
/** Β§A6 β€” marks the generator that emits the <Entity>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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "",
Expand Down
77 changes: 55 additions & 22 deletions server/typescript/packages/codegen-ts/src/generators/api-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, type RoutesExpose } from "../routes-expose.js";
import {
type MetaRoot,
type MetaObject,
Expand Down Expand Up @@ -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 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;
}

// ---------------------------------------------------------------------------
Expand All @@ -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[] = [];

Expand All @@ -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)) {
Expand Down Expand Up @@ -430,6 +436,7 @@ function buildEntityUnit(
relationMap: RelationMap,
includeHono: boolean,
apiPrefix: string,
expose: RoutesExpose,
): ApiUnitDoc {
const name = obj.name;
const symbols: ApiSymbol[] = [];
Expand Down Expand Up @@ -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.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));
if (includeHono) symbols.push(...restHonoSymbols(obj, layout, apiPrefix, expose.restHono));
}

// --- relation: the drizzle relations() export, when the resolver files entries
Expand Down Expand Up @@ -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 (`<entity>Routes`) from the routes
Expand All @@ -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<CrudVerb> {
if (isReadOnlySurface(obj)) return new Set<CrudVerb>(["list", "get"]);
const fixed = tphBase ? TPH_POLYMORPHIC_VERBS : CRUD_VERBS;
return new Set<CrudVerb>(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/
Expand Down Expand Up @@ -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`;
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading