Skip to content

Commit f12916a

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): routesFile({ registerAll: true }) registers every entity's routes in one call
An adopter estate found that no port generates an aggregate route registration, so every new entity meant a hand edit to the host file in each port. For TypeScript the two route generators can now emit it: `registerAll: true` adds `routes.index.ts` (`routes.index.hono.ts` for Hono) at the target root, exporting `registerAllRoutes(...)` that calls each generated entity's register function. Its doc comment carries the same unauthenticated-by-default note as the per-entity files. Off by default, so a project that does not ask gets no new file and `verify --codegen` reports nothing new after an upgrade. The rendering is one engine function (`renderRoutesIndex`, exported) that all four generators call, with handler names taken from the same derivations the per-entity renderers use. Other ports: their route templates are frozen for features; eject is the way to add one. Tests: every import in the index resolves to a routes file the same run emitted and names a function that file exports (Fastify and Hono, flat and package layout); TPH subtypes and value objects are left out; a filter narrows it; nothing is emitted when the option is off. ci-local --quick green. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent d2144e1 commit f12916a

15 files changed

Lines changed: 383 additions & 48 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ here.**
2525
with rows. The diff reports them as `hazards`, the CLI prints a warning, and the Postgres
2626
migration file carries the preparation step (a backfill `UPDATE` or the `SELECT` that finds
2727
violating rows) as a comment above the statement. `DiffResult` gains a `hazards` field.
28+
- **`routesFile({ registerAll: true })` and `routesFileHono({ registerAll: true })` emit one
29+
module that registers every generated entity's routes.** `routes.index.ts` (or
30+
`routes.index.hono.ts`) at the target root exports `registerAllRoutes(...)`, so adding an
31+
entity no longer means editing the host file. Off by default: a project that does not ask
32+
gets no new file. TypeScript only; in the other ports, eject the route generator.
2833

2934
### Fixed
3035

‎agent-context/skills/metaobjects-runtime-ui/references/typescript.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ import { registerAuthorRoutes } from "./generated/Author.routes";
176176
registerAuthorRoutes(app, { db }); // GET/POST/PATCH/PUT/DELETE under apiPrefix
177177
```
178178

179+
To register every entity at once, pass `registerAll: true` to the generator
180+
(`routesFile({ registerAll: true })` / `routesFileHono({ registerAll: true })`). It also
181+
emits `routes.index.ts` (`routes.index.hono.ts` for Hono) at the target root with one
182+
`registerAllRoutes(...)`, so a new entity needs no edit to the host file. It is off by
183+
default.
184+
179185
The routes call `parseFilterParams` (from `@metaobjectsdev/runtime-ts/drizzle-fastify`)
180186
to validate `?filter[..][..]=..&sort=..&limit=&offset=` against the generated
181187
`<Entity>FilterAllowlist` / `<Entity>SortAllowlist`, returning HTTP 400 on an

‎docs/features/own-your-codegen.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,10 @@ rather than off this page; its own JSDoc carries the recipe with the right one.)
209209

210210
Both are executed as tests (`runtime-ts/test/route-auth-seam.test.ts`) rather than
211211
asserted in prose. For mounting *fewer* endpoints, narrow the generator:
212-
`routesFile({ expose: ["list", "get"] })`. What neither can express is a row-ownership
212+
`routesFile({ expose: ["list", "get"] })`. To register every entity's routes in one call,
213+
pass `registerAll: true`: the generator also emits `routes.index.ts` (or
214+
`routes.index.hono.ts`) with a `registerAllRoutes(...)`, so adding an entity needs no edit
215+
to your host file. What neither can express is a row-ownership
213216
rule — "only the owner may read this row" is not a property of a mount — so hand-write
214217
those verbs and narrow the generated file around them.
215218

‎examples/advanced-modeling/codegen/generators/routes.ts‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import {
4545
isTphSubtype,
4646
servesReadApi,
4747
formatTs,
48+
renderRoutesIndex,
49+
routesIndexFileName,
4850
entityOutputPath,
4951
effectivePackage,
5052
} from "@metaobjectsdev/codegen-ts";
@@ -61,11 +63,26 @@ export interface RoutesFileOpts {
6163
* only remove the whole surface, not restrict it to a subset of verbs.
6264
*/
6365
expose?: ExposeOption;
66+
/**
67+
* Also emit `routes.index.ts` at the target root: one `registerAllRoutes(...)` that registers every
68+
* entity this generator emitted a routes file for, so adding an entity needs no edit to
69+
* your host file. Off by default, so a project that does not ask gets no new file.
70+
*/
71+
registerAll?: boolean;
6472
target?: string;
6573
}
6674

6775
export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator {
6876
const userFilter = opts?.filter ?? (() => true);
77+
const emitEntities = perEntity(async (entity, ctx) => {
78+
if (!ctx.renderContext) {
79+
throw new Error("routes-file: renderContext is required (provided by runGen)");
80+
}
81+
return {
82+
path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`),
83+
content: await formatTs(renderRoutesFile(entity, ctx.renderContext, resolveExpose(entity, opts?.expose))),
84+
};
85+
});
6986
const generator: Generator = {
7087
name: "routes-file",
7188
// TPH subtypes get no standalone routes file (their routes live in the discriminator
@@ -76,15 +93,16 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
7693
// abstract objects: an abstract level has no table of its own to mount.
7794
filter: (e: MetaObject) =>
7895
servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
79-
generate: perEntity(async (entity, ctx) => {
80-
if (!ctx.renderContext) {
81-
throw new Error("routes-file: renderContext is required (provided by runGen)");
82-
}
83-
return {
84-
path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`),
85-
content: await formatTs(renderRoutesFile(entity, ctx.renderContext, resolveExpose(entity, opts?.expose))),
86-
};
87-
}),
96+
generate: async (ctx) => {
97+
const files = await emitEntities(ctx);
98+
if (!opts?.registerAll) return files;
99+
const matched = ctx.entities.filter(ctx.matches);
100+
if (matched.length === 0 || !ctx.renderContext) return files;
101+
return [...files, {
102+
path: routesIndexFileName("fastify"),
103+
content: await formatTs(renderRoutesIndex(matched, ctx.renderContext, "fastify")),
104+
}];
105+
},
88106
};
89107
if (opts?.target) {
90108
generator.target = opts.target;

‎examples/showcase/codegen/generators/routes.ts‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import {
4545
isTphSubtype,
4646
servesReadApi,
4747
formatTs,
48+
renderRoutesIndex,
49+
routesIndexFileName,
4850
entityOutputPath,
4951
effectivePackage,
5052
} from "@metaobjectsdev/codegen-ts";
@@ -61,11 +63,26 @@ export interface RoutesFileOpts {
6163
* only remove the whole surface, not restrict it to a subset of verbs.
6264
*/
6365
expose?: ExposeOption;
66+
/**
67+
* Also emit `routes.index.ts` at the target root: one `registerAllRoutes(...)` that registers every
68+
* entity this generator emitted a routes file for, so adding an entity needs no edit to
69+
* your host file. Off by default, so a project that does not ask gets no new file.
70+
*/
71+
registerAll?: boolean;
6472
target?: string;
6573
}
6674

6775
export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator {
6876
const userFilter = opts?.filter ?? (() => true);
77+
const emitEntities = perEntity(async (entity, ctx) => {
78+
if (!ctx.renderContext) {
79+
throw new Error("routes-file: renderContext is required (provided by runGen)");
80+
}
81+
return {
82+
path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`),
83+
content: await formatTs(renderRoutesFile(entity, ctx.renderContext, resolveExpose(entity, opts?.expose))),
84+
};
85+
});
6986
const generator: Generator = {
7087
name: "routes-file",
7188
// TPH subtypes get no standalone routes file (their routes live in the discriminator
@@ -76,15 +93,16 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
7693
// abstract objects: an abstract level has no table of its own to mount.
7794
filter: (e: MetaObject) =>
7895
servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
79-
generate: perEntity(async (entity, ctx) => {
80-
if (!ctx.renderContext) {
81-
throw new Error("routes-file: renderContext is required (provided by runGen)");
82-
}
83-
return {
84-
path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`),
85-
content: await formatTs(renderRoutesFile(entity, ctx.renderContext, resolveExpose(entity, opts?.expose))),
86-
};
87-
}),
96+
generate: async (ctx) => {
97+
const files = await emitEntities(ctx);
98+
if (!opts?.registerAll) return files;
99+
const matched = ctx.entities.filter(ctx.matches);
100+
if (matched.length === 0 || !ctx.renderContext) return files;
101+
return [...files, {
102+
path: routesIndexFileName("fastify"),
103+
content: await formatTs(renderRoutesIndex(matched, ctx.renderContext, "fastify")),
104+
}];
105+
},
88106
};
89107
if (opts?.target) {
90108
generator.target = opts.target;

‎fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/typescript.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ import { registerAuthorRoutes } from "./generated/Author.routes";
176176
registerAuthorRoutes(app, { db }); // GET/POST/PATCH/PUT/DELETE under apiPrefix
177177
```
178178

179+
To register every entity at once, pass `registerAll: true` to the generator
180+
(`routesFile({ registerAll: true })` / `routesFileHono({ registerAll: true })`). It also
181+
emits `routes.index.ts` (`routes.index.hono.ts` for Hono) at the target root with one
182+
`registerAllRoutes(...)`, so a new entity needs no edit to the host file. It is off by
183+
default.
184+
179185
The routes call `parseFilterParams` (from `@metaobjectsdev/runtime-ts/drizzle-fastify`)
180186
to validate `?filter[..][..]=..&sort=..&limit=&offset=` against the generated
181187
`<Entity>FilterAllowlist` / `<Entity>SortAllowlist`, returning HTTP 400 on an

‎fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-runtime-ui/references/typescript.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ import { registerAuthorRoutes } from "./generated/Author.routes";
176176
registerAuthorRoutes(app, { db }); // GET/POST/PATCH/PUT/DELETE under apiPrefix
177177
```
178178

179+
To register every entity at once, pass `registerAll: true` to the generator
180+
(`routesFile({ registerAll: true })` / `routesFileHono({ registerAll: true })`). It also
181+
emits `routes.index.ts` (`routes.index.hono.ts` for Hono) at the target root with one
182+
`registerAllRoutes(...)`, so a new entity needs no edit to the host file. It is off by
183+
default.
184+
179185
The routes call `parseFilterParams` (from `@metaobjectsdev/runtime-ts/drizzle-fastify`)
180186
to validate `?filter[..][..]=..&sort=..&limit=&offset=` against the generated
181187
`<Entity>FilterAllowlist` / `<Entity>SortAllowlist`, returning HTTP 400 on an

‎server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
22
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
33
import { renderRoutesFileHono } from "../templates/routes-file-hono.js";
44
import { servesReadApi } from "../api-surface.js";
5+
import { renderRoutesIndex, routesIndexFileName } from "../templates/routes-index.js";
56
import { formatTs } from "../format.js";
67
import { entityOutputPath } from "../import-path.js";
78
import { isTphSubtype } from "../templates/zod-validators.js";
@@ -20,6 +21,12 @@ export interface RoutesFileHonoOpts {
2021
* only remove the whole surface, not restrict it to a subset of verbs.
2122
*/
2223
expose?: ExposeOption;
24+
/**
25+
* Also emit `routes.index.hono.ts` at the target root: one `registerAllRoutes(...)` that registers every
26+
* entity this generator emitted a routes file for, so adding an entity needs no edit to
27+
* your host file. Off by default, so a project that does not ask gets no new file.
28+
*/
29+
registerAll?: boolean;
2330
target?: string;
2431
}
2532

@@ -79,7 +86,14 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
7986
"dispatches TPH correctly, or hand-write the scoped routes.",
8087
);
8188
}
82-
return emit(ctx);
89+
const files = await emit(ctx);
90+
if (!opts?.registerAll) return files;
91+
const matched = ctx.entities.filter(ctx.matches);
92+
if (matched.length === 0 || !ctx.renderContext) return files;
93+
return [...files, {
94+
path: routesIndexFileName("hono"),
95+
content: await formatTs(renderRoutesIndex(matched, ctx.renderContext, "hono")),
96+
}];
8397
},
8498
};
8599
const emit = perEntity(async (entity, ctx) => {

‎server/typescript/packages/codegen-ts/src/generators/routes-file.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { perEntity, type Generator, type GeneratorFactory } from "../generator.j
33
import { renderRoutesFile } from "../templates/routes-file.js";
44
import { isTphSubtype } from "../templates/zod-validators.js";
55
import { servesReadApi } from "../api-surface.js";
6+
import { renderRoutesIndex, routesIndexFileName } from "../templates/routes-index.js";
67
import { formatTs } from "../format.js";
78
import { entityOutputPath } from "../import-path.js";
89
import { resolveExpose, type ExposeOption } from "../routes-expose.js";
@@ -20,6 +21,12 @@ export interface RoutesFileOpts {
2021
* only remove the whole surface, not restrict it to a subset of verbs.
2122
*/
2223
expose?: ExposeOption;
24+
/**
25+
* Also emit `routes.index.ts` at the target root: one `registerAllRoutes(...)` that registers every
26+
* entity this generator emitted a routes file for, so adding an entity needs no edit to
27+
* your host file. Off by default, so a project that does not ask gets no new file.
28+
*/
29+
registerAll?: boolean;
2330
target?: string;
2431
}
2532

@@ -41,20 +48,30 @@ export interface RoutesFileOpts {
4148
*/
4249
export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator {
4350
const userFilter = opts?.filter ?? (() => true);
51+
const emitEntities = perEntity(async (entity, ctx) => {
52+
if (!ctx.renderContext) {
53+
throw new Error("routes-file: renderContext is required (provided by runGen)");
54+
}
55+
return {
56+
path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`),
57+
content: await formatTs(renderRoutesFile(entity, ctx.renderContext, resolveExpose(entity, opts?.expose))),
58+
};
59+
});
4460
const generator: Generator = {
4561
name: "routes-file",
4662
// Always set: AND-composes the built-in gates with the optional user filter.
4763
filter: (e: MetaObject) =>
4864
servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
49-
generate: perEntity(async (entity, ctx) => {
50-
if (!ctx.renderContext) {
51-
throw new Error("routes-file: renderContext is required (provided by runGen)");
52-
}
53-
return {
54-
path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`),
55-
content: await formatTs(renderRoutesFile(entity, ctx.renderContext, resolveExpose(entity, opts?.expose))),
56-
};
57-
}),
65+
generate: async (ctx) => {
66+
const files = await emitEntities(ctx);
67+
if (!opts?.registerAll) return files;
68+
const matched = ctx.entities.filter(ctx.matches);
69+
if (matched.length === 0 || !ctx.renderContext) return files;
70+
return [...files, {
71+
path: routesIndexFileName("fastify"),
72+
content: await formatTs(renderRoutesIndex(matched, ctx.renderContext, "fastify")),
73+
}];
74+
},
5875
};
5976
if (opts?.target) {
6077
generator.target = opts.target;

‎server/typescript/packages/codegen-ts/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export { CRUD_VERBS, TPH_POLYMORPHIC_VERBS, resolveExpose, intersectExpose, expo
228228
export type { CrudVerb, ExposeOption } from "./routes-expose.js";
229229
export { renderRoutesFile } from "./templates/routes-file.js";
230230
export { renderRoutesFileHono } from "./templates/routes-file-hono.js";
231+
export { renderRoutesIndex, routesIndexFileName, type RoutesIndexFlavor } from "./templates/routes-index.js";
231232
export { renderValueObjectFile } from "./templates/value-object-file.js";
232233
export { renderNamesDecl } from "./templates/names-decl.js";
233234
export type { NamesDeclOpts } from "./templates/names-decl.js";

0 commit comments

Comments
 (0)