From 56288d866868720e694befef74e598e8e21185da Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 17:47:31 -0400
Subject: [PATCH 01/21] =?UTF-8?q?docs(fr-044):=20Plan=203=20answers=20?=
=?UTF-8?q?=E2=80=94=20no=20client=20hook=20or=20UI=20tier=20for=20reports?=
=?UTF-8?q?=20until=20Plan=205?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...-10-04-fr-044-plan-3-report-read-routes.md | 63 +++++++++++++------
1 file changed, 44 insertions(+), 19 deletions(-)
diff --git a/docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md b/docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md
index 75ceb6882..23d0a1c98 100644
--- a/docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md
+++ b/docs/superpowers/plans/2026-10-04-fr-044-plan-3-report-read-routes.md
@@ -2,11 +2,11 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-**Goal:** Serve every view-backed `object.report` over the cross-port REST contract in all five ports (a list route with filter, sort and paging on the derived fields, and a `405` on write), gate it with a new api-contract `report/` sub-corpus, stop the TypeScript, Java and Python generators from skipping reports, and give reports model and API pages in `meta docs`.
+**Goal:** Serve every view-backed `object.report` over the cross-port REST contract in all five ports (a list route with filter, sort and paging on the derived fields, and a `405` on write), gate it with a new api-contract `report/` sub-corpus, stop the TypeScript, Java and Python generators from skipping reports, and give reports model and API pages in `meta docs`. No typed client hook and no other UI-tier output is generated for a report in this plan; the UI tier stays off for reports until Plan 5 (ruled 2026-10-04, see [Answers](#answers-to-the-open-questions)).
**Architecture:** A report is served exactly as a keyless read-only projection is served today. Each port already has a detached, projection-shaped read model of a report (Plan 2); this plan hands that read model to the port's existing read-only generators instead of teaching each generator what a report is. Two things are added to make that work: every derived field is marked filterable on the read model, and the two ports whose read-only surface cannot be keyless yet (TypeScript and Python) learn to be. No SQL changes: the lowering Plan 2 landed is not edited.
-**Tech Stack:** TypeScript (Bun, Drizzle, Fastify, Hono, TanStack Query), C# (.NET, EF Core, ASP.NET Minimal API), Java (Maven, Spring MVC), Kotlin (KotlinPoet, Exposed, Spring MVC), Python (pytest, Pydantic, FastAPI). Postgres 16 for the full-stack lanes.
+**Tech Stack:** TypeScript (Bun, Drizzle, Fastify, Hono), C# (.NET, EF Core, ASP.NET Minimal API), Java (Maven, Spring MVC), Kotlin (KotlinPoet, Exposed, Spring MVC), Python (pytest, Pydantic, FastAPI). Postgres 16 for the full-stack lanes.
**Spec:** `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` (R5 "Read-only everywhere", §3 obligation 4, §7 acceptance "Every port's generated route lists a report with `?filter` and `?sort` on derived fields, and answers writes `405`"). Plan 2: `docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md`. What Plans 1 and 2 shipped: `docs/features/reporting.md`. The REST contract: `docs/features/api-contract.md`.
@@ -131,13 +131,13 @@ The existing Tier 1 encodings (`docs/features/api-contract.md`), applied to Plan
| Port | Before this plan | This plan adds |
|---|---|---|
-| TypeScript | nothing | `.ts` (Drizzle view binding, Zod read schema, row type, descriptor with `$path`, filter and sort allowlists, filter type), `.queries.ts` (`list…` only), `.routes.ts` and `.routes.hono.ts`, `.names.ts`, the barrel export, `.hooks.ts` (the list hook only) with `.meta.ts` |
+| TypeScript | nothing | `.ts` (Drizzle view binding, Zod read schema, row type, descriptor with `$path`, filter and sort allowlists, filter type), `.queries.ts` (`list…` only), `.routes.ts` and `.routes.hono.ts`, `.names.ts`, the barrel export. No `.hooks.ts`, `.meta.ts`, grid or form: the UI tier is off for reports until Plan 5 |
| C# | `.g.cs` (keyless row), the `DbContext` mapping | `Routes.g.cs`, `FilterAllowlist.g.cs` |
| Java | nothing | `Dto`, `Repository` (`list` and `count`), `FilterAllowlist`, `Controller` |
| Kotlin | `Table` (Exposed) | the `` data class, `FilterAllowlist`, `Controller` |
| Python | nothing | `.py` (Pydantic row), `_filter_allowlist.py`, `_router.py`, `_names.py` |
-Nothing generates a form, a create or update schema, a repository write method, a `findById`, or a detail hook for a report. A names artifact appears in TypeScript and Python because their read model flows through the names generator; C#, Java and Kotlin keep binding the view and columns by literal (open question 7).
+Nothing generates a form, a grid, a hook of any kind, a create or update schema, a repository write method or a `findById` for a report. A names artifact appears in TypeScript and Python because their read model flows through the names generator; C#, Java and Kotlin keep binding the view and columns by literal (open question 7).
Two refusals carry over from Plan 2 and now also stop the route tier: Java, Kotlin and C# refuse `gen` for a served report with a derived field over a `field.object`; C# refuses a derived field whose Pascal name equals the report's class name; Kotlin refuses a derived field named after a hard keyword or colliding on a column property. TypeScript and Python serve a report over a `field.object` and return the parsed JSON.
@@ -299,8 +299,8 @@ The api-contract corpus goes from 61 scenarios to 73 (`+ 12 report`).
| Surface | A served report | A sourceless report | The `@from` entity |
|---|---|---|---|
| Model page (`--model`, `docsFile`) | A page: kind `report`, its `@from` (linked), its view, its row scope (`@segment`, `@filter`), and a column table from `reportShape`: name, type, nullable, role, and a definition written from the dimension or measure ("`Invoice.issuedOn` truncated to month, UTC"; "sum of `Invoice.amountCents` where segment `paid`"; "`paidInvoices` / `invoices`, null when the denominator is 0") | The same page, with "Not served: declares no view source" in place of the view | A "Reporting" section: its dimensions, measures and segments, and the reports that name it |
-| API page (`--api`, `apiDocsFile`, and each port's api-docs builder) | One unit: the row model, `GET ` and nothing else, the list query function, the list hook | No unit | unchanged |
-| Agent pages (`--agent`) | `agent/ui.md` lists the list hook and says there is no detail hook and no form; `agent/schema.md` is unchanged (it already lists the view) | nothing | unchanged |
+| API page (`--api`, `apiDocsFile`, and each port's api-docs builder) | One unit: the row model, `GET ` and nothing else, the list query function. No hook | No unit | unchanged |
+| Agent pages (`--agent`) | `agent/ui.md` does not list a report: no UI tier is generated for one (Plan 5). `agent/schema.md` is unchanged (it already lists the view) | nothing | unchanged |
| Site (`--site`, `docs-site`) | A report page and an entry in the object index; the reporting nodes count as rendered in the coverage audit | A report page, marked not served | The same "Reporting" section |
### Table H — fixtures and gates
@@ -333,7 +333,7 @@ The api-contract corpus goes from 61 scenarios to 73 (`+ 12 report`).
| `integration-tests/test/api-contract-report.test.ts` | the generated lane |
| `runtime-ts/test/hono/mount-read-only.test.ts` | keyless Hono mount |
-**TypeScript, modified:** `metadata/src/core/reporting/report-read-model.ts`; `codegen-ts/src/source-detect.ts`, `runner.ts`, `api-surface.ts`; `codegen-ts/src/templates/routes-file.ts`, `routes-file-hono.ts`, `queries-file.ts`, `field-meta.ts`; `codegen-ts/src/reference/routes.ts`, `routes-hono.ts`, `queries.ts`; `codegen-ts/src/generators/docs-file.ts`, `docs-data-builder.ts`, `api-model.ts`, `agent-ui-page.ts`; `codegen-ts-tanstack/src/templates/hooks-file.ts`; `runtime-ts/src/drizzle-fastify/mount-read-only.ts`, `runtime-ts/src/hono/mount-read-only.ts`; `docs-site/src/coverage.ts`, `link-graph.ts`; `integration-tests/src/paths.ts`, `canonical-schema.ts`, `package.json`; `cli/test/unit/reporting-inert.test.ts`.
+**TypeScript, modified:** `metadata/src/core/reporting/report-read-model.ts`; `codegen-ts/src/source-detect.ts`, `runner.ts`, `api-surface.ts`; `codegen-ts/src/templates/routes-file.ts`, `routes-file-hono.ts`, `queries-file.ts`, `field-meta.ts`; `codegen-ts/src/reference/routes.ts`, `routes-hono.ts`, `queries.ts`; `codegen-ts/src/generators/docs-file.ts`, `docs-data-builder.ts`, `api-model.ts`, `agent-ui-page.ts`; `codegen-ts-tanstack/src/tanstack-query.ts`, `tanstack-grid.ts`, `tanstack-grid-hook.ts` (and their `reference/` copies: gate on `servesClientTier`), `codegen-ts-tanstack/src/templates/hooks-file.ts` (keyless projection only); `runtime-ts/src/drizzle-fastify/mount-read-only.ts`, `runtime-ts/src/hono/mount-read-only.ts`; `docs-site/src/coverage.ts`, `link-graph.ts`; `integration-tests/src/paths.ts`, `canonical-schema.ts`, `package.json`; `cli/test/unit/reporting-inert.test.ts`.
**Other ports:** listed in Tasks 6 to 9.
@@ -815,7 +815,7 @@ The Hono file holds the same two tests against `app.request(...)`.
- Modify: `server/typescript/packages/codegen-ts/src/source-detect.ts` (after `isReport`, line 96), `runner.ts` (line 413), `api-surface.ts` (line 47)
- Modify: `codegen-ts/src/templates/routes-file.ts` (projection branch, line 76), `routes-file-hono.ts` (line 78), `queries-file.ts` (`renderProjectionQueriesFile`, line 154), `field-meta.ts` (line 163), `projection-decl.ts` (comment wording only)
- Modify: `codegen-ts/src/reference/routes.ts`, `routes-hono.ts`, `queries.ts` (the ejectable copies)
-- Modify: `server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts` (`renderReadOnlyHooksFile`, reached from line 58)
+- Modify: `server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts` (`renderReadOnlyHooksFile`, reached from line 58; for a keyless **projection** only) and the three TanStack generators `tanstack-query.ts`, `tanstack-grid.ts`, `tanstack-grid-hook.ts` with their `reference/` copies (gate on `servesClientTier`, so no report reaches them); `codegen-ts/src/generators/agent-ui-page.ts` (`hasUiSurface`)
- Modify: `server/typescript/packages/cli/test/unit/reporting-inert.test.ts`
- Test: `metadata/test/report-read-model.test.ts`, `codegen-ts/test/projection/routes-file.test.ts`, `codegen-ts/test/projection/queries-file.test.ts`, `codegen-ts/test/codegen-compile-conformance.test.ts` (existing), `codegen-ts/test/reference-byte-identical.test.ts` (existing), `codegen-ts/test/routes-hono-parity.test.ts` (existing)
@@ -832,9 +832,14 @@ export function servedReport(obj: MetaObject): boolean;
/** True iff the object has a single-column primary identity, so its REST surface has
* /:id routes. Mirrors the JVM RestSurfaceGate.hasItemRoute. */
export function hasItemRoute(entity: MetaObject): boolean;
+
+/** True when the client UI tier (hooks, grids, `agent/ui.md`) is generated for the
+ * object: `servesReadApi(entity)` and not a report. A served report has a route and no
+ * UI tier until Plan 5. Exported from the package index beside `servesReadApi`. */
+export function servesClientTier(entity: MetaObject): boolean;
```
-**What the spike showed.** With the runner swapping a view-backed report for its read model, the existing generators already emit a correct Drizzle view binding, Zod read schema, descriptor (`$path: "/store_totals"`), names artifact, barrel export, Fastify and Hono route files and a hooks file. Five things were wrong, and they are this task: both allowlists were empty; `.queries.ts` had a `find…ById` that does not compile; the route files mounted item routes; the hooks file had a detail hook; a decimal was typed `number`. A report with an enum dimension emitted an inline `z.enum([...])` and compiled.
+**What the spike showed.** With the runner swapping a view-backed report for its read model, the existing generators already emit a correct Drizzle view binding, Zod read schema, descriptor (`$path: "/store_totals"`), names artifact, barrel export, Fastify and Hono route files and a hooks file. Four things were wrong, and they are this task: both allowlists were empty; `.queries.ts` had a `find…ById` that does not compile; the route files mounted item routes; a decimal was typed `number`. The spike also emitted a hooks file; this plan emits none for a report (answer 6), so the hook, grid and grid-hook generators are gated off for reports instead. A report with an enum dimension emitted an inline `z.enum([...])` and compiled.
- [ ] **Step 1: Write the failing tests.**
@@ -869,6 +874,14 @@ test("a decimal derived field is a string in the read schema", ...);
// ProgramMinutes.ts (from meta.fitness.json): avgMinutes: z.string().nullable(),
// longShare: z.string().nullable(); totalMinutes stays z.number().int().nullable()
+test("no UI-tier generator emits for a served report", ...);
+ // tanstackQuery, tanstackGrid, tanstackGridHook and formFile over the `with` model emit
+ // no file whose path starts with StoreTotals; servesReadApi(StoreTotals read model) is
+ // true and servesClientTier is false; hasUiSurface is false for it
+
+test("a keyless projection gets a list hook and no detail hook", ...);
+ // renderReadOnlyHooksFile for a projection with no single-column identity
+
test("a report and an entity that share a route segment are a generation error", ...);
// a model with entity Invoice and view-backed report Invoices throws the existing
// collection-name collision error, naming both
@@ -899,15 +912,15 @@ test("a report and an entity that share a route segment are a generation error",
Reword the warning below it: every selected object is a report with no view source, which generates nothing.
-- [ ] **Step 5: Answer `servesReadApi` and `hasItemRoute`.** `servesReadApi` returns `servedReport(entity)` for a report (declared node or read model) and its existing answer otherwise. Add `hasItemRoute`.
+- [ ] **Step 5: Answer `servesReadApi`, `hasItemRoute` and `servesClientTier`.** `servesReadApi` returns `servedReport(entity)` for a report (declared node or read model) and its existing answer otherwise. Add `hasItemRoute`. Add `servesClientTier` (`servesReadApi(entity) && !isReport(entity)`), export it from the package index, and switch every UI-tier gate from `servesReadApi` to it: `tanstack-query.ts`, `tanstack-grid.ts`, `tanstack-grid-hook.ts`, their `reference/` copies, and `hasUiSurface` in `agent-ui-page.ts`. `formFile` needs no change: `hasGeneratedForm` requires a writable source. The route and queries generators keep `servesReadApi`.
-- [ ] **Step 6: Make the read-only templates keyless-aware.** In `routes-file.ts` and `routes-file-hono.ts`, when `!hasItemRoute(entity)` add `itemRoutes: false,` to the mount options, and when `isReport(entity)` add `resource: "report",` and say "report" in the doc comment ("Exposes GET list only. POST returns 405."). In `renderProjectionQueriesFile`, emit the by-id function only when `hasItemRoute(obj)`, and write "report (read-only)" instead of "projection (read-only)" in the file header for a report. In `renderReadOnlyHooksFile`, emit the detail hook and the `details`/`detail` keys only when `hasItemRoute(entity)`. Apply the same edits to `reference/routes.ts`, `reference/routes-hono.ts` and `reference/queries.ts`. **UNVERIFIED:** how `test-generators/src/` and `codegen-ts-tanstack/src/reference/hooks.ts` stay in step with these; `reference-byte-identical.test.ts` and `reference-templates.test.ts` say, so read them first.
+- [ ] **Step 6: Make the read-only templates keyless-aware.** In `routes-file.ts` and `routes-file-hono.ts`, when `!hasItemRoute(entity)` add `itemRoutes: false,` to the mount options, and when `isReport(entity)` add `resource: "report",` and say "report" in the doc comment ("Exposes GET list only. POST returns 405."). In `renderProjectionQueriesFile`, emit the by-id function only when `hasItemRoute(obj)`, and write "report (read-only)" instead of "projection (read-only)" in the file header for a report. In `renderReadOnlyHooksFile`, emit the detail hook and the `details`/`detail` keys only when `hasItemRoute(entity)`; no report reaches it, so this is the keyless-projection correction only. Apply the same edits to `reference/routes.ts`, `reference/routes-hono.ts` and `reference/queries.ts`. **UNVERIFIED:** how `test-generators/src/` and `codegen-ts-tanstack/src/reference/hooks.ts` stay in step with these; `reference-byte-identical.test.ts` and `reference-templates.test.ts` say, so read them first.
This step also corrects a keyless **projection**, which today gets item routes and a by-id query it cannot serve (open question 4).
- [ ] **Step 7: Type a decimal as a string in a view read schema.** In `field-meta.ts`, move `FIELD_SUBTYPE_DECIMAL` out of the `z.number()` arm into a `z.string()` arm, with a comment that Drizzle's `numeric` reads a string and `field.decimal` is a `string` in TypeScript. This changes the read schema of an existing **projection** with a decimal field (open question 5).
-- [ ] **Step 8: Re-state the inert test.** In `cli/test/unit/reporting-inert.test.ts`: the codegen `describe` becomes "a sourceless report is inert; a served report emits exactly its read-only files". For each catalog generator, the files added by the `with` model are exactly the Table E TypeScript list for `StoreTotals`, and nothing is added for `ProgramEngagement` or `DailyRevenue`. Every file the `without` model emits is byte-identical in the `with` run, the barrel excepted. Update the header comment and `fixtures/codegen-noop/reporting/README.md`.
+- [ ] **Step 8: Re-state the inert test.** In `cli/test/unit/reporting-inert.test.ts`: the codegen `describe` becomes "a sourceless report is inert; a served report emits exactly its read-only files". For each catalog generator, the files added by the `with` model are exactly the Table E TypeScript list for `StoreTotals` (and none at all from a UI-tier generator: hooks, grid, grid hook, form), and nothing is added for `ProgramEngagement` or `DailyRevenue`. Every file the `without` model emits is byte-identical in the `with` run, the barrel excepted. Update the header comment and `fixtures/codegen-noop/reporting/README.md`.
- [ ] **Step 9: Run.**
@@ -952,20 +965,20 @@ test("the seeded report rows are what the views return", async () => {
### Task 5: Reports in `meta docs`
**Files:**
-- Modify: `codegen-ts/src/generators/docs-file.ts` (line 118), `docs-data-builder.ts`, `api-model.ts` (line 303 and `restSymbols`), `agent-ui-page.ts`
+- Modify: `codegen-ts/src/generators/docs-file.ts` (line 118), `docs-data-builder.ts`, `api-model.ts` (line 303 and `restSymbols`)
- Modify: `docs-site/src/coverage.ts` (the `isReportingVocabulary` predicate and `deferred`), `docs-site/src/link-graph.ts` (line 41), and the site's object page template
- Modify: `cli/test/unit/reporting-inert.test.ts` (the `meta docs` `describe`)
Required content is Table G. **UNVERIFIED:** the shape `buildEntityDocData` returns and which template renders a model page; the docs-site page templates; which golden tests pin model and API pages (`codegen-ts/test/golden/` holds `api-docs-accuracy.test.ts`); whether `GET /_meta` lists a report (this plan does not change it).
-- [ ] **Step 1: Read first.** `docs-data-builder.ts` (`buildEntityDocData`), the model page template it feeds, `api-model.ts` `buildEntityUnit` and `restSymbols` (line 617), `agent-ui-page.ts`, `docs-site/src/coverage.ts` and `link-graph.ts`.
+- [ ] **Step 1: Read first.** `docs-data-builder.ts` (`buildEntityDocData`), the model page template it feeds, `api-model.ts` `buildEntityUnit` and `restSymbols` (line 617), `agent-ui-page.ts` (read only; Task 3 already gated it on `servesClientTier`), `docs-site/src/coverage.ts` and `link-graph.ts`.
- [ ] **Step 2: Write failing tests** over `fixtures/codegen-noop/reporting/with/meta.shop.json`:
- the model surface has a page for each of `StoreTotals`, `ProgramEngagement` and `DailyRevenue`; `StoreTotals`'s page names `v_store_totals` and lists `purchases`, `buyers`, `revenue` with their Table B types; the two sourceless pages say they are not served; `Purchase`'s page has a Reporting section naming its dimensions, measures, segments and reports;
- the API surface has one unit for `StoreTotals` whose only REST symbol is `GET /store_totals`, and no unit for the other two;
- - `agent/ui.md` names `useStoreTotalsList` and no `useStoreTotals(id)`;
+ - `agent/ui.md` does not mention `StoreTotals` at all, and the API unit for `StoreTotals` lists no hook symbol;
- the site's coverage report has an empty `deferred` and no "not rendered" warning for a reporting kind;
- a model with no report renders every surface byte-identically to before (the `without` model against a snapshot taken before this task).
-- [ ] **Step 3: Implement.** Remove the two `isReport` skips and the `link-graph.ts` skip. Build a report's model page from `reportShape` (which resolves for a sourceless report too), not from `reportReadModel`. In `restSymbols`, emit the `/:id` symbol only when `hasItemRoute(obj)`, which also corrects the documented endpoints of a keyless projection. In `buildApiModel`, skip a report that is not `servedReport`, and build a served report's unit from `reportReadModel(obj, root)`, since the declared node has no fields for `modelFieldShapes` to read. Delete `isReportingVocabulary`, `deferred` and the branch in `walk`, as the comment at `coverage.ts:16` asks.
+- [ ] **Step 3: Implement.** Remove the two `isReport` skips and the `link-graph.ts` skip. Build a report's model page from `reportShape` (which resolves for a sourceless report too), not from `reportReadModel`. In `restSymbols`, emit the `/:id` symbol only when `hasItemRoute(obj)`, which also corrects the documented endpoints of a keyless projection. In `buildApiModel`, skip a report that is not `servedReport`, and build a served report's unit from `reportReadModel(obj, root)`, since the declared node has no fields for `modelFieldShapes` to read. The unit's hook symbols are emitted only when `servesClientTier(obj)`, so a report's unit names no hook. Delete `isReportingVocabulary`, `deferred` and the branch in `walk`, as the comment at `coverage.ts:16` asks.
- [ ] **Step 4: Re-state the docs `describe`** of `reporting-inert.test.ts` as "differs by exactly" the additions of Step 2.
- [ ] **Step 5: Run.** `cd server/typescript && bun test packages/codegen-ts/test packages/docs-site packages/cli/test/unit/reporting-inert.test.ts`. Expected: PASS.
- [ ] **Step 6: Commit (local).** `git commit -m "feat(docs): model and API pages for reports in meta docs (FR-044)"`.
@@ -1080,7 +1093,7 @@ In `_render_readonly_router`, emit the `get` handler, the three item refusals an
**Files:**
- Modify: `docs/features/reporting.md` ("What does not exist yet", "What the runtime does with it", the last sentence of that section, "Known limits", "What the corpus gates"), `docs/features/api-contract.md` (a "Reports" section after "Read-only projections"; a `date` and a `decimal` note under "Type encodings"), `docs/CONFORMANCE.md` (line 37, the heading at line 258, line 260, the total at line 401), `fixtures/api-contract-conformance/README.md` (the layout and the sub-corpus list)
-- Modify: `agent-context/skills/metaobjects-authoring/SKILL.md` (line 750) and `references/reporting.md`; the five `metaobjects-codegen` references (`typescript.md`, `csharp.md`, `java.md`, `kotlin.md`, `python.md`: a "Reports" paragraph after "Projections"); `agent-context/skills/metaobjects-runtime-ui/SKILL.md` (the list hook)
+- Modify: `agent-context/skills/metaobjects-authoring/SKILL.md` (line 750) and `references/reporting.md`; the five `metaobjects-codegen` references (`typescript.md`, `csharp.md`, `java.md`, `kotlin.md`, `python.md`: a "Reports" paragraph after "Projections"); `agent-context/skills/metaobjects-runtime-ui/SKILL.md` (one sentence: a report has a route and no generated hook, grid or form until Plan 5)
- Regenerate: `fixtures/agent-context-conformance/*/expected/`
- Modify: `docs/ports/typescript.md`, `csharp.md`, `java.md`, `kotlin.md`, `python.md`; `README.md` (line 157, "no routes yet"); `CHANGELOG.md` `[Unreleased]`; `.claude/rules/cross-language-porting.md` and `AGENTS.md` wherever they say a report has no routes
- Modify: `metaobjects/meta.requirements.yaml`, then regenerate `fixtures/requirement-harness/*`
@@ -1099,7 +1112,7 @@ cd ../../../.. && bun scripts/check-doc-examples.ts
Expected: PASS.
- [ ] **Step 4: Counts.** Update the four places in `docs/CONFORMANCE.md` (61 → 73, "+ 12 report") and run `bun test scripts/site/counts.test.ts`. **UNVERIFIED:** whether that test counts api-contract scenarios or only the metamodel corpus; read it first.
- [ ] **Step 5: The project's own requirements ledger.** `metaobjects/meta.requirements.yaml` has `objectReport` (near line 525) and the `reporting` branch (line 1153), all `status: planned`; its description says "how it is served is the API surface". Read those entries and `fixtures/requirement-harness/README.md`. Move to a non-`planned` status only what this plan makes true, with an `@implementedBy` that resolves, then run `bun scripts/generate-requirement-harness.ts` and the five harness tests. **UNVERIFIED:** which entries those are; if none is about serving, change nothing and say so in the commit.
-- [ ] **Step 6: CHANGELOG `[Unreleased]`:** a view-backed report is served by a generated list route in every port; TypeScript, Java and Python now generate a report's row type; a report has model and API pages in `meta docs`; and, under their own bullets, the two corrections that reach beyond reports (keyless projections in TypeScript and Python; a decimal in a TypeScript view read schema), each with the model shape it affects.
+- [ ] **Step 6: CHANGELOG `[Unreleased]`:** a view-backed report is served by a generated list route in every port; TypeScript, Java and Python now generate a report's row type; a report has model and API pages in `meta docs`; no client hook or other UI-tier output is generated for a report yet; and, under their own bullets, the two corrections that reach beyond reports (keyless projections in TypeScript and Python; a decimal in a TypeScript view read schema), each with the model shape it affects.
- [ ] **Step 7: Commit (local).** `git commit -m "docs(reporting): report routes, the report api-contract corpus, and the per-port generators (FR-044)"`.
---
@@ -1110,7 +1123,7 @@ Expected: PASS.
- [ ] **Step 2:** `scripts/integration-test.sh` for the api-contract and persistence lanes in all five ports. The persistence lanes prove the read models still read with `@filterable` set.
- [ ] **Step 3:** `node scripts/check-metamodel-version.mjs` (no `--set`). Expected: passes with no vocabulary change reported.
- [ ] **Step 4:** Independent review of the whole change by a fresh reviewer over `git diff origin/main..HEAD`; fix findings.
-- [ ] **Step 5:** Push and open the pull request. Comment on the FR-044 issue with the commit range and "Plan 3 of 5 done".
+- [ ] **Step 5:** Rebase on the current `origin/main`, re-run the full `scripts/ci-local.sh`, and hand the branch to the validation gate, which pushes and opens the pull request. The FR-044 issue comment is made after merge.
---
@@ -1150,6 +1163,18 @@ Each is the first step of the task that touches it.
| What `scripts/site/counts.test.ts` counts | 10 |
| Which entries of the project's own requirements ledger this plan makes true | 10 |
+## Answers to the open questions
+
+Ruled 2026-10-04, before execution. The questions are kept below as asked.
+
+1. All derived fields are filterable and sortable. No new `@filterable` vocabulary.
+2. The existing pluralized snake_case rule (`InvoicesByMonth` at `/invoices_by_months`).
+3. `/{id}` is not mounted; only the framework's own `404` status is asserted.
+4. Fix keyless projections in TypeScript and Python here, with the same switch a report needs. Unit tests only, no new `projection/` scenario. Recorded as behaviour change 2.
+5. Both parts confirmed: the corpus asserts a ratio is present and filterable, not its spelling; the TypeScript view read schema types a decimal as a string for reports and projections alike. Recorded as behaviour change 3.
+6. **Different from the plan as first written:** no typed client list hook (TanStack) and no other UI-tier output for a report in Plan 3. The UI tier stays off for reports until Plan 5. The tasks, tables and expected outputs above were changed to match: `servesReadApi` still answers true for a served report, so the route and queries generators emit; a new `servesClientTier` gate keeps the hook, grid and grid-hook generators and `agent/ui.md` off.
+7. Both asymmetries stay as described.
+
## Open questions for the captain
1. **What may a caller filter and sort on?** A report declares no fields, so there is nowhere to write `@filterable`. The plan makes every derived field filterable and sortable (Table C), which is what the spec's "the standard `?filter`, `?sort`, paging over the derived fields" says. Letting an author narrow the set would mean registering `@filterable` on dimensions and measures, which is outside §3.1. Confirm "all derived fields".
From 6936913f344caaf5ef9a7ec02e1c3e015a4ff4b3 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 17:49:20 -0400
Subject: [PATCH 02/21] test(api-contract): report sub-corpus, its schema
artifact and a self-check (FR-044)
---
.../api-contract-conformance/report/README.md | 121 ++++++++++++++++++
.../api-contract-conformance/report/meta.json | 53 ++++++++
.../scenarios/filter-invalid-field.yaml | 14 ++
.../report/scenarios/filter-invalid-op.yaml | 13 ++
.../report/scenarios/filter-on-dimension.yaml | 12 ++
.../report/scenarios/filter-on-measure.yaml | 39 ++++++
.../report/scenarios/list-time-grain.yaml | 16 +++
.../report/scenarios/list-totals.yaml | 22 ++++
.../report/scenarios/list.yaml | 17 +++
.../report/scenarios/no-item-route.yaml | 10 ++
.../report/scenarios/pagination.yaml | 22 ++++
.../scenarios/sort-desc-on-measure.yaml | 14 ++
.../report/scenarios/sort-invalid.yaml | 12 ++
.../report/scenarios/write-verbs-405.yaml | 15 +++
.../report/schema.postgres.sql | 40 ++++++
.../api-contract-conformance/report/seed.json | 24 ++++
.../packages/integration-tests/package.json | 1 +
.../src/api-contract-report-schema.ts | 19 +++
.../integration-tests/src/canonical-schema.ts | 11 +-
.../src/gen-api-contract-report-schema.ts | 35 +++++
.../packages/integration-tests/src/paths.ts | 4 +
.../test/api-contract-report-corpus.test.ts | 62 +++++++++
22 files changed, 574 insertions(+), 2 deletions(-)
create mode 100644 fixtures/api-contract-conformance/report/README.md
create mode 100644 fixtures/api-contract-conformance/report/meta.json
create mode 100644 fixtures/api-contract-conformance/report/scenarios/filter-invalid-field.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/filter-invalid-op.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/filter-on-dimension.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/filter-on-measure.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/list-time-grain.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/list-totals.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/list.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/no-item-route.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/pagination.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/sort-desc-on-measure.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/sort-invalid.yaml
create mode 100644 fixtures/api-contract-conformance/report/scenarios/write-verbs-405.yaml
create mode 100644 fixtures/api-contract-conformance/report/schema.postgres.sql
create mode 100644 fixtures/api-contract-conformance/report/seed.json
create mode 100644 server/typescript/packages/integration-tests/src/api-contract-report-schema.ts
create mode 100644 server/typescript/packages/integration-tests/src/gen-api-contract-report-schema.ts
create mode 100644 server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts
diff --git a/fixtures/api-contract-conformance/report/README.md b/fixtures/api-contract-conformance/report/README.md
new file mode 100644
index 000000000..c4ec77fc5
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/README.md
@@ -0,0 +1,121 @@
+# `api-contract-conformance/report/` — FR-044: a VIEW-BACKED report serves REST
+
+Cross-port REST contract for an **`object.report` that declares a read-only view**
+(`source.rdb @kind:view @view:v_invoice_status_totals`), over the writable `Invoice`
+entity it reads from.
+
+## What this gates
+
+Plan 2 lowers a view-backed report to a SQL view and every port reads it. This
+sub-corpus gates the next step: that every port's **generator emits a read route for
+it**, with the same filter, sort and paging surface as any other list route, and
+nothing else. A report is a derived read model: it has no identity, so it has no item
+address and no write verb.
+
+## The contract
+
+`` is the report name, `snake_case`d and then pluralized, the rule every
+object uses (`InvoicesByMonth` is `/api/invoices_by_months`).
+
+| Request | Answer |
+|---|---|
+| `GET /api/` | `200`, a JSON array of rows, one per distinct dimension tuple. `?filter[...]`, `?sort=`, `limit` and `offset` apply exactly as on any list route. `withCount=1` answers `{ "rows": [...], "total": N }`, where `N` is the number of groups after filtering. |
+| `POST /api/` | `405 {"error": "method_not_allowed"}`. `message` is free prose and is not asserted. |
+| any verb on `/api//{id}` | Not mounted. The framework's own `404`; its body is outside the contract. |
+| a filter or sort error | The four field-naming envelopes of `docs/features/api-contract.md`, unchanged. |
+
+- **Every derived field is filterable and sortable.** Dimensions and measures alike
+ carry a filter band, and the allowlists are generated from the **report's own**
+ derived fields (`reportShape`), not from the `@from` entity: `reference` is a field
+ of `Invoice` and is rejected on `InvoiceStatusTotals`.
+- **A time dimension at a grain** is a derived field named ``, typed
+ `date`, spelled `YYYY-MM-DD` (`issuedOnMonth`).
+- **A sum scoped by a segment is nullable**: the key is present and its value is
+ `null` for a group with no row in the segment.
+- **No typed client hook and no UI tier is generated for a report** (no TanStack hook,
+ grid, grid hook or form). The read route and its row type are the whole surface.
+- **A decimal's spelling is not asserted.** `paidShare` is a ratio, so it is a decimal,
+ and each port spells a decimal its own way. The scenarios that touch it assert only
+ how many rows match.
+- The default page size stays per port, as documented: TypeScript and C# return every
+ row when `limit` is omitted, Java, Kotlin and Python the first 50. The scenarios
+ that page pass `limit` explicitly.
+
+## The model
+
+One fact entity and four reports, three served and one sourceless on purpose.
+
+| Report | Route | View | Derived fields |
+|---|---|---|---|
+| `InvoiceStatusTotals` | `/api/invoice_status_totals` | `v_invoice_status_totals` | `status`, `invoices`, `totalCents`, `paidCents` |
+| `InvoicesByMonth` | `/api/invoices_by_months` | `v_invoices_by_month` | `issuedOnMonth`, `invoices`, `totalCents` |
+| `InvoiceTotals` | `/api/invoice_totals` | `v_invoice_totals` | `invoices`, `totalCents`, `paidShare` |
+| `InvoiceDays` | none | none | `issuedOnDay`, `invoices` |
+
+`InvoiceDays` declares no `source.rdb`. **A report is served only when it declares a
+view**, so a sourceless one must stay inert in every generator and mount nothing. It
+sits in the model so that a port which serves every report it finds fails this corpus
+(its generated tree would carry a route no scenario calls and a table no schema has).
+
+## Files
+
+```
+report/
+├── README.md # this file
+├── meta.json # Invoice + four object.report nodes (three served, one sourceless)
+├── seed.json # `invoices` (the base table) and `reports` (what the views return)
+├── schema.postgres.sql # TypeScript-produced: the table and the three views
+└── scenarios/
+ ├── list.yaml # GET list, dimension + segment-scoped sum
+ ├── list-time-grain.yaml # a time dimension at a grain
+ ├── list-totals.yaml # no dimensions: exactly one row; withCount envelope
+ ├── filter-on-dimension.yaml # ?filter on a dimension
+ ├── filter-on-measure.yaml # ?filter on a count, a null sum and a ratio
+ ├── filter-invalid-field.yaml # 400 envelope, naming the field
+ ├── filter-invalid-op.yaml # 400 envelope, naming the field
+ ├── sort-desc-on-measure.yaml # ?sort on a measure
+ ├── sort-invalid.yaml # 400 envelope, naming the field
+ ├── pagination.yaml # limit / offset / withCount count groups
+ ├── write-verbs-405.yaml # POST on the collection -> 405 + envelope
+ └── no-item-route.yaml # GET/PATCH/PUT/DELETE on /{id} -> 404, no body assertion
+```
+
+### Why `seed.json` has two halves
+
+`invoices` is the base table. The full-stack lanes (TypeScript, C#) insert it and let
+the real view derive the report rows from it. `reports` is what the three views return
+for those invoices, and it is what the **seam lanes** (Java, Kotlin, Python) serve:
+their in-memory repository or H2 table stands in for the view behind the consumer seam.
+A TypeScript test holds the two halves together, so the seam lanes cannot drift from
+what the SQL returns. `paidShare` is a string in the seed so a seam lane can build its
+own decimal from it without a float in between.
+
+### Why the view SQL is a committed artifact
+
+**View SQL is produced by TypeScript only** (ADR-0015). No other port emits SQL for a
+report, in product code or in a test harness. `schema.postgres.sql` is what TypeScript
+produces from `meta.json` (literal column naming), committed and drift-checked by
+`test/api-contract-report-corpus.test.ts`, and the C# lane executes that file. Java,
+Kotlin and Python do not run it: they seed `reports` rows behind their seam.
+Regenerate it with `bun run gen:report-api-schema` in
+`server/typescript/packages/integration-tests`.
+
+## Lane coverage — the GENERATED lane, on all five ports
+
+This sub-corpus runs **only the generated lane**, for the reason `projection/` gives:
+what is under test is whether a port's **generator emits the routes**, and a
+hand-rolled reference server would answer every scenario by construction.
+
+### Wiring status
+
+| Port | Generated lane | Note |
+|---|---|---|
+| TypeScript | not yet wired | `test/api-contract-report.test.ts` |
+| C# | not yet wired | `Api/ApiContractReportConformanceTest.cs` |
+| Java | not yet wired | `api/ReportGeneratedApiContractConformanceTest.java` |
+| Kotlin | not yet wired | `api/report/ReportGeneratedApiContractConformanceTest.kt` |
+| Python | not yet wired | `tests/integration/test_api_contract_report.py` |
+
+The corpus lands ahead of the ports on purpose: it is the contract they are changed to
+satisfy. The scenarios use only assertion keys every runner already has (`equals`,
+`length`, `envelope`, `error` with `field`, and a status with no `body`).
diff --git a/fixtures/api-contract-conformance/report/meta.json b/fixtures/api-contract-conformance/report/meta.json
new file mode 100644
index 000000000..4cd743d60
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/meta.json
@@ -0,0 +1,53 @@
+{
+ "metadata.root": {
+ "package": "acme::sales",
+ "children": [
+ { "object.entity": {
+ "name": "Invoice",
+ "children": [
+ { "source.rdb": { "@table": "invoices" } },
+ { "field.long": { "name": "id" } },
+ { "field.string": { "name": "reference", "@required": true, "@maxLength": 40 } },
+ { "field.string": { "name": "status", "@required": true, "@maxLength": 20 } },
+ { "field.long": { "name": "amountCents", "@required": true } },
+ { "field.date": { "name": "issuedOn", "@required": true } },
+ { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } },
+ { "segment.filter": { "name": "paid", "@filter": { "status": "PAID" } } },
+ { "dimension.attribute": { "name": "status", "@of": "Invoice.status" } },
+ { "dimension.time": { "name": "issuedOn", "@of": "Invoice.issuedOn", "@grains": ["day", "month"] } },
+ { "measure.aggregate": { "name": "invoices", "@agg": "count", "@of": "Invoice.id" } },
+ { "measure.aggregate": { "name": "paidInvoices", "@agg": "count", "@of": "Invoice.id", "@segment": "paid" } },
+ { "measure.aggregate": { "name": "totalCents", "@agg": "sum", "@of": "Invoice.amountCents" } },
+ { "measure.aggregate": { "name": "paidCents", "@agg": "sum", "@of": "Invoice.amountCents", "@segment": "paid" } },
+ { "measure.ratio": { "name": "paidShare", "@numerator": "paidInvoices", "@denominator": "invoices" } }
+ ]
+ }},
+ { "object.report": {
+ "name": "InvoiceStatusTotals",
+ "@from": "Invoice",
+ "@dimensions": ["status"],
+ "@measures": ["invoices", "totalCents", "paidCents"],
+ "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoice_status_totals" } } ]
+ }},
+ { "object.report": {
+ "name": "InvoicesByMonth",
+ "@from": "Invoice",
+ "@dimensions": ["issuedOn:month"],
+ "@measures": ["invoices", "totalCents"],
+ "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoices_by_month" } } ]
+ }},
+ { "object.report": {
+ "name": "InvoiceTotals",
+ "@from": "Invoice",
+ "@measures": ["invoices", "totalCents", "paidShare"],
+ "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoice_totals" } } ]
+ }},
+ { "object.report": {
+ "name": "InvoiceDays",
+ "@from": "Invoice",
+ "@dimensions": ["issuedOn:day"],
+ "@measures": ["invoices"]
+ }}
+ ]
+ }
+}
diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-invalid-field.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-invalid-field.yaml
new file mode 100644
index 000000000..215ff3b1f
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/filter-invalid-field.yaml
@@ -0,0 +1,14 @@
+name: report-filter-invalid-field
+description: >
+ `reference` is a field of Invoice, the report's @from entity, and not a derived field
+ of the report. The allowlist comes from the report's own derived fields, so it is
+ rejected with the envelope that names it.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_status_totals?filter[reference][eq]=INV-1001
+ expect:
+ status: 400
+ body:
+ error: "invalid_filter_field"
+ field: "reference"
diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-invalid-op.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-invalid-op.yaml
new file mode 100644
index 000000000..6ac30a4e5
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/filter-invalid-op.yaml
@@ -0,0 +1,13 @@
+name: report-filter-invalid-op
+description: >
+ A count is a long, so it takes the numeric operators and not `like`. The operator set
+ of a derived field is the one its derived subtype has on any other object.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_status_totals?filter[invoices][like]=2
+ expect:
+ status: 400
+ body:
+ error: "invalid_filter_op"
+ field: "invoices"
diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-on-dimension.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-on-dimension.yaml
new file mode 100644
index 000000000..e56481702
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/filter-on-dimension.yaml
@@ -0,0 +1,12 @@
+name: report-filter-on-dimension
+description: >
+ ?filter[status][eq]=OPEN — a dimension is a filterable derived field.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_status_totals?filter[status][eq]=OPEN
+ expect:
+ status: 200
+ body:
+ equals:
+ - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null }
diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-on-measure.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-on-measure.yaml
new file mode 100644
index 000000000..5427f7d10
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/filter-on-measure.yaml
@@ -0,0 +1,39 @@
+name: report-filter-on-measure
+description: >
+ The FR-009 filter grammar applies to a report's derived fields, measures included.
+ r1 filters on a count, r2 on a null sum, r3 and r4 on a ratio. A ratio is a decimal:
+ its spelling is the port's own and is not asserted, so r3 and r4 assert only how many
+ rows match (the ratio is 2/5).
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_status_totals?filter[invoices][gte]=2&sort=status:asc
+ expect:
+ status: 200
+ body:
+ equals:
+ - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null }
+ - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 }
+ - id: r2
+ method: GET
+ path: /api/invoice_status_totals?filter[paidCents][isNull]=true&sort=status:asc
+ expect:
+ status: 200
+ body:
+ equals:
+ - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null }
+ - { status: "VOID", invoices: 1, totalCents: 0, paidCents: null }
+ - id: r3
+ method: GET
+ path: /api/invoice_totals?filter[paidShare][gt]=0
+ expect:
+ status: 200
+ body:
+ length: 1
+ - id: r4
+ method: GET
+ path: /api/invoice_totals?filter[paidShare][gt]=0.5
+ expect:
+ status: 200
+ body:
+ length: 0
diff --git a/fixtures/api-contract-conformance/report/scenarios/list-time-grain.yaml b/fixtures/api-contract-conformance/report/scenarios/list-time-grain.yaml
new file mode 100644
index 000000000..f53e09581
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/list-time-grain.yaml
@@ -0,0 +1,16 @@
+name: report-list-time-grain
+description: >
+ GET /api/invoices_by_months — a time dimension at a grain is a derived field named
+ and typed date: the first day of the bucket, spelled YYYY-MM-DD.
+ The segment is the report NAME snake_cased and pluralized, like every other object.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoices_by_months?sort=issuedOnMonth:asc
+ expect:
+ status: 200
+ body:
+ equals:
+ - { issuedOnMonth: "2026-04-01", invoices: 1, totalCents: 125000 }
+ - { issuedOnMonth: "2026-05-01", invoices: 3, totalCents: 130500 }
+ - { issuedOnMonth: "2026-06-01", invoices: 1, totalCents: 60000 }
diff --git a/fixtures/api-contract-conformance/report/scenarios/list-totals.yaml b/fixtures/api-contract-conformance/report/scenarios/list-totals.yaml
new file mode 100644
index 000000000..a0f00419c
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/list-totals.yaml
@@ -0,0 +1,22 @@
+name: report-list-totals
+description: >
+ GET /api/invoice_totals — a report with no dimensions is exactly one row. It carries
+ a ratio, whose spelling is not asserted, so the row is counted rather than compared.
+ withCount=1 wraps the same list in the { rows, total } envelope.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_totals
+ expect:
+ status: 200
+ body:
+ length: 1
+ - id: r2
+ method: GET
+ path: /api/invoice_totals?withCount=1
+ expect:
+ status: 200
+ body:
+ envelope: true
+ rowsLength: 1
+ total: 1
diff --git a/fixtures/api-contract-conformance/report/scenarios/list.yaml b/fixtures/api-contract-conformance/report/scenarios/list.yaml
new file mode 100644
index 000000000..a92ff86a5
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/list.yaml
@@ -0,0 +1,17 @@
+name: report-list
+description: >
+ GET /api/invoice_status_totals — a view-backed object.report serves a list route.
+ One row per distinct dimension tuple, one key per derived field. paidCents is a
+ sum scoped by the `paid` segment: it is null for a group with no paid row, and the
+ key is present. Ordered by the dimension so the assertion is deterministic.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_status_totals?sort=status:asc
+ expect:
+ status: 200
+ body:
+ equals:
+ - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null }
+ - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 }
+ - { status: "VOID", invoices: 1, totalCents: 0, paidCents: null }
diff --git a/fixtures/api-contract-conformance/report/scenarios/no-item-route.yaml b/fixtures/api-contract-conformance/report/scenarios/no-item-route.yaml
new file mode 100644
index 000000000..cf5ac90fd
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/no-item-route.yaml
@@ -0,0 +1,10 @@
+name: report-no-item-route
+description: >
+ A report has no identity, so no /{id} route of any verb is mounted. The status is
+ the contract; the body is the framework's own 404 and is not asserted. In particular
+ a GET must never answer a row.
+requests:
+ - { id: get-item, method: GET, path: /api/invoice_status_totals/1, expect: { status: 404 } }
+ - { id: patch-item, method: PATCH, path: /api/invoice_status_totals/1, body: { invoices: 9 }, expect: { status: 404 } }
+ - { id: put-item, method: PUT, path: /api/invoice_status_totals/1, body: { invoices: 9 }, expect: { status: 404 } }
+ - { id: delete-item, method: DELETE, path: /api/invoice_status_totals/1, expect: { status: 404 } }
diff --git a/fixtures/api-contract-conformance/report/scenarios/pagination.yaml b/fixtures/api-contract-conformance/report/scenarios/pagination.yaml
new file mode 100644
index 000000000..8f0142075
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/pagination.yaml
@@ -0,0 +1,22 @@
+name: report-pagination
+description: >
+ limit and offset page the groups, and withCount=1 reports how many groups there are,
+ not how many invoices.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_status_totals?sort=status:asc&limit=1&offset=1
+ expect:
+ status: 200
+ body:
+ equals:
+ - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 }
+ - id: r2
+ method: GET
+ path: /api/invoice_status_totals?sort=status:asc&limit=2&withCount=1
+ expect:
+ status: 200
+ body:
+ envelope: true
+ rowsLength: 2
+ total: 3
diff --git a/fixtures/api-contract-conformance/report/scenarios/sort-desc-on-measure.yaml b/fixtures/api-contract-conformance/report/scenarios/sort-desc-on-measure.yaml
new file mode 100644
index 000000000..b25a25e9b
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/sort-desc-on-measure.yaml
@@ -0,0 +1,14 @@
+name: report-sort-desc-on-measure
+description: >
+ ?sort=totalCents:desc — a measure is a sortable derived field.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_status_totals?sort=totalCents:desc
+ expect:
+ status: 200
+ body:
+ equals:
+ - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 }
+ - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null }
+ - { status: "VOID", invoices: 1, totalCents: 0, paidCents: null }
diff --git a/fixtures/api-contract-conformance/report/scenarios/sort-invalid.yaml b/fixtures/api-contract-conformance/report/scenarios/sort-invalid.yaml
new file mode 100644
index 000000000..fb6bcf8c9
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/sort-invalid.yaml
@@ -0,0 +1,12 @@
+name: report-sort-invalid
+description: >
+ A sort on a field the report does not derive is rejected, naming the field.
+requests:
+ - id: r1
+ method: GET
+ path: /api/invoice_status_totals?sort=reference:asc
+ expect:
+ status: 400
+ body:
+ error: "invalid_sort"
+ field: "reference"
diff --git a/fixtures/api-contract-conformance/report/scenarios/write-verbs-405.yaml b/fixtures/api-contract-conformance/report/scenarios/write-verbs-405.yaml
new file mode 100644
index 000000000..b61df566e
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/scenarios/write-verbs-405.yaml
@@ -0,0 +1,15 @@
+name: report-write-verbs-405
+description: >
+ A report is read-only. POST on the collection answers 405 with the cross-port envelope,
+ not a 404 and not the framework's own body. There is no item address to refuse a
+ PATCH, PUT or DELETE on (see report-no-item-route). `message` is free prose and is
+ not asserted.
+requests:
+ - id: post-collection
+ method: POST
+ path: /api/invoice_status_totals
+ body: { status: "OPEN", invoices: 1, totalCents: 1, paidCents: 1 }
+ expect:
+ status: 405
+ body:
+ error: "method_not_allowed"
diff --git a/fixtures/api-contract-conformance/report/schema.postgres.sql b/fixtures/api-contract-conformance/report/schema.postgres.sql
new file mode 100644
index 000000000..49589fee1
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/schema.postgres.sql
@@ -0,0 +1,40 @@
+-- @generated by @metaobjectsdev/integration-tests — DO NOT EDIT.
+-- api-contract report sub-corpus schema (Postgres, literal column naming).
+-- Produced by TypeScript from report/meta.json; the C# generated lane executes it.
+-- Regenerate: `bun run gen:report-api-schema` in this package.
+
+CREATE TABLE "invoices" (
+ "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ "reference" VARCHAR(40) NOT NULL,
+ "status" VARCHAR(20) NOT NULL,
+ "amountCents" BIGINT NOT NULL,
+ "issuedOn" DATE NOT NULL,
+ CONSTRAINT "invoices_pkey" PRIMARY KEY ("id")
+);
+
+CREATE VIEW "v_invoice_status_totals" AS
+ SELECT
+ i."status" AS "status",
+ COUNT(i."id") AS "invoices",
+ CAST(SUM(i."amountCents") AS BIGINT) AS "totalCents",
+ CAST(SUM(i."amountCents") FILTER (WHERE i."status" = 'PAID') AS BIGINT) AS "paidCents"
+ FROM "invoices" i
+ GROUP BY i."status";
+COMMENT ON VIEW "v_invoice_status_totals" IS 'metaobjects:v1:sha256:e6680d9a571f00db22cea3a408719f2bf288c059dbf9da47a104130e0aa718c8';
+
+CREATE VIEW "v_invoices_by_month" AS
+ SELECT
+ CAST(date_trunc('month', CAST(i."issuedOn" AS TIMESTAMP)) AS DATE) AS "issuedOnMonth",
+ COUNT(i."id") AS "invoices",
+ CAST(SUM(i."amountCents") AS BIGINT) AS "totalCents"
+ FROM "invoices" i
+ GROUP BY CAST(date_trunc('month', CAST(i."issuedOn" AS TIMESTAMP)) AS DATE);
+COMMENT ON VIEW "v_invoices_by_month" IS 'metaobjects:v1:sha256:3fafe24c9b2e4f02f7d1d4c0860fdd67565c811e6cff57839e08e684632c2e40';
+
+CREATE VIEW "v_invoice_totals" AS
+ SELECT
+ COUNT(i."id") AS "invoices",
+ CAST(SUM(i."amountCents") AS BIGINT) AS "totalCents",
+ CAST(COUNT(i."id") FILTER (WHERE i."status" = 'PAID') AS NUMERIC) / NULLIF(COUNT(i."id"), 0) AS "paidShare"
+ FROM "invoices" i;
+COMMENT ON VIEW "v_invoice_totals" IS 'metaobjects:v1:sha256:d99e43a93181f09bf2af44297ae4bf3f0f52dc5fc1dd73279b2ab76fab12a5fb';
diff --git a/fixtures/api-contract-conformance/report/seed.json b/fixtures/api-contract-conformance/report/seed.json
new file mode 100644
index 000000000..9d389baee
--- /dev/null
+++ b/fixtures/api-contract-conformance/report/seed.json
@@ -0,0 +1,24 @@
+{
+ "invoices": [
+ { "id": 1, "reference": "INV-1001", "status": "PAID", "amountCents": 125000, "issuedOn": "2026-04-30" },
+ { "id": 2, "reference": "INV-1002", "status": "OPEN", "amountCents": 40000, "issuedOn": "2026-05-01" },
+ { "id": 3, "reference": "INV-1003", "status": "OPEN", "amountCents": 90500, "issuedOn": "2026-05-17" },
+ { "id": 4, "reference": "INV-1004", "status": "VOID", "amountCents": 0, "issuedOn": "2026-05-31" },
+ { "id": 5, "reference": "INV-1005", "status": "PAID", "amountCents": 60000, "issuedOn": "2026-06-01" }
+ ],
+ "reports": {
+ "InvoiceStatusTotals": [
+ { "status": "OPEN", "invoices": 2, "totalCents": 130500, "paidCents": null },
+ { "status": "PAID", "invoices": 2, "totalCents": 185000, "paidCents": 185000 },
+ { "status": "VOID", "invoices": 1, "totalCents": 0, "paidCents": null }
+ ],
+ "InvoicesByMonth": [
+ { "issuedOnMonth": "2026-04-01", "invoices": 1, "totalCents": 125000 },
+ { "issuedOnMonth": "2026-05-01", "invoices": 3, "totalCents": 130500 },
+ { "issuedOnMonth": "2026-06-01", "invoices": 1, "totalCents": 60000 }
+ ],
+ "InvoiceTotals": [
+ { "invoices": 5, "totalCents": 315500, "paidShare": "0.4" }
+ ]
+ }
+}
diff --git a/server/typescript/packages/integration-tests/package.json b/server/typescript/packages/integration-tests/package.json
index 8fb9f00d5..eca617be7 100644
--- a/server/typescript/packages/integration-tests/package.json
+++ b/server/typescript/packages/integration-tests/package.json
@@ -7,6 +7,7 @@
"scripts": {
"test": "bun test",
"gen:schema": "bun run src/gen-canonical-schema.ts",
+ "gen:report-api-schema": "bun run src/gen-api-contract-report-schema.ts",
"gen:report-shapes": "bun run src/gen-report-shapes.ts",
"oracle": "bun run src/oracle-cli.ts"
},
diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-schema.ts b/server/typescript/packages/integration-tests/src/api-contract-report-schema.ts
new file mode 100644
index 000000000..e7de495e1
--- /dev/null
+++ b/server/typescript/packages/integration-tests/src/api-contract-report-schema.ts
@@ -0,0 +1,19 @@
+// api-contract-report-schema.ts — the schema artifact of the api-contract `report/`
+// sub-corpus. TypeScript is the only producer of view SQL (ADR-0015); the C# generated
+// lane executes the committed file, and the seam lanes seed rows behind their seam.
+
+import type { MetaRoot } from "@metaobjectsdev/metadata";
+
+import { generateCanonicalSchemaSql } from "./canonical-schema.ts";
+
+export const REPORT_API_SCHEMA_HEADER = [
+ "-- @generated by @metaobjectsdev/integration-tests — DO NOT EDIT.",
+ "-- api-contract report sub-corpus schema (Postgres, literal column naming).",
+ "-- Produced by TypeScript from report/meta.json; the C# generated lane executes it.",
+ "-- Regenerate: `bun run gen:report-api-schema` in this package.",
+].join("\n");
+
+/** The bytes of report/schema.postgres.sql for a loaded report/meta.json. */
+export function generateReportApiSchemaSql(root: MetaRoot): Promise {
+ return generateCanonicalSchemaSql(root, { header: REPORT_API_SCHEMA_HEADER });
+}
diff --git a/server/typescript/packages/integration-tests/src/canonical-schema.ts b/server/typescript/packages/integration-tests/src/canonical-schema.ts
index 542e06e01..147cd1f77 100644
--- a/server/typescript/packages/integration-tests/src/canonical-schema.ts
+++ b/server/typescript/packages/integration-tests/src/canonical-schema.ts
@@ -62,8 +62,14 @@ const HEADER_LINES: readonly string[] = [
* and emit the full-CREATE `up` SQL. Deterministic given the same metadata —
* table/view order follows declaration order; emit() applies a stable stage
* sort — so the committed file does not churn.
+ *
+ * `opts.header` replaces the persistence-corpus header for another corpus's artifact
+ * (the api-contract report sub-corpus); absent, the output is byte-identical to before.
*/
-export async function generateCanonicalSchemaSql(root: MetaRoot): Promise {
+export async function generateCanonicalSchemaSql(
+ root: MetaRoot,
+ opts?: { header?: string },
+): Promise {
const expected = buildExpectedSchema(root, {
dialect: CANONICAL_SCHEMA_DIALECT,
columnNamingStrategy: CANONICAL_COLUMN_NAMING,
@@ -74,7 +80,8 @@ export async function generateCanonicalSchemaSql(root: MetaRoot): Promise {
+ const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json"));
+ const sql = await generateReportApiSchemaSql(root);
+
+ // Generated-wins, never silent: same policy as gen-canonical-schema.ts.
+ const existing = existsSync(API_CONTRACT_REPORT_SCHEMA_SQL_PATH)
+ ? readFileSync(API_CONTRACT_REPORT_SCHEMA_SQL_PATH, "utf8")
+ : undefined;
+ const replaced = describeRegenReplacement(existing, sql);
+
+ writeFileSync(API_CONTRACT_REPORT_SCHEMA_SQL_PATH, sql, "utf8");
+ /* eslint-disable no-console */
+ if (replaced !== undefined) console.warn(replaced);
+ console.log(`wrote ${API_CONTRACT_REPORT_SCHEMA_SQL_PATH} (${sql.length} bytes)`);
+ /* eslint-enable no-console */
+}
+
+main().catch((err: unknown) => {
+ // eslint-disable-next-line no-console
+ console.error(err);
+ process.exit(1);
+});
diff --git a/server/typescript/packages/integration-tests/src/paths.ts b/server/typescript/packages/integration-tests/src/paths.ts
index 04451e94b..caea94642 100644
--- a/server/typescript/packages/integration-tests/src/paths.ts
+++ b/server/typescript/packages/integration-tests/src/paths.ts
@@ -40,6 +40,10 @@ export const API_CONTRACT_WRITE_THROUGH_SCENARIOS_DIR = resolve(API_CONTRACT_WRI
// F22 view-only projection subcorpus (GENERATED lane only, every port).
export const API_CONTRACT_PROJECTION_DIR = resolve(API_CONTRACT_DIR, "projection");
export const API_CONTRACT_PROJECTION_SCENARIOS_DIR = resolve(API_CONTRACT_PROJECTION_DIR, "scenarios");
+// FR-044 view-backed report subcorpus (GENERATED lane only, every port).
+export const API_CONTRACT_REPORT_DIR = resolve(API_CONTRACT_DIR, "report");
+export const API_CONTRACT_REPORT_SCENARIOS_DIR = resolve(API_CONTRACT_REPORT_DIR, "scenarios");
+export const API_CONTRACT_REPORT_SCHEMA_SQL_PATH = resolve(API_CONTRACT_REPORT_DIR, "schema.postgres.sql");
// fixtures/validation-conformance/ — cross-port generated input-validation corpus.
export const VALIDATION_DIR = resolve(repoRoot, "fixtures", "validation-conformance");
diff --git a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts
new file mode 100644
index 000000000..838f95833
--- /dev/null
+++ b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import {
+ OBJECT_SUBTYPE_REPORT, pluralize, reportReadSource, reportShape, toSnakeCase,
+} from "@metaobjectsdev/metadata";
+import { loadMetadataFile } from "../src/load-metadata.ts";
+import { loadScenarios } from "../src/api-contract-scenario.ts";
+import { generateReportApiSchemaSql } from "../src/api-contract-report-schema.ts";
+import {
+ API_CONTRACT_REPORT_DIR, API_CONTRACT_REPORT_SCENARIOS_DIR, API_CONTRACT_REPORT_SCHEMA_SQL_PATH,
+} from "../src/paths.ts";
+
+const seed = JSON.parse(readFileSync(join(API_CONTRACT_REPORT_DIR, "seed.json"), "utf8")) as {
+ invoices: Array>;
+ reports: Record>>;
+};
+
+describe("api-contract report corpus", () => {
+ test("the model loads and three of its four reports are view-backed", async () => {
+ const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json"));
+ const reports = root.objects().filter((o) => o.subType === OBJECT_SUBTYPE_REPORT);
+ expect(reports.map((r) => r.name)).toEqual(
+ ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceDays"]);
+ expect(reports.filter((r) => reportReadSource(r) !== undefined).map((r) => r.name)).toEqual(
+ ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals"]);
+ });
+
+ test("the route segments are the ones the scenarios call", async () => {
+ const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json"));
+ const segment = (name: string): string => pluralize(toSnakeCase(name));
+ expect(["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals"].map(segment)).toEqual(
+ ["invoice_status_totals", "invoices_by_months", "invoice_totals"]);
+ expect(root.objects().length).toBe(5);
+ });
+
+ test("each seeded report row has exactly the report's derived fields", async () => {
+ const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json"));
+ for (const [name, rows] of Object.entries(seed.reports)) {
+ const report = root.objects().find((o) => o.name === name);
+ if (report === undefined) throw new Error(`seed.json names a report the model lacks: ${name}`);
+ const fields = reportShape(report, root).fields.map((f) => f.name);
+ for (const row of rows) expect(Object.keys(row)).toEqual(fields);
+ }
+ expect(Object.keys(seed.reports)).toEqual(["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals"]);
+ });
+
+ test("every scenario parses, and there are twelve", () => {
+ const scenarios = loadScenarios(API_CONTRACT_REPORT_SCENARIOS_DIR);
+ expect(scenarios.length).toBe(12);
+ for (const s of scenarios) expect(s.requests.length).toBeGreaterThan(0);
+ });
+
+ test("schema.postgres.sql is what TypeScript produces from meta.json", async () => {
+ const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json"));
+ const expected = await generateReportApiSchemaSql(root);
+ const committed = readFileSync(API_CONTRACT_REPORT_SCHEMA_SQL_PATH, "utf8");
+ if (committed !== expected) {
+ throw new Error("report/schema.postgres.sql is stale. Run `bun run gen:report-api-schema` in integration-tests.");
+ }
+ });
+});
From 7e3b50a8b876c95d566d7921b5434ceec19d35d3 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 17:51:14 -0400
Subject: [PATCH 03/21] feat(runtime-ts): a read-only mount can be keyless
(FR-044)
---
.../src/drizzle-fastify/mount-read-only.ts | 74 +++++++++-------
.../runtime-ts/src/hono/mount-read-only.ts | 86 +++++++++++--------
.../drizzle-fastify/mount-read-only.test.ts | 63 +++++++++++++-
.../test/hono/mount-read-only.test.ts | 65 ++++++++++++++
4 files changed, 223 insertions(+), 65 deletions(-)
create mode 100644 server/typescript/packages/runtime-ts/test/hono/mount-read-only.test.ts
diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts
index 20e068f88..a30395a3c 100644
--- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts
+++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts
@@ -33,15 +33,23 @@ export interface MountReadOnlyOptions {
* mount from an enclosing plugin scope works here too, and needs no option at all.)
*/
readonly routeOptions?: RouteShorthandOptions;
+ /**
+ * False for an object with no single-column primary identity: an `object.report`, or
+ * a keyless projection. Mounts the list route and the collection POST refusal only,
+ * and no `/:id` route of any verb. Default true, which is today's behaviour.
+ */
+ readonly itemRoutes?: boolean;
+ /** The noun in the 405 message, which is free prose. Default "projection". */
+ readonly resource?: "projection" | "report";
}
-const REJECT_MUTATION = async (
+const rejectMutation = (resource: string) => async (
request: { method: string },
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
) => {
reply
.code(405)
- .send({ error: "method_not_allowed", message: `${request.method} is not supported on a projection (read-only).` });
+ .send({ error: "method_not_allowed", message: `${request.method} is not supported on a ${resource} (read-only).` });
};
function resolveViewName(view: AnyView): string | undefined {
@@ -128,6 +136,8 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
// Route-scoped contract error handler: an unexpected error answers
// `500 { error: "internal" }` rather than Fastify's default (which echoes the SQL).
const ro = withContractErrorHandler(opts.routeOptions);
+ const reject = rejectMutation(opts.resource ?? "projection");
+ const itemRoutes = opts.itemRoutes !== false;
const viewName = resolveViewName(view);
const useRawSql = isEmptyColumnView(view) && !!viewName;
@@ -205,37 +215,43 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
}
});
- // ── Get by ID ─────────────────────────────────────────────────────────────
- fastify.get(`${path}/:id`, ro, async (req, reply) => {
- const { id } = req.params as { id: string };
- if (useRawSql) {
- // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
- const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
- const row = rows[0] ? camelizeRow(rows[0]) : undefined;
- return row ?? reply.code(404).send({ error: "not_found" });
- }
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
- const colRef = (view as any)[idCol];
- // Compare against the PK's real type — a uuid/text key must NOT go through Number().
- const idValue = coerceIdForColumn(colRef, id);
- if (idValue === undefined) {
- return reply.code(400).send({ error: "invalid_id" });
- }
- // Await + first row rather than `.get()` (libsql/better-sqlite3-only).
- const rows = await db.select().from(view).where(
- colRef !== undefined ? eq(colRef, idValue) : undefined
- ).limit(1);
- const row = (rows as unknown[])[0];
- return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
- });
+ // A keyless object (`itemRoutes: false`) has nothing to address by id: no `/:id` route
+ // of any verb, so the framework's own 404 answers.
+ if (itemRoutes) {
+ // ── Get by ID ─────────────────────────────────────────────────────────────
+ fastify.get(`${path}/:id`, ro, async (req, reply) => {
+ const { id } = req.params as { id: string };
+ if (useRawSql) {
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
+ const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
+ const row = rows[0] ? camelizeRow(rows[0]) : undefined;
+ return row ?? reply.code(404).send({ error: "not_found" });
+ }
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
+ const colRef = (view as any)[idCol];
+ // Compare against the PK's real type — a uuid/text key must NOT go through Number().
+ const idValue = coerceIdForColumn(colRef, id);
+ if (idValue === undefined) {
+ return reply.code(400).send({ error: "invalid_id" });
+ }
+ // Await + first row rather than `.get()` (libsql/better-sqlite3-only).
+ const rows = await db.select().from(view).where(
+ colRef !== undefined ? eq(colRef, idValue) : undefined
+ ).limit(1);
+ const row = (rows as unknown[])[0];
+ return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
+ });
+ }
// ── Mutations explicitly rejected (405) ───────────────────────────────────
// PUT is here because the WRITABLE mount serves it (an alias of PATCH), so a
// projection must reject it the same way the other three are rejected. Omitting it
// left `PUT //:id` falling through to Fastify's 404 — telling a caller
// the resource does not exist when it plainly does and answers GET.
- fastify.post(path, ro, REJECT_MUTATION);
- fastify.patch(`${path}/:id`, ro, REJECT_MUTATION);
- fastify.put(`${path}/:id`, ro, REJECT_MUTATION);
- fastify.delete(`${path}/:id`, ro, REJECT_MUTATION);
+ fastify.post(path, ro, reject);
+ if (itemRoutes) {
+ fastify.patch(`${path}/:id`, ro, reject);
+ fastify.put(`${path}/:id`, ro, reject);
+ fastify.delete(`${path}/:id`, ro, reject);
+ }
}
diff --git a/server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts b/server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts
index 98d7aef25..809ac6c88 100644
--- a/server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts
+++ b/server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts
@@ -32,6 +32,14 @@ export interface MountReadOnlyOptions {
readonly dialect: SqlDialect;
/** Override default ID column name (defaults to "id"). */
readonly idColumn?: string;
+ /**
+ * False for an object with no single-column primary identity: an `object.report`, or
+ * a keyless projection. Mounts the list route and the collection POST refusal only,
+ * and no `/:id` route of any verb. Default true, which is today's behaviour.
+ */
+ readonly itemRoutes?: boolean;
+ /** The noun in the 405 message, which is free prose. Default "projection". */
+ readonly resource?: "projection" | "report";
}
function resolveViewName(view: AnyView): string | undefined {
@@ -108,6 +116,7 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
const { app, path, db, view, filterAllowlist, sortAllowlist, dialect } = opts;
const idCol = opts.idColumn ?? "id";
+ const itemRoutes = opts.itemRoutes !== false;
const viewName = resolveViewName(view);
const useRawSql = isEmptyColumnView(view) && !!viewName;
@@ -180,47 +189,54 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
}
}));
- // ── Get by ID ─────────────────────────────────────────────────────────────
- app.get(`${path}/:id`, guardRoute(async (c) => {
- const id = c.req.param("id") ?? "";
- if (useRawSql) {
- // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
- const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
- const row = rows[0] ? camelizeRow(rows[0]) : undefined;
- return row ? c.json(row) : c.json({ error: "not_found" }, 404);
- }
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle view column ref
- const colRef = (view as any)[idCol];
- // Compare against the PK's real type — a uuid/text key must NOT go through Number().
- const idValue = coerceIdForColumn(colRef, id);
- if (idValue === undefined) {
- return c.json({ error: "invalid_id" }, 400);
- }
- // `.get()` is likewise libsql/better-sqlite3-only; `.limit(1)` + await + [0]
- // is the portable single-row read (#286).
- const rows = await db
- .select()
- .from(view)
- .where(colRef !== undefined ? eq(colRef, idValue) : undefined)
- .limit(1);
- const row = (rows as unknown[])[0];
- return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
- }));
+ // A keyless object (`itemRoutes: false`) has nothing to address by id: no `/:id` route
+ // of any verb, so the framework's own 404 answers.
+ if (itemRoutes) {
+ // ── Get by ID ─────────────────────────────────────────────────────────────
+ app.get(`${path}/:id`, guardRoute(async (c) => {
+ const id = c.req.param("id") ?? "";
+ if (useRawSql) {
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
+ const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
+ const row = rows[0] ? camelizeRow(rows[0]) : undefined;
+ return row ? c.json(row) : c.json({ error: "not_found" }, 404);
+ }
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle view column ref
+ const colRef = (view as any)[idCol];
+ // Compare against the PK's real type — a uuid/text key must NOT go through Number().
+ const idValue = coerceIdForColumn(colRef, id);
+ if (idValue === undefined) {
+ return c.json({ error: "invalid_id" }, 400);
+ }
+ // `.get()` is likewise libsql/better-sqlite3-only; `.limit(1)` + await + [0]
+ // is the portable single-row read (#286).
+ const rows = await db
+ .select()
+ .from(view)
+ .where(colRef !== undefined ? eq(colRef, idValue) : undefined)
+ .limit(1);
+ const row = (rows as unknown[])[0];
+ return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
+ }));
+ }
// ── Mutations explicitly rejected (405) ───────────────────────────────────
+ const resource = opts.resource ?? "projection";
const reject = (c: { req: { method: string }; json: (body: unknown, status: number) => unknown }) =>
c.json(
- { error: "method_not_allowed", message: `${c.req.method} is not supported on a projection (read-only).` },
+ { error: "method_not_allowed", message: `${c.req.method} is not supported on a ${resource} (read-only).` },
405,
);
// biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
app.post(path, reject as any);
- // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
- app.patch(`${path}/:id`, reject as any);
- // PUT too — the writable mount serves it, so a projection must reject it rather
- // than 404, which would deny a resource that answers GET on the same path.
- // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
- app.put(`${path}/:id`, reject as any);
- // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
- app.delete(`${path}/:id`, reject as any);
+ if (itemRoutes) {
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
+ app.patch(`${path}/:id`, reject as any);
+ // PUT too — the writable mount serves it, so a projection must reject it rather
+ // than 404, which would deny a resource that answers GET on the same path.
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
+ app.put(`${path}/:id`, reject as any);
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
+ app.delete(`${path}/:id`, reject as any);
+ }
}
diff --git a/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts b/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts
index 41c503d3f..7571c839d 100644
--- a/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts
+++ b/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts
@@ -3,7 +3,7 @@ import Fastify, { type FastifyInstance } from "fastify";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { sqliteView, integer, text } from "drizzle-orm/sqlite-core";
-import { mountReadOnlyCrudRoutes } from "../../src/drizzle-fastify/mount-read-only.js";
+import { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "../../src/drizzle-fastify/mount-read-only.js";
import type { FilterAllowlist, SortAllowlist } from "../../src/drizzle-fastify/filter-allowlist.js";
describe("mountReadOnlyCrudRoutes", () => {
@@ -210,3 +210,64 @@ describe("mountReadOnlyCrudRoutes — raw-SQL (opaque view) page bounds", () =>
});
}
});
+
+// A keyless object (an `object.report`, or a projection with no single-column key) has no
+// identity to address: `itemRoutes: false` mounts the list and the collection refusal only.
+describe("mountReadOnlyCrudRoutes — itemRoutes / resource", () => {
+ let client: ReturnType;
+ const apps: FastifyInstance[] = [];
+
+ beforeAll(async () => {
+ client = createClient({ url: ":memory:" });
+ await client.execute(`CREATE TABLE sales (id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)`);
+ await client.execute(`CREATE VIEW v_totals AS SELECT id, amount FROM sales`);
+ await client.execute(`INSERT INTO sales (id, amount) VALUES (1, 10), (2, 20)`);
+ });
+
+ afterAll(async () => {
+ for (const a of apps) await a.close();
+ client.close();
+ });
+
+ async function mountView(opts: Partial): Promise {
+ const app = Fastify();
+ mountReadOnlyCrudRoutes({
+ fastify: app,
+ path: "/totals",
+ db: drizzle(client),
+ view: sqliteView("v_totals", {
+ id: integer("id").notNull(),
+ amount: integer("amount").notNull(),
+ }).existing(),
+ filterAllowlist: {},
+ sortAllowlist: {},
+ dialect: "sqlite",
+ ...opts,
+ });
+ await app.ready();
+ apps.push(app);
+ return app;
+ }
+
+ test("itemRoutes: false mounts no /:id route of any verb", async () => {
+ const app = await mountView({ itemRoutes: false, resource: "report" });
+ expect((await app.inject({ method: "GET", url: "/totals" })).statusCode).toBe(200);
+ for (const method of ["GET", "PATCH", "PUT", "DELETE"] as const) {
+ expect((await app.inject({ method, url: "/totals/1" })).statusCode).toBe(404);
+ }
+ const post = await app.inject({ method: "POST", url: "/totals", payload: {} });
+ expect(post.statusCode).toBe(405);
+ expect(post.json()).toMatchObject({ error: "method_not_allowed" });
+ expect(String(post.json().message)).toContain("report");
+ });
+
+ test("the default still mounts GET :id and the three item refusals", async () => {
+ const app = await mountView({});
+ expect((await app.inject({ method: "GET", url: "/totals/1" })).statusCode).toBe(200);
+ for (const method of ["PATCH", "PUT", "DELETE"] as const) {
+ expect((await app.inject({ method, url: "/totals/1", payload: {} })).statusCode).toBe(405);
+ }
+ const post = await app.inject({ method: "POST", url: "/totals", payload: {} });
+ expect(post.json().message).toBe("POST is not supported on a projection (read-only).");
+ });
+});
diff --git a/server/typescript/packages/runtime-ts/test/hono/mount-read-only.test.ts b/server/typescript/packages/runtime-ts/test/hono/mount-read-only.test.ts
new file mode 100644
index 000000000..88986a2ed
--- /dev/null
+++ b/server/typescript/packages/runtime-ts/test/hono/mount-read-only.test.ts
@@ -0,0 +1,65 @@
+// Hono twin of the read-only mount's itemRoutes / resource tests (drizzle-fastify/mount-read-only.test.ts).
+
+import { describe, test, expect, beforeAll, afterAll } from "bun:test";
+import { Hono } from "hono";
+import { createClient } from "@libsql/client";
+import { drizzle } from "drizzle-orm/libsql";
+import { sqliteView, integer } from "drizzle-orm/sqlite-core";
+import { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "../../src/hono/mount-read-only.js";
+
+describe("hono mountReadOnlyCrudRoutes — itemRoutes / resource", () => {
+ let client: ReturnType;
+
+ beforeAll(async () => {
+ client = createClient({ url: ":memory:" });
+ await client.execute(`CREATE TABLE sales (id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)`);
+ await client.execute(`CREATE VIEW v_totals AS SELECT id, amount FROM sales`);
+ await client.execute(`INSERT INTO sales (id, amount) VALUES (1, 10), (2, 20)`);
+ });
+
+ afterAll(() => {
+ client.close();
+ });
+
+ function mountView(opts: Partial): Hono {
+ const app = new Hono();
+ mountReadOnlyCrudRoutes({
+ app,
+ path: "/totals",
+ db: drizzle(client),
+ view: sqliteView("v_totals", {
+ id: integer("id").notNull(),
+ amount: integer("amount").notNull(),
+ }).existing(),
+ filterAllowlist: {},
+ sortAllowlist: {},
+ dialect: "sqlite",
+ ...opts,
+ });
+ return app;
+ }
+
+ test("itemRoutes: false mounts no /:id route of any verb", async () => {
+ const app = mountView({ itemRoutes: false, resource: "report" });
+ expect((await app.request("/totals")).status).toBe(200);
+ for (const method of ["GET", "PATCH", "PUT", "DELETE"]) {
+ expect((await app.request("/totals/1", { method })).status).toBe(404);
+ }
+ const post = await app.request("/totals", { method: "POST", body: "{}" });
+ expect(post.status).toBe(405);
+ const body = (await post.json()) as { error: string; message: string };
+ expect(body.error).toBe("method_not_allowed");
+ expect(body.message).toContain("report");
+ });
+
+ test("the default still mounts GET :id and the three item refusals", async () => {
+ const app = mountView({});
+ expect((await app.request("/totals/1")).status).toBe(200);
+ for (const method of ["PATCH", "PUT", "DELETE"]) {
+ expect((await app.request("/totals/1", { method })).status).toBe(405);
+ }
+ const post = await app.request("/totals", { method: "POST", body: "{}" });
+ const body = (await post.json()) as { message: string };
+ expect(body.message).toBe("POST is not supported on a projection (read-only).");
+ });
+});
From 06e38ba0252927e7bbebbc61e315ecc2bce27c2c Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:08:07 -0400
Subject: [PATCH 04/21] feat(csharp): read-only routes and filter allowlist for
a view-backed report (FR-044)
---
.../CodegenCompileConformanceTests.cs | 13 +-
.../ReportRowCodegenTests.cs | 141 ++++++++++-
.../ReportingInertTests.cs | 160 ++++++++++---
.../ApiDocs/CSharpApiModel.cs | 2 +-
.../ApiDocs/CSharpApiModelBuilder.cs | 20 +-
.../MetaObjects.Codegen/CodegenRunner.cs | 7 +-
.../Generators/FilterAllowlistGenerator.cs | 24 +-
.../Generators/RoutesGenerator.cs | 52 ++--
.../csharp/MetaObjects.Codegen/ReportRows.cs | 24 +-
.../Api/ApiContractCorpusPaths.cs | 11 +
.../Api/ApiContractReportConformanceTest.cs | 86 +++++++
.../Api/ReportFixture.cs | 78 ++++++
.../Api/ReportGeneratedServerFactory.cs | 225 ++++++++++++++++++
13 files changed, 762 insertions(+), 81 deletions(-)
create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs
create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/ReportFixture.cs
create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs
diff --git a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs
index 3b6927de7..6c904aa02 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs
@@ -146,8 +146,10 @@ public void Every_generated_file_compiles_with_zero_errors(string selection, boo
var files = generators.SelectMany(g => g.Generate(ctx)).ToList();
Assert.True(files.Count > 0, $"selection '{selection}' generated no files at all");
- // FR-044 — the corpus's six view-backed reports each generate a keyless row class.
- // Named, because a compile gate passes trivially over a file that was never emitted.
+ // FR-044 — the corpus's six view-backed reports each generate a keyless row class and
+ // a filter allowlist (their routes file is in the excluded framework tier), and no
+ // names artifact. Named, because a compile gate passes trivially over a file that
+ // was never emitted.
foreach (var report in new[]
{
"ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek",
@@ -155,9 +157,10 @@ public void Every_generated_file_compiles_with_zero_errors(string selection, boo
})
{
Assert.True(files.Any(f => f.Path == report + ".g.cs"), $"no row class was generated for report {report}");
- Assert.False(files.Any(f => f.Path.StartsWith(report + "Names", StringComparison.Ordinal)
- || f.Path.StartsWith(report + "FilterAllowlist", StringComparison.Ordinal)),
- $"report {report} leaked into the names or filter-allowlist tier");
+ Assert.True(files.Any(f => f.Path == report + "FilterAllowlist.g.cs"),
+ $"no filter allowlist was generated for report {report}");
+ Assert.False(files.Any(f => f.Path.StartsWith(report + "Names", StringComparison.Ordinal)),
+ $"report {report} leaked into the names tier");
}
if (isTemplateTier)
diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs
index 9b4902a04..e808802d6 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs
@@ -151,10 +151,11 @@ public void A_view_backed_report_generates_its_row_and_mapping(string what, stri
Assert.Contains(" public DbSet SalesTotals { get; set; } = default!;", ctx);
Assert.Contains(" modelBuilder.Entity().HasNoKey().ToView(\"v_sales\");", ctx);
- // Nothing else: no names artifact, filter allowlist or routes for a report.
+ // Its read surface (Plan 3): the allowlist and the routes file. No names artifact.
Assert.Equal(
- ["SalesTotal.g.cs"],
- files.Keys.Where(k => k.Contains("SalesTotal", StringComparison.Ordinal)).ToList());
+ ["SalesTotal.g.cs", "SalesTotalFilterAllowlist.g.cs", "SalesTotalRoutes.g.cs"],
+ files.Keys.Where(k => k.Contains("SalesTotal", StringComparison.Ordinal))
+ .OrderBy(k => k, StringComparer.Ordinal).ToList());
}
public static TheoryData InertSources => new()
@@ -197,6 +198,138 @@ public void A_context_built_from_the_unfiltered_root_generates_the_same_files()
Assert.DoesNotContain(actual.Keys, k => k.Contains("Sourceless", StringComparison.Ordinal));
}
+ // ---------------------------------------------------------------------
+ // Plan 3 — the read-only routes file and the filter allowlist
+ // ---------------------------------------------------------------------
+
+ /// fixtures/codegen-noop/reporting/with: `StoreTotals` is view-backed,
+ /// `ProgramEngagement` and `DailyRevenue` are sourceless.
+ private static MetaRoot LoadWithModel()
+ {
+ string path = Path.Combine(
+ CorpusPaths.RepoRoot(), "fixtures", "codegen-noop", "reporting", "with", "meta.shop.json");
+ var result = new MetaDataLoader().Load([new FileSource(path)]);
+ Assert.True(result.Errors.Count == 0,
+ "model did not load:\n" + string.Join("\n", result.Errors.Select(e => $" {e.Code}: {e.Message}")));
+ return result.Root;
+ }
+
+ private static int Count(string haystack, string needle)
+ {
+ int n = 0;
+ for (int i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
+ i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) n++;
+ return n;
+ }
+
+ [Fact]
+ public void A_served_report_gets_a_routes_file_with_the_list_route_and_a_405_and_no_item_route()
+ {
+ var files = Emit(RunnerContext(LoadWithModel()), new RoutesGenerator());
+
+ Assert.True(files.ContainsKey("StoreTotalsRoutes.g.cs"), "no routes file for the view-backed report");
+ var routes = files["StoreTotalsRoutes.g.cs"];
+ Assert.Equal(1, Count(routes, "app.MapGet("));
+ Assert.Equal(1, Count(routes, "app.MapPost("));
+ Assert.DoesNotContain("{id}", routes);
+ Assert.DoesNotContain("app.MapPatch(", routes);
+ Assert.DoesNotContain("app.MapPut(", routes);
+ Assert.DoesNotContain("app.MapDelete(", routes);
+
+ // Table B: the segment is the pluralized snake_case name; the 405 says "report".
+ Assert.Contains("app.MapGet(prefix + \"/store_totals\",", routes);
+ Assert.Contains("app.MapPost(prefix + \"/store_totals\", () =>", routes);
+ Assert.Contains(
+ "Results.Json(new { error = \"method_not_allowed\", message = \"POST is not supported on a report (read-only).\" }, statusCode: 405));",
+ routes);
+ Assert.DoesNotContain("projection", routes);
+ // It reads the report's own DbSet and names the report's own allowlist.
+ Assert.Contains("IQueryable q = db.StoreTotals.AsNoTracking();", routes);
+ Assert.Contains("FilterParser.Parse(qs, StoreTotalsFilterAllowlist.Fields, StoreTotalsFilterAllowlist.OpsByField);", routes);
+ // Every derived scalar is sortable.
+ foreach (var field in new[] { "Purchases", "Buyers", "Revenue" })
+ Assert.Contains($" \"{field}\",", routes);
+ }
+
+ [Fact]
+ public void A_served_report_gets_a_filter_allowlist_naming_every_derived_field()
+ {
+ var files = Emit(RunnerContext(LoadWithModel()), new FilterAllowlistGenerator());
+
+ Assert.True(files.ContainsKey("StoreTotalsFilterAllowlist.g.cs"), "no allowlist for the view-backed report");
+ var allowlist = files["StoreTotalsFilterAllowlist.g.cs"];
+ // Table C: a count and a sum of a currency take the numeric band.
+ foreach (var field in new[] { "purchases", "buyers", "revenue" })
+ Assert.Contains(
+ $" [\"{field}\"] = new(System.StringComparer.Ordinal) {{ \"eq\", \"ne\", \"gt\", \"gte\", \"lt\", \"lte\", \"in\", \"isNull\" }},",
+ allowlist);
+ // The report's own derived fields, never the @from entity's.
+ Assert.DoesNotContain("customerEmail", allowlist);
+ }
+
+ [Fact]
+ public void A_sourceless_report_gets_no_routes_file_and_no_allowlist()
+ {
+ var files = Emit(RunnerContext(LoadWithModel()), new RoutesGenerator(), new FilterAllowlistGenerator());
+ foreach (var sourceless in new[] { "ProgramEngagement", "DailyRevenue" })
+ {
+ Assert.DoesNotContain(files.Keys, k => k.Contains(sourceless, StringComparison.Ordinal));
+ Assert.DoesNotContain(files.Values, c => c.Contains(sourceless, StringComparison.Ordinal));
+ }
+ }
+
+ [Fact]
+ public void A_served_report_raises_no_keyless_warning()
+ {
+ // "no single-column primary key" is a finding on an entity. A report has no identity
+ // by definition, so the same sentence about one is noise on every run.
+ var root = LoadWithModel();
+ var warnings = new List();
+ var ctx = new GenContext
+ {
+ Entities = root.Objects().Where(o => !o.IsReport()).ToList(),
+ Root = root,
+ Config = Config(),
+ Warn = warnings.Add,
+ };
+ var files = new RoutesGenerator().Generate(ctx).ToList();
+
+ Assert.Contains(files, f => f.Path == "StoreTotalsRoutes.g.cs");
+ Assert.DoesNotContain(warnings, w => w.Contains("StoreTotals", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void The_applies_to_predicates_answer_for_a_declared_report_node()
+ {
+ // The api-docs builder and the integration harness pass DECLARED nodes, not row models.
+ var root = LoadWithModel();
+ MetaObject Named(string name) => root.Objects().Single(o => o.Name == name);
+
+ Assert.True(RoutesGenerator.AppliesTo(Named("StoreTotals"), root));
+ Assert.True(FilterAllowlistGenerator.AppliesTo(Named("StoreTotals")));
+ foreach (var sourceless in new[] { "ProgramEngagement", "DailyRevenue" })
+ {
+ Assert.False(RoutesGenerator.AppliesTo(Named(sourceless), root));
+ Assert.False(FilterAllowlistGenerator.AppliesTo(Named(sourceless)));
+ }
+ }
+
+ [Fact]
+ public void A_field_with_no_filter_band_is_not_filterable_and_every_other_derived_field_is()
+ {
+ var allowlist = Emit(RunnerContext(Cube()), new FilterAllowlistGenerator())["SalesCubeFilterAllowlist.g.cs"];
+ foreach (var field in new[]
+ {
+ "store", "channel", "status", "storeRegion", "soldAtHour", "soldAtDay", "soldAtMonth",
+ "bookedAtHour", "soldOnWeek", "sales", "channels", "unitsSold", "revenue", "totalWeight",
+ "totalScore", "avgUnits", "avgScore", "minUnits", "lastSoldAt", "maxWeight", "unitsPerSale",
+ })
+ Assert.Contains($" [\"{field}\"] = ", allowlist);
+ // A string dimension takes the string band, a date the ordered band.
+ Assert.Contains("[\"channel\"] = new(System.StringComparer.Ordinal) { \"eq\", \"ne\", \"in\", \"like\", \"isNull\" },", allowlist);
+ Assert.Contains("[\"soldAtDay\"] = new(System.StringComparer.Ordinal) { \"eq\", \"ne\", \"gt\", \"gte\", \"lt\", \"lte\", \"in\", \"isNull\" },", allowlist);
+ }
+
// ---------------------------------------------------------------------
// Table B — the C# type and nullability of every derived field
// ---------------------------------------------------------------------
@@ -293,7 +426,7 @@ public void An_enum_dimension_declares_its_enum_and_reads_it_as_text()
public void The_row_and_its_mapping_compile(bool includeNames)
{
var ctx = RunnerContext(Cube(), Config(includeNames: includeNames));
- var generators = new List { new EntityGenerator(), new DbContextGenerator() };
+ var generators = new List { new EntityGenerator(), new DbContextGenerator(), new FilterAllowlistGenerator() };
if (includeNames) generators.Add(new NamesGenerator());
var files = generators.SelectMany(g => g.Generate(ctx)).ToList();
diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs
index b1872250a..e45a20920 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs
@@ -1,15 +1,16 @@
// FR-044 — what the reporting vocabulary generates in C#, and what it does not.
//
// `dimension.*`, `measure.*` and `segment.*` generate nothing. A report generates nothing
-// either, with ONE exception that landed in Plan 2: a report that declares a read-only
-// `source.rdb @kind: view` is a database view, and C# reads it through a generated keyless
-// row class and its `HasNoKey().ToView(...)` mapping plus a DbSet. So:
+// either, with ONE exception: a report that declares a read-only `source.rdb @kind: view`
+// is a database view. C# reads it through a generated keyless row class and its
+// `HasNoKey().ToView(...)` mapping plus a DbSet (Plan 2), and serves it through a filter
+// allowlist and a read-only routes file (Plan 3). So:
//
// - a SOURCELESS report (`ProgramEngagement`, `DailyRevenue`) stays inert in every
// registered generator and in the api docs;
-// - the VIEW-BACKED report (`StoreTotals`) adds exactly one file (its row class) and
-// exactly its lines in AppDbContext.g.cs, and nothing in the routes, filter-allowlist,
-// names or api-docs tiers (Plan 3).
+// - the VIEW-BACKED report (`StoreTotals`) adds exactly three files (its row class, its
+// filter allowlist, its routes file), exactly its lines in AppDbContext.g.cs, one unit
+// in the api docs, and nothing in the names tier or in any other generator.
//
// The model pair is fixtures/codegen-noop/reporting/{with,without}, shared with the other
// four ports' copies of this test.
@@ -83,13 +84,6 @@ public static TheoryData GeneratorNames()
return data;
}
- private static void AssertSame(SortedDictionary expected, SortedDictionary actual)
- {
- Assert.Equal(expected.Keys.ToList(), actual.Keys.ToList());
- foreach (var (path, content) in expected)
- Assert.True(content == actual[path], $"{path} differs once reporting nodes are declared");
- }
-
[Fact]
public void The_with_model_really_carries_the_vocabulary()
{
@@ -99,13 +93,73 @@ public void The_with_model_really_carries_the_vocabulary()
Assert.DoesNotContain(Load("without").Objects(), o => o.IsReport());
}
- // The two generators that lower a view-backed report (ReportRows). Every other
+ // The four generators that emit for a view-backed report (ReportRows). Every other
// registered generator must not notice a report at all.
private const string EntityGeneratorName = "entity";
private const string DbContextGeneratorName = "db-context";
+ private const string FilterAllowlistGeneratorName = "filter-allowlist";
+ private const string RoutesGeneratorName = "routes";
private const string RowFile = "StoreTotals.g.cs";
+ private const string AllowlistFile = "StoreTotalsFilterAllowlist.g.cs";
+ private const string RoutesFile = "StoreTotalsRoutes.g.cs";
private const string DbContextFile = "AppDbContext.g.cs";
+ /// The file each report-aware generator adds for the view-backed report.
+ private static readonly (string Generator, string File)[] ReportFiles =
+ [
+ (EntityGeneratorName, RowFile),
+ (FilterAllowlistGeneratorName, AllowlistFile),
+ (RoutesGeneratorName, RoutesFile),
+ ];
+
+ // Every derived field is filterable: a count and a sum of a currency take the ordered band.
+ private const string ExpectedAllowlist =
+ """
+ //
+ // Generated by MetaObjects filter-allowlist-generator. Do not edit by hand.
+ #nullable enable
+ using System.Collections.Generic;
+
+ namespace MetaObjects.ReportingInert.Generated;
+
+ ///
+ /// GENERATED — per-entity FR-009 filter allowlist for StoreTotals.
+ /// lists the filterable field names;
+ /// constrains the operator vocabulary for each field by its subtype.
+ ///
+ public static class StoreTotalsFilterAllowlist
+ {
+ public static readonly HashSet Fields = new(System.StringComparer.Ordinal)
+ {
+ "purchases",
+ "buyers",
+ "revenue",
+ };
+
+ public static readonly Dictionary> OpsByField = new(System.StringComparer.Ordinal)
+ {
+ ["purchases"] = new(System.StringComparer.Ordinal) { "eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull" },
+ ["buyers"] = new(System.StringComparer.Ordinal) { "eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull" },
+ ["revenue"] = new(System.StringComparer.Ordinal) { "eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull" },
+ };
+ }
+
+ """;
+
+ /// The routes file of the view-backed report: the list GET, a 405 on POST, and
+ /// no item address or write verb.
+ private static void AssertReportRoutes(string routes)
+ {
+ Assert.Contains("public static class StoreTotalsRoutes", routes);
+ Assert.Contains(" app.MapGet(prefix + \"/store_totals\", async (HttpContext http, AppDbContext db) =>", routes);
+ Assert.Contains(" app.MapPost(prefix + \"/store_totals\", () =>", routes);
+ Assert.Contains("message = \"POST is not supported on a report (read-only).\" }, statusCode: 405));", routes);
+ Assert.Single(routes.Split('\n'), l => l.Contains("app.MapGet(", StringComparison.Ordinal));
+ Assert.Single(routes.Split('\n'), l => l.Contains("app.MapPost(", StringComparison.Ordinal));
+ foreach (var absent in new[] { "{id}", "app.MapPatch(", "app.MapPut(", "app.MapDelete(", "StoreTotalsNames" })
+ Assert.DoesNotContain(absent, routes);
+ }
+
// Snake-case is the naming strategy of this suite's GenConfig, so the columns prove the
// strategy is applied to the DERIVED field name. `revenue` is a sum of a currency:
// integer minor units, nullable because a sum of nothing is null. A count is never null.
@@ -152,7 +206,7 @@ private static List AddedLines(string expected, string actual)
if (i < want.Length && want[i] == line) i++;
else added.Add(line);
}
- Assert.True(i == want.Length, "AppDbContext.g.cs lost or changed a line once a report was declared");
+ Assert.True(i == want.Length, "a file lost or changed a line once a report was declared");
return added;
}
@@ -164,12 +218,16 @@ private static void AssertSameExceptTheReportRow(
SortedDictionary expected, SortedDictionary actual,
IReadOnlyCollection selection)
{
- var allowedNew = selection.Contains(EntityGeneratorName) ? new[] { RowFile } : [];
+ var allowedNew = ReportFiles.Where(r => selection.Contains(r.Generator)).Select(r => r.File).ToList();
Assert.Equal(
expected.Keys.Concat(allowedNew).OrderBy(k => k, StringComparer.Ordinal).ToList(),
actual.Keys.ToList());
- if (allowedNew.Length > 0)
+ if (allowedNew.Contains(RowFile))
Assert.Equal(ExpectedRow.ReplaceLineEndings("\n"), actual[RowFile].ReplaceLineEndings("\n"));
+ if (allowedNew.Contains(AllowlistFile))
+ Assert.Equal(ExpectedAllowlist.ReplaceLineEndings("\n"), actual[AllowlistFile].ReplaceLineEndings("\n"));
+ if (allowedNew.Contains(RoutesFile))
+ AssertReportRoutes(actual[RoutesFile]);
foreach (var (path, content) in expected)
{
@@ -189,7 +247,8 @@ public void Generator_emits_the_same_files_with_and_without_reporting_nodes(stri
var entry = GeneratorRegistry.Entries[name];
var expected = Emit(Load("without"), [Build(entry)]);
var actual = Emit(Load("with"), [Build(entry)]);
- // For every generator but `entity` and `db-context` this is plain equality.
+ // For every generator but `entity`, `db-context`, `filter-allowlist` and `routes`
+ // this is plain equality.
AssertSameExceptTheReportRow(expected, actual, [name]);
}
@@ -223,30 +282,32 @@ public void Every_runnable_generator_in_one_run_emits_the_same_files()
}
[Fact]
- public void A_report_reaches_no_tier_but_its_row_and_its_DbContext_mapping()
+ public void A_report_reaches_no_tier_but_its_row_its_mapping_its_allowlist_and_its_routes()
{
var files = Emit(Load("with"), GeneratorRegistry.Entries.Values.Select(Build).ToList());
Assert.False(files.ContainsKey(""), files.GetValueOrDefault(""));
- // The view-backed report: one file, named for it; no routes, allowlist or names.
- Assert.Equal([RowFile], files.Keys.Where(k => k.Contains("StoreTotals", StringComparison.Ordinal)).ToList());
+ // The view-backed report: its row, its allowlist and its routes file; nothing in the
+ // names tier.
+ Assert.Equal(
+ [RowFile, AllowlistFile, RoutesFile],
+ files.Keys.Where(k => k.Contains("StoreTotals", StringComparison.Ordinal)).ToList());
// A sourceless report: no file at all, and no mention in any file.
foreach (var sourceless in new[] { "ProgramEngagement", "DailyRevenue" })
{
Assert.DoesNotContain(files.Keys, k => k.Contains(sourceless, StringComparison.Ordinal));
Assert.DoesNotContain(files.Values, c => c.Contains(sourceless, StringComparison.Ordinal));
}
- // Outside its row and the DbContext, no generated file mentions the report.
+ // Outside those three and the DbContext, no generated file mentions the report.
var mentions = files.Where(f => f.Value.Contains("StoreTotals", StringComparison.Ordinal))
.Select(f => f.Key).OrderBy(k => k, StringComparer.Ordinal).ToList();
- Assert.Equal([DbContextFile, RowFile], mentions);
+ Assert.Equal([DbContextFile, RowFile, AllowlistFile, RoutesFile], mentions);
}
///
/// The api docs surface (`dotnet meta docs`): every unit page, the index and the agent
- /// page, rendered exactly as DocsCommand renders them. A report has no generated API to
- /// document (its routes are Plan 3), so the docs carry nothing for any report,
- /// view-backed or not.
+ /// page, rendered exactly as DocsCommand renders them. A served report is one unit; a
+ /// sourceless report has no generated API to document and gets none.
///
private static SortedDictionary ApiDocs(MetaRoot root)
{
@@ -262,11 +323,54 @@ private static SortedDictionary ApiDocs(MetaRoot root)
return pages;
}
+ private const string ReportDocPage = "acme/shop/StoreTotals.md";
+ private static readonly string[] SharedDocPages = ["README.md", "AGENT-API.md"];
+
[Fact]
- public void Api_docs_are_the_same_with_and_without_reporting_nodes()
+ public void Api_docs_add_one_page_for_the_served_report_and_change_nothing_else()
{
var expected = ApiDocs(Load("without"));
+ var actual = ApiDocs(Load("with"));
Assert.True(expected.Count > 3, $"only {expected.Count} pages — the docs barely ran");
- AssertSame(expected, ApiDocs(Load("with")));
+
+ Assert.Equal(
+ expected.Keys.Append(ReportDocPage).OrderBy(k => k, StringComparer.Ordinal).ToList(),
+ actual.Keys.ToList());
+ foreach (var (path, content) in expected)
+ {
+ if (SharedDocPages.Contains(path))
+ {
+ // The index and the agent page gain lines for the report and lose none.
+ var added = AddedLines(content, actual[path]);
+ Assert.NotEmpty(added);
+ Assert.DoesNotContain(added, l => l.Contains("ProgramEngagement", StringComparison.Ordinal)
+ || l.Contains("DailyRevenue", StringComparison.Ordinal));
+ continue;
+ }
+ Assert.True(content == actual[path], $"{path} differs once reporting nodes are declared");
+ }
+ // A sourceless report is mentioned nowhere.
+ foreach (var sourceless in new[] { "ProgramEngagement", "DailyRevenue" })
+ Assert.DoesNotContain(actual.Values, c => c.Contains(sourceless, StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void The_served_report_is_documented_as_its_row_its_DbSet_the_list_GET_and_its_allowlist()
+ {
+ var config = new GenConfig { OutDir = "/unused", Namespace = "Shop" };
+ var units = new CSharpApiModelBuilder(config).Build(Load("with"), "shop").Units;
+
+ Assert.DoesNotContain(units, u => u.Node is "ProgramEngagement" or "DailyRevenue");
+ var unit = Assert.Single(units, u => u.Node == "StoreTotals");
+ Assert.Equal("report", unit.Kind);
+ Assert.Equal("acme::shop", unit.Package);
+ Assert.Equal(
+ [
+ (ApiSymbolKind.Model, "StoreTotals"),
+ (ApiSymbolKind.DataAccess, "StoreTotals"),
+ (ApiSymbolKind.Rest, "GET /api/store_totals"),
+ (ApiSymbolKind.Filter, "StoreTotalsFilterAllowlist"),
+ ],
+ unit.Symbols.Select(s => (s.Kind, s.Name)).ToList());
}
}
diff --git a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModel.cs b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModel.cs
index 03d7f0662..7f854cef1 100644
--- a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModel.cs
+++ b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModel.cs
@@ -62,7 +62,7 @@ public sealed record FieldShape(string Name, string Type, bool Optional, string?
/// One documented unit (an entity / value object, or a template) + its symbols.
/// The unit's short name (the doc-page basename).
/// The unit's metadata package (e.g. acme::shop ).
-/// "entity" | "value" | "template".
+/// "entity" | "projection" | "report" | "value" | "template".
/// The documented symbols, in canonical IR order.
public sealed record ApiUnit(
string Node,
diff --git a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs
index a9c2208ac..d60da3fc0 100644
--- a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs
+++ b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs
@@ -12,7 +12,9 @@
// DATA_ACCESS / REST / VALIDATION / FILTER, each gated by the matching generator's
// AppliesTo. A read-only projection (object.projection) adds a read-only DbSet +
// read routes only (the write surfaces gate on a writable entity). A value object
-// → MODEL only.
+// → MODEL only. A served object.report (FR-044) is documented from its row model:
+// MODEL, its read-only DbSet, the list GET (no item route, no write verb) and FILTER.
+// A report with no view source generates nothing and gets no unit.
// • Templates (root.RootTemplates()): each template.output → PAYLOAD / RENDER /
// PROMPT / OUTPUT_PARSER, gated by the matching generator's AppliesTo.
//
@@ -47,10 +49,12 @@ public CSharpApiModel Build(MetaRoot root, string project)
// Objects: one unit per concrete object.entity / object.value.
foreach (var obj in root.Objects())
{
- // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- // It has no generated API to document, and its derived fields do not exist yet.
- if (obj.IsReport()) continue;
- var unit = BuildObjectUnit(obj, root);
+ // FR-044: a SERVED report (its read source is a view) is documented from its row
+ // model, the same object the generators emit from, so its unit carries exactly
+ // what is generated: the keyless row, its DbSet, the list GET and the allowlist.
+ // A report that is not served generates nothing and gets no unit.
+ if (obj.IsReport() && !ReportRows.IsViewBacked(obj)) continue;
+ var unit = BuildObjectUnit(obj.IsReport() ? ReportRows.RowModel(obj, root) : obj, root);
if (unit is not null) units.Add(unit);
}
@@ -68,7 +72,8 @@ public CSharpApiModel Build(MetaRoot root, string project)
var entity = obj.IsEntity();
var projection = obj.IsProjection();
var ns = ResolveNamespace(obj);
- var unitKind = entity ? "entity" : projection ? "projection" : "value";
+ var report = obj.IsReport(); // a served report's row model (see Build)
+ var unitKind = entity ? "entity" : projection ? "projection" : report ? "report" : "value";
var symbols = new List();
@@ -84,6 +89,7 @@ public CSharpApiModel Build(MetaRoot root, string project)
$"class {model}",
entity ? "the EF Core entity / in-memory model object"
: projection ? "the EF Core read-model / read-only projection POCO"
+ : report ? "the keyless EF Core row of the report's view, one property per derived field"
: "the value-object POCO"));
}
@@ -126,7 +132,7 @@ public CSharpApiModel Build(MetaRoot root, string project)
if (RoutesGenerator.AppliesTo(obj, root))
AddRestSymbols(symbols, obj, ns, root);
- // FILTER — the per-entity sort/filter allowlist (writable entity only).
+ // FILTER — the per-entity filter allowlist (every routed read surface).
if (FilterAllowlistGenerator.AppliesTo(obj))
{
var filter = CSharpNaming.FilterAllowlistName(obj);
diff --git a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs
index fe87ec940..cac81d2c7 100644
--- a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs
+++ b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs
@@ -35,9 +35,10 @@ public static RunResult Run(GenConfig config, MetaRoot root, IReadOnlyList !o.IsReport()).ToList(),
Root = root,
Config = config,
diff --git a/server/csharp/MetaObjects.Codegen/Generators/FilterAllowlistGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/FilterAllowlistGenerator.cs
index 5893e4b88..dad246683 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/FilterAllowlistGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/FilterAllowlistGenerator.cs
@@ -14,8 +14,8 @@
// decimal / currency / date / timestamp / time → eq, ne, gt, gte, lt, lte, in, isNull
// boolean → eq, isNull
//
-// Read-only projections (source.rdb @kind=view/...) are skipped — they have no
-// filter routes today (G3 in the routes generator's gap list).
+// A view-backed `object.report` (FR-044) gets one too, from its row model: every derived
+// field with a filter band is filterable (ReportRows). A report with no view source gets none.
using System.Text;
using MetaObjects.Meta;
@@ -53,13 +53,19 @@ public class FilterAllowlistGenerator : PerEntityGenerator
///
///
public static bool AppliesTo(MetaObject entity) =>
- // FR-044 — a report has no filter allowlist (it has no routes to name one). Stated
- // here as well as at CodegenRunner's entity set, for the same reason as
- // RoutesGenerator.AppliesTo.
- !entity.IsReport()
- && (((entity.IsEntity() || entity.DbView is not null)
- && InstanceArtifacts.EmitsInstanceArtifacts(entity))
- || InstanceArtifacts.IsSourcelessEntity(entity));
+ // FR-044 — a report applies iff it is served, the same answer RoutesGenerator.AppliesTo
+ // gives, and for the same reason the two predicates agree everywhere else: the routes
+ // file names this allowlist. True for the declared node and for its row model alike.
+ entity.IsReport()
+ ? ReportRows.IsViewBacked(entity)
+ : ((entity.IsEntity() || entity.DbView is not null)
+ && InstanceArtifacts.EmitsInstanceArtifacts(entity))
+ || InstanceArtifacts.IsSourcelessEntity(entity);
+
+ // FR-044 — a served report joins the set as its ROW MODEL (ReportRows), whose derived
+ // fields are what the allowlist lists; a raw report node in the entity set is dropped.
+ public override IEnumerable Generate(GenContext ctx) =>
+ ReportRows.WithReportRows(ctx).Where(Filter).Select(e => GenerateOne(e, ctx));
protected override EmittedFile GenerateOne(MetaObject entity, GenContext ctx)
{
diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs
index 1632e467a..591da7821 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs
@@ -18,6 +18,8 @@
// - 404 responses carry a JSON envelope: { "error": "not_found" }.
// - A read-only projection mounts the reads AND every write verb, each answering
// 405 { "error": "method_not_allowed" } (F22) — see AppendProjectionRejects.
+// - A view-backed report (FR-044) is served as a keyless read: the list GET and a 405
+// on POST, with no /{id} address at all (a report has no identity).
//
// Filter operators (eq/ne/gt/gte/lt/lte/in/like/isNull) ship via FR-009 — the
// generated list handler calls FilterParser.Parse against the per-entity
@@ -47,8 +49,10 @@ public class RoutesGenerator : PerEntityGenerator
private const string HelperRuntimeNamespace = "MetaObjects.Codegen.Runtime";
public override bool Filter(MetaObject entity) =>
- !entity.IsReport() // FR-044: a report has no routes (see AppliesTo)
- && (entity.IsEntity() || entity.DbView is not null) && InstanceArtifacts.EmitsInstanceArtifacts(entity);
+ // FR-044: a report has routes iff it is served (see AppliesTo).
+ entity.IsReport()
+ ? ReportRows.IsViewBacked(entity)
+ : (entity.IsEntity() || entity.DbView is not null) && InstanceArtifacts.EmitsInstanceArtifacts(entity);
///
/// True iff this entity gets a generated routes file: it passes
@@ -57,20 +61,25 @@ public override bool Filter(MetaObject entity) =>
/// loop AND the api-docs builder (so docs never claim REST a routes-off entity lacks).
///
public static bool AppliesTo(MetaObject entity, MetaRoot root) =>
- // FR-044 — a report has no routes. CodegenRunner already keeps reports out of the
- // entity set; this holds for a caller that builds its context from the unfiltered
- // root, where a view-backed report would otherwise pass as a projection.
- !entity.IsReport()
- && (entity.IsEntity() || entity.DbView is not null)
- && InstanceArtifacts.EmitsInstanceArtifacts(entity)
- && !TphPlanBuilder.IsTphSubtype(entity, root);
+ // FR-044 — a report applies iff it is served: its read source is a view (contract
+ // Table A). Asked of the report itself, so the declared node (what the api-docs
+ // builder and a test harness pass) and its row model (what Generate iterates) get
+ // the same answer. A sourceless report mounts nothing.
+ entity.IsReport()
+ ? ReportRows.IsViewBacked(entity)
+ : (entity.IsEntity() || entity.DbView is not null)
+ && InstanceArtifacts.EmitsInstanceArtifacts(entity)
+ && !TphPlanBuilder.IsTphSubtype(entity, root);
// FR-017 TPH: a concrete subtype is served via its base's per-subtype routes — it
// emits NO standalone routes file (the base mounts polymorphic + per-subtype CRUD).
// The subtype skip needs the root, which Filter doesn't receive, so it is applied
// here where the GenContext is in scope.
+ // FR-044 — a served report joins the set as its ROW MODEL (ReportRows): a keyless,
+ // projection-shaped object, so GenerateStandardRoutes emits the list GET and the POST
+ // refusal and nothing else. A raw report node in the entity set is dropped.
public override IEnumerable Generate(GenContext ctx) =>
- ctx.Entities
+ ReportRows.WithReportRows(ctx)
.Where(e => AppliesTo(e, ctx.Root))
.Select(e => GenerateOne(e, ctx));
@@ -151,7 +160,9 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte
var pkColumnRef = pkFields.Count == 1 && entity.Fields().FirstOrDefault(f => f.Name == pkFields[0]) is { } pkf
? CSharpNaming.ColumnRef(entity, pkf, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames)
: "\"id\"";
- if (!hasItem)
+ // A report has no identity by definition, so "no primary key" is not a finding on one.
+ bool isReport = entity.IsReport();
+ if (!hasItem && !isReport)
ctx.Warn($"{Name}: \"{entity.Name}\" has no single-column primary key — emitting collection GET only.");
// Sort allowlist: every scalar field on the entity is sortable. The
@@ -323,7 +334,7 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte
}
else if (isProjection)
{
- AppendProjectionRejects(sb, route, pkType, hasItem);
+ AppendProjectionRejects(sb, route, pkType, hasItem, isReport ? "report" : "projection");
}
// FR-018 M:N traversal — GET //{id}/ through the
@@ -375,25 +386,28 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte
// The item verbs follow the item GET: a keyless projection mounts no /{id} route at
// all, so refusing a PATCH there would claim an address the port does not serve.
// `message` is free prose and is deliberately not part of the asserted contract.
- private static void AppendProjectionRejects(StringBuilder sb, string route, string? pkType, bool hasItem)
+ //
+ // A view-backed report (FR-044) takes the keyless arm: POST only. `noun` is what the
+ // free-prose message calls the resource ("projection" or "report").
+ private static void AppendProjectionRejects(StringBuilder sb, string route, string? pkType, bool hasItem, string noun)
{
sb.AppendLine();
- AppendReject(sb, "MapPost", "/" + route, "POST", null);
+ AppendReject(sb, "MapPost", "/" + route, "POST", null, noun);
if (!hasItem) return;
- AppendReject(sb, "MapPatch", "/" + route + "/{id}", "PATCH", pkType);
- AppendReject(sb, "MapPut", "/" + route + "/{id}", "PUT", pkType);
- AppendReject(sb, "MapDelete", "/" + route + "/{id}", "DELETE", pkType);
+ AppendReject(sb, "MapPatch", "/" + route + "/{id}", "PATCH", pkType, noun);
+ AppendReject(sb, "MapPut", "/" + route + "/{id}", "PUT", pkType, noun);
+ AppendReject(sb, "MapDelete", "/" + route + "/{id}", "DELETE", pkType, noun);
}
// The item handlers take the route's `id` even though they ignore it: a typed
// parameter makes an unparsable id a 404 from routing rather than a 405 claiming
// the write was refused on a row that could never have been addressed.
- private static void AppendReject(StringBuilder sb, string map, string path, string verb, string? pkType)
+ private static void AppendReject(StringBuilder sb, string map, string path, string verb, string? pkType, string noun)
{
var parms = pkType is null ? "()" : "(" + pkType + " id)";
sb.AppendLine(" app." + map + "(prefix + \"" + path + "\", " + parms + " =>");
sb.AppendLine(" Results.Json(new { error = \"method_not_allowed\", message = \"" + verb
- + " is not supported on a projection (read-only).\" }, statusCode: 405));");
+ + " is not supported on a " + noun + " (read-only).\" }, statusCode: 405));");
}
// FR-017 TPH routes for a discriminator base. Mirrors the TS routes-file TPH branch:
diff --git a/server/csharp/MetaObjects.Codegen/ReportRows.cs b/server/csharp/MetaObjects.Codegen/ReportRows.cs
index 2de5a582c..7f2777dc7 100644
--- a/server/csharp/MetaObjects.Codegen/ReportRows.cs
+++ b/server/csharp/MetaObjects.Codegen/ReportRows.cs
@@ -5,8 +5,11 @@
// A report that declares a read-only `source.rdb @kind: view` is a database view
// (contract Table A). C# has no metadata-driven runtime, so reading that view means
// generating its row: a keyless entity class (EntityGenerator) and its
-// `HasNoKey().ToView(...)` mapping plus a DbSet (DbContextGenerator). Nothing else is
-// generated for a report: no routes, filter allowlist, names artifact or api docs.
+// `HasNoKey().ToView(...)` mapping plus a DbSet (DbContextGenerator). Its read surface
+// (Plan 3) is a filter allowlist (FilterAllowlistGenerator) and a routes file
+// (RoutesGenerator) that mounts the list GET and refuses POST with a 405: a report has no
+// identity, so there is no item route and no write. No names artifact is generated; the
+// row binds its view and columns by literal.
//
// The view's existence is what matters, not who creates it. `@unmanaged: true` (migrate
// never creates it) and `@sql` (the author wrote the body) both still name a view with
@@ -25,7 +28,10 @@
// report already satisfies their projection predicates (`IsReadOnlyProjection()`,
// `DbView`), so it is handed to them as a ROW MODEL: a detached object with one real
// `field.*` child per derived field and a copy of the report's read source. They then
-// emit it exactly as they emit a keyless read-only projection.
+// emit it exactly as they emit a keyless read-only projection. Each derived field that has
+// a filter band carries `@filterable: true` (contract Table C), so the allowlist generator
+// needs no report branch either. That attr is set on the detached row model only, never on
+// a dimension, measure or report node.
//
// The row model is never added to the root and nothing in the loaded tree is mutated to
// build it (the source is copied, not re-parented). It keeps the report's name, package
@@ -33,6 +39,7 @@
//
// Mirrors server/typescript/packages/metadata/src/core/reporting/report-read-model.ts.
+using MetaObjects.Core.Query;
using MetaObjects.Core.Reporting;
using MetaObjects.Meta;
using static MetaObjects.Core.Field.FieldConstants;
@@ -65,7 +72,9 @@ public static class ReportRows
///
/// True iff is a report whose read source is a view, the one
- /// shape that generates a row (see the file header for the other kinds).
+ /// shape that generates a row and is served (see the file header for the other kinds).
+ /// Answers the same for a declared report node and for its ,
+ /// which carries a copy of the read source.
///
public static bool IsViewBacked(MetaObject obj) =>
obj.IsReport() && !obj.IsAbstract
@@ -164,6 +173,11 @@ private static MetaField DerivedField(ReportField f)
// `isArray` is a native flag, not an attr; ResolvedIsArray() is its resolving read.
if (src.ResolvedIsArray()) field.SetIsArray(true);
}
+ // Table C: every derived field whose type has a filter band is filterable. Asked
+ // last, of the finished field, because an int-backed enum's band depends on a
+ // carried attr. A report author cannot narrow this set: there is no node to put
+ // `@filterable` on.
+ if (QueryConstants.OpsForField(field).Length > 0) field.SetAttr(FIELD_ATTR_FILTERABLE, true);
return field;
}
@@ -186,7 +200,7 @@ public static IReadOnlyList For(MetaRoot root) =>
root.Objects().Where(IsViewBacked).Select(r => RowModel(r, root)).ToList();
///
- /// The objects a row-emitting generator iterates: the run's entity set with every
+ /// The objects a report-aware generator iterates: the run's entity set with every
/// report node removed, then the row model of each view-backed report.
///
/// The row models come from the root, not from :
diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs
index df914fd48..b7360598b 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs
@@ -51,6 +51,17 @@ internal static class ApiContractCorpusPaths
public static readonly string ProjectionSeedFile = Path.Combine(ProjectionDir, "seed.json");
public static readonly string ProjectionMetaJson = Path.Combine(ProjectionDir, "meta.json");
+ // FR-044 report subcorpus — a writable Invoice table plus four object.report nodes,
+ // three served (each declares a view) and one sourceless. The list GET is served; POST
+ // answers the cross-port 405 envelope; no /{id} address is mounted. The schema is the
+ // committed TypeScript-produced artifact (ADR-0015): this port executes it and writes
+ // no view SQL of its own.
+ public static readonly string ReportDir = Path.Combine(Corpus, "report");
+ public static readonly string ReportScenariosDir = Path.Combine(ReportDir, "scenarios");
+ public static readonly string ReportSeedFile = Path.Combine(ReportDir, "seed.json");
+ public static readonly string ReportMetaJson = Path.Combine(ReportDir, "meta.json");
+ public static readonly string ReportSchemaSql = Path.Combine(ReportDir, "schema.postgres.sql");
+
// #214 write-through read-your-writes subcorpus.
public static readonly string WriteThroughDir = Path.Combine(Corpus, "write-through");
public static readonly string WriteThroughScenariosDir = Path.Combine(WriteThroughDir, "scenarios");
diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs
new file mode 100644
index 000000000..8005db77e
--- /dev/null
+++ b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs
@@ -0,0 +1,86 @@
+// ApiContractReportConformanceTest — the C# FR-044 report lane.
+//
+// Drives the fixtures/api-contract-conformance/report/ scenarios over HTTP against the
+// GENERATED report routes (the deployed artifact): the emitted Routes booted
+// unmodified on Kestrel against a Postgres testcontainer in which the committed
+// TypeScript-produced schema has created the `invoices` table and the three views.
+//
+// Generated lane ONLY, on purpose and on every port (see the subcorpus README). What is
+// under test is whether the port's GENERATOR emits a read route for a view-backed report
+// and nothing for a sourceless one; a hand-rolled reference server would answer every
+// scenario by construction.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using MetaObjects.IntegrationTests.Runner;
+using Xunit;
+
+namespace MetaObjects.IntegrationTests.Api;
+
+public sealed class ApiContractReportConformanceTest
+{
+ [Theory]
+ [MemberData(nameof(Scenarios))]
+ public async Task Api_contract_report_generated(string scenarioPath)
+ {
+ var scenario = ApiContractScenarioLoader.LoadScenario(scenarioPath);
+ await using var pg = await PostgresContainer.StartAsync();
+ await using var server = await ReportGeneratedServerFactory.StartAsync(pg);
+ await server.ApplySeedAsync();
+ await RunAsync(scenario, server.BaseUrl);
+ }
+
+ private static async Task RunAsync(ApiScenario scenario, string baseUrl)
+ {
+ using var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
+ foreach (var req in scenario.Requests)
+ {
+ var request = new HttpRequestMessage(new HttpMethod(req.Method), ApiContractWire.VerbatimUri(client, req.Path));
+ if (req.Body is not null)
+ {
+ string json = JsonSerializer.Serialize(req.Body, JsonOpts);
+ request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+ }
+ var response = await client.SendAsync(request);
+ string bodyText = await response.Content.ReadAsStringAsync();
+ object? parsed = string.IsNullOrEmpty(bodyText) ? null : ToObject(JsonNode.Parse(bodyText));
+ ApiContractAssertions.AssertResponse(scenario.Name, req, (int)response.StatusCode, parsed);
+ }
+ }
+
+ public static IEnumerable Scenarios() =>
+ Directory.EnumerateFiles(ApiContractCorpusPaths.ReportScenariosDir, "*.yaml", SearchOption.TopDirectoryOnly)
+ .OrderBy(p => p, StringComparer.Ordinal)
+ .Select(p => new object[] { p });
+
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
+ };
+
+ private static object? ToObject(JsonNode? node)
+ {
+ if (node is null) return null;
+ if (node is JsonObject obj)
+ {
+ var d = new Dictionary(StringComparer.Ordinal);
+ foreach (var kvp in obj) d[kvp.Key] = ToObject(kvp.Value);
+ return d;
+ }
+ if (node is JsonArray arr)
+ {
+ var l = new List(arr.Count);
+ foreach (var item in arr) l.Add(ToObject(item));
+ return l;
+ }
+ if (node is JsonValue jv)
+ {
+ if (jv.TryGetValue(out var b)) return b;
+ if (jv.TryGetValue(out var lv)) return lv;
+ if (jv.TryGetValue(out var dv)) return dv;
+ if (jv.TryGetValue(out var sv)) return sv;
+ return jv.ToString();
+ }
+ return null;
+ }
+}
diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ReportFixture.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ReportFixture.cs
new file mode 100644
index 000000000..609786a8f
--- /dev/null
+++ b/server/csharp/MetaObjects.IntegrationTests/Api/ReportFixture.cs
@@ -0,0 +1,78 @@
+// ReportFixture — schema provisioning + seed for the FR-044 report api-contract
+// GENERATED lane.
+//
+// The schema is NOT written here. View SQL is produced by TypeScript only (ADR-0015), so
+// this lane executes the committed, TypeScript-produced, drift-checked artifact
+// `report/schema.postgres.sql` verbatim: the `invoices` table and the three views the
+// served reports read. That artifact carries LITERAL column naming (amountCents,
+// issuedOnMonth), which is the strategy this lane generates with.
+//
+// The seed is `report/seed.json`. Only its `invoices` half is inserted: the views derive
+// the report rows from it. Its `reports` half is what the seam lanes (Java, Kotlin, Python)
+// serve in place of the views, and is not used here.
+
+using System.Globalization;
+using System.Text.Json; // JsonValueKind
+using System.Text.Json.Nodes; // JsonNode / JsonObject / JsonArray
+using Npgsql;
+
+namespace MetaObjects.IntegrationTests.Api;
+
+internal static class ReportFixture
+{
+ private static readonly string[] InvoiceCols = { "id", "reference", "status", "amountCents", "issuedOn" };
+
+ // The one DATE column: bound as a DateOnly so Npgsql sends a date, not text.
+ private const string DateCol = "issuedOn";
+
+ /// Execute the committed TypeScript-produced schema on a fresh container.
+ public static async Task ProvisionSchemaAsync(string connString)
+ {
+ await using var c = new NpgsqlConnection(connString);
+ await c.OpenAsync();
+ await using var cmd = c.CreateCommand();
+ cmd.CommandText = await File.ReadAllTextAsync(ApiContractCorpusPaths.ReportSchemaSql);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Insert the seed's `invoices`. The views are never seeded — they derive from
+ /// `invoices`, which is what makes this a full-stack lane.
+ ///
+ public static async Task ApplySeedAsync(string connString, string seedPath)
+ {
+ var root = JsonNode.Parse(File.ReadAllText(seedPath)) as JsonObject
+ ?? throw new InvalidOperationException($"{seedPath}: top-level must be an object");
+ if (root["invoices"] is not JsonArray rows || rows.Count == 0)
+ throw new InvalidOperationException($"{seedPath}: no `invoices` rows to seed");
+
+ await using var c = new NpgsqlConnection(connString);
+ await c.OpenAsync();
+
+ var colList = string.Join(", ", InvoiceCols.Select(col => "\"" + col + "\""));
+ var paramList = string.Join(", ", InvoiceCols.Select((_, i) => "@p" + i));
+ foreach (var rowNode in rows)
+ {
+ if (rowNode is not JsonObject row) continue;
+ await using var ins = c.CreateCommand();
+ ins.CommandText = $"INSERT INTO \"invoices\" ({colList}) VALUES ({paramList})";
+ for (int i = 0; i < InvoiceCols.Length; i++)
+ {
+ var v = row[InvoiceCols[i]];
+ object val = v is null ? DBNull.Value
+ : InvoiceCols[i] == DateCol
+ ? DateOnly.ParseExact(v.GetValue(), "yyyy-MM-dd", CultureInfo.InvariantCulture)
+ : v.GetValueKind() == JsonValueKind.Number ? v.GetValue()
+ : v.GetValue();
+ ins.Parameters.AddWithValue("@p" + i, val);
+ }
+ await ins.ExecuteNonQueryAsync();
+ }
+
+ await using var bump = c.CreateCommand();
+ bump.CommandText =
+ "SELECT setval(pg_get_serial_sequence('invoices', 'id'), " +
+ "COALESCE((SELECT MAX(id) FROM \"invoices\"), 1))";
+ await bump.ExecuteScalarAsync();
+ }
+}
diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs
new file mode 100644
index 000000000..5fd434b56
--- /dev/null
+++ b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs
@@ -0,0 +1,225 @@
+// ReportGeneratedServerFactory — the C# GENERATED-server lane for the FR-044 report
+// corpus.
+//
+// Runs the real MetaObjects.Codegen generators (Entity + DbContext + FilterAllowlist
+// + Routes + Names) on the report model (the Invoice table, three view-backed reports and
+// one sourceless report), Roslyn-compiles the emitted sources in-memory, and hosts them on
+// Kestrel against Testcontainers Postgres with the three views present. A failing scenario
+// is a real generator bug (RoutesGenerator / DbContextGenerator / FilterAllowlistGenerator
+// / ReportRows), never something fixed by hand-editing emitted code.
+//
+// Mirrors ProjectionGeneratedServerFactory. The routes to mount are chosen by
+// RoutesGenerator.AppliesTo over the DECLARED nodes, and must be exactly Invoice and the
+// three served reports: the sourceless InvoiceDays sits in the model so that a generator
+// which serves every report it finds fails here.
+//
+// No JSON options are configured on the host. The date wire format under test
+// (`issuedOnMonth` as YYYY-MM-DD) is what the generated row's DateOnly property gives by
+// default, which is what an adopter mounting these routes gets.
+
+using System.Reflection;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using MetaObjects.Codegen;
+using MetaObjects.Codegen.Generators;
+using MetaObjects.IntegrationTests.Runner;
+using MetaObjects.Loader;
+
+namespace MetaObjects.IntegrationTests.Api;
+
+internal sealed class ReportGeneratedServerFactory : IAsyncDisposable
+{
+ private const string GeneratedNamespace = "MetaObjects.ApiContract.ReportGenerated";
+
+ // Ordinal order. InvoiceDays is in the model and declares no source.
+ private static readonly string[] ExpectedRoutedNames =
+ ["Invoice", "InvoiceStatusTotals", "InvoiceTotals", "InvoicesByMonth"];
+ private const string SourcelessReport = "InvoiceDays";
+
+ private readonly PostgresContainer _pg;
+ private readonly WebApplication _app;
+
+ public string BaseUrl { get; }
+
+ private ReportGeneratedServerFactory(PostgresContainer pg, WebApplication app, string baseUrl)
+ {
+ _pg = pg;
+ _app = app;
+ BaseUrl = baseUrl;
+ }
+
+ public static async Task StartAsync(PostgresContainer pg)
+ {
+ await ReportFixture.ProvisionSchemaAsync(pg.ConnectionString);
+
+ var (assembly, routedNames) = CompileGeneratedServer();
+ var dbContextType = assembly.GetType($"{GeneratedNamespace}.AppDbContext")
+ ?? throw new InvalidOperationException("generated AppDbContext type not found");
+
+ int port = PickFreePort();
+ string baseUrl = $"http://127.0.0.1:{port}";
+
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseUrls(baseUrl);
+ builder.Logging.ClearProviders();
+ RegisterGeneratedDbContext(builder.Services, dbContextType, pg.ConnectionString);
+
+ var app = builder.Build();
+
+ // Mount every generated MapRoutes(app, "/api") — Invoice's writable set and
+ // the three served reports' read-only ones. Invoice is mounted so the lane also
+ // proves the two coexist; only the reports' routes carry scenarios.
+ foreach (var name in routedNames)
+ {
+ var routesType = assembly.GetType($"{GeneratedNamespace}.{name}Routes")
+ ?? throw new InvalidOperationException($"generated {name}Routes type not found");
+ var mapMethod = routesType.GetMethod($"Map{name}Routes", BindingFlags.Public | BindingFlags.Static)
+ ?? throw new InvalidOperationException($"generated Map{name}Routes method not found");
+ mapMethod.Invoke(null, new object[] { app, "/api" });
+ }
+
+ await app.StartAsync();
+ return new ReportGeneratedServerFactory(pg, app, baseUrl);
+ }
+
+ public async Task ApplySeedAsync() =>
+ await ReportFixture.ApplySeedAsync(_pg.ConnectionString, ApiContractCorpusPaths.ReportSeedFile);
+
+ public async ValueTask DisposeAsync()
+ {
+ try { await _app.StopAsync(); } catch { /* ignored */ }
+ try { await _app.DisposeAsync(); } catch { /* ignored */ }
+ }
+
+ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGeneratedServer()
+ {
+ var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.ReportMetaJson)]);
+ if (loadResult.Errors.Count != 0)
+ throw new InvalidOperationException(
+ "report corpus metadata failed to load: " +
+ string.Join("; ", loadResult.Errors.Select(e => e.ToString())));
+
+ var root = loadResult.Root;
+ // Asked of the DECLARED nodes: Invoice and the three served reports, and not the
+ // sourceless InvoiceDays. Exactly these, so a generator that serves every report it
+ // finds (or none) fails here by name instead of as twelve unexplained 404s.
+ var routedNames = root.Objects()
+ .Where(o => RoutesGenerator.AppliesTo(o, root))
+ .Select(o => CSharpNaming.Pascal(o.Name))
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToList();
+ if (!routedNames.SequenceEqual(ExpectedRoutedNames))
+ throw new InvalidOperationException(
+ "expected routes for exactly " + string.Join(", ", ExpectedRoutedNames) +
+ ", got: " + string.Join(", ", routedNames));
+
+ var ctx = new GenContext
+ {
+ Entities = root.Objects(),
+ Root = root,
+ Config = new GenConfig
+ {
+ OutDir = "/unused",
+ Namespace = GeneratedNamespace,
+ ColumnNamingStrategy = ColumnNamingStrategy.Literal,
+ EmitAbstractShapes = false,
+ // Invoice binds through its names artifact. A report has none (it binds its
+ // view and columns by literal), so this also proves no generated report file
+ // references a Names class that was never emitted.
+ IncludeNames = true,
+ },
+ };
+
+ var files = new EntityGenerator().Generate(ctx)
+ .Concat(new DbContextGenerator().Generate(ctx))
+ .Concat(new FilterAllowlistGenerator().Generate(ctx))
+ .Concat(new RoutesGenerator().Generate(ctx))
+ .Concat(new NamesGenerator().Generate(ctx))
+ .ToList();
+
+ // The emitted tree itself: a routes file for each expected name, and no file at
+ // all for the sourceless report.
+ foreach (var name in ExpectedRoutedNames)
+ if (!files.Any(f => f.Path == name + "Routes.g.cs"))
+ throw new InvalidOperationException($"no {name}Routes.g.cs was generated");
+ if (files.FirstOrDefault(f => f.Path.Contains(SourcelessReport, StringComparison.Ordinal)) is { } leaked)
+ throw new InvalidOperationException(
+ $"the sourceless report {SourcelessReport} must generate nothing, but {leaked.Path} was emitted");
+
+ var trees = files
+ .Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12)))
+ .ToArray();
+
+ var refs = BuildReferenceSet();
+ var comp = CSharpCompilation.Create(
+ "apicontract_report_generated_" + Guid.NewGuid().ToString("N"),
+ trees, refs,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ using var ms = new MemoryStream();
+ var emit = comp.Emit(ms);
+ if (!emit.Success)
+ {
+ var errors = emit.Diagnostics
+ .Where(d => d.Severity == DiagnosticSeverity.Error)
+ .Select(d => $"{d.Id}: {d.GetMessage()}")
+ .ToList();
+ throw new InvalidOperationException(
+ "generated report server failed to compile:\n " + string.Join("\n ", errors));
+ }
+
+ ms.Seek(0, SeekOrigin.Begin);
+ return (Assembly.Load(ms.ToArray()), routedNames);
+ }
+
+ private static List BuildReferenceSet()
+ {
+ var byFileName = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var tpa = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? "";
+ foreach (var path in tpa.Split(Path.PathSeparator))
+ if (path.Length > 0 && path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+ byFileName[Path.GetFileName(path)] = path;
+
+ var aspNetDir = Path.GetDirectoryName(typeof(WebApplication).Assembly.Location);
+ if (aspNetDir is not null && Directory.Exists(aspNetDir))
+ foreach (var dll in Directory.EnumerateFiles(aspNetDir, "*.dll"))
+ byFileName[Path.GetFileName(dll)] = dll;
+
+ return byFileName.Values
+ .Select(loc => (MetadataReference)MetadataReference.CreateFromFile(loc))
+ .ToList();
+ }
+
+ private static void RegisterGeneratedDbContext(
+ IServiceCollection services, Type dbContextType, string connString)
+ {
+ var addDbContext = typeof(EntityFrameworkServiceCollectionExtensions)
+ .GetMethods(BindingFlags.Public | BindingFlags.Static)
+ .First(m => m.Name == "AddDbContext"
+ && m.IsGenericMethodDefinition
+ && m.GetGenericArguments().Length == 1
+ && m.GetParameters().Length == 4)
+ .MakeGenericMethod(dbContextType);
+
+ Action configure = opts => opts.UseNpgsql(connString);
+ addDbContext.Invoke(null, new object?[]
+ {
+ services, configure, ServiceLifetime.Scoped, ServiceLifetime.Scoped,
+ });
+ }
+
+ private static int PickFreePort()
+ {
+ var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ l.Start();
+ int port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
+ l.Stop();
+ return port;
+ }
+}
From 3d41c8c2bfd9cf0935e63dd44388a645775266dc Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:12:43 -0400
Subject: [PATCH 05/21] fix(csharp): a report's enum dimension is sortable
(FR-044)
---
.../ReportRowCodegenTests.cs | 70 +++++++++++++++++++
.../Generators/RoutesGenerator.cs | 11 ++-
2 files changed, 80 insertions(+), 1 deletion(-)
diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs
index e808802d6..42cbd1bd1 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs
@@ -330,6 +330,76 @@ public void A_field_with_no_filter_band_is_not_filterable_and_every_other_derive
Assert.Contains("[\"soldAtDay\"] = new(System.StringComparer.Ordinal) { \"eq\", \"ne\", \"gt\", \"gte\", \"lt\", \"lte\", \"in\", \"isNull\" },", allowlist);
}
+ [Fact]
+ public void An_enum_dimension_of_a_report_is_sortable()
+ {
+ // Table C: a field with a filter band sorts. The entity sort rule takes C# scalars
+ // only, which leaves an enum out; a report's enum dimension is in.
+ var routes = Emit(RunnerContext(Cube()), new RoutesGenerator())["SalesCubeRoutes.g.cs"];
+ string allowlist = routes[
+ routes.IndexOf("SortAllowlist =", StringComparison.Ordinal)..routes.IndexOf("SortDefaultDesc =", StringComparison.Ordinal)];
+ Assert.Contains(" \"Status\",\n", allowlist.ReplaceLineEndings("\n"));
+ Assert.Contains(
+ " \"Status\" => desc ? q.OrderByDescending(x => EF.Property(x!, \"Status\")) : q.OrderBy(x => EF.Property(x!, \"Status\")),",
+ routes);
+ // Every derived field of the cube is in the sort allowlist, in Table B order.
+ var sortable = allowlist.Split('\n').Select(l => l.Trim()).Where(l => l.StartsWith('"'))
+ .Select(l => l.Trim('"', ',')).ToList();
+ Assert.Equal(
+ [
+ "Store", "Channel", "Status", "StoreRegion", "SoldAtHour", "SoldAtDay", "SoldAtMonth",
+ "BookedAtHour", "SoldOnWeek", "Sales", "Channels", "UnitsSold", "Revenue", "TotalWeight",
+ "TotalScore", "AvgUnits", "AvgScore", "MinUnits", "LastSoldAt", "MaxWeight", "UnitsPerSale",
+ ],
+ sortable);
+ }
+
+ [Fact]
+ public void An_entitys_enum_field_stays_out_of_its_sort_allowlist()
+ {
+ // The enum case is report-only: an entity's routes keep their bytes.
+ var routes = Emit(RunnerContext(Cube()), new RoutesGenerator())["SaleRoutes.g.cs"];
+ Assert.DoesNotContain("\"Status\"", routes);
+ }
+
+ [Fact]
+ public void The_routes_of_a_report_with_an_enum_dimension_compile()
+ {
+ // The codegen-compile gate leaves the routes tier out (it needs ASP.NET Core), so the
+ // sort arm over the enum property is compiled here, with the shared framework added.
+ var ctx = RunnerContext(Cube(), Config(includeNames: true));
+ var files = new IGenerator[]
+ {
+ new EntityGenerator(), new DbContextGenerator(), new NamesGenerator(),
+ new FilterAllowlistGenerator(), new RoutesGenerator(),
+ }
+ .SelectMany(g => g.Generate(ctx)).ToList();
+ Assert.Contains(files, f => f.Path == "SalesCubeRoutes.g.cs");
+
+ var paths = DbContextCompileTests.BuildReferences()
+ .OfType().Select(r => r.FilePath!)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ string aspNetDir = Path.GetDirectoryName(typeof(Microsoft.AspNetCore.Http.IQueryCollection).Assembly.Location)!;
+ foreach (var dll in Directory.GetFiles(aspNetDir, "*.dll")) paths.Add(dll);
+ paths.Add(typeof(Microsoft.AspNetCore.Builder.WebApplication).Assembly.Location);
+ paths.Add(typeof(Microsoft.AspNetCore.Http.Results).Assembly.Location);
+ // The routes import FilterParser and EfCoreFilterDispatch from the codegen package.
+ paths.Add(typeof(RoutesGenerator).Assembly.Location);
+
+ var trees = files
+ .Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path))
+ .ToList();
+ var comp = CSharpCompilation.Create(
+ "report_routes_" + Guid.NewGuid().ToString("N"), trees,
+ paths.Where(File.Exists).Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(),
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ var errors = comp.GetDiagnostics()
+ .Where(d => d.Severity == DiagnosticSeverity.Error)
+ .Select(d => $"{d.Location.GetLineSpan().Path}: {d.Id}: {d.GetMessage()}")
+ .ToList();
+ Assert.True(errors.Count == 0, string.Join("\n", errors));
+ }
+
// ---------------------------------------------------------------------
// Table B — the C# type and nullability of every derived field
// ---------------------------------------------------------------------
diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs
index 591da7821..f3774bcea 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs
@@ -168,8 +168,17 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte
// Sort allowlist: every scalar field on the entity is sortable. The
// generated handler does case-insensitive lookup so the wire grammar
// (?sort=createdAt:desc) matches the C# property name (CreatedAt).
+ //
+ // FR-044 — on a REPORT an enum dimension sorts too (contract Table C: every derived
+ // field with a filter band is filterable and sortable). Report-only, so an entity's
+ // or projection's allowlist keeps its bytes. The sort arm is the same
+ // EF.Property(x, "") as any other field; the row maps the enum with
+ // HasConversion() (or to its integer under @intValueMap), so the ORDER BY is
+ // over the stored column.
var sortFields = entity.Fields()
- .Where(f => CSharpNaming.ScalarFor(f.SubType) is not null && !f.ResolvedIsArray())
+ .Where(f => (CSharpNaming.ScalarFor(f.SubType) is not null
+ || (isReport && f.SubType == FIELD_SUBTYPE_ENUM))
+ && !f.ResolvedIsArray())
.Select(f => CSharpNaming.Pascal(f.Name))
.ToList();
From b554362caa5b99bd423ba88e7f51f26b56accffb Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:15:19 -0400
Subject: [PATCH 06/21] feat(codegen-ts): generate the read-only surface of a
view-backed report (FR-044)
---
.../codegen/generators/routes.ts | 34 +++-
.../drizzle-fastify/mount-read-only.ts | 74 +++++---
.../showcase/codegen/generators/routes.ts | 34 +++-
.../drizzle-fastify/mount-read-only.ts | 74 +++++---
fixtures/codegen-noop/reporting/README.md | 17 +-
.../cli/test/unit/reporting-inert.test.ts | 156 +++++++++++++---
.../src/generators/angular-grid.ts | 4 +-
.../src/generators/angular-service.ts | 4 +-
.../src/generators/barrel.ts | 6 +-
.../src/reference/grid-hook.ts | 4 +-
.../codegen-ts-tanstack/src/reference/grid.ts | 4 +-
.../src/reference/hooks.ts | 4 +-
.../src/tanstack-grid-hook.ts | 4 +-
.../codegen-ts-tanstack/src/tanstack-grid.ts | 4 +-
.../codegen-ts-tanstack/src/tanstack-query.ts | 4 +-
.../src/templates/hooks-file.ts | 26 ++-
.../test/report-no-ui-tier.test.ts | 139 ++++++++++++++
.../packages/codegen-ts/src/api-surface.ts | 50 ++++-
.../src/generators/agent-ui-page.ts | 13 +-
.../packages/codegen-ts/src/index.ts | 4 +-
.../codegen-ts/src/reference/routes-hono.ts | 29 ++-
.../codegen-ts/src/reference/routes.ts | 34 +++-
.../packages/codegen-ts/src/runner.ts | 15 +-
.../packages/codegen-ts/src/source-detect.ts | 42 ++++-
.../codegen-ts/src/templates/field-meta.ts | 7 +-
.../src/templates/projection-decl.ts | 5 +-
.../codegen-ts/src/templates/queries-file.ts | 34 ++--
.../src/templates/routes-file-hono.ts | 23 ++-
.../codegen-ts/src/templates/routes-file.ts | 32 +++-
.../test/codegen-compile-conformance.test.ts | 20 +-
.../test/projection/queries-file.test.ts | 98 +++++++++-
.../test/projection/routes-file.test.ts | 174 +++++++++++++++++-
.../src/core/reporting/report-read-model.ts | 6 +
.../metadata/test/report-read-model.test.ts | 12 ++
.../packages/test-generators/src/routes.ts | 34 +++-
35 files changed, 1006 insertions(+), 218 deletions(-)
create mode 100644 server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts
diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts
index 138413862..342d5cba9 100644
--- a/examples/advanced-modeling/codegen/generators/routes.ts
+++ b/examples/advanced-modeling/codegen/generators/routes.ts
@@ -17,7 +17,7 @@
// discovers a sibling module: a `.extra.ts` next to the output is a naming
// convention, not a plugin point, so its handlers only mount if your server calls them.
// emits: /.routes.ts — full CRUD for write-through entities, read-only
-// (GET list + GET :id) for projections, polymorphic + per-subtype for TPH bases.
+// (GET list + GET :id) for projections (GET list alone for a keyless one or a report), polymorphic + per-subtype for TPH bases.
// Skipped for any sourceless object (incl. every object.value, source-less by
// value purity) and for TPH subtypes — no source.rdb means no table/allowlist
// for a routes file to import (#248 R2).
@@ -71,6 +71,8 @@ import {
tphStorageObject,
isProjection,
isWriteThrough,
+ isReport,
+ hasItemRoute,
servesReadApi,
formatTs,
renderRoutesIndex,
@@ -92,7 +94,8 @@ import {
// --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
-// projection → mountReadOnlyCrudRoutes (GET list + GET :id); every other writable entity →
+// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has a
+// single-column identity); every other writable entity →
// mountCrudRoutes (+ one mountM2mRoute per M:N navigation). Under an `apiPrefix` the mounts
// are wrapped in `fastify.register(..., { prefix })`.
@@ -133,9 +136,22 @@ function renderRoutes(
// Where the mount helpers come from: the package, or an owned copy (owned-runtime.ts).
const runtimeSpec = httpRuntimeSpecifier("drizzle-fastify", ctx, entityPkg);
- // --- Projection path: read-only routes (GET list + GET :id) ---
+ // --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
+ // A keyless read-only object (a projection with no single-column identity, and every
+ // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
+ // keeps its output byte-identical.
+ const keyless = !hasItemRoute(entity);
+ const report = isReport(entity);
+ const noun = report ? "report" : "projection";
+ const exposes = keyless
+ ? "Exposes GET list only. POST returns 405."
+ : "Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.";
+ const keylessOpts = (indent: string): string =>
+ (keyless ? `\n${indent}itemRoutes: false,` : "") +
+ (report ? `\n${indent}resource: "report",` : "");
const FastifyInstanceSym = imp("t:FastifyInstance@fastify");
const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`);
// A projection mount is read-only by construction, so `expose` cannot narrow it —
@@ -159,9 +175,9 @@ import {
const body = ctx.apiPrefix
? code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -175,16 +191,16 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}, { prefix: ${JSON.stringify(ctx.apiPrefix)} });
}
`
: code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -197,7 +213,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}
`;
diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts
index 20e068f88..a30395a3c 100644
--- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts
+++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts
@@ -33,15 +33,23 @@ export interface MountReadOnlyOptions {
* mount from an enclosing plugin scope works here too, and needs no option at all.)
*/
readonly routeOptions?: RouteShorthandOptions;
+ /**
+ * False for an object with no single-column primary identity: an `object.report`, or
+ * a keyless projection. Mounts the list route and the collection POST refusal only,
+ * and no `/:id` route of any verb. Default true, which is today's behaviour.
+ */
+ readonly itemRoutes?: boolean;
+ /** The noun in the 405 message, which is free prose. Default "projection". */
+ readonly resource?: "projection" | "report";
}
-const REJECT_MUTATION = async (
+const rejectMutation = (resource: string) => async (
request: { method: string },
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
) => {
reply
.code(405)
- .send({ error: "method_not_allowed", message: `${request.method} is not supported on a projection (read-only).` });
+ .send({ error: "method_not_allowed", message: `${request.method} is not supported on a ${resource} (read-only).` });
};
function resolveViewName(view: AnyView): string | undefined {
@@ -128,6 +136,8 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
// Route-scoped contract error handler: an unexpected error answers
// `500 { error: "internal" }` rather than Fastify's default (which echoes the SQL).
const ro = withContractErrorHandler(opts.routeOptions);
+ const reject = rejectMutation(opts.resource ?? "projection");
+ const itemRoutes = opts.itemRoutes !== false;
const viewName = resolveViewName(view);
const useRawSql = isEmptyColumnView(view) && !!viewName;
@@ -205,37 +215,43 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
}
});
- // ── Get by ID ─────────────────────────────────────────────────────────────
- fastify.get(`${path}/:id`, ro, async (req, reply) => {
- const { id } = req.params as { id: string };
- if (useRawSql) {
- // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
- const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
- const row = rows[0] ? camelizeRow(rows[0]) : undefined;
- return row ?? reply.code(404).send({ error: "not_found" });
- }
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
- const colRef = (view as any)[idCol];
- // Compare against the PK's real type — a uuid/text key must NOT go through Number().
- const idValue = coerceIdForColumn(colRef, id);
- if (idValue === undefined) {
- return reply.code(400).send({ error: "invalid_id" });
- }
- // Await + first row rather than `.get()` (libsql/better-sqlite3-only).
- const rows = await db.select().from(view).where(
- colRef !== undefined ? eq(colRef, idValue) : undefined
- ).limit(1);
- const row = (rows as unknown[])[0];
- return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
- });
+ // A keyless object (`itemRoutes: false`) has nothing to address by id: no `/:id` route
+ // of any verb, so the framework's own 404 answers.
+ if (itemRoutes) {
+ // ── Get by ID ─────────────────────────────────────────────────────────────
+ fastify.get(`${path}/:id`, ro, async (req, reply) => {
+ const { id } = req.params as { id: string };
+ if (useRawSql) {
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
+ const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
+ const row = rows[0] ? camelizeRow(rows[0]) : undefined;
+ return row ?? reply.code(404).send({ error: "not_found" });
+ }
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
+ const colRef = (view as any)[idCol];
+ // Compare against the PK's real type — a uuid/text key must NOT go through Number().
+ const idValue = coerceIdForColumn(colRef, id);
+ if (idValue === undefined) {
+ return reply.code(400).send({ error: "invalid_id" });
+ }
+ // Await + first row rather than `.get()` (libsql/better-sqlite3-only).
+ const rows = await db.select().from(view).where(
+ colRef !== undefined ? eq(colRef, idValue) : undefined
+ ).limit(1);
+ const row = (rows as unknown[])[0];
+ return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
+ });
+ }
// ── Mutations explicitly rejected (405) ───────────────────────────────────
// PUT is here because the WRITABLE mount serves it (an alias of PATCH), so a
// projection must reject it the same way the other three are rejected. Omitting it
// left `PUT //:id` falling through to Fastify's 404 — telling a caller
// the resource does not exist when it plainly does and answers GET.
- fastify.post(path, ro, REJECT_MUTATION);
- fastify.patch(`${path}/:id`, ro, REJECT_MUTATION);
- fastify.put(`${path}/:id`, ro, REJECT_MUTATION);
- fastify.delete(`${path}/:id`, ro, REJECT_MUTATION);
+ fastify.post(path, ro, reject);
+ if (itemRoutes) {
+ fastify.patch(`${path}/:id`, ro, reject);
+ fastify.put(`${path}/:id`, ro, reject);
+ fastify.delete(`${path}/:id`, ro, reject);
+ }
}
diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts
index 138413862..342d5cba9 100644
--- a/examples/showcase/codegen/generators/routes.ts
+++ b/examples/showcase/codegen/generators/routes.ts
@@ -17,7 +17,7 @@
// discovers a sibling module: a `.extra.ts` next to the output is a naming
// convention, not a plugin point, so its handlers only mount if your server calls them.
// emits: /.routes.ts — full CRUD for write-through entities, read-only
-// (GET list + GET :id) for projections, polymorphic + per-subtype for TPH bases.
+// (GET list + GET :id) for projections (GET list alone for a keyless one or a report), polymorphic + per-subtype for TPH bases.
// Skipped for any sourceless object (incl. every object.value, source-less by
// value purity) and for TPH subtypes — no source.rdb means no table/allowlist
// for a routes file to import (#248 R2).
@@ -71,6 +71,8 @@ import {
tphStorageObject,
isProjection,
isWriteThrough,
+ isReport,
+ hasItemRoute,
servesReadApi,
formatTs,
renderRoutesIndex,
@@ -92,7 +94,8 @@ import {
// --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
-// projection → mountReadOnlyCrudRoutes (GET list + GET :id); every other writable entity →
+// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has a
+// single-column identity); every other writable entity →
// mountCrudRoutes (+ one mountM2mRoute per M:N navigation). Under an `apiPrefix` the mounts
// are wrapped in `fastify.register(..., { prefix })`.
@@ -133,9 +136,22 @@ function renderRoutes(
// Where the mount helpers come from: the package, or an owned copy (owned-runtime.ts).
const runtimeSpec = httpRuntimeSpecifier("drizzle-fastify", ctx, entityPkg);
- // --- Projection path: read-only routes (GET list + GET :id) ---
+ // --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
+ // A keyless read-only object (a projection with no single-column identity, and every
+ // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
+ // keeps its output byte-identical.
+ const keyless = !hasItemRoute(entity);
+ const report = isReport(entity);
+ const noun = report ? "report" : "projection";
+ const exposes = keyless
+ ? "Exposes GET list only. POST returns 405."
+ : "Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.";
+ const keylessOpts = (indent: string): string =>
+ (keyless ? `\n${indent}itemRoutes: false,` : "") +
+ (report ? `\n${indent}resource: "report",` : "");
const FastifyInstanceSym = imp("t:FastifyInstance@fastify");
const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`);
// A projection mount is read-only by construction, so `expose` cannot narrow it —
@@ -159,9 +175,9 @@ import {
const body = ctx.apiPrefix
? code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -175,16 +191,16 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}, { prefix: ${JSON.stringify(ctx.apiPrefix)} });
}
`
: code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -197,7 +213,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}
`;
diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts b/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts
index 20e068f88..a30395a3c 100644
--- a/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts
+++ b/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts
@@ -33,15 +33,23 @@ export interface MountReadOnlyOptions {
* mount from an enclosing plugin scope works here too, and needs no option at all.)
*/
readonly routeOptions?: RouteShorthandOptions;
+ /**
+ * False for an object with no single-column primary identity: an `object.report`, or
+ * a keyless projection. Mounts the list route and the collection POST refusal only,
+ * and no `/:id` route of any verb. Default true, which is today's behaviour.
+ */
+ readonly itemRoutes?: boolean;
+ /** The noun in the 405 message, which is free prose. Default "projection". */
+ readonly resource?: "projection" | "report";
}
-const REJECT_MUTATION = async (
+const rejectMutation = (resource: string) => async (
request: { method: string },
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
) => {
reply
.code(405)
- .send({ error: "method_not_allowed", message: `${request.method} is not supported on a projection (read-only).` });
+ .send({ error: "method_not_allowed", message: `${request.method} is not supported on a ${resource} (read-only).` });
};
function resolveViewName(view: AnyView): string | undefined {
@@ -128,6 +136,8 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
// Route-scoped contract error handler: an unexpected error answers
// `500 { error: "internal" }` rather than Fastify's default (which echoes the SQL).
const ro = withContractErrorHandler(opts.routeOptions);
+ const reject = rejectMutation(opts.resource ?? "projection");
+ const itemRoutes = opts.itemRoutes !== false;
const viewName = resolveViewName(view);
const useRawSql = isEmptyColumnView(view) && !!viewName;
@@ -205,37 +215,43 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
}
});
- // ── Get by ID ─────────────────────────────────────────────────────────────
- fastify.get(`${path}/:id`, ro, async (req, reply) => {
- const { id } = req.params as { id: string };
- if (useRawSql) {
- // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
- const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
- const row = rows[0] ? camelizeRow(rows[0]) : undefined;
- return row ?? reply.code(404).send({ error: "not_found" });
- }
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
- const colRef = (view as any)[idCol];
- // Compare against the PK's real type — a uuid/text key must NOT go through Number().
- const idValue = coerceIdForColumn(colRef, id);
- if (idValue === undefined) {
- return reply.code(400).send({ error: "invalid_id" });
- }
- // Await + first row rather than `.get()` (libsql/better-sqlite3-only).
- const rows = await db.select().from(view).where(
- colRef !== undefined ? eq(colRef, idValue) : undefined
- ).limit(1);
- const row = (rows as unknown[])[0];
- return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
- });
+ // A keyless object (`itemRoutes: false`) has nothing to address by id: no `/:id` route
+ // of any verb, so the framework's own 404 answers.
+ if (itemRoutes) {
+ // ── Get by ID ─────────────────────────────────────────────────────────────
+ fastify.get(`${path}/:id`, ro, async (req, reply) => {
+ const { id } = req.params as { id: string };
+ if (useRawSql) {
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
+ const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
+ const row = rows[0] ? camelizeRow(rows[0]) : undefined;
+ return row ?? reply.code(404).send({ error: "not_found" });
+ }
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
+ const colRef = (view as any)[idCol];
+ // Compare against the PK's real type — a uuid/text key must NOT go through Number().
+ const idValue = coerceIdForColumn(colRef, id);
+ if (idValue === undefined) {
+ return reply.code(400).send({ error: "invalid_id" });
+ }
+ // Await + first row rather than `.get()` (libsql/better-sqlite3-only).
+ const rows = await db.select().from(view).where(
+ colRef !== undefined ? eq(colRef, idValue) : undefined
+ ).limit(1);
+ const row = (rows as unknown[])[0];
+ return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
+ });
+ }
// ── Mutations explicitly rejected (405) ───────────────────────────────────
// PUT is here because the WRITABLE mount serves it (an alias of PATCH), so a
// projection must reject it the same way the other three are rejected. Omitting it
// left `PUT //:id` falling through to Fastify's 404 — telling a caller
// the resource does not exist when it plainly does and answers GET.
- fastify.post(path, ro, REJECT_MUTATION);
- fastify.patch(`${path}/:id`, ro, REJECT_MUTATION);
- fastify.put(`${path}/:id`, ro, REJECT_MUTATION);
- fastify.delete(`${path}/:id`, ro, REJECT_MUTATION);
+ fastify.post(path, ro, reject);
+ if (itemRoutes) {
+ fastify.patch(`${path}/:id`, ro, reject);
+ fastify.put(`${path}/:id`, ro, reject);
+ fastify.delete(`${path}/:id`, ro, reject);
+ }
}
diff --git a/fixtures/codegen-noop/reporting/README.md b/fixtures/codegen-noop/reporting/README.md
index bc3139e8f..fd9f15c79 100644
--- a/fixtures/codegen-noop/reporting/README.md
+++ b/fixtures/codegen-noop/reporting/README.md
@@ -11,20 +11,27 @@ Two models that differ ONLY by the FR-044 reporting vocabulary:
to leak output.
What is lowered (FR-044 Plan 2): a report that declares a read-only `source.rdb @kind: view`
-becomes that view. `StoreTotals` is that report, so `with/` differs from `without/` in exactly
+becomes that view. What is served (FR-044 Plan 3): that same report gets a keyless read-only
+REST surface. `StoreTotals` is that report, so `with/` differs from `without/` in exactly
these places and no others:
- TypeScript `meta migrate` proposes one extra statement, `CREATE VIEW v_store_totals`, and the
`meta docs` agent schema page lists that view (the `## Views` section).
+- TypeScript codegen (`meta gen`) writes the report's read-only files and one barrel export:
+ `StoreTotals.ts` (Drizzle view binding, Zod read schema, row type, descriptor, filter and sort
+ allowlists), `StoreTotals.queries.ts` (the list query only), `StoreTotals.routes.ts` and
+ `StoreTotals.routes.hono.ts` (GET list, `POST` answers 405, no `/:id` route) and
+ `StoreTotals.names.ts`. It writes nothing from the client UI tier (hooks, grid, grid hook,
+ form): that tier is off for reports until Plan 5.
- C# codegen (`dotnet meta gen`) writes one extra file, the keyless row class `StoreTotals.g.cs`, and two extra
lines in `AppDbContext.g.cs` (a `DbSet` and `HasNoKey().ToView("v_store_totals")`).
- Kotlin codegen (`metaobjects:generate`) writes an Exposed table object for `StoreTotals`.
What stays inert: a report with no read-only source (`ProgramEngagement`, `DailyRevenue`),
-everywhere; every generator in TypeScript, Java and Python, for every report; routes, typed
-clients, filter allowlists and api-docs in every port; and every other C# and Kotlin generator.
-No port but TypeScript emits SQL for a report (ADR-0015), so for Java and Python `with/` and
-`without/` still generate byte-identical files. The per-port tests that hold this:
+everywhere; the client UI tier and api-docs for every report, in every port. No port but
+TypeScript emits SQL for a report (ADR-0015). The C#, Java, Kotlin and Python entries here
+describe Plan 2; each port's Plan 3 task re-states its own line and its own test when it
+starts serving `StoreTotals`. The per-port tests that hold this:
| Port | Test |
|---|---|
diff --git a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
index d7b767e66..d438f66a6 100644
--- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
+++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
@@ -1,23 +1,26 @@
// FR-044 — what a report generates, and what it does not.
//
// Plan 1 registered `dimension.*`, `measure.*`, `segment.*` and `object.report` and gave
-// them no output. Plan 2 lowers exactly ONE thing: a report that declares a read-only
-// `source.rdb @kind: view` becomes that view in TypeScript migrate (and so on the
-// `meta docs` agent schema page, which lists the views migrate would create). Everything
-// else stays inert, and this file holds it there: a sourceless report is inert everywhere,
-// every catalog generator emits the same files with and without the reporting nodes (no
-// TypeScript generator emits for a report; routes and the typed row are Plan 3), and every
-// docs surface other than that one schema entry is byte-identical.
+// them no output. Plan 2 lowers a report that declares a read-only `source.rdb @kind: view`
+// to that view in TypeScript migrate (and so on the `meta docs` agent schema page, which
+// lists the views migrate would create). Plan 3 serves that same report: the TypeScript
+// generators emit its keyless read-only surface, and nothing else.
+//
+// So this file holds two lines. A SOURCELESS report is inert everywhere. A SERVED report
+// (`StoreTotals`) adds exactly its read-only files: the entity module (Drizzle view
+// binding, Zod read schema, descriptor, allowlists), the list query, the Fastify and Hono
+// routes, the names artifact and its barrel export. It adds nothing from the UI tier
+// (hooks, grid, grid hook, form), which is off for reports until Plan 5, and every file
+// the model without reporting nodes emits is byte-identical, the barrel excepted.
//
// The model pair lives in fixtures/codegen-noop/reporting/ and is shared with the other
// four ports' copies of this test. `with/` carries a report that declares a read-only
-// `source.rdb @kind: view` (R5 allows one): that is the case that leaked in C#, where it
-// emitted a keyless DbSet, a GET route and a filter allowlist for an object with no fields.
+// `source.rdb @kind: view` (R5 allows one): that is the case that once leaked in C#, where
+// it emitted a keyless DbSet, a GET route and a filter allowlist for an object with no fields.
//
-// `meta docs` is held to the same rule (controller ruling, 2026-10-03): a report's fields
-// are derived by its lowering, so a page for one today would show none of them. Every docs
-// surface — model pages, agent pages, requirements, the HTML site, and the api surface —
-// must come out identical with and without the reporting nodes, bar the one view entry.
+// `meta docs` is still held to the Plan 1 rule here (controller ruling, 2026-10-03): every
+// docs surface — model pages, agent pages, requirements, the HTML site, and the api surface
+// — comes out identical with and without the reporting nodes, bar the one view entry.
import { describe, test, expect, beforeAll } from "bun:test";
import { mkdtempSync, mkdirSync, copyFileSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -98,7 +101,42 @@ beforeAll(async () => {
withoutReporting = await load("without");
});
-describe("FR-044 reporting nodes are inert in codegen", () => {
+/** Table E, TypeScript: the files a served report adds, by the catalog generator that
+ * writes them. A generator that is not listed adds none. */
+const OUT = "src/generated";
+const SERVED_REPORT_FILES: Readonly> = {
+ entity: [`${OUT}/StoreTotals.ts`],
+ names: [`${OUT}/StoreTotals.names.ts`],
+ queries: [`${OUT}/StoreTotals.queries.ts`],
+ routes: [`${OUT}/StoreTotals.routes.ts`],
+ "routes-hono": [`${OUT}/StoreTotals.routes.hono.ts`],
+};
+/** The one existing file a served report changes: it gains the report's export line. */
+const BARREL = `${OUT}/index.ts`;
+/** The client UI tier, off for reports until Plan 5 (answer 6). */
+const UI_TIER = ["hooks", "grid", "grid-hook", "form"] as const;
+const SOURCELESS_REPORTS = ["ProgramEngagement", "DailyRevenue"] as const;
+
+/** Assert `actual` is `expected` plus exactly `added`, with every shared file
+ * byte-identical except the barrel. */
+function expectOnlyAdds(
+ expected: Record,
+ actual: Record,
+ added: readonly string[],
+): void {
+ expect(Object.keys(actual).filter((p) => !(p in expected)).sort()).toEqual([...added].sort());
+ expect(Object.keys(expected).filter((p) => !(p in actual))).toEqual([]);
+ for (const [path, content] of Object.entries(expected)) {
+ if (path === BARREL) continue;
+ expect({ path, content: actual[path] }).toEqual({ path, content });
+ }
+ // Nothing at all for a sourceless report, in any file name.
+ for (const name of SOURCELESS_REPORTS) {
+ expect(Object.keys(actual).filter((p) => p.includes(name))).toEqual([]);
+ }
+}
+
+describe("FR-044 a sourceless report is inert; a served report emits exactly its read-only files", () => {
test("the with-model really carries the vocabulary (else every check below is vacuous)", () => {
const reports = withReporting.objects().filter((o) => o.subType === OBJECT_SUBTYPE_REPORT);
expect(reports.map((o) => o.name).sort()).toEqual(["DailyRevenue", "ProgramEngagement", "StoreTotals"]);
@@ -106,12 +144,53 @@ describe("FR-044 reporting nodes are inert in codegen", () => {
});
const catalog = composeCatalog();
+
+ test("every generator named in the expected-files table is in the catalog", () => {
+ // A renamed catalog entry would otherwise turn its row into dead text and its
+ // generator into one that is expected to add nothing.
+ for (const name of [...Object.keys(SERVED_REPORT_FILES), "barrel", ...UI_TIER]) {
+ expect(Object.keys(catalog)).toContain(name);
+ }
+ });
+
for (const [name, entry] of Object.entries(catalog)) {
- test(`generator "${name}" emits the same files with and without reporting nodes`, async () => {
+ test(`generator "${name}" adds exactly the served report's files and changes nothing else`, async () => {
const expected = await emit(withoutReporting, [entry.factory()]);
const actual = await emit(withReporting, [entry.factory()]);
- expect(Object.keys(actual)).toEqual(Object.keys(expected));
- expect(actual).toEqual(expected);
+ if ("" in expected) {
+ // Cannot run from a bare model (pinned by name below): the same throw both ways.
+ expect(actual).toEqual(expected);
+ return;
+ }
+ expectOnlyAdds(expected, actual, SERVED_REPORT_FILES[name] ?? []);
+ if (name === "barrel") {
+ // The barrel is the one shared file that moves, and it moves by the report's
+ // export alone: every line it had is still there, in order.
+ const before = expected[BARREL]!.split("\n");
+ const after = actual[BARREL]!.split("\n");
+ const addedLines = after.filter((l) => !before.includes(l));
+ expect(addedLines.length).toBeGreaterThan(0);
+ for (const l of addedLines) expect(l).toContain("StoreTotals");
+ expect(after.filter((l) => before.includes(l))).toEqual(before);
+ } else if (BARREL in expected) {
+ expect(actual[BARREL]).toBe(expected[BARREL]!);
+ }
+ });
+ }
+
+ for (const name of UI_TIER) {
+ test(`UI-tier generator "${name}" emits no file for any report`, async () => {
+ const actual = await emit(withReporting, [catalog[name]!.factory()]);
+ expect(actual[""]).toBeUndefined();
+ // Not vacuous for hooks and form: they do emit for the entities beside the reports.
+ // The two grid generators emit only for an object with a `layout.dataGrid`, which
+ // nothing in this model declares, so for them this run shows only that nothing
+ // leaks; their gate (`servesClientTier`) is asserted directly in codegen-ts and
+ // codegen-ts-tanstack.
+ if (name === "hooks" || name === "form") {
+ expect(Object.keys(actual).some((p) => p.includes("Program"))).toBe(true);
+ }
+ expect(Object.keys(actual).filter((p) => p.includes("StoreTotals"))).toEqual([]);
});
}
@@ -126,10 +205,10 @@ describe("FR-044 reporting nodes are inert in codegen", () => {
expect(threw.sort()).toEqual(["shared-model"]);
});
- test("every runnable generator in ONE run emits the same files (barrels see the whole suite)", async () => {
+ test("every runnable generator in ONE run adds exactly the Table E list (barrels see the whole suite)", async () => {
// A generator that cannot run from a bare model (shared-model needs a `files`
- // selection, render-helper a template root) throws in both variants above, which is
- // equal and so passes; it would sink the whole combined run, so it sits this one out.
+ // selection) throws in both variants above, which is equal and so passes; it would
+ // sink the whole combined run, so it sits this one out.
const runnable: string[] = [];
for (const [name, entry] of Object.entries(catalog)) {
const alone = await emit(withoutReporting, [entry.factory()]);
@@ -139,28 +218,49 @@ describe("FR-044 reporting nodes are inert in codegen", () => {
const expected = await emit(withoutReporting, suite());
const actual = await emit(withReporting, suite());
expect(expected[""]).toBeUndefined();
+ expect(actual[""]).toBeUndefined();
expect(Object.keys(expected).length).toBeGreaterThan(10);
- expect(Object.keys(actual)).toEqual(Object.keys(expected));
- expect(actual).toEqual(expected);
+ expectOnlyAdds(expected, actual, Object.values(SERVED_REPORT_FILES).flat());
+ // In the full suite the barrel re-exports the report's modules.
+ expect(actual[BARREL]).toContain("StoreTotals");
+ expect(expected[BARREL]).not.toContain("StoreTotals");
});
});
describe("FR-044 a selection of only reports", () => {
- test("warns that there is nothing to generate, like an empty selection", async () => {
+ const allGenerators = (): Generator[] =>
+ Object.values(composeCatalog()).filter((e) => e.name !== "shared-model").map((e) => e.factory());
+ const run = async (entityFilter: string[]) => {
const root = mkdtempSync(join(tmpdir(), "reporting-inert-only-"));
try {
- const result = await runGen({
- config: { outDir: "src/generated", extStyle: "js", dialect: "postgres", dbImport: "../db", generators: Object.values(composeCatalog()).filter((e) => e.name !== "shared-model").map((e) => e.factory()) },
+ return await runGen({
+ config: { outDir: "src/generated", extStyle: "js", dialect: "postgres", dbImport: "../db", generators: allGenerators() },
metadata: withReporting,
projectRoot: root,
genStateDir: join(root, GEN_STATE),
- entityFilter: ["DailyRevenue", "ProgramEngagement", "StoreTotals"],
+ entityFilter,
});
- expect(result.files).toEqual([]);
- expect(result.warnings.some((w) => w.startsWith("No entities to generate") && w.includes("object.report"))).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
+ };
+
+ test("only sourceless reports: warns that there is nothing to generate, like an empty selection", async () => {
+ const result = await run([...SOURCELESS_REPORTS]);
+ expect(result.files).toEqual([]);
+ expect(result.warnings.some((w) => w.startsWith("No entities to generate") && w.includes("object.report"))).toBe(true);
+ });
+
+ test("a served report among them generates, and only for itself", async () => {
+ const result = await run(["DailyRevenue", "ProgramEngagement", "StoreTotals"]);
+ expect(result.warnings.some((w) => w.startsWith("No entities to generate"))).toBe(false);
+ const names = result.files.map((f) => f.path.split(sep).pop()!);
+ for (const file of Object.values(SERVED_REPORT_FILES).flat()) {
+ expect(names).toContain(file.split("/").pop()!);
+ }
+ for (const name of SOURCELESS_REPORTS) {
+ expect(names.filter((n) => n.includes(name))).toEqual([]);
+ }
});
});
diff --git a/server/typescript/packages/codegen-ts-angular/src/generators/angular-grid.ts b/server/typescript/packages/codegen-ts-angular/src/generators/angular-grid.ts
index 17912e003..30c4f2d2f 100644
--- a/server/typescript/packages/codegen-ts-angular/src/generators/angular-grid.ts
+++ b/server/typescript/packages/codegen-ts-angular/src/generators/angular-grid.ts
@@ -6,7 +6,7 @@ import {
type GeneratorFactory,
formatTs,
entityOutputPath,
- servesReadApi,
+ servesClientTier,
effectivePackage,
} from "@metaobjectsdev/codegen-ts";
import { renderGridFile } from "../templates/grid-file.js";
@@ -45,7 +45,7 @@ export const angularGridFile = function angularGridFile(
filter: (e: MetaObject) =>
// A grid renders what a generated READ endpoint returns — no endpoint, no grid
// (see api-surface.ts).
- servesReadApi(e) && userFilter(e) && hasDataGridLayout(e),
+ servesClientTier(e) && userFilter(e) && hasDataGridLayout(e),
generate: perEntity(async (entity, ctx) => {
if (!ctx.renderContext) {
throw new Error("angular-grid: renderContext is required (provided by runGen)");
diff --git a/server/typescript/packages/codegen-ts-angular/src/generators/angular-service.ts b/server/typescript/packages/codegen-ts-angular/src/generators/angular-service.ts
index 4fc1cba38..908549ceb 100644
--- a/server/typescript/packages/codegen-ts-angular/src/generators/angular-service.ts
+++ b/server/typescript/packages/codegen-ts-angular/src/generators/angular-service.ts
@@ -5,7 +5,7 @@ import {
type GeneratorFactory,
formatTs,
entityOutputPath,
- servesReadApi,
+ servesClientTier,
effectivePackage,
} from "@metaobjectsdev/codegen-ts";
import { renderServiceFile } from "../templates/service-file.js";
@@ -38,7 +38,7 @@ export const angularServiceFile = function angularServiceFile(
// A service is a client of a generated READ endpoint — no endpoint, no service
// (an `object.value`, a sourceless entity/projection or an abstract object has
// nothing to fetch, and its emitted output could never compile; see api-surface.ts).
- filter: (e: MetaObject) => servesReadApi(e) && userFilter(e),
+ filter: (e: MetaObject) => servesClientTier(e) && userFilter(e),
generate: perEntity(async (entity, ctx) => {
if (!ctx.renderContext) {
throw new Error("angular-service: renderContext is required (provided by runGen)");
diff --git a/server/typescript/packages/codegen-ts-angular/src/generators/barrel.ts b/server/typescript/packages/codegen-ts-angular/src/generators/barrel.ts
index 5a3616fd7..635ffb4bf 100644
--- a/server/typescript/packages/codegen-ts-angular/src/generators/barrel.ts
+++ b/server/typescript/packages/codegen-ts-angular/src/generators/barrel.ts
@@ -5,7 +5,7 @@ import {
type GeneratorFactory,
formatTs,
packageToPath,
- servesReadApi,
+ servesClientTier,
servesWriteApi,
isProjection,
effectivePackage,
@@ -52,13 +52,13 @@ export const barrel = function barrel(opts?: AngularBarrelOpts): Generator {
const pkg = effectivePackage(e);
// Each line mirrors its generator's filter exactly — a re-export of a file
// that was never emitted is a hard build break in the consumer app.
- if (servesReadApi(e)) {
+ if (servesClientTier(e)) {
lines.push(`export * from ${JSON.stringify(specifierFor(layout, pkg, `${e.name}.service`))};`);
}
if (servesWriteApi(e) && !isProjection(e)) {
lines.push(`export * from ${JSON.stringify(specifierFor(layout, pkg, `${e.name}.form.component`))};`);
}
- if (servesReadApi(e) && hasDataGridLayout(e)) {
+ if (servesClientTier(e) && hasDataGridLayout(e)) {
lines.push(`export * from ${JSON.stringify(specifierFor(layout, pkg, `${e.name}.grid.component`))};`);
}
}
diff --git a/server/typescript/packages/codegen-ts-tanstack/src/reference/grid-hook.ts b/server/typescript/packages/codegen-ts-tanstack/src/reference/grid-hook.ts
index 628fb3cd8..9a6b3463c 100644
--- a/server/typescript/packages/codegen-ts-tanstack/src/reference/grid-hook.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/src/reference/grid-hook.ts
@@ -31,7 +31,7 @@ import {
entityOutputPath,
entityMetaFileName,
renderEntityMetaFile,
- servesReadApi,
+ servesClientTier,
isTphSubtype,
withClientDirective,
namesRef,
@@ -101,7 +101,7 @@ export const tanstackGridHook = function tanstackGridHook(opts?: TanstackGridHoo
// outright TS2307 when the inherited layout carries an `@filter` preset (the hook then
// imports `DefaultFilter` from the missing columns module).
const passesOtherGates = (e: MetaObject): boolean =>
- servesReadApi(e)
+ servesClientTier(e)
&& userFilter(e)
&& (!isTphSubtype(e) || tphSubtypeGrids(e));
const emit = perEntity(async (entity: MetaObject, ctx) => {
diff --git a/server/typescript/packages/codegen-ts-tanstack/src/reference/grid.ts b/server/typescript/packages/codegen-ts-tanstack/src/reference/grid.ts
index 6b077ceda..a3c667313 100644
--- a/server/typescript/packages/codegen-ts-tanstack/src/reference/grid.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/src/reference/grid.ts
@@ -29,7 +29,7 @@ import {
type GeneratorFactory,
formatTs,
entityOutputPath,
- servesReadApi,
+ servesClientTier,
isTphSubtype,
withClientDirective,
effectivePackage,
@@ -88,7 +88,7 @@ export const tanstackGrid = function tanstackGrid(opts?: TanstackGridOpts): Gene
// Split out so the discoverability note can name exactly the entities the LAYOUT
// gate alone held back (#287) — an abstract type is not a surprise.
const passesOtherGates = (e: MetaObject): boolean =>
- servesReadApi(e)
+ servesClientTier(e)
&& userFilter(e)
&& (!isTphSubtype(e) || tphSubtypeGrids(e));
const emit = perEntity(async (entity: MetaObject, ctx) => {
diff --git a/server/typescript/packages/codegen-ts-tanstack/src/reference/hooks.ts b/server/typescript/packages/codegen-ts-tanstack/src/reference/hooks.ts
index 0bc8e85ad..a58c9d6fe 100644
--- a/server/typescript/packages/codegen-ts-tanstack/src/reference/hooks.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/src/reference/hooks.ts
@@ -29,7 +29,7 @@ import {
entityOutputPath,
entityMetaFileName,
renderEntityMetaFile,
- servesReadApi,
+ servesClientTier,
isTphSubtype,
withClientDirective,
@@ -69,7 +69,7 @@ export const tanstackQuery = function tanstackQuery(opts?: TanstackQueryOpts): G
// hooks via renderHooksFile's isProjection branch.
// FR-017 Tier 3: TPH subtypes get no standalone hooks file — their per-subtype
// hooks live in the discriminator base's hooks file (polymorphic + per-subtype).
- filter: (e: MetaObject) => servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
+ filter: (e: MetaObject) => servesClientTier(e) && !isTphSubtype(e) && userFilter(e),
generate: perEntity(async (entity, ctx) => {
if (!ctx.renderContext) {
throw new Error(
diff --git a/server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid-hook.ts b/server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid-hook.ts
index 9db42fee0..a83ce11e9 100644
--- a/server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid-hook.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid-hook.ts
@@ -1,5 +1,5 @@
import type { MetaObject } from "@metaobjectsdev/metadata";
-import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesReadApi, isTphSubtype,
+import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesClientTier, isTphSubtype,
withClientDirective, namesRef, namesConstArg,
effectivePackage,
} from "@metaobjectsdev/codegen-ts";
@@ -63,7 +63,7 @@ export const tanstackGridHook = function tanstackGridHook(opts?: TanstackGridHoo
// outright TS2307 when the inherited layout carries an `@filter` preset (the hook then
// imports `DefaultFilter` from the missing columns module).
const passesOtherGates = (e: MetaObject): boolean =>
- servesReadApi(e)
+ servesClientTier(e)
&& userFilter(e)
&& (!isTphSubtype(e) || tphSubtypeGrids(e));
const emit = perEntity(async (entity: MetaObject, ctx) => {
diff --git a/server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid.ts b/server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid.ts
index 75976599c..95cd1ab14 100644
--- a/server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid.ts
@@ -1,5 +1,5 @@
import type { MetaObject } from "@metaobjectsdev/metadata";
-import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, servesReadApi, isTphSubtype,
+import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, servesClientTier, isTphSubtype,
withClientDirective,
effectivePackage,
} from "@metaobjectsdev/codegen-ts";
@@ -54,7 +54,7 @@ export const tanstackGrid = function tanstackGrid(opts?: TanstackGridOpts): Gene
// Split out so the discoverability note can name exactly the entities the LAYOUT
// gate alone held back (#287) — an abstract type is not a surprise.
const passesOtherGates = (e: MetaObject): boolean =>
- servesReadApi(e)
+ servesClientTier(e)
&& userFilter(e)
&& (!isTphSubtype(e) || tphSubtypeGrids(e));
const emit = perEntity(async (entity: MetaObject, ctx) => {
diff --git a/server/typescript/packages/codegen-ts-tanstack/src/tanstack-query.ts b/server/typescript/packages/codegen-ts-tanstack/src/tanstack-query.ts
index 057c44a09..402162ba3 100644
--- a/server/typescript/packages/codegen-ts-tanstack/src/tanstack-query.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/src/tanstack-query.ts
@@ -1,5 +1,5 @@
import type { MetaObject } from "@metaobjectsdev/metadata";
-import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesReadApi, isTphSubtype,
+import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesClientTier, isTphSubtype,
withClientDirective, namesRef, namesConstArg,
effectivePackage,
} from "@metaobjectsdev/codegen-ts";
@@ -35,7 +35,7 @@ export const tanstackQuery = function tanstackQuery(opts?: TanstackQueryOpts): G
// hooks via renderHooksFile's isProjection branch.
// FR-017 Tier 3: TPH subtypes get no standalone hooks file — their per-subtype
// hooks live in the discriminator base's hooks file (polymorphic + per-subtype).
- filter: (e: MetaObject) => servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
+ filter: (e: MetaObject) => servesClientTier(e) && !isTphSubtype(e) && userFilter(e),
generate: perEntity(async (entity, ctx) => {
if (!ctx.renderContext) {
throw new Error(
diff --git a/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts b/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts
index 82bb915f3..4458c1dbe 100644
--- a/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts
@@ -15,6 +15,7 @@ import {
GENERATED_HEADER,
GENERATED_EDIT_NOTE,
isProjection,
+ hasItemRoute,
hookListNameSegment,
entityModuleSpecifier,
isTphDiscriminatorBase,
@@ -29,7 +30,8 @@ import {
*
* Projections (view-backed, read-only) emit only:
* - Keys query-key factory
- * - use(id) — useQuery on GET :id
+ * - use(id) — useQuery on GET :id (only with a single-column identity;
+ * a keyless projection has no item route to fetch)
* - use(filter) — useQuery on list
*
* Full (writable) entities additionally emit:
@@ -190,18 +192,25 @@ import {
} from ${JSON.stringify(entityModule)};
`;
+ // A keyless projection (no single-column identity) is served GET list only: the route
+ // generator mounts no `/:id`, so a detail hook would fetch an address nothing answers.
+ // It gets the list hook and no `details`/`detail` keys. A keyed projection is unchanged.
+ const keyed = hasItemRoute(entity);
+ const detailKeyLines = keyed
+ ? `\n details: () => [...${keysVar}.all(), "detail"] as const,` +
+ `\n detail: (id: ${pkType}) => [...${keysVar}.details(), id] as const,`
+ : "";
+
const queryKeys: Code = code`
export const ${keysVar} = {
all: () => [${JSON.stringify(lcEntity)}] as const,
lists: () => [...${keysVar}.all(), "list"] as const,
- list: (filter?: ${entityName}Filter) => [...${keysVar}.lists(), filter ?? {}] as const,
- details: () => [...${keysVar}.all(), "detail"] as const,
- detail: (id: ${pkType}) => [...${keysVar}.details(), id] as const,${relationKeyLine}
+ list: (filter?: ${entityName}Filter) => [...${keysVar}.lists(), filter ?? {}] as const,${detailKeyLines}${relationKeyLine}
};
`;
- const queries: Code = code`
-export function use${entityName}(
+ // Spliced into the one template below so a keyed projection's bytes do not move.
+ const detailQuery: Code | string = !keyed ? "" : code`export function use${entityName}(
id: ${pkType},
opts?: Omit<${useQueryOptionsSym}<${entityName}Row>, "queryKey" | "queryFn">,
): ${useQueryResultSym}<${entityName}Row> {
@@ -213,7 +222,10 @@ export function use${entityName}(
});
}
-export function use${entityNamePlural}(
+`;
+
+ const queries: Code = code`
+${detailQuery}export function use${entityNamePlural}(
filter?: ${entityName}Filter,
opts?: Omit<${useQueryOptionsSym}<${entityName}Row[]>, "queryKey" | "queryFn">,
): ${useQueryResultSym}<${entityName}Row[]> {
diff --git a/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts b/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts
new file mode 100644
index 000000000..90d1185dd
--- /dev/null
+++ b/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts
@@ -0,0 +1,139 @@
+// FR-044 Plan 3, answer 6 — a served report has a route and NO client tier, and a keyless
+// projection gets a list hook and no detail hook.
+//
+// A view-backed report passes every source-keyed gate (`servesReadApi` is true for it: the
+// route and queries generators emit), so the UI generators gate on `servesClientTier`
+// instead. Hooks, grids and grid hooks for a report are Plan 5; until then nothing here may
+// emit a file for one. `formFile` is held by the same model pair in
+// cli/test/unit/reporting-inert.test.ts, which runs every catalog generator.
+import { describe, test, expect } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { InMemoryStringSource, MetaDataLoader, loadUris, reportReadModel } from "@metaobjectsdev/metadata";
+import {
+ buildPkMap, buildRelationMap, defineConfig, hasItemRoute, makeRenderContext, runGen,
+ servesClientTier, servesReadApi,
+} from "@metaobjectsdev/codegen-ts";
+import type { Generator } from "@metaobjectsdev/codegen-ts";
+import { tanstackGrid, tanstackGridHook, tanstackQuery } from "../src/index.js";
+import { tanstackQuery as refHooks } from "../src/reference/hooks.js";
+import { tanstackGrid as refGrid } from "../src/reference/grid.js";
+import { tanstackGridHook as refGridHook } from "../src/reference/grid-hook.js";
+import { renderHooksFile } from "../src/templates/hooks-file.js";
+
+// test → codegen-ts-tanstack → packages → typescript → server → repo root
+const REPO_FIXTURES = resolve(import.meta.dir, "..", "..", "..", "..", "..", "fixtures");
+const WITH = join(REPO_FIXTURES, "codegen-noop", "reporting", "with", "meta.shop.json");
+
+async function loadWith() {
+ const result = await loadUris([pathToFileURL(WITH).href]);
+ expect(result.errors).toEqual([]);
+ return result.root;
+}
+
+async function emittedPaths(generators: Generator[]): Promise {
+ const dir = mkdtempSync(join(tmpdir(), "report-no-ui-"));
+ try {
+ const result = await runGen({
+ config: defineConfig({ outDir: dir, extStyle: "none", dbImport: "../db", dialect: "postgres", generators }),
+ metadata: await loadWith(),
+ dryRun: true,
+ });
+ return result.files.map((f) => f.path.split("/").pop() ?? f.path);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+describe("no UI-tier generator emits for a served report", () => {
+ test("the report is served and has no client tier", async () => {
+ const root = await loadWith();
+ const report = root.objects().find((o) => o.name === "StoreTotals");
+ if (!report) throw new Error("StoreTotals not found");
+ for (const o of [report, reportReadModel(report, root)]) {
+ expect(servesReadApi(o)).toBe(true);
+ expect(servesClientTier(o)).toBe(false);
+ }
+ });
+
+ for (const [name, generators] of [
+ ["built-in", () => [tanstackQuery(), tanstackGrid(), tanstackGridHook()]],
+ ["reference", () => [refHooks(), refGrid(), refGridHook()]],
+ ] as const) {
+ test(`${name} hooks, grid and grid hook emit nothing named for the report`, async () => {
+ const paths = await emittedPaths(generators());
+ // Not vacuous: the entities beside the report do get their hooks.
+ expect(paths).toContain("Program.hooks.ts");
+ expect(paths.filter((p) => p.startsWith("StoreTotals"))).toEqual([]);
+ for (const sourceless of ["ProgramEngagement", "DailyRevenue"]) {
+ expect(paths.filter((p) => p.startsWith(sourceless))).toEqual([]);
+ }
+ });
+ }
+});
+
+describe("a keyless projection gets a list hook and no detail hook", () => {
+ async function projection(keyed: boolean) {
+ const json = JSON.stringify({ "metadata.root": { package: "test", children: [
+ {
+ "object.entity": {
+ name: "Tag",
+ children: [
+ { "source.rdb": { "@table": "tags" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "label" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ ],
+ },
+ },
+ {
+ "object.projection": {
+ name: "TagLabel",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_tag_label" } },
+ ...(keyed
+ ? [
+ { "field.long": { name: "id", extends: "Tag.id" } },
+ { "identity.primary": { name: "id", extends: "Tag.id" } },
+ ]
+ : []),
+ { "field.string": { name: "label", extends: "Tag.label" } },
+ ],
+ },
+ },
+ ] } });
+ const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]);
+ expect(result.errors).toEqual([]);
+ const root = result.root;
+ const obj = root.findObject("TagLabel");
+ if (!obj) throw new Error("TagLabel not found");
+ const ctx = makeRenderContext({
+ dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db",
+ pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
+ });
+ return { obj, out: renderHooksFile(obj, ctx) };
+ }
+
+ test("keyless: list hook and list keys only", async () => {
+ const { obj, out } = await projection(false);
+ expect(hasItemRoute(obj)).toBe(false);
+ expect(out).toContain("export function useTagLabels(");
+ expect(out).not.toContain("export function useTagLabel(");
+ expect(out).toContain("lists:");
+ expect(out).not.toContain("details:");
+ expect(out).not.toContain("detail:");
+ // Nothing fetches an item address the routes do not mount.
+ expect(out).not.toContain("/${id}");
+ });
+
+ test("keyed: the detail hook and its keys are still there", async () => {
+ const { obj, out } = await projection(true);
+ expect(hasItemRoute(obj)).toBe(true);
+ expect(out).toContain("export function useTagLabel(");
+ expect(out).toContain("export function useTagLabels(");
+ expect(out).toContain("details:");
+ expect(out).toContain("detail:");
+ });
+});
diff --git a/server/typescript/packages/codegen-ts/src/api-surface.ts b/server/typescript/packages/codegen-ts/src/api-surface.ts
index b2fe615b5..f72c4f5f2 100644
--- a/server/typescript/packages/codegen-ts/src/api-surface.ts
+++ b/server/typescript/packages/codegen-ts/src/api-surface.ts
@@ -17,11 +17,17 @@
// So the reach-through lives in exactly one place now, named for what it means. When
// route derivation grows a second source of truth, this function changes and every
// UI generator follows for free.
+//
+// Two questions since FR-044 Plan 3: `servesReadApi` asks whether a read endpoint exists
+// (the route and queries generators), and `servesClientTier` asks whether the client UI
+// tier is generated for it (hooks, grids, `agent/ui.md`). They differ for a served
+// report, which has a route and no UI tier until Plan 5.
import type { MetaObject } from "@metaobjectsdev/metadata";
import { isAbstract } from "./instance-artifacts.js";
import { isProjection } from "./projection/projection-detector.js";
-import { hasAnyRdbSource, hasWritableRdbSource, isReport } from "./source-detect.js";
+import { hasAnyRdbSource, hasWritableRdbSource, isReport, servedReport } from "./source-detect.js";
+import { getPkFields } from "./templates/queries.js";
import { resourcePath, restPath } from "./templates/entity-ui-descriptor.js";
import {
declaresTphDiscriminator,
@@ -32,19 +38,45 @@ import {
import { tphRouteSegment } from "./templates/tph-discriminator.js";
/**
- * True when the object is served by a generated READ endpoint — so a hook has
- * something to fetch and a grid has something to render.
+ * True when the object is served by a generated READ endpoint.
*
* Abstract types are excluded (no instance to address). Today the endpoint test is
* "declares or inherits a `source.rdb`", which is precisely the predicate
- * `routesFile` / `routesFileHono` gate on, so hooks exist exactly where routes do.
+ * `routesFile` / `routesFileHono` gate on. The UI tier asks `servesClientTier`, which
+ * is this answer minus reports.
*/
export function servesReadApi(entity: MetaObject): boolean {
- // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- // A report may declare a read-only `source.rdb @kind: view` (R5), which would pass the
- // source test below although no route serves it; runGen already drops reports, so this
- // matters to the doors that read the model directly (agent/ui.md, owned generators).
- return !isAbstract(entity) && !isReport(entity) && hasAnyRdbSource(entity);
+ // FR-044 Plan 3: a report is served exactly when Table A says so (`servedReport`: not
+ // abstract, read source `@kind: view`). The question is asked of the declared report
+ // node by the doors that read the model directly (docs, owned generators) and of its
+ // read model by the generators `runGen` drives; both answer the same. A report whose
+ // read-only source is any other kind passes the source test below and is served by
+ // nothing, so a report never reaches that test.
+ if (isReport(entity)) return servedReport(entity);
+ return !isAbstract(entity) && hasAnyRdbSource(entity);
+}
+
+/**
+ * True iff the object has a single-column primary identity, so its REST surface has
+ * `/:id` routes. Mirrors the JVM `RestSurfaceGate.hasItemRoute`.
+ *
+ * Only the read-only surface asks. A projection's identity is optional (ADR-0028) and a
+ * report has none, and a keyless one mounts no item GET, so it gets no by-id query and no
+ * detail hook either: there is no column to address a row by.
+ */
+export function hasItemRoute(entity: MetaObject): boolean {
+ // ADR-0039: resolving. `getPkFields` reads `primaryIdentity()`, which walks the super
+ // chain; a projection's identity is typically inherited from its base entity.
+ return getPkFields(entity).length === 1;
+}
+
+/**
+ * True when the client UI tier (hooks, grids, `agent/ui.md`) is generated for the
+ * object: `servesReadApi(entity)` and not a report. A served report has a route and no
+ * UI tier until Plan 5.
+ */
+export function servesClientTier(entity: MetaObject): boolean {
+ return servesReadApi(entity) && !isReport(entity);
}
/**
diff --git a/server/typescript/packages/codegen-ts/src/generators/agent-ui-page.ts b/server/typescript/packages/codegen-ts/src/generators/agent-ui-page.ts
index 4172dd975..bfc8ea09d 100644
--- a/server/typescript/packages/codegen-ts/src/generators/agent-ui-page.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/agent-ui-page.ts
@@ -41,7 +41,7 @@ import {
} from "@metaobjectsdev/metadata";
import type { MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
import { GENERATED_HEADER } from "../constants.js";
-import { hasGeneratedForm, restPath, servedPath, servesReadApi } from "../api-surface.js";
+import { hasGeneratedForm, restPath, servedPath, servesClientTier } from "../api-surface.js";
import {
buildEntityUiDescriptor,
type UiFieldDescriptor,
@@ -155,10 +155,11 @@ function dataGrids(obj: MetaObject): MetaData[] {
/**
* True when a UI generator would emit for this object.
*
- * `servesReadApi` — the api-surface predicate the hook, grid and form generators
- * themselves gate on — NOT "has fields" and never an object-subtype test. A form, a grid
- * and a hook are all clients of a generated endpoint, so an object with no endpoint has no
- * UI to document.
+ * `servesClientTier` — the api-surface predicate the hook and grid generators
+ * themselves gate on — NOT "has fields". A form, a grid and a hook are all clients of a
+ * generated endpoint, so an object with no endpoint has no UI to document. A served
+ * report (FR-044) has an endpoint and no UI tier until Plan 5, which is the one place
+ * this differs from `servesReadApi`.
*
* Getting this wrong is not cosmetic. Gating on "has fields" put a prompt payload
* (`object.value`, no source, no routes) on the page under a heading that announced an
@@ -167,7 +168,7 @@ function dataGrids(obj: MetaObject): MetaData[] {
* UI tier asks the endpoint question and never a storage or subtype one.
*/
export function hasUiSurface(obj: MetaObject): boolean {
- return servesReadApi(obj);
+ return servesClientTier(obj);
}
function gridSection(obj: MetaObject, grid: MetaData): string[] {
diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts
index 631033b6e..c60b451b2 100644
--- a/server/typescript/packages/codegen-ts/src/index.ts
+++ b/server/typescript/packages/codegen-ts/src/index.ts
@@ -131,7 +131,7 @@ export type { DocPageNode, DocPagePlacement } from "./docs-paths.js";
export { isProjection, isWriteThrough } from "./projection/projection-detector.js";
export { isAbstract, emitsInstanceArtifacts, emitsWriteArtifacts } from "./instance-artifacts.js";
// The UI tier asks THESE — "is there an endpoint?" — never the storage predicates.
-export { hasGeneratedForm, restPath, servesReadApi, servesWriteApi } from "./api-surface.js";
+export { hasGeneratedForm, hasItemRoute, restPath, servesClientTier, servesReadApi, servesWriteApi } from "./api-surface.js";
// #356 — every emitter selects a field's view by the SURFACE it renders, never by
// declaration position. An owned generator (FR-040) composing the render layer must
// use this too, or it reinstates the order-dependence in its own copy.
@@ -169,7 +169,7 @@ export type { SortOrder, GridDefaultSort } from "./templates/filter-shared.js";
// package-internal relative path. These are the assembly pieces the built-in
// entity/queries composers use; the reference templates relocate that assembly.
export { renderTphDiscriminatorUnion } from "./templates/tph-discriminator.js";
-export { hasWritableRdbSource, hasAnyRdbSource, isSourcelessEntity } from "./source-detect.js";
+export { generatableObjects, hasWritableRdbSource, hasAnyRdbSource, isReport, isSourcelessEntity, servedReport } from "./source-detect.js";
export { dialectModule, dbTypeBlock, supportsReturning, normalizeTimestampMode, type DialectModule } from "./dialect-module.js";
export { renderSharedEnumsFile, SHARED_ENUMS_BASENAME } from "./templates/enums-file.js";
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 5345a4226..542227f6d 100644
--- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts
+++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts
@@ -15,7 +15,8 @@
// framework-neutral and stays as-is.
// use-when: you want generated Hono CRUD routes per entity.
// emits: /.routes.hono.ts — full CRUD for write-through entities,
-// read-only (GET list + GET :id) for projections. Skipped for any sourceless
+// read-only (GET list + GET :id) for projections, GET list alone for a keyless
+// projection or a served report. Skipped for any sourceless
// object and for TPH subtypes.
// owns: ALL of it. The route COMPOSITION is below (`renderRoutesHono`), not a call
// into the engine. And the emitted file does not import its mount helpers from
@@ -55,6 +56,8 @@ import {
isTphSubtype,
isProjection,
isWriteThrough,
+ isReport,
+ hasItemRoute,
servesReadApi,
formatTs,
renderRoutesIndex,
@@ -70,7 +73,8 @@ import {
} from "@metaobjectsdev/codegen-ts";
// --- composition (OWNED) — assembles one .routes.hono.ts. Change this to change the output. ---
-// Dispatch: a projection → mountReadOnlyCrudRoutes (GET list + GET :id); every other
+// Dispatch: a projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id
+// when it has a single-column identity); every other
// writable entity → mountCrudRoutes. `apiPrefix` is composed into the path string (Hono has
// no register-with-prefix primitive). TPH subtypes never reach here — see the filter below.
@@ -113,9 +117,22 @@ function renderRoutesHono(
? `\`${ctx.apiPrefix}\${${entityName}.$path}/*\``
: `\`\${${entityName}.$path}/*\``;
- // --- Projection path: read-only routes (GET list + GET :id) ---
+ // --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
+ // A keyless read-only object (a projection with no single-column identity, and every
+ // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
+ // keeps its output byte-identical.
+ const keyless = !hasItemRoute(entity);
+ const report = isReport(entity);
+ const noun = report ? "report" : "projection";
+ const exposes = keyless
+ ? "Exposes GET list only. POST returns 405."
+ : "Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.";
+ const keylessOpts = (indent: string): string =>
+ (keyless ? `\n${indent}itemRoutes: false,` : "") +
+ (report ? `\n${indent}resource: "report",` : "");
const HonoSym = imp("t:Hono@hono");
const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`);
@@ -130,9 +147,9 @@ import {
const body = code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${authSeamJsDoc({ framework: "hono", handlerName, mountPathExpr: authPathExpr, narrowable: false })}
@@ -146,7 +163,7 @@ export function ${handlerName}(app: ${HonoSym}, deps: { db: unkno
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}
`;
diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts
index 138413862..342d5cba9 100644
--- a/server/typescript/packages/codegen-ts/src/reference/routes.ts
+++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts
@@ -17,7 +17,7 @@
// discovers a sibling module: a `.extra.ts` next to the output is a naming
// convention, not a plugin point, so its handlers only mount if your server calls them.
// emits: /.routes.ts — full CRUD for write-through entities, read-only
-// (GET list + GET :id) for projections, polymorphic + per-subtype for TPH bases.
+// (GET list + GET :id) for projections (GET list alone for a keyless one or a report), polymorphic + per-subtype for TPH bases.
// Skipped for any sourceless object (incl. every object.value, source-less by
// value purity) and for TPH subtypes — no source.rdb means no table/allowlist
// for a routes file to import (#248 R2).
@@ -71,6 +71,8 @@ import {
tphStorageObject,
isProjection,
isWriteThrough,
+ isReport,
+ hasItemRoute,
servesReadApi,
formatTs,
renderRoutesIndex,
@@ -92,7 +94,8 @@ import {
// --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
-// projection → mountReadOnlyCrudRoutes (GET list + GET :id); every other writable entity →
+// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has a
+// single-column identity); every other writable entity →
// mountCrudRoutes (+ one mountM2mRoute per M:N navigation). Under an `apiPrefix` the mounts
// are wrapped in `fastify.register(..., { prefix })`.
@@ -133,9 +136,22 @@ function renderRoutes(
// Where the mount helpers come from: the package, or an owned copy (owned-runtime.ts).
const runtimeSpec = httpRuntimeSpecifier("drizzle-fastify", ctx, entityPkg);
- // --- Projection path: read-only routes (GET list + GET :id) ---
+ // --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
+ // A keyless read-only object (a projection with no single-column identity, and every
+ // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
+ // keeps its output byte-identical.
+ const keyless = !hasItemRoute(entity);
+ const report = isReport(entity);
+ const noun = report ? "report" : "projection";
+ const exposes = keyless
+ ? "Exposes GET list only. POST returns 405."
+ : "Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.";
+ const keylessOpts = (indent: string): string =>
+ (keyless ? `\n${indent}itemRoutes: false,` : "") +
+ (report ? `\n${indent}resource: "report",` : "");
const FastifyInstanceSym = imp("t:FastifyInstance@fastify");
const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`);
// A projection mount is read-only by construction, so `expose` cannot narrow it —
@@ -159,9 +175,9 @@ import {
const body = ctx.apiPrefix
? code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -175,16 +191,16 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}, { prefix: ${JSON.stringify(ctx.apiPrefix)} });
}
`
: code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -197,7 +213,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}
`;
diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts
index cd08300f3..0d549e435 100644
--- a/server/typescript/packages/codegen-ts/src/runner.ts
+++ b/server/typescript/packages/codegen-ts/src/runner.ts
@@ -15,7 +15,7 @@ import { assignEmittedNames } from "./naming/collision-names.js";
import { assertNoCollectionNameCollisions } from "./naming/collection-name-collision.js";
import { isAbstract } from "./instance-artifacts.js";
import { dbEmittingObjects, missingDialectMessage } from "./db-emitting.js";
-import { hasAnyRdbSource, isReport } from "./source-detect.js";
+import { generatableObjects, hasAnyRdbSource } from "./source-detect.js";
import type { Generator, GenContext, EmittedFile } from "./generator.js";
import type { MetaobjectsGenConfig } from "./metaobjects-config.js";
import { normalizeConfig, DEFAULT_TARGET_NAME } from "./metaobjects-config.js";
@@ -407,14 +407,15 @@ export async function runGen(opts: RunGenOpts): Promise {
return { files: [], warnings, conflicts: [] };
}
- // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- // Dropped here, at the entity set every generator reads. A selection made only of
- // reports gets the same "nothing to generate" warning as an empty one.
- const generatable = filtered.filter((o) => !isReport(o));
+ // FR-044: a served report (Table A) is generated from its read model, which the
+ // projection generators emit as a keyless read-only object. Every other report
+ // generates nothing. A selection made only of unserved reports gets the same
+ // "nothing to generate" warning as an empty one.
+ const generatable = generatableObjects(filtered, root);
if (generatable.length === 0) {
warnings.push(
- "No entities to generate — every selected object is an object.report, which has no " +
- "generated output until its lowering lands (FR-044 Plan 2/3).",
+ "No entities to generate — every selected object is an object.report with no " +
+ "`source.rdb @kind: view`, which generates nothing.",
);
return { files: [], warnings, conflicts: [] };
}
diff --git a/server/typescript/packages/codegen-ts/src/source-detect.ts b/server/typescript/packages/codegen-ts/src/source-detect.ts
index c5eca4477..e41779889 100644
--- a/server/typescript/packages/codegen-ts/src/source-detect.ts
+++ b/server/typescript/packages/codegen-ts/src/source-detect.ts
@@ -17,11 +17,14 @@
import {
OBJECT_SUBTYPE_PROJECTION,
OBJECT_SUBTYPE_REPORT,
+ SOURCE_KIND_VIEW,
SOURCE_SUBTYPE_RDB,
isMetaSource,
isWritableSource,
+ reportReadModel,
+ reportReadSource,
} from "@metaobjectsdev/metadata";
-import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
+import type { MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
/** True when the child is a source.rdb node (subType-scoped — the rdb paradigm only). */
function isRdbSource(child: MetaData): boolean {
@@ -87,12 +90,39 @@ export function isSourcelessEntity(obj: MetaObject): boolean {
}
/**
- * True for an `object.report` (FR-044). Plan 1 registers and validates the reporting
- * vocabulary but gives a report no lowering yet, so the runner drops it from the entity
- * set every generator reads — including a report that declares a read-only
- * `source.rdb @kind: view` (R5 allows one), which would otherwise pass every
- * source-keyed gate below and emit an empty projection tier.
+ * True for an `object.report` (FR-044): the declared node AND its read model, which keeps
+ * the report subtype. A report has no identity and no write surface, so the generators
+ * that emit for one (see `servedReport`) take the keyless read-only path.
*/
export function isReport(obj: MetaObject): boolean {
return obj.subType === OBJECT_SUBTYPE_REPORT;
}
+
+/** Table A: a non-abstract object.report whose read source is @kind: view. */
+export function servedReport(obj: MetaObject): boolean {
+ if (!isReport(obj) || obj.isAbstract === true) return false;
+ // `reportReadSource` is Plan 2's rule (own read-only source with @role: primary, else
+ // the first own read-only source), so the report that is served is exactly the report
+ // whose view the lowering names. It holds for the read model too: its one source is a
+ // copy of that source.
+ return reportReadSource(obj)?.effectiveKind === SOURCE_KIND_VIEW;
+}
+
+/**
+ * The objects a generator run reads: every non-report object as declared, each served
+ * report (Table A) replaced by its read model, and every other report dropped.
+ *
+ * A report declares no fields; its read shape is derived. The read model carries that
+ * shape as real `field.*` children plus a copy of the report's view source, which makes
+ * it a keyless read-only object the projection generators already know how to emit. So
+ * the swap happens once, here, and no generator has a report branch for its shape.
+ *
+ * `runGen` applies this to its selection. Anything that drives generators without
+ * `runGen` must apply it too, or it hands them a declared report with no fields.
+ */
+export function generatableObjects(objects: readonly MetaObject[], root: MetaRoot): MetaObject[] {
+ return objects.flatMap((o) => {
+ if (!isReport(o)) return [o];
+ return servedReport(o) ? [reportReadModel(o, root)] : [];
+ });
+}
diff --git a/server/typescript/packages/codegen-ts/src/templates/field-meta.ts b/server/typescript/packages/codegen-ts/src/templates/field-meta.ts
index ebe6f6ccc..ff4e3a5d2 100644
--- a/server/typescript/packages/codegen-ts/src/templates/field-meta.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/field-meta.ts
@@ -162,8 +162,13 @@ export function zodTypeFor(field: MetaField, timestampMode: "date" | "string" =
return "z.number().int()";
case FIELD_SUBTYPE_DOUBLE:
case FIELD_SUBTYPE_FLOAT:
- case FIELD_SUBTYPE_DECIMAL:
return "z.number()";
+ case FIELD_SUBTYPE_DECIMAL:
+ // A view column of this subtype is Drizzle's `numeric`, which READS a string (the
+ // driver does not parse an arbitrary-precision value into a lossy double), and
+ // `field.decimal` is a `string` in TypeScript everywhere else. `z.number()` here
+ // disagreed with the view's own inferred row type and did not compile.
+ return "z.string()";
case FIELD_SUBTYPE_ENUM: {
const values = enumValues(field);
return values !== undefined ? zodEnumExpr(values) : "z.string()";
diff --git a/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts b/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts
index d5108cc23..9382a6606 100644
--- a/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts
@@ -1,7 +1,10 @@
// Projection declaration template — emits a Drizzle view declaration,
// a Zod read schema, the TS type via z.infer, and a constants block.
//
-// This is the read-only counterpart to entity-file.ts.
+// This is the read-only counterpart to entity-file.ts. It renders a projection, and
+// (FR-044 Plan 3) the read model of a served report: a detached object with one real
+// field per derived field and a copy of the report's view source, which is a keyless
+// read-only object and so takes this path with no report branch of its own.
// It does NOT emit:
// - Drizzle table declaration (view-only)
// - Zod Insert/Update schemas
diff --git a/server/typescript/packages/codegen-ts/src/templates/queries-file.ts b/server/typescript/packages/codegen-ts/src/templates/queries-file.ts
index 746d10129..5e5387e44 100644
--- a/server/typescript/packages/codegen-ts/src/templates/queries-file.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/queries-file.ts
@@ -27,6 +27,8 @@ import { pluralize, findByIdFnName, listFnName, createFnName, insertPreservingFn
import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants.js";
import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator.js";
import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
+import { isReport } from "../source-detect.js";
+import { hasItemRoute } from "../api-surface.js";
import { hasAutoSetFields } from "./zod-validators.js";
import { effectivePackage } from "../docs-paths.js";
@@ -144,12 +146,15 @@ import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}I
}
/**
- * Read-only queries file for a projection (view-backed, ADR Project E).
+ * Read-only queries file for a projection (view-backed, ADR Project E) or a served
+ * report's read model (FR-044).
*
- * Emits only `findById` + `list`, selecting from the projection's
- * `View` Drizzle view and returning the inferred read type. Deliberately
- * NO create/update/delete and NO `InsertSchema` import — a projection is
- * read-only and its entity file never exports an insert schema.
+ * Emits `list`, plus `findById` when the object has a single-column
+ * identity, selecting from the object's `View` Drizzle view and returning the
+ * inferred read type. A keyless projection and every report get the list alone: there is
+ * no column to look a row up by, and a by-id function over a made-up `id` does not
+ * compile. Deliberately NO create/update/delete and NO `InsertSchema` import — a
+ * read-only object's entity file never exports an insert schema.
*/
function renderProjectionQueriesFile(obj: MetaObject, ctx: RenderContext): string {
const entityName = obj.name;
@@ -158,8 +163,6 @@ function renderProjectionQueriesFile(obj: MetaObject, ctx: RenderContext): strin
const entityFileName = entityModuleSpecifier(
ctx.selfTarget, ctx.entityModuleTarget, effectivePackage(obj), entityName, ctx.extStyle,
);
- const { fieldName: pkField, tsType: pkType } = getPkInfo(obj, ctx);
- const eqSym = imp("eq@drizzle-orm");
const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
@@ -170,13 +173,21 @@ ${dbTypeAlias}
import { ${viewVar}, type ${entityName} } from ${JSON.stringify(entityFileName)};
`;
- const reads = code`
-export async function ${findByIdFnName(entityName)}(db: Db, ${pkField}: ${pkType}): Promise<${entityName} | null> {
+ // Spliced into the one template below so a keyed projection's bytes do not move.
+ let findById: Code | string = "";
+ if (hasItemRoute(obj)) {
+ const { fieldName: pkField, tsType: pkType } = getPkInfo(obj, ctx);
+ const eqSym = imp("eq@drizzle-orm");
+ findById = code`export async function ${findByIdFnName(entityName)}(db: Db, ${pkField}: ${pkType}): Promise<${entityName} | null> {
const [row] = await db.select().from(${viewVar}).where(${eqSym}(${viewVar}.${pkField}, ${pkField})).limit(1);
return row ?? null;
}
-export async function ${listFnName(entityName)}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${entityName}[]> {
+`;
+ }
+
+ const reads = code`
+${findById}export async function ${listFnName(entityName)}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${entityName}[]> {
let q = db.select().from(${viewVar}).$dynamic();
if (opts?.limit !== undefined) q = q.limit(opts.limit);
if (opts?.offset !== undefined) q = q.offset(opts.offset);
@@ -185,9 +196,10 @@ export async function ${listFnName(entityName)}(db: Db, opts?: { limit?: number;
`;
const body = joinCode([literalImports, reads], { on: "\n" }).toString();
+ const noun = isReport(obj) ? "report" : "projection";
const header =
`// ${GENERATED_HEADER} — ${GENERATED_EDIT_NOTE}\n` +
- `// Source metadata: ${entityName} (${obj.fqn()}) — projection (read-only)\n` +
+ `// Source metadata: ${entityName} (${obj.fqn()}) — ${noun} (read-only)\n` +
sidecarLine(`${entityName}.extra.ts`);
return header + body;
}
diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts
index a40623907..77c1bd6a3 100644
--- a/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts
@@ -30,6 +30,8 @@ import { type RenderContext } from "../render-context.js";
import { entityModuleSpecifier } from "../import-path.js";
import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants.js";
import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
+import { isReport } from "../source-detect.js";
+import { hasItemRoute } from "../api-surface.js";
import { authSeamJsDoc, type CrudVerb, exposeLine } from "../routes-expose.js";
import { effectivePackage } from "../docs-paths.js";
import { httpRuntimeSpecifier } from "../owned-runtime.js";
@@ -74,9 +76,22 @@ export function renderRoutesFileHono(
? `\`${ctx.apiPrefix}\${${entityName}.$path}/*\``
: `\`\${${entityName}.$path}/*\``;
- // --- Projection path: read-only routes (GET list + GET :id) ---
+ // --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
+ // A keyless read-only object (a projection with no single-column identity, and every
+ // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
+ // keeps its output byte-identical.
+ const keyless = !hasItemRoute(entity);
+ const report = isReport(entity);
+ const noun = report ? "report" : "projection";
+ const exposes = keyless
+ ? "Exposes GET list only. POST returns 405."
+ : "Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.";
+ const keylessOpts = (indent: string): string =>
+ (keyless ? `\n${indent}itemRoutes: false,` : "") +
+ (report ? `\n${indent}resource: "report",` : "");
const HonoSym = imp("t:Hono@hono");
const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`);
@@ -91,9 +106,9 @@ import {
const body = code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${authSeamJsDoc({ framework: "hono", handlerName, mountPathExpr: authPathExpr, narrowable: false })}
@@ -107,7 +122,7 @@ export function ${handlerName}(app: ${HonoSym}, deps: { db: unkno
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}
`;
diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts
index 79fb339fa..873811324 100644
--- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts
@@ -2,7 +2,8 @@
// CRUD verbs to helpers from @metaobjectsdev/runtime-ts/drizzle-fastify.
//
// Dispatch logic:
-// isProjection(entity) → mountReadOnlyCrudRoutes (GET list + GET :id only)
+// isProjection(entity) → mountReadOnlyCrudRoutes (GET list + GET :id; GET list alone
+// for a keyless projection or a served report, FR-044)
// vanilla / write-through entity → mountCrudRoutes (all 5 CRUD verbs)
//
// apiPrefix behaviour:
@@ -27,6 +28,8 @@ import { namesRef, columnExpr } from "../names.js";
import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants.js";
import { routesHandlerName } from "../naming.js";
import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
+import { isReport } from "../source-detect.js";
+import { hasItemRoute } from "../api-surface.js";
import type { RelationEntry } from "../relation-resolver.js";
import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js";
import { authSeamJsDoc, type CrudVerb, exposeLine, intersectExpose, TPH_POLYMORPHIC_VERBS } from "../routes-expose.js";
@@ -72,9 +75,22 @@ export function renderRoutesFile(
// Where the mount helpers come from: the package, or an owned copy (owned-runtime.ts).
const runtimeSpec = httpRuntimeSpecifier("drizzle-fastify", ctx, entityPkg);
- // --- Projection path: read-only routes (GET list + GET :id) ---
+ // --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
+ // A keyless read-only object (a projection with no single-column identity, and every
+ // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
+ // keeps its output byte-identical.
+ const keyless = !hasItemRoute(entity);
+ const report = isReport(entity);
+ const noun = report ? "report" : "projection";
+ const exposes = keyless
+ ? "Exposes GET list only. POST returns 405."
+ : "Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.";
+ const keylessOpts = (indent: string): string =>
+ (keyless ? `\n${indent}itemRoutes: false,` : "") +
+ (report ? `\n${indent}resource: "report",` : "");
const FastifyInstanceSym = imp("t:FastifyInstance@fastify");
const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`);
// A projection mount is read-only by construction, so `expose` cannot narrow it —
@@ -98,9 +114,9 @@ import {
const body = ctx.apiPrefix
? code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -114,16 +130,16 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}, { prefix: ${JSON.stringify(ctx.apiPrefix)} });
}
`
: code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -136,7 +152,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}
`;
diff --git a/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts b/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts
index 8be347619..9da163b97 100644
--- a/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts
+++ b/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts
@@ -38,6 +38,7 @@ import { loadUris, type MetaRoot, type MetaObject } from "@metaobjectsdev/metada
import { entityFile } from "../src/generators/entity-file.js";
import { namesFile } from "../src/generators/names-file.js";
import { queriesFile } from "../src/generators/queries-file.js";
+import { generatableObjects } from "../src/source-detect.js";
import { barrel } from "../src/generators/barrel.js";
import { promptRender } from "../src/generators/prompt-render-file.js";
import { outputPrompt } from "../src/generators/output-prompt-file.js";
@@ -110,6 +111,11 @@ const DEFAULT_OPTIONS: ts.CompilerOptions = {
skipLibCheck: true,
};
+/** The corpus's view-backed reports (FR-044). */
+const SERVED_REPORTS = [
+ "ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity",
+] as const;
+
const PROFILES: ReadonlyArray = [
["defaults", DEFAULT_OPTIONS],
["tsc --init", TSC_INIT_OPTIONS],
@@ -139,7 +145,10 @@ describe("codegen-compile conformance — the shared fitness corpus", () => {
// subtype no standalone one — as if they were emit bugs. The gate has to run the
// generators the way the runner runs them or it measures the harness.
const genCtx = (generator: { filter?: (e: MetaObject) => boolean }): GenContext => ({
- entities: root.objects(),
+ // The runner's own entity set: a served report is generated from its read
+ // model, an unserved one not at all. Handing the generators the declared
+ // report nodes compiled an empty view for each and proved nothing about them.
+ entities: generatableObjects(root.objects(), root),
loadedRoot: root,
matches: (e) => generator.filter?.(e) ?? true,
projectRoot: dir,
@@ -188,9 +197,18 @@ describe("codegen-compile conformance — the shared fitness corpus", () => {
"ProgramBrief.ts",
"ProgramVerdict.ts",
"WeekLabel.ts",
+ // FR-044 Plan 3: the six view-backed reports' entity, names and queries files.
+ ...SERVED_REPORTS.flatMap((r) => [`${r}.ts`, `${r}.names.ts`, `${r}.queries.ts`]),
]) {
expect([...emitted]).toContain(expected);
}
+ // A report's entity file carries its DERIVED fields (it declares none), and its
+ // queries file the list alone: a by-id query over a keyless view does not compile.
+ const minutes = files.find((f) => f.path === "ProgramMinutes.ts");
+ expect(minutes?.content).toContain("avgMinutes");
+ for (const r of SERVED_REPORTS) {
+ expect(files.find((f) => f.path === `${r}.queries.ts`)?.content).not.toContain("ById");
+ }
// A value object NESTING another (`ProgramBrief.weekLabels: WeekLabel[]`) is the
// shape whose type import shipped as a value import (TS1484 under the `tsc --init`
// profile). Pin that the corpus still carries it, so an edit dropping the nesting
diff --git a/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts
index 30e26ad82..fd125ce5c 100644
--- a/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts
+++ b/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts
@@ -6,7 +6,11 @@
// findById + list selecting from the VIEW var, no insert import, no writes.
import { describe, test, expect } from "bun:test";
-import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
+import { join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { MetaDataLoader, InMemoryStringSource, loadUris, reportReadModel } from "@metaobjectsdev/metadata";
+import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
+import { renderEntityFile } from "../../src/templates/entity-file.js";
import { renderQueriesFile } from "../../src/templates/queries-file.js";
import { makeRenderContext } from "../../src/render-context.js";
import { buildPkMap } from "../../src/pk-resolver.js";
@@ -53,7 +57,11 @@ async function loadProjectionFixture() {
name: "ProgramSummary",
children: [
{ "source.rdb": { "@kind": "view", "@table": "v_program_summary" } },
- { "field.int": { name: "id" } },
+ // A single-column identity, inherited from the base: this is what gives the
+ // projection a by-id query. Without it the projection is keyless (FR-044 Plan 3,
+ // answer 4) and gets the list alone — see the keyless tests below.
+ { "field.int": { name: "id", extends: "Program.id" } },
+ { "identity.primary": { name: "id", extends: "Program.id" } },
{
"field.int": {
name: "weekCount",
@@ -142,3 +150,89 @@ describe("renderQueriesFile — source-aware dispatch", () => {
});
});
});
+
+// ---------------------------------------------------------------------------
+// FR-044 Plan 3 — a served report, a keyless projection, and the decimal read type
+// ---------------------------------------------------------------------------
+
+// test/projection → test → codegen-ts → packages → typescript → server → repo root
+const REPO_FIXTURES = resolve(import.meta.dir, "..", "..", "..", "..", "..", "..", "fixtures");
+
+async function loadFile(...segments: string[]): Promise {
+ const result = await loadUris([pathToFileURL(join(REPO_FIXTURES, ...segments)).href]);
+ if (result.errors.length > 0) {
+ throw new Error(`Loader errors:\n${result.errors.map((e) => e.message).join("\n")}`);
+ }
+ return result.root;
+}
+
+function readModel(root: MetaRoot, name: string): MetaObject {
+ const report = root.objects().find((o) => o.name === name);
+ if (!report) throw new Error(`${name} not found`);
+ return reportReadModel(report, root);
+}
+
+function pgCtx(root: MetaRoot) {
+ return makeRenderContext({
+ dialect: "postgres", loadedRoot: root, outDir: "/x", dbImport: "~/db",
+ pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
+ });
+}
+
+describe("renderQueriesFile — a served report and a keyless projection (FR-044 Plan 3)", () => {
+ test("a served report gets a list query and no by-id query", async () => {
+ const root = await loadFile("codegen-noop", "reporting", "with", "meta.shop.json");
+ const out = renderQueriesFile(readModel(root, "StoreTotals"), pgCtx(root));
+ expect(out).toContain("export async function listStoreTotals(");
+ expect(out).toContain("from(storeTotalsView)");
+ expect(out).not.toContain("findStoreTotalsById");
+ expect(out).not.toContain("ById");
+ // `eq` was only ever imported for the by-id predicate.
+ expect(out).not.toContain("drizzle-orm\"");
+ expect(out).toContain("— report (read-only)");
+ expect(out).not.toContain("projection");
+ });
+
+ test("a keyless projection gets a list query and no by-id query", async () => {
+ const root = await loadMetadata([
+ {
+ "object.entity": {
+ name: "Tag",
+ children: [
+ { "source.rdb": { "@table": "tags" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "label" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ ],
+ },
+ },
+ {
+ "object.projection": {
+ name: "TagLabel",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_tag_label" } },
+ { "field.string": { name: "label", extends: "Tag.label" } },
+ ],
+ },
+ },
+ ]);
+ const projection = root.objects().find((o) => o.name === "TagLabel");
+ if (!projection) throw new Error("TagLabel not found");
+ const out = renderQueriesFile(projection, makeRenderContext({
+ dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db",
+ pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
+ }));
+ expect(out).toContain("export async function listTagLabels(");
+ expect(out).not.toContain("ById");
+ expect(out).toContain("— projection (read-only)");
+ });
+
+ test("a decimal derived field is a string in the read schema", async () => {
+ const root = await loadFile("persistence-conformance", "canonical", "meta.fitness.json");
+ const out = renderEntityFile(readModel(root, "ProgramMinutes"), pgCtx(root));
+ const squashed = out.replace(/\s+/g, " ");
+ expect(squashed).toContain("avgMinutes: z.string().nullable()");
+ expect(squashed).toContain("longShare: z.string().nullable()");
+ expect(squashed).toContain("totalMinutes: z.number().int().nullable()");
+ });
+});
diff --git a/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts
index a0dd4fd06..f9d575d14 100644
--- a/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts
+++ b/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts
@@ -4,8 +4,18 @@
// - vanilla entities still emit mountCrudRoutes + table var import
import { describe, test, expect } from "bun:test";
-import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
+import { join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { MetaDataLoader, InMemoryStringSource, loadUris, reportReadModel } from "@metaobjectsdev/metadata";
+import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
import { renderRoutesFile } from "../../src/templates/routes-file.js";
+import { renderRoutesFileHono } from "../../src/templates/routes-file-hono.js";
+import { hasGeneratedForm, hasItemRoute, servesClientTier, servesReadApi } from "../../src/api-surface.js";
+import { servedReport } from "../../src/source-detect.js";
+import { hasUiSurface } from "../../src/generators/agent-ui-page.js";
+import { runGen } from "../../src/runner.js";
+import { routesFile } from "../../src/generators/routes-file.js";
+import { ERR_COLLECTION_NAME_COLLISION } from "../../src/naming/collection-name-collision.js";
import { makeRenderContext } from "../../src/render-context.js";
import { buildPkMap } from "../../src/pk-resolver.js";
import { buildRelationMap } from "../../src/relation-resolver.js";
@@ -320,3 +330,165 @@ describe("renderRoutesFile — source-aware dispatch", () => {
});
});
});
+
+// ---------------------------------------------------------------------------
+// FR-044 Plan 3 — a served report, and the keyless projection it shares a path with
+// ---------------------------------------------------------------------------
+
+// test/projection → test → codegen-ts → packages → typescript → server → repo root
+const REPO_FIXTURES = resolve(import.meta.dir, "..", "..", "..", "..", "..", "..", "fixtures");
+const REPORTING_WITH = join(REPO_FIXTURES, "codegen-noop", "reporting", "with", "meta.shop.json");
+
+async function loadReportingModel(): Promise {
+ const result = await loadUris([pathToFileURL(REPORTING_WITH).href]);
+ if (result.errors.length > 0) {
+ throw new Error(`Loader errors:\n${result.errors.map((e) => e.message).join("\n")}`);
+ }
+ return result.root;
+}
+
+function declared(root: MetaRoot, name: string): MetaObject {
+ const found = root.objects().find((o) => o.name === name);
+ if (!found) throw new Error(`${name} not found`);
+ return found;
+}
+
+function ctxFor(root: MetaRoot, apiPrefix = "") {
+ return makeRenderContext({
+ dialect: "postgres", loadedRoot: root, outDir: "/x", dbImport: "~/db", apiPrefix,
+ pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
+ });
+}
+
+/** The projection fixture above with a single-column identity inherited from its base. */
+async function loadKeyedProjectionFixture() {
+ const root = await loadMetadata([
+ {
+ "object.entity": {
+ name: "Program",
+ children: [
+ { "source.rdb": { "@table": "programs" } },
+ { "field.int": { name: "id" } },
+ { "field.string": { name: "title" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ ],
+ },
+ },
+ {
+ "object.projection": {
+ name: "ProgramCard",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_program_card" } },
+ { "field.int": { name: "id", extends: "Program.id" } },
+ { "identity.primary": { name: "id", extends: "Program.id" } },
+ { "field.string": { name: "title", extends: "Program.title" } },
+ ],
+ },
+ },
+ ]);
+ return { projection: declared(root, "ProgramCard"), ctx: makeRenderContext({
+ dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db",
+ pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
+ }) };
+}
+
+describe("renderRoutesFile — a served report (FR-044 Plan 3)", () => {
+ test("a served report mounts a keyless read-only surface", async () => {
+ const root = await loadReportingModel();
+ const model = reportReadModel(declared(root, "StoreTotals"), root);
+ for (const out of [
+ renderRoutesFile(model, ctxFor(root)),
+ renderRoutesFile(model, ctxFor(root, "/api")),
+ renderRoutesFileHono(model, ctxFor(root)),
+ ]) {
+ expect(out).toContain("mountReadOnlyCrudRoutes");
+ expect(out).toContain("itemRoutes: false,");
+ expect(out).toContain('resource: "report",');
+ expect(out).toContain("(report — view-backed, no writes)");
+ expect(out).toContain("Exposes GET list only. POST returns 405.");
+ expect(out).not.toContain("projection");
+ expect(out).not.toContain("GET :id");
+ }
+ });
+
+ test("a projection with a single-column identity is unchanged", async () => {
+ const { projection, ctx } = await loadKeyedProjectionFixture();
+ expect(hasItemRoute(projection)).toBe(true);
+ for (const out of [renderRoutesFile(projection, ctx), renderRoutesFileHono(projection, ctx)]) {
+ expect(out).toContain("(projection — view-backed, no writes)");
+ expect(out).toContain("Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.");
+ // No key at all, not `itemRoutes: true`: the keyed output keeps its bytes.
+ expect(out).not.toContain("itemRoutes");
+ expect(out).not.toContain("resource:");
+ }
+ });
+
+ test("a keyless projection mounts no item routes and is still called a projection", async () => {
+ const { projection, ctx } = await loadProjectionFixture();
+ expect(hasItemRoute(projection)).toBe(false);
+ for (const out of [renderRoutesFile(projection, ctx), renderRoutesFileHono(projection, ctx)]) {
+ expect(out).toContain("itemRoutes: false,");
+ expect(out).not.toContain("resource:");
+ expect(out).toContain("(projection — view-backed, no writes)");
+ expect(out).toContain("Exposes GET list only. POST returns 405.");
+ }
+ });
+
+ test("a served report has a read API and no client tier; an unserved one has neither", async () => {
+ const root = await loadReportingModel();
+ const storeTotals = declared(root, "StoreTotals");
+ // The declared node and its read model answer the same.
+ for (const o of [storeTotals, reportReadModel(storeTotals, root)]) {
+ expect(servedReport(o)).toBe(true);
+ expect(servesReadApi(o)).toBe(true);
+ expect(servesClientTier(o)).toBe(false);
+ expect(hasUiSurface(o)).toBe(false);
+ expect(hasGeneratedForm(o)).toBe(false);
+ expect(hasItemRoute(o)).toBe(false);
+ }
+ for (const name of ["ProgramEngagement", "DailyRevenue"]) {
+ const sourceless = declared(root, name);
+ expect(servedReport(sourceless)).toBe(false);
+ expect(servesReadApi(sourceless)).toBe(false);
+ expect(servesClientTier(sourceless)).toBe(false);
+ }
+ // An entity is untouched by the split: both answers are the old one.
+ const program = declared(root, "Program");
+ expect(servesReadApi(program)).toBe(true);
+ expect(servesClientTier(program)).toBe(true);
+ });
+
+ test("a report and an entity that share a route segment are a generation error", async () => {
+ const root = await loadMetadata([
+ {
+ "object.entity": {
+ name: "Invoice",
+ children: [
+ { "source.rdb": { "@table": "invoices" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "status" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ { "dimension.attribute": { name: "status", "@of": "Invoice.status" } },
+ { "measure.aggregate": { name: "invoices", "@agg": "count", "@of": "Invoice.id" } },
+ ],
+ },
+ },
+ {
+ "object.report": {
+ name: "Invoices",
+ "@from": "Invoice",
+ "@dimensions": ["status"],
+ "@measures": ["invoices"],
+ children: [{ "source.rdb": { "@kind": "view", "@table": "v_invoices" } }],
+ },
+ },
+ ]);
+ const run = runGen({
+ config: { outDir: "src/generated", extStyle: "js", dialect: "postgres", dbImport: "../db", generators: [routesFile()] },
+ metadata: root,
+ dryRun: true,
+ });
+ await expect(run).rejects.toThrow(ERR_COLLECTION_NAME_COLLISION);
+ await expect(run).rejects.toThrow(/"Invoice" and "Invoices"/);
+ });
+});
diff --git a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts
index d499bc23c..7649e9b2c 100644
--- a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts
+++ b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts
@@ -33,6 +33,7 @@ import { MetaObject } from "../object/meta-object.js";
import { MetaField } from "../field/meta-field.js";
import {
FIELD_ATTR_CURRENCY,
+ FIELD_ATTR_FILTERABLE,
FIELD_ATTR_INT_VALUE_MAP,
FIELD_ATTR_MAX_LENGTH,
FIELD_ATTR_OBJECT_REF,
@@ -42,6 +43,7 @@ import {
FIELD_ATTR_STORAGE,
FIELD_ATTR_VALUES,
} from "../field/field-constants.js";
+import { opsForField } from "../query/query-constants.js";
import { reportShape, type ReportField } from "./report-shape.js";
/**
@@ -86,6 +88,10 @@ function derivedField(f: ReportField): MetaField {
// `isArray` is a native flag, not an attr; resolvedIsArray() is its resolving read.
if (src.resolvedIsArray()) field.setIsArray(true);
}
+ // Table C (Plan 3): a report author has no node to put @filterable on, so every
+ // derived field that has a filter band is filterable. Set on this detached model
+ // only; no vocabulary is added and the declared tree is not touched.
+ if (opsForField(field).length > 0) field.setAttr(FIELD_ATTR_FILTERABLE, true);
return field;
}
diff --git a/server/typescript/packages/metadata/test/report-read-model.test.ts b/server/typescript/packages/metadata/test/report-read-model.test.ts
index a552a03c5..2f98198b8 100644
--- a/server/typescript/packages/metadata/test/report-read-model.test.ts
+++ b/server/typescript/packages/metadata/test/report-read-model.test.ts
@@ -4,8 +4,10 @@ import { pathToFileURL } from "node:url";
import {
FIELD_ATTR_COLUMN,
FIELD_ATTR_CURRENCY,
+ FIELD_ATTR_FILTERABLE,
FIELD_ATTR_LOCAL_TIME,
FIELD_ATTR_REQUIRED,
+ FIELD_ATTR_SORTABLE,
FIELD_ATTR_VALUES,
InMemoryStringSource,
MetaDataLoader,
@@ -56,6 +58,16 @@ describe("reportReadModel (FR-044 Table B as a detached read model)", () => {
]);
});
+ test("every derived field with a filter band is filterable; @sortable is never set", async () => {
+ const root = await load();
+ const fields = fieldsOf(model(root, "ProgramMinutes"));
+ expect(fields.length).toBe(11);
+ for (const f of fields) {
+ expect(f.attr(FIELD_ATTR_FILTERABLE)).toBe(true);
+ expect(f.attr(FIELD_ATTR_SORTABLE)).toBeUndefined();
+ }
+ });
+
test("min keeps the source field's subtype: minMinutes is field.int", async () => {
const root = await load();
const min = model(root, "ProgramMinutes").fields().find((f) => f.name === "minMinutes");
diff --git a/server/typescript/packages/test-generators/src/routes.ts b/server/typescript/packages/test-generators/src/routes.ts
index 138413862..342d5cba9 100644
--- a/server/typescript/packages/test-generators/src/routes.ts
+++ b/server/typescript/packages/test-generators/src/routes.ts
@@ -17,7 +17,7 @@
// discovers a sibling module: a `.extra.ts` next to the output is a naming
// convention, not a plugin point, so its handlers only mount if your server calls them.
// emits: /.routes.ts — full CRUD for write-through entities, read-only
-// (GET list + GET :id) for projections, polymorphic + per-subtype for TPH bases.
+// (GET list + GET :id) for projections (GET list alone for a keyless one or a report), polymorphic + per-subtype for TPH bases.
// Skipped for any sourceless object (incl. every object.value, source-less by
// value purity) and for TPH subtypes — no source.rdb means no table/allowlist
// for a routes file to import (#248 R2).
@@ -71,6 +71,8 @@ import {
tphStorageObject,
isProjection,
isWriteThrough,
+ isReport,
+ hasItemRoute,
servesReadApi,
formatTs,
renderRoutesIndex,
@@ -92,7 +94,8 @@ import {
// --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
-// projection → mountReadOnlyCrudRoutes (GET list + GET :id); every other writable entity →
+// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has a
+// single-column identity); every other writable entity →
// mountCrudRoutes (+ one mountM2mRoute per M:N navigation). Under an `apiPrefix` the mounts
// are wrapped in `fastify.register(..., { prefix })`.
@@ -133,9 +136,22 @@ function renderRoutes(
// Where the mount helpers come from: the package, or an owned copy (owned-runtime.ts).
const runtimeSpec = httpRuntimeSpecifier("drizzle-fastify", ctx, entityPkg);
- // --- Projection path: read-only routes (GET list + GET :id) ---
+ // --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
+ // A keyless read-only object (a projection with no single-column identity, and every
+ // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
+ // keeps its output byte-identical.
+ const keyless = !hasItemRoute(entity);
+ const report = isReport(entity);
+ const noun = report ? "report" : "projection";
+ const exposes = keyless
+ ? "Exposes GET list only. POST returns 405."
+ : "Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.";
+ const keylessOpts = (indent: string): string =>
+ (keyless ? `\n${indent}itemRoutes: false,` : "") +
+ (report ? `\n${indent}resource: "report",` : "");
const FastifyInstanceSym = imp("t:FastifyInstance@fastify");
const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`);
// A projection mount is read-only by construction, so `expose` cannot narrow it —
@@ -159,9 +175,9 @@ import {
const body = ctx.apiPrefix
? code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -175,16 +191,16 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}, { prefix: ${JSON.stringify(ctx.apiPrefix)} });
}
`
: code`
/**
- * Mount read-only REST endpoints for ${entityName} (projection — view-backed, no writes).
+ * Mount read-only REST endpoints for ${entityName} (${noun} — view-backed, no writes).
*
- * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
+ * ${exposes}
* Customize: register this as-is, or import individual route helpers from
* ${runtimeSpec}.
${readOnlyAuthJsDoc}
@@ -197,7 +213,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
view: ${camelName}View,
filterAllowlist: ${entityName}FilterAllowlist,
sortAllowlist: ${entityName}SortAllowlist,
- dialect: ${JSON.stringify(ctx.dialect)},
+ dialect: ${JSON.stringify(ctx.dialect)},${keylessOpts(" ")}
});
}
`;
From 1e7a33a56e16785e6dcbcd188d70fe13b187a366 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:18:03 -0400
Subject: [PATCH 07/21] feat(python): read-only FastAPI router for a
view-backed report (FR-044)
---
.../src/metaobjects/apidocs/api_model.py | 2 +-
.../python/src/metaobjects/apidocs/builder.py | 63 +++++-
.../codegen/generators/router_generator.py | 74 +++++--
.../metaobjects/codegen/instance_artifacts.py | 39 ++++
.../python/src/metaobjects/codegen/runner.py | 21 +-
.../meta/core/reporting/report_read_model.py | 9 +
.../tests/codegen/test_projection_compile.py | 29 ++-
.../tests/codegen/test_report_router.py | 114 ++++++++++
.../tests/codegen/test_router_generator.py | 16 +-
.../tests/integration/generated_report_app.py | 196 ++++++++++++++++++
.../integration/test_api_contract_report.py | 136 ++++++++++++
server/python/tests/test_report_read_model.py | 18 +-
server/python/tests/test_reporting_inert.py | 82 ++++++--
13 files changed, 748 insertions(+), 51 deletions(-)
create mode 100644 server/python/tests/codegen/test_report_router.py
create mode 100644 server/python/tests/integration/generated_report_app.py
create mode 100644 server/python/tests/integration/test_api_contract_report.py
diff --git a/server/python/src/metaobjects/apidocs/api_model.py b/server/python/src/metaobjects/apidocs/api_model.py
index 8bb666368..12766ddef 100644
--- a/server/python/src/metaobjects/apidocs/api_model.py
+++ b/server/python/src/metaobjects/apidocs/api_model.py
@@ -91,7 +91,7 @@ class ApiUnit:
:param node: the unit's short name (the doc-page basename).
:param package: the unit's metadata package (e.g. ``acme::shop``).
- :param kind: ``"entity"`` | ``"value"`` | ``"template"``.
+ :param kind: ``"entity"`` | ``"value"`` | ``"report"`` | ``"template"``.
:param symbols: the documented symbols, in canonical IR order.
:param example: an optional unit-level worked example (reserved).
"""
diff --git a/server/python/src/metaobjects/apidocs/builder.py b/server/python/src/metaobjects/apidocs/builder.py
index 7732cd085..bfa673362 100644
--- a/server/python/src/metaobjects/apidocs/builder.py
+++ b/server/python/src/metaobjects/apidocs/builder.py
@@ -45,7 +45,11 @@
from metaobjects.codegen.generators.find_inbound import is_xml, response_shape
from metaobjects.codegen.value_objects import is_field_required, pkg_of, resolve_payload_vo
from metaobjects.codegen.generators.tph_plan import is_tph_subtype
-from metaobjects.codegen.instance_artifacts import emits_instance_artifacts, is_abstract
+from metaobjects.codegen.instance_artifacts import (
+ emits_instance_artifacts,
+ is_abstract,
+ is_served_report,
+)
from metaobjects.source_resolution import primary_rdb_source
from metaobjects.meta.core.field import field_constants as fc
from metaobjects.meta.core.field.meta_field import MetaField
@@ -54,6 +58,7 @@
OBJECT_SUBTYPE_ENTITY,
OBJECT_SUBTYPE_REPORT,
)
+from metaobjects.meta.core.reporting.report_read_model import report_read_model
from metaobjects.meta.meta_data import MetaData
from metaobjects.meta.persistence.source.source_constants import SOURCE_KIND_TABLE
from metaobjects.meta.template import template_constants as tc
@@ -119,9 +124,14 @@ def build(self, root: MetaData, project: str) -> ApiModel:
units: list[ApiUnit] = []
for obj in objects:
- # FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- # It has no generated API to document, and its derived fields do not exist yet.
+ # FR-044: a served report (Table A) has a generated read API, documented from
+ # its read model (its derived fields); every other report has no output.
if obj.sub_type == OBJECT_SUBTYPE_REPORT:
+ unit = (
+ self._build_report_unit(obj, root) if is_served_report(obj) else None
+ )
+ if unit is not None:
+ units.append(unit)
continue
unit = self._build_object_unit(obj, root, object_index)
if unit is not None:
@@ -216,6 +226,53 @@ def _build_object_unit(
return None
return ApiUnit(obj.name, _package_of(obj), unit_kind, symbols)
+ def _build_report_unit(self, obj: MetaObject, root: MetaData) -> ApiUnit:
+ """The unit for a SERVED report (FR-044 Table G): the row model, the repository
+ seam (``list`` and ``count``), ``GET `` and the filter allowlist.
+ Nothing else is generated for it: no item route, no write verb, no validation
+ model. Built over the report's read model so the names are the generators'."""
+ model_obj = report_read_model(obj, root) # type: ignore[arg-type]
+ model = naming.model_class_name(model_obj)
+ router_module = naming.router_module_name(obj.name)
+ base_path = "/api/" + naming.route_path(obj.name)
+ repo = naming.repository_class_name(obj.name)
+ fields_const = naming.filter_fields_const(obj.name)
+ symbols = [
+ ApiSymbol(
+ name=model,
+ kind=ApiSymbolKind.MODEL,
+ module=naming.model_import(model_obj),
+ signature=f"class {model}(BaseModel)",
+ usage="the Pydantic v2 report row model",
+ ),
+ ApiSymbol(
+ name=repo,
+ kind=ApiSymbolKind.DATA_ACCESS,
+ module=f"from .{router_module} import {repo}",
+ signature=f"class {repo}(Protocol)",
+ usage="data access — the read-only repository Protocol the consumer implements",
+ returns="list / count",
+ ),
+ ApiSymbol(
+ name="GET " + base_path,
+ kind=ApiSymbolKind.REST,
+ module=f"# {router_module}.py — FastAPI APIRouter",
+ signature="GET " + base_path,
+ usage="list with pagination / sort / filters",
+ ),
+ ApiSymbol(
+ name=fields_const,
+ kind=ApiSymbolKind.FILTER,
+ module=(
+ f"from .{naming.filter_allowlist_module_name(obj.name)} "
+ f"import {fields_const}"
+ ),
+ signature=f"{fields_const}: frozenset[str]",
+ usage="the filterable-field + filter-operator allowlist",
+ ),
+ ]
+ return ApiUnit(obj.name, _package_of(obj), "report", symbols)
+
def _add_rest_symbols(
self,
symbols: list[ApiSymbol],
diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py
index 5475bcadb..a6f892834 100644
--- a/server/python/src/metaobjects/codegen/generators/router_generator.py
+++ b/server/python/src/metaobjects/codegen/generators/router_generator.py
@@ -55,7 +55,7 @@
resolve_m2m_descriptors,
)
from metaobjects.codegen.generators.tph_plan import TphPlan, is_tph_subtype, tph_plan_for
-from metaobjects.codegen.instance_artifacts import emits_instance_artifacts
+from metaobjects.codegen.instance_artifacts import emits_instance_artifacts, has_item_route
from metaobjects.source_resolution import primary_rdb_source
from metaobjects.codegen.type_map import PyType, py_type_for
from metaobjects.meta.core.field import field_constants as fc
@@ -70,6 +70,7 @@
IDENTITY_SUBTYPE_REFERENCE,
)
from metaobjects.meta.core.object.meta_object import MetaObject
+from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT
from metaobjects.meta.core.relationship.relationship_references import (
reference_target_entity,
)
@@ -1418,7 +1419,15 @@ def render_router(
return "\n".join(parts)
- def _emit_readonly_reject_handlers(self, snake: str, plural: str, pk_param: str) -> list[str]:
+ def _emit_readonly_reject_handlers(
+ self,
+ snake: str,
+ plural: str,
+ pk_param: str,
+ *,
+ item_route: bool = True,
+ noun: str = "projection",
+ ) -> list[str]:
"""The write verbs on a read-only projection, each answering the cross-port
405 envelope.
@@ -1430,25 +1439,31 @@ def _emit_readonly_reject_handlers(self, snake: str, plural: str, pk_param: str)
PUT is here because the writable router serves it; a projection has to
refuse every verb the writable surface offers, or the one it forgets falls
- through to a 404 (which is exactly what TypeScript did until F22)."""
+ through to a 404 (which is exactly what TypeScript did until F22).
+
+ *item_route* is false for a keyless object (a report, or a projection with no
+ single-field identity): it has no ``/{id}`` path, so only the collection ``POST``
+ is refused and the item verbs fall through to the framework's own 404 (FR-044).
+ *noun* is what the 405 message calls the resource ("projection" or "report")."""
lines: list[str] = []
- for i, (verb, path, fn) in enumerate((
+ refusals = (
("post", '""', f"create_{snake}"),
("patch", f'"/{{{pk_param}}}"', f"update_{snake}"),
("put", f'"/{{{pk_param}}}"', f"replace_{snake}"),
("delete", f'"/{{{pk_param}}}"', f"delete_{snake}"),
- )):
+ )
+ for i, (verb, path, fn) in enumerate(refusals[: 4 if item_route else 1]):
if i > 0:
lines.append("")
lines.append("")
lines.append(f"@router.{verb}({path})")
lines.append(f"def {fn}() -> Any:")
- lines.append(f' """GENERATED — {plural} is a read-only projection; writes are rejected."""')
+ lines.append(f' """GENERATED — {plural} is a read-only {noun}; writes are rejected."""')
lines.append(" return JSONResponse(")
lines.append(" status_code=405,")
lines.append(' content={')
lines.append(' "error": "method_not_allowed",')
- lines.append(f' "message": "{verb.upper()} is not supported on a projection (read-only).",')
+ lines.append(f' "message": "{verb.upper()} is not supported on a {noun} (read-only).",')
lines.append(" },")
lines.append(" )")
return lines
@@ -1459,7 +1474,9 @@ def _render_readonly_router(
column_naming: str = DEFAULT_COLUMN_NAMING,
) -> str:
"""Render a read-only (`@kind: view` / `materializedView`) object as a FastAPI
- ``APIRouter``: GET list + GET by id, and the four write verbs answering 405.
+ ``APIRouter``: GET list + GET by id, and the four write verbs answering 405. An
+ object with no single-field identity (every ``object.report``, and a keyless
+ projection) has no item address: GET list and the collection POST refusal only.
Deliberately a separate assembly from the writable path rather than a pile of
``if writable`` branches through it. The writable router carries create/update
@@ -1473,8 +1490,14 @@ def _render_readonly_router(
snake = _snake_case(short_name)
plural = _route_path(short_name)
pk_param = f"{snake}_id"
- pk = _pk_py_type(entity)
- pk_type = pk.expr
+ # FR-044: a report (and any projection without a single-field identity) has no
+ # item address. It gets the collection routes only: no GET /{id}, no item-verb
+ # refusals, no find_by_id on the seam. The framework answers /{id} with its own 404.
+ item_route = has_item_route(entity)
+ is_report = entity.sub_type == OBJECT_SUBTYPE_REPORT
+ noun = "report" if is_report else "projection"
+ pk = _pk_py_type(entity) if item_route else None
+ pk_type = pk.expr if pk is not None else ""
repo_class = f"{short_name}Repository"
sort_field_nodes = list(_scalar_fields(entity))
upper = short_name.upper()
@@ -1483,19 +1506,27 @@ def _render_readonly_router(
allowlist_module = f"{snake}_filter_allowlist"
parts: list[str] = []
+ contract = (
+ "Implements the cross-port API contract: GET list + GET by id; every write\n"
+ 'verb answers 405 {"error": "method_not_allowed"}.\n'
+ if item_route
+ else "Implements the cross-port API contract: GET list only (the resource has no\n"
+ "identity, so no item route); POST answers 405 "
+ '{"error": "method_not_allowed"}.\n'
+ )
parts.append(
generated_header(short_name, _effective_fqn(entity)).rstrip() + "\n"
- + f'"""GENERATED — read-only REST router for the {short_name} projection.\n\n'
- + "Implements the cross-port API contract: GET list + GET by id; every write\n"
- + 'verb answers 405 {"error": "method_not_allowed"}.\n'
+ + f'"""GENERATED — read-only REST router for the {short_name} {noun}.\n\n'
+ + contract
+ _auth_docstring_paragraph(read_only=True)
+ '"""\n'
)
parts.append("from __future__ import annotations")
parts.append("")
- for import_line in sorted(pk.imports):
+ pk_imports = pk.imports if pk is not None else ()
+ for import_line in sorted(pk_imports):
parts.append(import_line)
- if pk.imports:
+ if pk_imports:
parts.append("")
parts.append("from typing import Annotated, Any, Protocol")
parts.append("")
@@ -1522,7 +1553,7 @@ def _render_readonly_router(
parts.append(f"class {repo_class}(Protocol):")
parts.append(' """GENERATED — consumer implements with their preferred persistence layer.')
parts.append("")
- parts.append(" Read-only: a projection is not writable, so the seam offers no")
+ parts.append(f" Read-only: a {noun} is not writable, so the seam offers no")
parts.append(' create / update / delete."""')
parts.append(" def list(")
parts.append(" self,")
@@ -1532,7 +1563,8 @@ def _render_readonly_router(
parts.append(" filters: list[FilterPredicate],")
parts.append(" ) -> list[Any]: ...")
parts.append(" def count(self, filters: list[FilterPredicate]) -> int: ...")
- parts.append(f" def find_by_id(self, id: {pk_type}) -> Any | None: ...")
+ if item_route:
+ parts.append(f" def find_by_id(self, id: {pk_type}) -> Any | None: ...")
parts.append("")
parts.append("")
parts.append(f"def get_repository() -> {repo_class}:")
@@ -1554,14 +1586,18 @@ def _render_readonly_router(
model_name="",
patch_model="",
)
- for i, hname in enumerate(("list", "get")):
+ for i, hname in enumerate(("list", "get") if item_route else ("list",)):
if i > 0:
parts.append("")
parts.append("")
parts.extend(self._emit_route_handler(hname, **_handler_kwargs))
parts.append("")
parts.append("")
- parts.extend(self._emit_readonly_reject_handlers(snake, plural, pk_param))
+ parts.extend(
+ self._emit_readonly_reject_handlers(
+ snake, plural, pk_param, item_route=item_route, noun=noun
+ )
+ )
parts.append("")
return "\n".join(parts)
diff --git a/server/python/src/metaobjects/codegen/instance_artifacts.py b/server/python/src/metaobjects/codegen/instance_artifacts.py
index 312952075..268bea994 100644
--- a/server/python/src/metaobjects/codegen/instance_artifacts.py
+++ b/server/python/src/metaobjects/codegen/instance_artifacts.py
@@ -7,7 +7,11 @@
field was read by nothing and entity_model never consulted it, so `GenConfig` now
refuses to accept a value it cannot honour.
"""
+from metaobjects.meta.core.identity.identity_constants import IDENTITY_ATTR_FIELDS
from metaobjects.meta.core.object.meta_object import MetaObject
+from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT
+from metaobjects.meta.core.reporting.report_read_model import report_read_source
+from metaobjects.meta.persistence.source.source_constants import SOURCE_KIND_VIEW
def is_abstract(entity: MetaObject) -> bool:
@@ -41,3 +45,38 @@ def is_sourceless_entity(entity: MetaObject) -> bool:
return False
# ADR-0039: children() resolves — an inherited source makes the object persistable.
return not any(isinstance(c, MetaSource) for c in entity.children())
+
+
+def is_served_report(obj: MetaObject) -> bool:
+ """FR-044 Table A: an ``object.report`` is SERVED (gets a row model, a filter
+ allowlist, a read-only router and a names module) iff it is concrete and its read
+ source (:func:`report_read_source`) has ``@kind: view``. A sourceless report, an
+ abstract one, and one over a ``materializedView`` / ``storedProc`` / ``tableFunction``
+ are not: the lowering skips those kinds, so no relation with Table B's columns is
+ promised.
+
+ Answers the same for a declared report and for its read model (which keeps the
+ ``object.report`` subtype and carries a copy of the read source as its only source).
+ """
+ if obj.sub_type != OBJECT_SUBTYPE_REPORT or is_abstract(obj):
+ return False
+ source = report_read_source(obj)
+ return source is not None and source.effective_kind() == SOURCE_KIND_VIEW
+
+
+def has_item_route(entity: MetaObject) -> bool:
+ """Whether a read-only object is addressable by key: it has a primary identity over
+ EXACTLY ONE field. A report has no identity at all, and a keyless projection has none
+ either, so neither gets a ``/{id}`` route or a ``find_by_id`` on its repository seam
+ (FR-044 open question 4). A composite identity has no single path parameter to bind."""
+ identity = entity.primary_identity()
+ if identity is None:
+ return False
+ fields = identity.get_meta_attr(IDENTITY_ATTR_FIELDS) # ADR-0039: resolving (identity attr)
+ if isinstance(fields, str):
+ names = [n for n in (p.strip() for p in fields.split(",")) if n]
+ elif isinstance(fields, (list, tuple)):
+ names = [n for n in fields if isinstance(n, str) and n]
+ else:
+ names = []
+ return len(names) == 1
diff --git a/server/python/src/metaobjects/codegen/runner.py b/server/python/src/metaobjects/codegen/runner.py
index 2dbb8bf20..395402871 100644
--- a/server/python/src/metaobjects/codegen/runner.py
+++ b/server/python/src/metaobjects/codegen/runner.py
@@ -10,11 +10,13 @@
from metaobjects.meta.meta_data import MetaData
from metaobjects.meta.core.object.meta_object import MetaObject
from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT
+from metaobjects.meta.core.reporting.report_read_model import report_read_model
from metaobjects.shared.base_types import TYPE_OBJECT
from .collection_name_collision import assert_no_collection_name_collisions
from .config import GenConfig
from .constants import generated_package_init
from .generator import GenContext, Generator
+from .instance_artifacts import is_served_report
from .overwrite_policy import decide_and_write, has_hash_manifest
_VALID_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@@ -98,16 +100,19 @@ def run_gen(
result.warnings.append(f"No entities to generate — {reason}.")
return result
- # FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- # Dropped here, at the entity set every generator reads, and not per generator: a
- # report may declare a read-only `source.rdb @kind: view` (R5), which would otherwise
- # pass every source-keyed gate and emit routes, names and an allowlist. A selection made
- # only of reports gets the same "nothing to generate" warning as an empty one.
- objs = [o for o in objs if o.sub_type != OBJECT_SUBTYPE_REPORT]
+ # FR-044: a served report (Table A) is generated from its read model, which the
+ # read-only generators emit as a keyless object. Every other report generates nothing:
+ # a sourceless report, or one over a kind the lowering skips, has no relation to read.
+ # Decided here, at the entity set every generator reads, and not per generator.
+ objs = [
+ report_read_model(o, metadata) if o.sub_type == OBJECT_SUBTYPE_REPORT else o
+ for o in objs
+ if o.sub_type != OBJECT_SUBTYPE_REPORT or is_served_report(o)
+ ]
if not objs:
result.warnings.append(
- "No entities to generate — every selected object is an object.report, which has "
- "no generated output until its lowering lands (FR-044 Plan 2/3)."
+ "No entities to generate — every selected object is an object.report with no "
+ "view source, which has no generated output (FR-044)."
)
return result
diff --git a/server/python/src/metaobjects/meta/core/reporting/report_read_model.py b/server/python/src/metaobjects/meta/core/reporting/report_read_model.py
index 854305ac1..437a373ca 100644
--- a/server/python/src/metaobjects/meta/core/reporting/report_read_model.py
+++ b/server/python/src/metaobjects/meta/core/reporting/report_read_model.py
@@ -37,6 +37,7 @@
from ...persistence.source.source_constants import SOURCE_ATTR_ROLE, SOURCE_ROLE_PRIMARY
from ..field.field_constants import (
FIELD_ATTR_CURRENCY,
+ FIELD_ATTR_FILTERABLE,
FIELD_ATTR_INT_VALUE_MAP,
FIELD_ATTR_MAX_LENGTH,
FIELD_ATTR_OBJECT_REF,
@@ -93,6 +94,14 @@ def _derived_field(f: ReportField) -> MetaField:
# ``is_array`` is a native flag, not an attr; ``resolved_is_array()`` is its resolving read.
if src.resolved_is_array():
field.is_array = True
+ # Table C (Plan 3): a report author has no node to put @filterable on, so every
+ # derived field that has a filter band is filterable. Set on this detached model
+ # only; no vocabulary is added and the declared tree is not touched. Imported here
+ # because the loader's validation passes import this package.
+ from ....loader.validation_passes import ops_for_field
+
+ if ops_for_field(field):
+ field.set_attr(FIELD_ATTR_FILTERABLE, True)
return field
diff --git a/server/python/tests/codegen/test_projection_compile.py b/server/python/tests/codegen/test_projection_compile.py
index 2c2983497..e5e927962 100644
--- a/server/python/tests/codegen/test_projection_compile.py
+++ b/server/python/tests/codegen/test_projection_compile.py
@@ -13,6 +13,11 @@
from metaobjects.codegen.generators.router_generator import render_router
from metaobjects.meta.core.field import field_constants as fc
from metaobjects.meta.core.field.meta_field import MetaField
+from metaobjects.meta.core.identity.identity_constants import (
+ IDENTITY_ATTR_FIELDS,
+ IDENTITY_SUBTYPE_PRIMARY,
+)
+from metaobjects.meta.core.identity.meta_identity import MetaIdentity
from metaobjects.meta.core.object.meta_object import MetaObject
from metaobjects.meta.persistence.source.meta_source import MetaSource
from metaobjects.meta.persistence.source.source_constants import (
@@ -20,7 +25,7 @@
SOURCE_KIND_VIEW,
SOURCE_SUBTYPE_RDB,
)
-from metaobjects.shared.base_types import TYPE_FIELD, TYPE_OBJECT, TYPE_SOURCE
+from metaobjects.shared.base_types import TYPE_FIELD, TYPE_IDENTITY, TYPE_OBJECT, TYPE_SOURCE
def _f(name: str, sub: str, *, required: bool = False) -> MetaField:
@@ -30,7 +35,7 @@ def _f(name: str, sub: str, *, required: bool = False) -> MetaField:
return f
-def _view_projection() -> MetaObject:
+def _view_projection(*, keyed: bool = True) -> MetaObject:
o = MetaObject(TYPE_OBJECT, "entity", "ProgramSummary")
o.package = "acme::test"
src = MetaSource(TYPE_SOURCE, SOURCE_SUBTYPE_RDB, "")
@@ -38,6 +43,10 @@ def _view_projection() -> MetaObject:
o.add_child(src)
o.add_child(_f("id", fc.FIELD_SUBTYPE_INT, required=True))
o.add_child(_f("weekCount", fc.FIELD_SUBTYPE_INT)) # non-required derived field
+ if keyed:
+ identity = MetaIdentity(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, "pk")
+ identity.set_attr(IDENTITY_ATTR_FIELDS, ["id"])
+ o.add_child(identity)
return o
@@ -82,3 +91,19 @@ def test_projection_router_is_read_only() -> None:
assert "ProgramSummaryCreate" not in src
assert "ProgramSummaryPatch" not in src
assert "classify_constraint_error" not in src
+
+
+def test_keyless_projection_router_has_no_item_routes() -> None:
+ """FR-044 open question 4: a projection with no single-field identity has no item
+ address, so it gets GET list and the collection POST refusal only, and its repository
+ seam has no ``find_by_id``. (Before, it bound an ``id: int`` it could not honour.)"""
+ src = render_router(_view_projection(keyed=False))
+ assert src is not None
+ compile(src, "", "exec")
+ assert '@router.get("")' in src and '@router.post("")' in src
+ assert "{" not in "".join(
+ line for line in src.splitlines() if line.startswith("@router.")
+ )
+ assert "find_by_id" not in src
+ assert src.count('"error": "method_not_allowed",') == 1
+ assert "not supported on a projection" in src
diff --git a/server/python/tests/codegen/test_report_router.py b/server/python/tests/codegen/test_report_router.py
new file mode 100644
index 000000000..4895350d3
--- /dev/null
+++ b/server/python/tests/codegen/test_report_router.py
@@ -0,0 +1,114 @@
+"""FR-044 Plan 3 — the Python port generates a keyless read-only surface for a served
+report (Table A/B/C/E) and no item routes for ANY keyless object (open question 4).
+
+The model is the shared ``fixtures/api-contract-conformance/report`` corpus: three
+view-backed reports and one sourceless one (``InvoiceDays``), over a writable ``Invoice``.
+"""
+from __future__ import annotations
+
+import ast
+import re
+import shutil
+import tempfile
+from pathlib import Path
+
+from metaobjects import MetaDataLoader
+from metaobjects.codegen.config import GenConfig
+from metaobjects.codegen.generator_registry import GeneratorBuildContext, list_generators
+from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist
+from metaobjects.codegen.generators.router_generator import render_router
+from metaobjects.codegen.instance_artifacts import has_item_route, is_served_report
+from metaobjects.codegen.runner import run_gen
+from metaobjects.meta.core.object.meta_object import MetaObject
+from metaobjects.meta.core.reporting.report_read_model import report_read_model
+from metaobjects.shared.base_types import TYPE_OBJECT
+
+_CORPUS = Path(__file__).parents[3].parent / "fixtures" / "api-contract-conformance" / "report"
+
+
+def _load():
+ tmp = Path(tempfile.mkdtemp(prefix="report-router-"))
+ shutil.copy(_CORPUS / "meta.json", tmp / "meta.json")
+ result = MetaDataLoader.from_directory(str(tmp))
+ assert not result.errors, [e.message for e in result.errors]
+ return result.root
+
+
+def _obj(root, name: str) -> MetaObject:
+ return next(c for c in root.children() if c.type == TYPE_OBJECT and c.name == name)
+
+
+def test_served_report_predicate_follows_table_a() -> None:
+ root = _load()
+ assert is_served_report(_obj(root, "InvoiceStatusTotals"))
+ assert is_served_report(_obj(root, "InvoiceTotals"))
+ assert not is_served_report(_obj(root, "InvoiceDays")) # sourceless
+ assert not is_served_report(_obj(root, "Invoice")) # an entity
+ # the read model keeps the report subtype and a copy of the source: also served
+ assert is_served_report(report_read_model(_obj(root, "InvoiceStatusTotals"), root))
+
+
+def test_only_an_object_with_a_single_field_identity_has_an_item_route() -> None:
+ root = _load()
+ assert has_item_route(_obj(root, "Invoice"))
+ assert not has_item_route(report_read_model(_obj(root, "InvoiceStatusTotals"), root))
+
+
+def test_report_router_has_the_collection_routes_and_no_item_route() -> None:
+ root = _load()
+ model = report_read_model(_obj(root, "InvoiceStatusTotals"), root)
+ src = render_router(model)
+ assert src is not None
+ ast.parse(src)
+ decorators = re.findall(r'@router\.(\w+)\(("[^"]*")', src)
+ assert decorators == [("get", '""'), ("post", '""')], decorators
+ assert "{" not in "".join(p for _, p in decorators)
+ assert "find_by_id" not in src
+ assert "def list(" in src and "def count(" in src
+ assert "class InvoiceStatusTotalsRepository(Protocol)" in src
+ assert "report" in src and "projection" not in src.split("class InvoiceStatusTotalsRepository")[1]
+
+
+def test_report_allowlist_names_the_report_s_own_derived_fields() -> None:
+ root = _load()
+ model = report_read_model(_obj(root, "InvoiceStatusTotals"), root)
+ src = render_filter_allowlist(model)
+ assert src is not None
+ for name in ("status", "invoices", "totalCents", "paidCents"):
+ assert f'"{name}"' in src
+ assert '"reference"' not in src
+
+
+def test_run_gen_serves_three_reports_and_nothing_for_the_sourceless_one(tmp_path: Path) -> None:
+ root = _load()
+ templates = tmp_path / "t"
+ templates.mkdir()
+ gens = [
+ e.factory(GeneratorBuildContext(template_root=str(templates)))
+ for e in list_generators()
+ if e.name in {"entity", "filter-allowlist", "names", "routes"}
+ ]
+ out = tmp_path / "out"
+ run_gen(GenConfig(out_dir=str(out)), root, generators=gens)
+ files = {p.name for p in out.rglob("*.py")}
+ for snake in ("invoice_status_totals", "invoices_by_month", "invoice_totals"):
+ assert f"{snake}_router.py" in files
+ assert f"{snake}_filter_allowlist.py" in files
+ assert f"{snake}_names.py" in files
+ assert not any(f.startswith("invoice_days") for f in files)
+ assert not any("invoice_days" in f for f in files)
+
+
+def test_a_composite_identity_has_no_single_path_parameter() -> None:
+ from metaobjects.meta.core.identity.identity_constants import (
+ IDENTITY_ATTR_FIELDS,
+ IDENTITY_SUBTYPE_PRIMARY,
+ )
+ from metaobjects.meta.core.identity.meta_identity import MetaIdentity
+ from metaobjects.shared.base_types import TYPE_IDENTITY
+
+ obj = MetaObject(TYPE_OBJECT, "projection", "Pair")
+ identity = MetaIdentity(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, "pk")
+ identity.set_attr(IDENTITY_ATTR_FIELDS, ["a", "b"])
+ obj.add_child(identity)
+ assert not has_item_route(obj)
diff --git a/server/python/tests/codegen/test_router_generator.py b/server/python/tests/codegen/test_router_generator.py
index 517a94843..d9cf9a386 100644
--- a/server/python/tests/codegen/test_router_generator.py
+++ b/server/python/tests/codegen/test_router_generator.py
@@ -17,6 +17,11 @@
from metaobjects.codegen.runtime.filter_parser import parse_filter
from metaobjects.meta.core.field.meta_field import MetaField
from metaobjects.meta.core.field import field_constants as fc
+from metaobjects.meta.core.identity.identity_constants import (
+ IDENTITY_ATTR_FIELDS,
+ IDENTITY_SUBTYPE_PRIMARY,
+)
+from metaobjects.meta.core.identity.meta_identity import MetaIdentity
from metaobjects.meta.core.object.meta_object import MetaObject
from metaobjects.meta.persistence.source.meta_source import MetaSource
from metaobjects.meta.persistence.source.source_constants import (
@@ -24,7 +29,7 @@
SOURCE_KIND_VIEW,
SOURCE_SUBTYPE_RDB,
)
-from metaobjects.shared.base_types import TYPE_FIELD, TYPE_OBJECT, TYPE_SOURCE
+from metaobjects.shared.base_types import TYPE_FIELD, TYPE_IDENTITY, TYPE_OBJECT, TYPE_SOURCE
def _entity(
@@ -34,6 +39,7 @@ def _entity(
source_kind: str | None = "table",
package: str | None = None,
subtype: str = "entity",
+ pk: bool = False,
) -> MetaObject:
o = MetaObject(TYPE_OBJECT, subtype, name)
o.package = package
@@ -44,6 +50,12 @@ def _entity(
o.add_child(src)
for f in fields:
o.add_child(f)
+ if pk:
+ # A single-field primary identity: what makes a read-only object addressable by
+ # key (FR-044: a keyless one gets no item route).
+ identity = MetaIdentity(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, "pk")
+ identity.set_attr(IDENTITY_ATTR_FIELDS, ["id"])
+ o.add_child(identity)
return o
@@ -130,6 +142,7 @@ def test_view_kind_gets_a_read_only_router() -> None:
[_f("id", fc.FIELD_SUBTYPE_INT, required=True)],
source_kind=SOURCE_KIND_VIEW,
package="acme::blog",
+ pk=True,
)
out = render_router(view)
assert out is not None
@@ -166,6 +179,7 @@ def test_projection_subtype_gets_a_read_only_router() -> None:
source_kind=SOURCE_KIND_VIEW,
package="acme::sales",
subtype="projection",
+ pk=True,
)
out = render_router(proj)
assert out is not None
diff --git a/server/python/tests/integration/generated_report_app.py b/server/python/tests/integration/generated_report_app.py
new file mode 100644
index 000000000..8f8a36181
--- /dev/null
+++ b/server/python/tests/integration/generated_report_app.py
@@ -0,0 +1,196 @@
+"""FR-044 — boot the GENERATED read-only report routers over HTTP.
+
+Peer of ``generated_projection_app.py``, for the ``report/`` api-contract corpus
+(``fixtures/api-contract-conformance/report/``). Runs the REAL generation path
+(``run_gen``, the one ``metaobjects gen`` takes) for the three served reports, writes the
+emitted package to a temp dir, imports each generated router UNMODIFIED, and mounts it
+behind an in-memory repository.
+
+The generated routers are the artifact under test: what is under test is whether this
+port's GENERATOR emits a keyless read-only surface for a view-backed ``object.report``.
+
+Python emits no SQL for a report (ADR-0015: view SQL is TypeScript-only), so the in-memory
+repository stands in for the view. It is seeded with the ``reports`` half of the corpus
+``seed.json``, which is what the three views return for the ``invoices`` rows. Only
+``list`` / ``count`` exist, matching the generated ``Protocol``: a report has no
+``find_by_id``.
+"""
+from __future__ import annotations
+
+import datetime
+import importlib.util
+import shutil
+import sys
+import tempfile
+import uuid
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI
+
+from metaobjects import MetaDataLoader
+from metaobjects.codegen.config import GenConfig
+from metaobjects.codegen.generator_registry import GeneratorBuildContext, list_generators
+from metaobjects.codegen.runner import run_gen
+from metaobjects.codegen.runtime.filter_parser import FilterPredicate
+
+#: The three served reports, as (report name, generated module stem).
+SERVED_REPORTS: dict[str, str] = {
+ "InvoiceStatusTotals": "invoice_status_totals",
+ "InvoicesByMonth": "invoices_by_month",
+ "InvoiceTotals": "invoice_totals",
+}
+#: The sourceless report: generated nowhere, mounted nowhere.
+UNSERVED_REPORT = "InvoiceDays"
+
+_GENERATORS = ("entity", "filter-allowlist", "routes")
+
+
+def _load_root(meta_json: Path):
+ # Copy meta.json into its own dir so the loader does not try to parse the sibling
+ # seed.json / scenario yaml as metadata.
+ tmp = Path(tempfile.mkdtemp(prefix="apic-report-meta-"))
+ shutil.copy(meta_json, tmp / "meta.json")
+ result = MetaDataLoader.from_directory(str(tmp))
+ if result.errors:
+ msgs = "; ".join(f"{e.code}: {e.message}" for e in result.errors)
+ raise RuntimeError(f"report meta.json failed to load: {msgs}")
+ return result.root
+
+
+def _import(name: str, path: Path, **kwargs: Any):
+ spec = importlib.util.spec_from_file_location(name, path, **kwargs)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def build_generated_report_app(
+ corpus_root: Path,
+) -> tuple[FastAPI, dict[str, "InMemoryReportRepository"], set[str]]:
+ """Generate the report routers, import them, mount them, and wire the seams.
+
+ Returns ``(app, repos by report name, names of the files generation emitted)``.
+ """
+ root = _load_root(corpus_root / "meta.json")
+ pkg_name = f"genreport_{uuid.uuid4().hex[:8]}"
+ tmp = Path(tempfile.mkdtemp(prefix="apic-report-gen-"))
+ out = tmp / pkg_name
+ templates = tmp / "templates"
+ templates.mkdir()
+ generators = [
+ entry.factory(GeneratorBuildContext(template_root=str(templates)))
+ for entry in list_generators()
+ if entry.name in _GENERATORS
+ ]
+ run_gen(
+ GenConfig(out_dir=str(out)),
+ root,
+ generators=generators,
+ entity_filter=[*SERVED_REPORTS, UNSERVED_REPORT],
+ )
+ emitted = {p.name for p in out.rglob("*.py")}
+
+ sys.path.insert(0, str(tmp))
+ _import(pkg_name, out / "__init__.py", submodule_search_locations=[str(out)])
+
+ app = FastAPI()
+ repos: dict[str, InMemoryReportRepository] = {}
+ for report, stem in SERVED_REPORTS.items():
+ router_mod = _import(f"{pkg_name}.{stem}_router", out / f"{stem}_router.py")
+ repo = InMemoryReportRepository()
+ repos[report] = repo
+ app.include_router(router_mod.router)
+ app.dependency_overrides[router_mod.get_repository] = lambda r=repo: r
+ return app, repos, emitted
+
+
+def seed_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """What a driver hands back for the view's columns: a ``date`` column is a ``date``
+ and a ``numeric`` one a ``Decimal``. The seed spells both as strings so no float sits
+ between the corpus and the repository."""
+ out: list[dict[str, Any]] = []
+ for row in rows:
+ typed = dict(row)
+ if isinstance(typed.get("issuedOnMonth"), str):
+ typed["issuedOnMonth"] = datetime.date.fromisoformat(typed["issuedOnMonth"])
+ if isinstance(typed.get("paidShare"), str):
+ typed["paidShare"] = Decimal(typed["paidShare"])
+ out.append(typed)
+ return out
+
+
+class InMemoryReportRepository:
+ """In-memory impl of a GENERATED read-only ``Repository`` (``list`` / ``count``)."""
+
+ def __init__(self) -> None:
+ self._rows: list[dict[str, Any]] = []
+
+ def reset(self) -> None:
+ self._rows = []
+
+ def seed(self, rows: list[dict[str, Any]]) -> None:
+ self._rows = [dict(r) for r in rows]
+
+ def _coerce(self, field: str, raw: str) -> Any:
+ for r in self._rows:
+ v = r.get(field)
+ if v is None:
+ continue
+ if isinstance(v, bool):
+ return raw == "true"
+ if isinstance(v, int):
+ return int(raw)
+ if isinstance(v, float):
+ return float(raw)
+ if isinstance(v, Decimal):
+ return Decimal(raw)
+ if isinstance(v, datetime.date):
+ return datetime.date.fromisoformat(raw)
+ break
+ return raw
+
+ def _matches(self, row: dict[str, Any], p: FilterPredicate) -> bool:
+ actual = row.get(p.field)
+ if p.op == "isNull":
+ return (actual is None) == bool(p.value)
+ if actual is None:
+ return False
+ if p.op == "in":
+ return actual in {self._coerce(p.field, str(v)) for v in p.value}
+ want = self._coerce(p.field, str(p.value))
+ if p.op == "eq":
+ return actual == want
+ if p.op == "ne":
+ return actual != want
+ if p.op == "gt":
+ return actual > want
+ if p.op == "gte":
+ return actual >= want
+ if p.op == "lt":
+ return actual < want
+ if p.op == "lte":
+ return actual <= want
+ raise ValueError(f"unsupported op: {p.op}")
+
+ def _filtered(self, filters: list[FilterPredicate]) -> list[dict[str, Any]]:
+ rows = self._rows
+ for p in filters:
+ rows = [r for r in rows if self._matches(r, p)]
+ return rows
+
+ # --- the GENERATED read-only Protocol surface ---
+ def list(self, limit: int, offset: int, sort: Any, filters: list[FilterPredicate]) -> list[Any]:
+ rows = self._filtered(filters)
+ if sort is not None:
+ rows = sorted(
+ rows,
+ key=lambda r: (r.get(sort.field) is None, r.get(sort.field)),
+ reverse=(sort.direction == "desc"),
+ )
+ return [dict(r) for r in rows[offset : offset + limit]]
+
+ def count(self, filters: list[FilterPredicate]) -> int:
+ return len(self._filtered(filters))
diff --git a/server/python/tests/integration/test_api_contract_report.py b/server/python/tests/integration/test_api_contract_report.py
new file mode 100644
index 000000000..c7fac4fb6
--- /dev/null
+++ b/server/python/tests/integration/test_api_contract_report.py
@@ -0,0 +1,136 @@
+"""FR-044 — cross-port API contract conformance for a VIEW-BACKED ``object.report``.
+
+Drives ``fixtures/api-contract-conformance/report/`` against the GENERATED routers for the
+three served reports (the deployed artifact), with a read-only in-memory repo behind each
+generated consumer seam, seeded from ``seed.json``'s ``reports`` half.
+
+Generated lane only, by design — see the corpus README. The thing under test is whether
+Python's GENERATOR emits a keyless read-only surface for a report: row model, filter
+allowlist, router. A hand-rolled reference server would answer every scenario by
+construction and prove nothing.
+
+Run on-demand:
+
+ cd server/python
+ uv run --extra integration pytest tests/integration/test_api_contract_report.py -v
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+import yaml
+from fastapi.testclient import TestClient
+
+from . import api_contract_assertions
+from .generated_report_app import (
+ SERVED_REPORTS,
+ UNSERVED_REPORT,
+ build_generated_report_app,
+ seed_rows,
+)
+
+
+def _find_corpus_root(start: Path | None = None) -> Path:
+ cur = (start or Path.cwd()).resolve()
+ while cur != cur.parent:
+ candidate = cur / "fixtures" / "api-contract-conformance" / "report"
+ if candidate.is_dir():
+ return candidate
+ cur = cur.parent
+ raise RuntimeError(
+ "Could not find fixtures/api-contract-conformance/report from "
+ f"{Path.cwd().resolve()}"
+ )
+
+
+_CORPUS = _find_corpus_root()
+
+
+def _load_scenarios() -> list[tuple[str, dict[str, Any]]]:
+ out: list[tuple[str, dict[str, Any]]] = []
+ for path in sorted((_CORPUS / "scenarios").glob("*.yaml")):
+ raw = yaml.safe_load(path.read_text())
+ out.append((raw["name"], raw))
+ if not out:
+ raise RuntimeError(f"no scenarios found under {_CORPUS / 'scenarios'}")
+ return out
+
+
+def _load_seed_reports() -> dict[str, list[dict[str, Any]]]:
+ """The seam lane's half of the seed: what the three views return for the base rows."""
+ parsed = json.loads((_CORPUS / "seed.json").read_text())
+ reports = parsed.get("reports")
+ if not isinstance(reports, dict):
+ raise RuntimeError("report seed.json: missing 'reports' object")
+ return {name: seed_rows(reports[name]) for name in SERVED_REPORTS}
+
+
+_SEED = _load_seed_reports()
+_SCENARIOS = _load_scenarios()
+
+_APP, _REPOS, _EMITTED = build_generated_report_app(_CORPUS)
+_CLIENT = TestClient(_APP)
+
+
+def test_exactly_the_served_reports_are_generated() -> None:
+ """The sourceless report generates nothing; each served one generates its row model,
+ allowlist and router (and the names module is not a route concern, so it is not asked
+ for here: only the three generators the lane runs)."""
+ for report, stem in SERVED_REPORTS.items():
+ assert f"{report}.py" in _EMITTED
+ assert f"{stem}_filter_allowlist.py" in _EMITTED
+ assert f"{stem}_router.py" in _EMITTED
+ assert not any(UNSERVED_REPORT in f or "invoice_days" in f for f in _EMITTED), _EMITTED
+ # The base entity was not asked for, so no writable router sits beside the reports.
+ assert "invoice_router.py" not in _EMITTED
+
+
+def test_the_unserved_report_mounts_no_route() -> None:
+ assert _CLIENT.get("/api/invoice_days").status_code == 404
+
+
+@pytest.mark.parametrize(
+ "scenario_name,scenario",
+ _SCENARIOS,
+ ids=[name for name, _ in _SCENARIOS],
+)
+def test_report_scenario(scenario_name: str, scenario: dict[str, Any]) -> None:
+ """Run one report api-contract scenario against the GENERATED routers."""
+ for report, repo in _REPOS.items():
+ repo.reset()
+ if not (scenario.get("setup") or {}).get("truncate"):
+ repo.seed(_SEED[report])
+ for req in scenario.get("requests", []):
+ _run_request(scenario_name, req)
+
+
+def _run_request(scenario_name: str, req: dict[str, Any]) -> None:
+ method = str(req["method"]).upper()
+ path = str(req["path"])
+ body = req.get("body")
+ expect = req["expect"]
+
+ kwargs: dict[str, Any] = {}
+ if body is not None:
+ kwargs["json"] = body
+ response = _CLIENT.request(method, path, **kwargs)
+ api_contract_assertions.assert_response(
+ scenario_name=scenario_name,
+ request_id=str(req.get("id", "?")),
+ expect_status=int(expect["status"]),
+ expect_body=expect.get("body"),
+ status=response.status_code,
+ body=_parse_response_body(response.text),
+ )
+
+
+def _parse_response_body(text: str | None) -> Any:
+ if text is None or text == "":
+ return None
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ return text
diff --git a/server/python/tests/test_report_read_model.py b/server/python/tests/test_report_read_model.py
index 4dfcc3fda..36a8b5f24 100644
--- a/server/python/tests/test_report_read_model.py
+++ b/server/python/tests/test_report_read_model.py
@@ -18,6 +18,7 @@
FIELD_ATTR_COLUMN,
FIELD_ATTR_CURRENCY,
FIELD_ATTR_DEFAULT,
+ FIELD_ATTR_FILTERABLE,
FIELD_ATTR_MAX_LENGTH,
FIELD_ATTR_PRECISION,
FIELD_ATTR_REQUIRED,
@@ -161,4 +162,19 @@ def test_nothing_else_is_carried() -> None:
# @of field's @column is never inherited; nor is its @default.
assert ref.get_meta_attr(FIELD_ATTR_COLUMN) is None
assert ref.get_meta_attr(FIELD_ATTR_DEFAULT) is None
- assert sorted(ref.attrs()) == sorted([FIELD_ATTR_REQUIRED, FIELD_ATTR_DB_COLUMN_TYPE])
+ # @filterable is the one Plan 3 addition (Table C); nothing else rides along.
+ assert sorted(ref.attrs()) == sorted(
+ [FIELD_ATTR_REQUIRED, FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_FILTERABLE]
+ )
+
+
+def test_every_derived_field_with_a_filter_band_is_filterable() -> None:
+ # Table C: a report author has no node to put @filterable on, so the read model
+ # sets it on every derived field whose subtype has a filter band. Read RESOLVING
+ # (ADR-0039): get_meta_attr, since Python attr() is OWN-only.
+ fields = _fields()
+ assert all(f.get_meta_attr(FIELD_ATTR_FILTERABLE) is True for f in fields.values())
+ assert set(fields) == {
+ "code", "ref", "tags", "status", "bookedAtHour", "bookedAtDay",
+ "sales", "revenue", "minAmount", "totalWeight", "maxWeight",
+ }
diff --git a/server/python/tests/test_reporting_inert.py b/server/python/tests/test_reporting_inert.py
index 0def0b0f6..6caa34f22 100644
--- a/server/python/tests/test_reporting_inert.py
+++ b/server/python/tests/test_reporting_inert.py
@@ -1,20 +1,24 @@
-"""FR-044 Plan 1 — the reporting vocabulary is INERT in every Python generator.
+"""FR-044 — the reporting vocabulary is INERT in every Python generator, except for the one
+report Plan 3 serves.
Plan 1 registers ``dimension.*``, ``measure.*``, ``segment.*`` and ``object.report`` and
-validates them at load, but gives none of them output: a report's lowering lands in Plan
-2/3. Until then a model that USES the vocabulary must generate exactly what the same model
-without it generates, byte for byte, through every registered generator.
+validates them at load. A model that USES the vocabulary must generate exactly what the
+same model without it generates, byte for byte, through every registered generator, with
+ONE exception: a report that declares a read-only ``source.rdb @kind: view`` is SERVED
+(Plan 3, Table A) and gains exactly the files below, all for ``StoreTotals``. The other two
+reports (``DailyRevenue``, ``ProgramEngagement``) declare no view and generate nothing.
The model pair is ``fixtures/codegen-noop/reporting/{with,without}``, shared with the other
four ports' copies of this test. ``with/`` carries a report that declares a read-only
``source.rdb @kind: view`` (R5 allows one) — the shape that leaked in C#. Here the
``entity`` generator used to write an empty ``BaseModel`` module per report.
-Runs through ``run_gen`` — the path ``metaobjects gen`` takes — because the skip lives at
+Runs through ``run_gen`` — the path ``metaobjects gen`` takes — because the choice lives at
its entity-set choke point.
"""
from __future__ import annotations
+import re
from pathlib import Path
import pytest
@@ -37,6 +41,15 @@
MODELS = Path(__file__).parents[3] / "fixtures" / "codegen-noop" / "reporting"
THREW = ""
+#: What a served report adds, by generator (Table E: row model, allowlist, names, router).
+#: Every other generator emits the same files with and without the vocabulary.
+SERVED_REPORT_FILES: dict[str, list[str]] = {
+ "entity": ["StoreTotals.py"],
+ "filter-allowlist": ["store_totals_filter_allowlist.py"],
+ "names": ["store_totals_names.py"],
+ "routes": ["store_totals_router.py"],
+}
+
def _load(variant: str):
result = load_uris([(MODELS / variant / "meta.shop.json").as_uri()])
@@ -92,8 +105,11 @@ def test_generator_emits_the_same_files_with_and_without_reporting_nodes(
) -> None:
expected = _emit("without", [entry], tmp_path / "a")
actual = _emit("with", [entry], tmp_path / "b")
- assert list(actual) == list(expected)
- assert actual == expected
+ added = SERVED_REPORT_FILES.get(entry.name, [])
+ # Nothing that exists without the vocabulary changes by a byte; the served report
+ # adds exactly its own files, and only for the generators Table E names.
+ assert sorted(actual) == sorted([*expected, *added])
+ assert {k: v for k, v in actual.items() if k not in added} == expected
def test_every_runnable_generator_in_one_run_emits_the_same_files(tmp_path: Path) -> None:
@@ -105,14 +121,16 @@ def test_every_runnable_generator_in_one_run_emits_the_same_files(tmp_path: Path
actual = _emit("with", runnable, tmp_path / "b")
assert THREW not in expected, expected.get(THREW)
assert len(expected) > 10, f"only {len(expected)} files — the suite barely ran"
- assert list(actual) == list(expected)
- assert actual == expected
+ added = sorted(f for files in SERVED_REPORT_FILES.values() for f in files)
+ assert len(added) == 4
+ assert sorted(set(actual) - set(expected)) == added
+ assert set(expected) <= set(actual)
+ assert {k: v for k, v in actual.items() if k in expected} == expected
def _api_docs(variant: str) -> dict[str, str]:
"""The api docs surface (``metaobjects docs``): every unit page, the index and the
- agent page. A report has no generated API to document, and its derived fields do not
- exist until its lowering lands."""
+ agent page. A served report is documented; every other report has no generated API."""
model = PythonApiModelBuilder().build(_load(variant), "shop")
pages = {
doc_page_output_path(Layout.PACKAGE, unit.package, unit.node): render_unit_page(unit, None)
@@ -123,12 +141,31 @@ def _api_docs(variant: str) -> dict[str, str]:
return dict(sorted(pages.items()))
-def test_api_docs_are_the_same_with_and_without_reporting_nodes() -> None:
+_STORE_TOTALS_MARKERS = ("StoreTotals", "STORETOTALS", "store_totals")
+
+
+def _without_store_totals(page: str) -> str:
+ """A page with every line that names the served report removed, blank runs collapsed."""
+ kept = [ln for ln in page.splitlines() if not any(m in ln for m in _STORE_TOTALS_MARKERS)]
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).rstrip()
+
+
+def test_api_docs_gain_only_the_served_report() -> None:
expected = _api_docs("without")
assert len(expected) > 3, f"only {len(expected)} pages — the docs barely ran"
actual = _api_docs("with")
- assert list(actual) == list(expected)
- assert actual == expected
+ # One new unit page, for the served report; the two sourceless reports get none.
+ assert sorted(set(actual) - set(expected)) == ["acme/shop/StoreTotals.md"]
+ assert set(expected) <= set(actual)
+ page = actual["acme/shop/StoreTotals.md"]
+ assert "GET /api/store_totals" in page
+ # A report has no item route and no write verb, so none is documented.
+ assert "POST" not in page and "{" not in page.split("GET /api/store_totals")[1].split("\n")[0]
+ for name, text in expected.items():
+ assert _without_store_totals(actual[name]) == _without_store_totals(text), name
+ for name in expected:
+ if name not in ("README.md", "AGENT-API.md"):
+ assert actual[name] == expected[name], name
def test_exactly_these_generators_cannot_run_from_a_bare_model(tmp_path: Path) -> None:
@@ -142,16 +179,29 @@ def test_exactly_these_generators_cannot_run_from_a_bare_model(tmp_path: Path) -
assert threw == []
-def test_a_selection_of_only_reports_warns_that_there_is_nothing_to_generate(
+def test_a_selection_of_only_unserved_reports_warns_that_there_is_nothing_to_generate(
tmp_path: Path,
) -> None:
result = run_gen(
GenConfig(out_dir=str(tmp_path / "out")),
_load("with"),
generators=[_build(e, tmp_path) for e in list_generators()],
- entity_filter=["DailyRevenue", "ProgramEngagement", "StoreTotals"],
+ entity_filter=["DailyRevenue", "ProgramEngagement"],
)
assert result.files == []
assert any(
w.startswith("No entities to generate") and "object.report" in w for w in result.warnings
), result.warnings
+
+
+def test_a_selection_of_only_reports_generates_only_the_served_one(tmp_path: Path) -> None:
+ result = run_gen(
+ GenConfig(out_dir=str(tmp_path / "out")),
+ _load("with"),
+ generators=[_build(e, tmp_path) for e in list_generators()],
+ entity_filter=["DailyRevenue", "ProgramEngagement", "StoreTotals"],
+ )
+ names = sorted(Path(path).name for path, _ in result.files)
+ assert names == sorted(
+ ["__init__.py", *(f for files in SERVED_REPORT_FILES.values() for f in files)]
+ )
From 45f91249b6bffbbff20651e4b0b5a97bb8211fee Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:21:50 -0400
Subject: [PATCH 08/21] test(integration): report api-contract lane, TypeScript
generated routes (FR-044)
---
.../api-contract-conformance/report/README.md | 2 +-
.../api-contract-report-generated-server.ts | 159 ++++++++++++++++++
.../test/api-contract-report.test.ts | 107 ++++++++++++
3 files changed, 267 insertions(+), 1 deletion(-)
create mode 100644 server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts
create mode 100644 server/typescript/packages/integration-tests/test/api-contract-report.test.ts
diff --git a/fixtures/api-contract-conformance/report/README.md b/fixtures/api-contract-conformance/report/README.md
index c4ec77fc5..206dd5c5c 100644
--- a/fixtures/api-contract-conformance/report/README.md
+++ b/fixtures/api-contract-conformance/report/README.md
@@ -110,7 +110,7 @@ hand-rolled reference server would answer every scenario by construction.
| Port | Generated lane | Note |
|---|---|---|
-| TypeScript | not yet wired | `test/api-contract-report.test.ts` |
+| TypeScript | wired | `test/api-contract-report.test.ts` (12 scenarios + a seed-vs-view check) |
| C# | not yet wired | `Api/ApiContractReportConformanceTest.cs` |
| Java | not yet wired | `api/ReportGeneratedApiContractConformanceTest.java` |
| Kotlin | not yet wired | `api/report/ReportGeneratedApiContractConformanceTest.kt` |
diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts
new file mode 100644
index 000000000..a5b5fd307
--- /dev/null
+++ b/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts
@@ -0,0 +1,159 @@
+// api-contract-report-generated-server.ts — boots the GENERATED report routes (the
+// deployed artifact) over HTTP and drives them against the FR-044 report corpus.
+//
+// The model is one writable `Invoice` entity and four `object.report` nodes; three
+// declare `source.rdb @kind:view` and are served, `InvoiceDays` declares no source and
+// must emit nothing. It:
+// 1. runs the real codegen (runGen) over report/meta.json into a temp dir and fails
+// if a routes file was emitted for the sourceless report;
+// 2. provisions `invoices` by hand in the EMITTED snake_case spelling, then creates
+// each view from buildReportViews (the real lowering, the real view SQL) under
+// the emitted names, so the route reads through the real view;
+// 3. imports the three EMITTED .routes.ts files unmodified and mounts them.
+//
+// Generated lane only, and on every port — see the corpus README.
+
+import Fastify, { type FastifyInstance } from "fastify";
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { runGen, defineConfig, buildReportViews } from "@metaobjectsdev/codegen-ts";
+import { DEFAULT_COLUMN_NAMING_STRATEGY } from "@metaobjectsdev/metadata";
+import { entityFile, routesFile } from "@metaobjectsdev/test-generators";
+import pg from "pg";
+import { executeSql } from "./postgres-sql.ts";
+import { loadMetadataFile } from "./load-metadata.ts";
+
+export interface ReportSeed {
+ invoices: Array<{
+ id: number;
+ reference: string;
+ status: string;
+ amountCents: number;
+ issuedOn: string;
+ }>;
+ reports?: Record>>;
+}
+
+export interface GeneratedReportServerHandle {
+ baseUrl: string;
+ applySeed(seed: ReportSeed): Promise;
+ close(): Promise;
+}
+
+/** The served reports: emitted registrar, and the route the corpus calls. */
+const SERVED_REPORTS = [
+ { name: "InvoiceStatusTotals", registrar: "invoiceStatusTotalsRoutes" },
+ { name: "InvoicesByMonth", registrar: "invoicesByMonthRoutes" },
+ { name: "InvoiceTotals", registrar: "invoiceTotalsRoutes" },
+] as const;
+
+export async function startGeneratedReportServer(
+ connectionUri: string,
+ metaPath: string,
+): Promise {
+ const here = dirname(fileURLToPath(import.meta.url));
+ const genTmpRoot = join(here, "..", ".gen-tmp");
+ mkdirSync(genTmpRoot, { recursive: true });
+ const tmp = mkdtempSync(join(genTmpRoot, "api-contract-report-"));
+
+ // 1. Emit the real artifacts for the report model.
+ const root = await loadMetadataFile(metaPath);
+ const lr = await runGen({
+ config: defineConfig({
+ outDir: tmp,
+ extStyle: "none",
+ dbImport: "./db",
+ dialect: "postgres",
+ apiPrefix: "/api",
+ generators: [entityFile(), routesFile()],
+ }),
+ metadata: root,
+ });
+ if (existsSync(join(tmp, "InvoiceDays.routes.ts"))) {
+ rmSync(tmp, { recursive: true, force: true });
+ throw new Error("codegen emitted a routes file for the sourceless report InvoiceDays");
+ }
+ if (lr.warnings.length > 0) {
+ throw new Error(`codegen produced warnings: ${lr.warnings.join("; ")}`);
+ }
+
+ // 2. db module the emitted routes import (`import { db } from "./db"`).
+ const bigintTypesImport = pathToFileURL(join(here, "pg-bigint-number-types.ts")).href;
+ const dbModule = `
+import { drizzle } from "drizzle-orm/node-postgres";
+import pg from "pg";
+import { bigintAsNumberTypes } from ${JSON.stringify(bigintTypesImport)};
+export const pool = new pg.Pool({ connectionString: ${JSON.stringify(connectionUri)}, types: bigintAsNumberTypes });
+export const db = drizzle(pool);
+`;
+ writeFileSync(join(tmp, "db.ts"), dbModule, "utf8");
+
+ // 3. Provision the base table (snake_case, the EMITTED Drizzle table's spelling) and
+ // each view from the real lowering under the same naming strategy.
+ const views = buildReportViews(root, {
+ dialect: "postgres",
+ columnNamingStrategy: DEFAULT_COLUMN_NAMING_STRATEGY,
+ });
+ const createViews = views
+ .map((v) => {
+ if (v.sql === undefined) throw new Error(`view ${v.name} has no SQL`);
+ return `CREATE VIEW "${v.name}" AS ${v.sql};`;
+ })
+ .join("\n");
+ await executeSql(connectionUri, `
+ CREATE TABLE IF NOT EXISTS "invoices" (
+ "id" bigserial PRIMARY KEY,
+ "reference" varchar(40) NOT NULL,
+ "status" varchar(20) NOT NULL,
+ "amount_cents" bigint NOT NULL,
+ "issued_on" date NOT NULL
+ );
+ ${createViews}
+ `);
+
+ // 4. Import the EMITTED route files unmodified and mount all three.
+ const fastify = Fastify();
+ for (const r of SERVED_REPORTS) {
+ const mod = (await import(pathToFileURL(join(tmp, `${r.name}.routes.ts`)).href)) as Record<
+ string,
+ (f: FastifyInstance) => Promise
+ >;
+ const registrar = mod[r.registrar];
+ if (registrar === undefined) {
+ throw new Error(`${r.name}.routes.ts does not export ${r.registrar}`);
+ }
+ await fastify.register(registrar);
+ }
+ const dbMod = (await import(pathToFileURL(join(tmp, "db.ts")).href)) as { pool: pg.Pool };
+ await fastify.ready();
+ const baseUrl = await fastify.listen({ host: "127.0.0.1", port: 0 });
+
+ return {
+ baseUrl,
+ applySeed: async (seed: ReportSeed) => {
+ await seedReport(connectionUri, seed);
+ },
+ close: async () => {
+ await fastify.close();
+ await dbMod.pool.end();
+ rmSync(tmp, { recursive: true, force: true });
+ },
+ };
+}
+
+/** Truncate + insert the base table. Only `invoices` is seeded; the views derive on read. */
+export async function seedReport(connectionUri: string, seed: ReportSeed): Promise {
+ await executeSql(connectionUri, `TRUNCATE TABLE "invoices" RESTART IDENTITY CASCADE;`);
+ for (const i of seed.invoices) {
+ await executeSql(
+ connectionUri,
+ `INSERT INTO "invoices" ("id","reference","status","amount_cents","issued_on")
+ VALUES (${i.id}, ${str(i.reference)}, ${str(i.status)}, ${i.amountCents}, ${str(i.issuedOn)})`,
+ );
+ }
+}
+
+function str(v: string): string {
+ return `'${v.replace(/'/g, "''")}'`;
+}
diff --git a/server/typescript/packages/integration-tests/test/api-contract-report.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report.test.ts
new file mode 100644
index 000000000..05aa8d903
--- /dev/null
+++ b/server/typescript/packages/integration-tests/test/api-contract-report.test.ts
@@ -0,0 +1,107 @@
+// FR-044 view-backed-report api-contract conformance (GENERATED lane).
+//
+// Drives fixtures/api-contract-conformance/report/ over HTTP against the GENERATED
+// report routes — the emitted .routes.ts files booted unmodified against a
+// real Postgres testcontainer, with the views the real lowering produces. Generated
+// lane only — see the corpus README. One Postgres testcontainer per scenario.
+
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { API_CONTRACT_REPORT_DIR, API_CONTRACT_REPORT_SCENARIOS_DIR } from "../src/paths.ts";
+import { loadScenarios, assertResponse, type ApiScenario } from "../src/api-contract-scenario.ts";
+import { startPostgres } from "../src/postgres-container.ts";
+import {
+ startGeneratedReportServer,
+ type GeneratedReportServerHandle,
+ type ReportSeed,
+} from "../src/api-contract-report-generated-server.ts";
+
+const SEED = JSON.parse(
+ readFileSync(join(API_CONTRACT_REPORT_DIR, "seed.json"), "utf8"),
+) as ReportSeed;
+const META_PATH = join(API_CONTRACT_REPORT_DIR, "meta.json");
+
+const SERVED_PATHS: Array<[string, string]> = [
+ ["InvoiceStatusTotals", "/api/invoice_status_totals"],
+ ["InvoicesByMonth", "/api/invoices_by_months"],
+ ["InvoiceTotals", "/api/invoice_totals"],
+];
+
+describe("api contract report (FR-044) — GENERATED routes lane", () => {
+ for (const scenario of loadScenarios(API_CONTRACT_REPORT_SCENARIOS_DIR)) {
+ test(scenario.name, async () => {
+ const pg = await startPostgres();
+ let server: GeneratedReportServerHandle | null = null;
+ try {
+ server = await startGeneratedReportServer(pg.connectionUri, META_PATH);
+ await server.applySeed(SEED);
+ await runScenario(scenario, server);
+ } finally {
+ if (server) await server.close();
+ await pg.stop();
+ }
+ }, { timeout: 60_000 });
+ }
+
+ test("the seeded report rows are what the views return", async () => {
+ const pg = await startPostgres();
+ let server: GeneratedReportServerHandle | null = null;
+ try {
+ server = await startGeneratedReportServer(pg.connectionUri, META_PATH);
+ await server.applySeed(SEED);
+ for (const [name, path] of SERVED_PATHS) {
+ const res = await fetch(server.baseUrl + path);
+ expect(res.status).toBe(200);
+ const actual = (await res.json()) as Array>;
+ const expected = SEED.reports?.[name];
+ if (expected === undefined) throw new Error(`seed.json has no reports.${name}`);
+ expect(actual.length).toBe(expected.length);
+ const unmatched = [...actual];
+ for (const want of expected) {
+ const idx = unmatched.findIndex((got) => rowsEqual(got, want));
+ if (idx < 0) {
+ throw new Error(
+ `${name}: no returned row equals ${JSON.stringify(want)}; got ${JSON.stringify(actual)}`,
+ );
+ }
+ unmatched.splice(idx, 1);
+ }
+ }
+ } finally {
+ if (server) await server.close();
+ await pg.stop();
+ }
+ }, { timeout: 60_000 });
+});
+
+/** paidShare compares numerically (a decimal's spelling is the port's own); every other
+ * key by strict equality. */
+function rowsEqual(a: Record, b: Record): boolean {
+ const keysA = Object.keys(a).sort();
+ const keysB = Object.keys(b).sort();
+ if (keysA.join(",") !== keysB.join(",")) return false;
+ return keysA.every((k) =>
+ k === "paidShare" ? Number(a[k]) === Number(b[k]) : a[k] === b[k],
+ );
+}
+
+async function runScenario(
+ scenario: ApiScenario,
+ server: GeneratedReportServerHandle,
+): Promise {
+ for (const req of scenario.requests) {
+ const init: RequestInit = { method: req.method };
+ if (req.body !== undefined) {
+ init.body = JSON.stringify(req.body);
+ init.headers = { "content-type": "application/json" };
+ }
+ const res = await fetch(server.baseUrl + req.path, init);
+ const bodyText = await res.text();
+ let body: unknown = null;
+ if (bodyText.length > 0) {
+ try { body = JSON.parse(bodyText); } catch { body = bodyText; }
+ }
+ assertResponse(scenario.name, req, res.status, body);
+ }
+}
From 09da5bcf9964651df548f6bf5ac03e9f2513d91a Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:24:09 -0400
Subject: [PATCH 09/21] fix(codegen-ts): keep item routes for a projection
whose id column exists (FR-044)
---
.../codegen/generators/routes.ts | 8 +-
.../showcase/codegen/generators/routes.ts | 8 +-
.../test/sourceless-objects.test.ts | 28 ++++-
.../src/templates/hooks-file.ts | 6 +-
.../test/report-no-ui-tier.test.ts | 101 ++++++++++++++--
.../packages/codegen-ts/src/api-surface.ts | 35 ++++--
.../codegen-ts/src/reference/routes-hono.ts | 6 +-
.../codegen-ts/src/reference/routes.ts | 8 +-
.../packages/codegen-ts/src/runner.ts | 5 +-
.../codegen-ts/src/templates/queries-file.ts | 4 +-
.../codegen-ts/src/templates/queries.ts | 7 +-
.../src/templates/routes-file-hono.ts | 4 +-
.../codegen-ts/src/templates/routes-file.ts | 4 +-
.../test/projection/queries-file.test.ts | 52 +++++++-
.../test/projection/routes-file.test.ts | 114 +++++++++++++++++-
.../packages/test-generators/src/routes.ts | 8 +-
16 files changed, 337 insertions(+), 61 deletions(-)
diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts
index 342d5cba9..4cfaeedce 100644
--- a/examples/advanced-modeling/codegen/generators/routes.ts
+++ b/examples/advanced-modeling/codegen/generators/routes.ts
@@ -94,8 +94,8 @@ import {
// --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
-// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has a
-// single-column identity); every other writable entity →
+// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has
+// an id column to address a row by); every other writable entity →
// mountCrudRoutes (+ one mountM2mRoute per M:N navigation). Under an `apiPrefix` the mounts
// are wrapped in `fastify.register(..., { prefix })`.
@@ -139,8 +139,8 @@ function renderRoutes(
// --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
- // A keyless read-only object (a projection with no single-column identity, and every
- // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // A keyless read-only object (a projection with no identity and no `id` column, and
+ // every report: FR-044) has no row to address, so it mounts GET list and the collection 405
// and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
// keeps its output byte-identical.
const keyless = !hasItemRoute(entity);
diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts
index 342d5cba9..4cfaeedce 100644
--- a/examples/showcase/codegen/generators/routes.ts
+++ b/examples/showcase/codegen/generators/routes.ts
@@ -94,8 +94,8 @@ import {
// --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
-// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has a
-// single-column identity); every other writable entity →
+// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has
+// an id column to address a row by); every other writable entity →
// mountCrudRoutes (+ one mountM2mRoute per M:N navigation). Under an `apiPrefix` the mounts
// are wrapped in `fastify.register(..., { prefix })`.
@@ -139,8 +139,8 @@ function renderRoutes(
// --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
- // A keyless read-only object (a projection with no single-column identity, and every
- // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // A keyless read-only object (a projection with no identity and no `id` column, and
+ // every report: FR-044) has no row to address, so it mounts GET list and the collection 405
// and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
// keeps its output byte-identical.
const keyless = !hasItemRoute(entity);
diff --git a/server/typescript/packages/codegen-ts-angular/test/sourceless-objects.test.ts b/server/typescript/packages/codegen-ts-angular/test/sourceless-objects.test.ts
index 06b20dc81..bce827fec 100644
--- a/server/typescript/packages/codegen-ts-angular/test/sourceless-objects.test.ts
+++ b/server/typescript/packages/codegen-ts-angular/test/sourceless-objects.test.ts
@@ -16,7 +16,9 @@ import {
angularGridFile,
barrel,
} from "../src/index.js";
-import { makeRenderContext, buildPkMap, buildRelationMap } from "@metaobjectsdev/codegen-ts";
+import {
+ makeRenderContext, buildPkMap, buildRelationMap, servesClientTier, servesReadApi,
+} from "@metaobjectsdev/codegen-ts";
import type { GenContext, Generator } from "@metaobjectsdev/codegen-ts";
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
@@ -31,6 +33,17 @@ const META = JSON.stringify({
{ "field.string": { name: "name", "@filterable": true, children: [{ "view.text": {} }] } },
{ "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } },
{ "layout.dataGrid": { name: "default", "@columns": ["id", "name"] } },
+ { "dimension.attribute": { name: "name", "@of": "Author.name" } },
+ { "measure.aggregate": { name: "authors", "@agg": "count", "@of": "Author.id" } },
+ ] } },
+ // Served report (FR-044 Plan 3) — it HAS a read endpoint (`servesReadApi` is true),
+ // and the dataGrid layout is deliberate bait, so it passes every gate but the
+ // client-tier one. The UI tier is off for reports until Plan 5: no service, no grid,
+ // no barrel line. Reverting any gate here to `servesReadApi` emits for it.
+ { "object.report": { name: "AuthorTotals", "@from": "Author",
+ "@dimensions": ["name"], "@measures": ["authors"], children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_author_totals" } },
+ { "layout.dataGrid": { name: "default", "@columns": ["name"] } },
] } },
// View-backed projection — read endpoint exists, so the service stays; a form
// (nothing to submit) and no write surface must NOT be emitted for it.
@@ -118,5 +131,18 @@ describe("endpoint guards — no artifact without an endpoint", () => {
expect(content).not.toContain("NotePayload");
expect(content).not.toContain("Sourceless");
expect(content).not.toContain("AuthorCard");
+ expect(content).not.toContain("AuthorTotals");
+ });
+
+ test("a served report is the bait it claims to be: an endpoint, and no client tier", async () => {
+ const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(META)]);
+ expect(errors).toEqual([]);
+ const report = root.objects().find((o) => o.name === "AuthorTotals");
+ if (!report) throw new Error("AuthorTotals not found");
+ // Without this the "no AuthorTotals" assertions above could pass for the wrong reason.
+ expect(servesReadApi(report)).toBe(true);
+ expect(servesClientTier(report)).toBe(false);
+ expect(angularServiceFile().filter?.(report)).toBe(false);
+ expect(angularGridFile().filter?.(report)).toBe(false);
});
});
diff --git a/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts b/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts
index 4458c1dbe..e34b138a5 100644
--- a/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts
@@ -30,8 +30,8 @@ import {
*
* Projections (view-backed, read-only) emit only:
* - Keys query-key factory
- * - use(id) — useQuery on GET :id (only with a single-column identity;
- * a keyless projection has no item route to fetch)
+ * - use(id) — useQuery on GET :id (only when the projection has an id
+ * column; a keyless one has no item route to fetch)
* - use(filter) — useQuery on list
*
* Full (writable) entities additionally emit:
@@ -192,7 +192,7 @@ import {
} from ${JSON.stringify(entityModule)};
`;
- // A keyless projection (no single-column identity) is served GET list only: the route
+ // A keyless projection (no identity and no `id` column) is served GET list only: the route
// generator mounts no `/:id`, so a detail hook would fetch an address nothing answers.
// It gets the list hook and no `details`/`detail` keys. A keyed projection is unchanged.
const keyed = hasItemRoute(entity);
diff --git a/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts b/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts
index 90d1185dd..628c007d9 100644
--- a/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts
+++ b/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts
@@ -22,6 +22,7 @@ import { tanstackQuery as refHooks } from "../src/reference/hooks.js";
import { tanstackGrid as refGrid } from "../src/reference/grid.js";
import { tanstackGridHook as refGridHook } from "../src/reference/grid-hook.js";
import { renderHooksFile } from "../src/templates/hooks-file.js";
+import { hasDataGridLayout } from "../src/data-grid-gate.js";
// test → codegen-ts-tanstack → packages → typescript → server → repo root
const REPO_FIXTURES = resolve(import.meta.dir, "..", "..", "..", "..", "..", "fixtures");
@@ -75,7 +76,8 @@ describe("no UI-tier generator emits for a served report", () => {
});
describe("a keyless projection gets a list hook and no detail hook", () => {
- async function projection(keyed: boolean) {
+ type Shape = "keyless" | "identity" | "id-by-convention";
+ async function projection(shape: Shape) {
const json = JSON.stringify({ "metadata.root": { package: "test", children: [
{
"object.entity": {
@@ -93,12 +95,14 @@ describe("a keyless projection gets a list hook and no detail hook", () => {
name: "TagLabel",
children: [
{ "source.rdb": { "@kind": "view", "@table": "v_tag_label" } },
- ...(keyed
+ ...(shape === "identity"
? [
{ "field.long": { name: "id", extends: "Tag.id" } },
{ "identity.primary": { name: "id", extends: "Tag.id" } },
]
: []),
+ // An `id` column and no declared identity: addressed by convention.
+ ...(shape === "id-by-convention" ? [{ "field.long": { name: "id" } }] : []),
{ "field.string": { name: "label", extends: "Tag.label" } },
],
},
@@ -116,8 +120,10 @@ describe("a keyless projection gets a list hook and no detail hook", () => {
return { obj, out: renderHooksFile(obj, ctx) };
}
- test("keyless: list hook and list keys only", async () => {
- const { obj, out } = await projection(false);
+ test("keyless (no identity, no `id` column): list hook and list keys only", async () => {
+ const { obj, out } = await projection("keyless");
+ expect(obj.primaryIdentity()).toBeUndefined();
+ expect(obj.findField("id")).toBeUndefined();
expect(hasItemRoute(obj)).toBe(false);
expect(out).toContain("export function useTagLabels(");
expect(out).not.toContain("export function useTagLabel(");
@@ -128,12 +134,83 @@ describe("a keyless projection gets a list hook and no detail hook", () => {
expect(out).not.toContain("/${id}");
});
- test("keyed: the detail hook and its keys are still there", async () => {
- const { obj, out } = await projection(true);
- expect(hasItemRoute(obj)).toBe(true);
- expect(out).toContain("export function useTagLabel(");
- expect(out).toContain("export function useTagLabels(");
- expect(out).toContain("details:");
- expect(out).toContain("detail:");
- });
+ for (const shape of ["identity", "id-by-convention"] as const) {
+ test(`${shape}: the detail hook and its keys are still there`, async () => {
+ const { obj, out } = await projection(shape);
+ expect(obj.primaryIdentity() === undefined).toBe(shape === "id-by-convention");
+ expect(hasItemRoute(obj)).toBe(true);
+ expect(out).toContain("export function useTagLabel(");
+ expect(out).toContain("export function useTagLabels(");
+ expect(out).toContain("details:");
+ expect(out).toContain("detail:");
+ });
+ }
+});
+
+// The grid generators gate on `servesClientTier` AND on a `layout.dataGrid`. Through
+// `runGen` a report can never reach them with a layout: it is generated from its read
+// model, which carries fields and a source and nothing else. So the gate is proven where
+// it is reachable, on the generator's own `filter`, with the DECLARED report node, which
+// the loader lets carry a `layout.dataGrid`. That node is served (`servesReadApi` is true)
+// and has the layout, so it passes every other gate: reverting any of these generators to
+// `servesReadApi` turns its row red. This is also the door an adopter driving a generator
+// outside `runGen` comes through.
+describe("each UI-tier gate refuses a served report that passes its other gates", () => {
+ async function reportWithGrid() {
+ const json = JSON.stringify({ "metadata.root": { package: "test", children: [
+ {
+ "object.entity": {
+ name: "Invoice",
+ children: [
+ { "source.rdb": { "@table": "invoices" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "status" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ { "layout.dataGrid": { name: "default", "@columns": ["status"] } },
+ { "dimension.attribute": { name: "status", "@of": "Invoice.status" } },
+ { "measure.aggregate": { name: "invoices", "@agg": "count", "@of": "Invoice.id" } },
+ ],
+ },
+ },
+ {
+ "object.report": {
+ name: "InvoiceTotals",
+ "@from": "Invoice",
+ "@dimensions": ["status"],
+ "@measures": ["invoices"],
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_invoice_totals" } },
+ { "layout.dataGrid": { name: "default", "@columns": ["status"] } },
+ ],
+ },
+ },
+ ] } });
+ const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]);
+ expect(result.errors).toEqual([]);
+ const report = result.root.findObject("InvoiceTotals");
+ const entity = result.root.findObject("Invoice");
+ if (!report || !entity) throw new Error("fixture objects not found");
+ return { report, entity };
+ }
+
+ for (const [name, make] of [
+ ["tanstackQuery", tanstackQuery],
+ ["tanstackGrid", tanstackGrid],
+ ["tanstackGridHook", tanstackGridHook],
+ ["reference hooks", refHooks],
+ ["reference grid", refGrid],
+ ["reference grid-hook", refGridHook],
+ ] as const) {
+ test(name, async () => {
+ const { report, entity } = await reportWithGrid();
+ // The report passes everything but the client-tier gate.
+ expect(servesReadApi(report)).toBe(true);
+ expect(hasDataGridLayout(report)).toBe(true);
+ const filter = make().filter;
+ if (!filter) throw new Error(`${name} has no filter`);
+ // Not vacuous: the same filter admits the entity beside it.
+ expect(filter(entity)).toBe(true);
+ expect(filter(report)).toBe(false);
+ });
+ }
});
diff --git a/server/typescript/packages/codegen-ts/src/api-surface.ts b/server/typescript/packages/codegen-ts/src/api-surface.ts
index f72c4f5f2..669ca8fa9 100644
--- a/server/typescript/packages/codegen-ts/src/api-surface.ts
+++ b/server/typescript/packages/codegen-ts/src/api-surface.ts
@@ -27,7 +27,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
import { isAbstract } from "./instance-artifacts.js";
import { isProjection } from "./projection/projection-detector.js";
import { hasAnyRdbSource, hasWritableRdbSource, isReport, servedReport } from "./source-detect.js";
-import { getPkFields } from "./templates/queries.js";
+import { DEFAULT_ID_FIELD, getPkFields } from "./templates/queries.js";
import { resourcePath, restPath } from "./templates/entity-ui-descriptor.js";
import {
declaresTphDiscriminator,
@@ -57,17 +57,34 @@ export function servesReadApi(entity: MetaObject): boolean {
}
/**
- * True iff the object has a single-column primary identity, so its REST surface has
- * `/:id` routes. Mirrors the JVM `RestSurfaceGate.hasItemRoute`.
+ * True when the read-only surface of the object has `/:id` routes, a by-id query and a
+ * detail hook: the column a row would be addressed by actually exists on it.
*
- * Only the read-only surface asks. A projection's identity is optional (ADR-0028) and a
- * report has none, and a keyless one mounts no item GET, so it gets no by-id query and no
- * detail hook either: there is no column to address a row by.
+ * - A report (the declared node or its read model) never has one, even when a derived
+ * field happens to be named `id`: a report has no identity, and its rows are groups.
+ * - Otherwise the answer is whether the by-id column resolves to a field of the object.
+ * That column is `getPkInfo`'s: the first field of the primary identity, or `id` by
+ * convention when the object declares no primary identity. So a projection with an
+ * identity has item routes, a projection with no identity and a field named `id` has
+ * them too (it always did, and they work), and a projection with neither has none.
+ *
+ * A composite identity answers true, as before this predicate existed: its by-id query
+ * reads the first component. Nothing here changes what that shape generates.
+ *
+ * This is NOT the JVM's `RestSurfaceGate.hasItemRoute`, which requires a DECLARED
+ * single-column primary identity. TypeScript has always also served the `id`-by-convention
+ * projection, and this keeps doing so. The one thing removed is a surface that could never
+ * serve a row: no identity and no `id` column.
+ *
+ * Only the read-only templates ask. The writable surface emits item routes unconditionally.
*/
export function hasItemRoute(entity: MetaObject): boolean {
- // ADR-0039: resolving. `getPkFields` reads `primaryIdentity()`, which walks the super
- // chain; a projection's identity is typically inherited from its base entity.
- return getPkFields(entity).length === 1;
+ if (isReport(entity)) return false;
+ // ADR-0039: resolving. `getPkFields` reads `primaryIdentity()` and `findField` reads
+ // `fields()`, both of which walk the super chain; a projection's identity and its `id`
+ // field are typically inherited from its base entity.
+ const idField = getPkFields(entity)[0] ?? DEFAULT_ID_FIELD;
+ return entity.findField(idField) !== undefined;
}
/**
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 542227f6d..30af9735b 100644
--- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts
+++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts
@@ -74,7 +74,7 @@ import {
// --- composition (OWNED) — assembles one .routes.hono.ts. Change this to change the output. ---
// Dispatch: a projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id
-// when it has a single-column identity); every other
+// when it has an id column to address a row by); every other
// writable entity → mountCrudRoutes. `apiPrefix` is composed into the path string (Hono has
// no register-with-prefix primitive). TPH subtypes never reach here — see the filter below.
@@ -120,8 +120,8 @@ function renderRoutesHono(
// --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
- // A keyless read-only object (a projection with no single-column identity, and every
- // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // A keyless read-only object (a projection with no identity and no `id` column, and
+ // every report: FR-044) has no row to address, so it mounts GET list and the collection 405
// and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
// keeps its output byte-identical.
const keyless = !hasItemRoute(entity);
diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts
index 342d5cba9..4cfaeedce 100644
--- a/server/typescript/packages/codegen-ts/src/reference/routes.ts
+++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts
@@ -94,8 +94,8 @@ import {
// --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
-// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has a
-// single-column identity); every other writable entity →
+// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has
+// an id column to address a row by); every other writable entity →
// mountCrudRoutes (+ one mountM2mRoute per M:N navigation). Under an `apiPrefix` the mounts
// are wrapped in `fastify.register(..., { prefix })`.
@@ -139,8 +139,8 @@ function renderRoutes(
// --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
- // A keyless read-only object (a projection with no single-column identity, and every
- // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // A keyless read-only object (a projection with no identity and no `id` column, and
+ // every report: FR-044) has no row to address, so it mounts GET list and the collection 405
// and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
// keeps its output byte-identical.
const keyless = !hasItemRoute(entity);
diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts
index 0d549e435..93856bb9a 100644
--- a/server/typescript/packages/codegen-ts/src/runner.ts
+++ b/server/typescript/packages/codegen-ts/src/runner.ts
@@ -414,8 +414,9 @@ export async function runGen(opts: RunGenOpts): Promise {
const generatable = generatableObjects(filtered, root);
if (generatable.length === 0) {
warnings.push(
- "No entities to generate — every selected object is an object.report with no " +
- "`source.rdb @kind: view`, which generates nothing.",
+ "No entities to generate — every selected object is an object.report that is not " +
+ "served (it is abstract, or its read source is not a `source.rdb @kind: view`), " +
+ "which generates nothing.",
);
return { files: [], warnings, conflicts: [] };
}
diff --git a/server/typescript/packages/codegen-ts/src/templates/queries-file.ts b/server/typescript/packages/codegen-ts/src/templates/queries-file.ts
index 5e5387e44..c790b7870 100644
--- a/server/typescript/packages/codegen-ts/src/templates/queries-file.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/queries-file.ts
@@ -149,8 +149,8 @@ import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}I
* Read-only queries file for a projection (view-backed, ADR Project E) or a served
* report's read model (FR-044).
*
- * Emits `list`, plus `findById` when the object has a single-column
- * identity, selecting from the object's `View` Drizzle view and returning the
+ * Emits `list`, plus `findById` when the object has an id column
+ * (`hasItemRoute`), selecting from the object's `View` Drizzle view and returning the
* inferred read type. A keyless projection and every report get the list alone: there is
* no column to look a row up by, and a by-id function over a made-up `id` does not
* compile. Deliberately NO create/update/delete and NO `InsertSchema` import — a
diff --git a/server/typescript/packages/codegen-ts/src/templates/queries.ts b/server/typescript/packages/codegen-ts/src/templates/queries.ts
index 11e2beeda..346bcefce 100644
--- a/server/typescript/packages/codegen-ts/src/templates/queries.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/queries.ts
@@ -27,10 +27,15 @@ function subTypeToTsType(subType: string): "number" | "boolean" | "string" {
: "string";
}
+/** The by-id field of an object that declares no primary identity: `id`, by convention.
+ * `hasItemRoute` (api-surface.ts) asks whether this field exists before a read-only
+ * surface addresses a row by it. An instance field name, not a metamodel string. */
+export const DEFAULT_ID_FIELD = "id";
+
/** Get the PK field name and its TS type for a given entity. */
export function getPkInfo(entity: MetaObject, ctx: RenderContext): { fieldName: string; tsType: string } {
// Use primaryIdentity() to find the primary identity (may be inherited from extends:/super:).
- const pkFieldName = getPkFields(entity)[0] ?? "id";
+ const pkFieldName = getPkFields(entity)[0] ?? DEFAULT_ID_FIELD;
const pkInfo = ctx.pkMap.get(entity.name);
const subType = pkInfo?.fieldSubType ?? "long";
return { fieldName: pkFieldName, tsType: subTypeToTsType(subType) };
diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts
index 77c1bd6a3..718f99bc7 100644
--- a/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts
@@ -79,8 +79,8 @@ export function renderRoutesFileHono(
// --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
- // A keyless read-only object (a projection with no single-column identity, and every
- // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // A keyless read-only object (a projection with no identity and no `id` column, and
+ // every report: FR-044) has no row to address, so it mounts GET list and the collection 405
// and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
// keeps its output byte-identical.
const keyless = !hasItemRoute(entity);
diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts
index 873811324..4cb4c90fe 100644
--- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts
@@ -78,8 +78,8 @@ export function renderRoutesFile(
// --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
- // A keyless read-only object (a projection with no single-column identity, and every
- // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // A keyless read-only object (a projection with no identity and no `id` column, and
+ // every report: FR-044) has no row to address, so it mounts GET list and the collection 405
// and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
// keeps its output byte-identical.
const keyless = !hasItemRoute(entity);
diff --git a/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts
index fd125ce5c..59b19d7ee 100644
--- a/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts
+++ b/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts
@@ -57,11 +57,7 @@ async function loadProjectionFixture() {
name: "ProgramSummary",
children: [
{ "source.rdb": { "@kind": "view", "@table": "v_program_summary" } },
- // A single-column identity, inherited from the base: this is what gives the
- // projection a by-id query. Without it the projection is keyless (FR-044 Plan 3,
- // answer 4) and gets the list alone — see the keyless tests below.
- { "field.int": { name: "id", extends: "Program.id" } },
- { "identity.primary": { name: "id", extends: "Program.id" } },
+ { "field.int": { name: "id" } },
{
"field.int": {
name: "weekCount",
@@ -179,6 +175,33 @@ function pgCtx(root: MetaRoot) {
});
}
+/** A served report whose first dimension is named `id`: the read model then has a field
+ * called `id`, which must not be mistaken for an identity. */
+const REPORT_WITH_ID_FIELD = [
+ {
+ "object.entity": {
+ name: "Invoice",
+ children: [
+ { "source.rdb": { "@table": "invoices" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "status" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ { "dimension.attribute": { name: "id", "@of": "Invoice.id" } },
+ { "measure.aggregate": { name: "invoices", "@agg": "count", "@of": "Invoice.id" } },
+ ],
+ },
+ },
+ {
+ "object.report": {
+ name: "InvoiceRows",
+ "@from": "Invoice",
+ "@dimensions": ["id"],
+ "@measures": ["invoices"],
+ children: [{ "source.rdb": { "@kind": "view", "@table": "v_invoice_rows" } }],
+ },
+ },
+];
+
describe("renderQueriesFile — a served report and a keyless projection (FR-044 Plan 3)", () => {
test("a served report gets a list query and no by-id query", async () => {
const root = await loadFile("codegen-noop", "reporting", "with", "meta.shop.json");
@@ -193,7 +216,24 @@ describe("renderQueriesFile — a served report and a keyless projection (FR-044
expect(out).not.toContain("projection");
});
- test("a keyless projection gets a list query and no by-id query", async () => {
+ test("a projection with an `id` column and no declared identity keeps its by-id query", async () => {
+ // The id-by-convention shape: `getPkInfo` falls back to `id`, the column exists, and
+ // the query compiles and works. It is not keyless, and its output does not move.
+ const { projection, ctx } = await loadProjectionFixture();
+ expect(projection.primaryIdentity()).toBeUndefined();
+ const out = renderQueriesFile(projection, ctx);
+ expect(out).toContain("export async function findProgramSummaryById(db: Db, id: number)");
+ expect(out).toContain("eq(programSummaryView.id, id)");
+ });
+
+ test("a report with a derived field named `id` still gets no by-id query", async () => {
+ const root = await loadMetadata(REPORT_WITH_ID_FIELD);
+ const out = renderQueriesFile(readModel(root, "InvoiceRows"), pgCtx(root));
+ expect(out).toContain("export async function listInvoiceRows(");
+ expect(out).not.toContain("ById");
+ });
+
+ test("a keyless projection (no identity, no `id` column) gets a list query and no by-id query", async () => {
const root = await loadMetadata([
{
"object.entity": {
diff --git a/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts
index f9d575d14..812a13eb9 100644
--- a/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts
+++ b/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts
@@ -360,7 +360,7 @@ function ctxFor(root: MetaRoot, apiPrefix = "") {
});
}
-/** The projection fixture above with a single-column identity inherited from its base. */
+/** A projection with a single-column identity inherited from its base. */
async function loadKeyedProjectionFixture() {
const root = await loadMetadata([
{
@@ -423,8 +423,118 @@ describe("renderRoutesFile — a served report (FR-044 Plan 3)", () => {
}
});
- test("a keyless projection mounts no item routes and is still called a projection", async () => {
+ test("a projection with an `id` column and no declared identity is unchanged", async () => {
+ // The id-by-convention shape: the mount addresses `id` by default and the column is
+ // there, so the item routes work and stay.
+ const root = await loadMetadata([
+ {
+ "object.entity": {
+ name: "Program",
+ children: [
+ { "source.rdb": { "@table": "programs" } },
+ { "field.int": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ ],
+ },
+ },
+ {
+ "object.projection": {
+ name: "ProgramRow",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_program_row" } },
+ { "field.int": { name: "id" } },
+ ],
+ },
+ },
+ ]);
+ const projection = declared(root, "ProgramRow");
+ expect(projection.primaryIdentity()).toBeUndefined();
+ expect(hasItemRoute(projection)).toBe(true);
+ const ctx = makeRenderContext({
+ dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db",
+ pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
+ });
+ for (const out of [renderRoutesFile(projection, ctx), renderRoutesFileHono(projection, ctx)]) {
+ expect(out).toContain("Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.");
+ expect(out).not.toContain("itemRoutes");
+ }
+ });
+
+ test("a projection with a composite identity keeps its item routes, as before", async () => {
+ const root = await loadMetadata([
+ {
+ "object.entity": {
+ name: "Seat",
+ children: [
+ { "source.rdb": { "@table": "seats" } },
+ { "field.int": { name: "row" } },
+ { "field.int": { name: "num" } },
+ { "identity.primary": { name: "pk", "@fields": ["row", "num"] } },
+ ],
+ },
+ },
+ {
+ "object.projection": {
+ name: "SeatView",
+ children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_seat" } },
+ { "field.int": { name: "row", extends: "Seat.row" } },
+ { "field.int": { name: "num", extends: "Seat.num" } },
+ { "identity.primary": { name: "pk", extends: "Seat.pk" } },
+ ],
+ },
+ },
+ ]);
+ const projection = declared(root, "SeatView");
+ expect(hasItemRoute(projection)).toBe(true);
+ const ctx = makeRenderContext({
+ dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db",
+ pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
+ });
+ expect(renderRoutesFile(projection, ctx)).not.toContain("itemRoutes");
+ });
+
+ test("a report with a derived field named `id` still mounts no item routes", async () => {
+ const root = await loadMetadata([
+ {
+ "object.entity": {
+ name: "Invoice",
+ children: [
+ { "source.rdb": { "@table": "invoices" } },
+ { "field.long": { name: "id" } },
+ { "identity.primary": { name: "id", "@fields": "id" } },
+ { "dimension.attribute": { name: "id", "@of": "Invoice.id" } },
+ { "measure.aggregate": { name: "invoices", "@agg": "count", "@of": "Invoice.id" } },
+ ],
+ },
+ },
+ {
+ "object.report": {
+ name: "InvoiceRows",
+ "@from": "Invoice",
+ "@dimensions": ["id"],
+ "@measures": ["invoices"],
+ children: [{ "source.rdb": { "@kind": "view", "@table": "v_invoice_rows" } }],
+ },
+ },
+ ]);
+ const report = declared(root, "InvoiceRows");
+ const model = reportReadModel(report, root);
+ // Not vacuous: the read model really has a field called `id`.
+ expect(model.findField("id")).toBeDefined();
+ expect(hasItemRoute(report)).toBe(false);
+ expect(hasItemRoute(model)).toBe(false);
+ const ctx = ctxFor(root);
+ for (const out of [renderRoutesFile(model, ctx), renderRoutesFileHono(model, ctx)]) {
+ expect(out).toContain("itemRoutes: false,");
+ expect(out).toContain('resource: "report",');
+ }
+ });
+
+ test("a keyless projection (no identity, no `id` column) mounts no item routes and is still called a projection", async () => {
const { projection, ctx } = await loadProjectionFixture();
+ expect(projection.primaryIdentity()).toBeUndefined();
+ expect(projection.findField("id")).toBeUndefined();
expect(hasItemRoute(projection)).toBe(false);
for (const out of [renderRoutesFile(projection, ctx), renderRoutesFileHono(projection, ctx)]) {
expect(out).toContain("itemRoutes: false,");
diff --git a/server/typescript/packages/test-generators/src/routes.ts b/server/typescript/packages/test-generators/src/routes.ts
index 342d5cba9..4cfaeedce 100644
--- a/server/typescript/packages/test-generators/src/routes.ts
+++ b/server/typescript/packages/test-generators/src/routes.ts
@@ -94,8 +94,8 @@ import {
// --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
-// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has a
-// single-column identity); every other writable entity →
+// projection or served report → mountReadOnlyCrudRoutes (GET list, + GET :id when it has
+// an id column to address a row by); every other writable entity →
// mountCrudRoutes (+ one mountM2mRoute per M:N navigation). Under an `apiPrefix` the mounts
// are wrapped in `fastify.register(..., { prefix })`.
@@ -139,8 +139,8 @@ function renderRoutes(
// --- Projection / report path: read-only routes (GET list, + GET :id when keyed) ---
if (isProjection(entity)) {
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
- // A keyless read-only object (a projection with no single-column identity, and every
- // report: FR-044) has no row to address, so it mounts GET list and the collection 405
+ // A keyless read-only object (a projection with no identity and no `id` column, and
+ // every report: FR-044) has no row to address, so it mounts GET list and the collection 405
// and no `/:id` route of any verb. Both keys are absent for a keyed projection, which
// keeps its output byte-identical.
const keyless = !hasItemRoute(entity);
From c3f1d96b5292d6077038e247b944a03e653bc455 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:36:02 -0400
Subject: [PATCH 10/21] feat(java): read-only Spring surface for a view-backed
report (FR-044)
A served object.report (its read source is @kind: view) now gets the keyless
read-only Spring surface, generated from its read model: Dto, Repository
(list and count, no findById), FilterAllowlist and Controller (GET list,
POST answers 405, no /{id} mapping). A sourceless report, and one over any
other read-only kind, stays inert.
- RestSurfaceGate gains isServedReport and restShapeOf; isReadOnly is true for
a served report and for its read model. Every REST-surface loop maps each
object through restShapeOf and skips on null.
- ReportReadModel sets @filterable on every derived field whose subtype has a
filter band (dimension or measure), on the detached model only. No
vocabulary is added.
- A derived enum field is typed by an enum nested in the report's own DTO
(), not by the @of entity's enum: the read model carries the
members and no extends, so the row stays self-contained.
- The filter allowlist spells a map of more than ten fields with
Map.ofEntries. Map.of stops at ten pairs, and a wide report (every derived
field is filterable) crosses it; ten or fewer keep the Map.of form, so
existing output is byte-identical.
- JavaApiModelBuilder documents a served report as a `report` unit: the row
DTO, the read-only repository, the one GET route and the allowlist.
- New generated lane ReportGeneratedApiContractConformanceTest drives the 12
report/ scenarios against the three generated controllers on one Tomcat,
behind an in-memory seam seeded with what the views return.
- Kotlin: the controller and filter-allowlist loops skip a report until the
Kotlin port switches them to restShapeOf; without that the widened gate
would emit a field-less controller for the declared node.
---
.../generator/util/GeneratorUtil.java | 10 +-
.../generator/util/RestSurfaceGate.java | 56 +++
.../kotlin/KotlinFilterAllowlistGenerator.kt | 5 +
.../kotlin/KotlinSpringControllerGenerator.kt | 5 +
.../generator/apidocs/ApiUnit.java | 2 +-
.../apidocs/JavaApiModelBuilder.java | 29 +-
.../spring/SpringControllerGenerator.java | 37 +-
.../generator/spring/SpringDtoGenerator.java | 32 +-
.../SpringFilterAllowlistGenerator.java | 23 +-
.../generator/spring/SpringNaming.java | 9 +
.../spring/SpringRepositoryGenerator.java | 17 +-
.../spring/CodegenCompileConformanceTest.java | 12 +-
.../generator/spring/ReportingInertTest.java | 110 +++++-
.../spring/SpringReportRestSurfaceTest.java | 323 ++++++++++++++++++
.../integration/api/ReportCorpus.java | 57 ++++
...rtGeneratedApiContractConformanceTest.java | 82 +++++
.../GeneratedReportControllerHarness.java | 232 +++++++++++++
.../InMemoryReportRepositorySource.java | 192 +++++++++++
.../reporting/ReportReadModel.java | 19 +-
.../reporting/ReportReadModelTest.java | 41 ++-
20 files changed, 1236 insertions(+), 57 deletions(-)
create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java
create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java
create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java
create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedReportControllerHarness.java
create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java
index 580effd26..32989aae1 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java
@@ -31,11 +31,13 @@ public static Collection getFilteredMetaData(MetaDataLoa
}
/**
- * True for an {@code object.report} (FR-044). The generators that ask this skip a
- * report: every Java generator, and every Kotlin generator but one. A report that
+ * True for an {@code object.report} (FR-044): the declared node or its read model.
+ * The model-tier generators that ask this skip a report outright; a report that
* declares a read-only {@code source.rdb @kind: view} would otherwise pass every
- * source-keyed gate. The one exception is the Kotlin Exposed table generator, which
- * emits the read-only table object of a view-backed report.
+ * source-keyed gate. The REST-surface generators do not ask this to decide: they map
+ * each object through {@link RestSurfaceGate#restShapeOf}, which serves a view-backed
+ * report from its read model and drops every other report (Plan 3). The Kotlin Exposed
+ * table generator emits the read-only table object of a view-backed report.
*/
public static boolean isReport(MetaData md) {
return md instanceof MetaObject && MetaObject.SUBTYPE_REPORT.equals(md.getSubType());
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java
index a36c71ca6..bc2c9f1e1 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java
@@ -3,6 +3,8 @@
import com.metaobjects.MetaData;
import com.metaobjects.identity.MetaIdentity;
import com.metaobjects.object.MetaObject;
+import com.metaobjects.reporting.ReportReadModel;
+import com.metaobjects.reporting.ReportShape;
import com.metaobjects.source.MetaSource;
import com.metaobjects.source.RdbSource;
@@ -26,6 +28,14 @@
* abstract objects, {@code object.value}, sourceless projections, and the proc /
* table-function kinds that have no controller story on the JVM — gets nothing.
*
+ * A third, since FR-044 Plan 3. A SERVED {@code object.report}
+ * ({@link #isServedReport}: its read source is {@code @kind: view}) gets the read-only
+ * surface too, keyless: the list, the collection {@code 405}, and no item route. A report
+ * declares no fields, so a generator never emits from the declared node — it emits from
+ * {@link #restShapeOf}, the report's read model, which carries one field per derived
+ * column. Every generator loop asks {@code restShapeOf} FIRST and skips on {@code null};
+ * that one call is what keeps a report that is not served inert.
+ *
* Lives beside {@link RouteNaming} and for the same reason: two hand-maintained
* copies of a rule, with nothing tying them together, is exactly what let that one go
* stale across the JVM ports.
@@ -91,6 +101,9 @@ public static boolean isWritable(MetaObject obj) {
* "at least one view source" requirement.
*/
public static boolean isReadOnly(MetaObject obj) {
+ // FR-044: a served report (the declared node or its read model) is read-only and
+ // keyless. Any other report has no surface at all.
+ if (isServedReport(obj)) return true;
if (!MetaObject.SUBTYPE_PROJECTION.equals(obj.getSubType())) return false;
if (GeneratorUtil.isAbstract(obj)) return false;
boolean anyView = false;
@@ -107,6 +120,49 @@ public static boolean isReadOnly(MetaObject obj) {
return anyView;
}
+ /**
+ * Table A (FR-044 Plan 3): true iff {@code obj} is a SERVED report — a non-abstract
+ * {@code object.report} whose read source ({@link ReportShape#readSource}: its own
+ * read-only source with {@code @role: primary}, else its first own read-only source) is
+ * {@code @kind: view}. Answers the same for the declared report and for its
+ * {@link ReportReadModel}.
+ *
+ * A sourceless report is not served, and neither is one over a
+ * {@code materializedView}, {@code storedProc} or {@code tableFunction}: the lowering
+ * skips those kinds, so no relation with the derived columns is promised.
+ */
+ public static boolean isServedReport(MetaObject obj) {
+ if (obj == null || !MetaObject.SUBTYPE_REPORT.equals(obj.getSubType())) return false;
+ // A read model answers for the report it was built from (it has no parent, so its
+ // own abstract flag and sources are a copy; the declared node is the authority).
+ MetaObject declared = obj instanceof ReportReadModel && ((ReportReadModel) obj).report() != null
+ ? ((ReportReadModel) obj).report() : obj;
+ if (GeneratorUtil.isAbstract(declared)) return false;
+ MetaSource source = ReportShape.readSource(declared);
+ return source != null && MetaSource.KIND_VIEW.equals(source.getEffectiveKind());
+ }
+
+ /**
+ * The object a REST-surface generator emits for {@code obj}: the report's read model
+ * for a served report, {@code null} for any other report, {@code obj} itself otherwise.
+ *
+ * Every generator that emits part of the REST surface (DTO, repository, filter
+ * allowlist, controller, api docs) maps each loaded object through this before any
+ * other test and skips on {@code null}. The read model keeps the report's name and
+ * package, so the emitted names and paths are the report's; it carries one ordinary
+ * {@code field.*} per derived column and no identity, so the existing read-only emit
+ * path produces the keyless surface with no report branch of its own. Passing a read
+ * model returns it unchanged.
+ *
+ * @throws com.metaobjects.MetaDataException naming the report, when a derived field is
+ * typed by a {@code field.object} (the read model refuses it), so {@code gen}
+ * stops rather than emitting a row it cannot bind
+ */
+ public static MetaObject restShapeOf(MetaObject obj) {
+ if (obj == null || !MetaObject.SUBTYPE_REPORT.equals(obj.getSubType())) return obj;
+ return isServedReport(obj) ? ReportReadModel.of(obj) : null;
+ }
+
/**
* True iff {@code obj} is addressable by a single-column primary key, i.e. its REST
* surface carries the {@code /{id}} item routes at all.
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt
index 774f82123..6a63257fe 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt
@@ -64,6 +64,11 @@ open class KotlinFilterAllowlistGenerator : MultiFileDirectGeneratorBase symbols, UnitExample example /* nullable */) {}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
index 165a19285..dda1ba199 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
@@ -44,7 +44,11 @@
* projection ({@code object.projection}) is a read-only model and yields a
* read DTO only (no VALIDATION / DATA_ACCESS / REST / FILTER — those
* generators gate on a writable table entity and skip a projection). A value
- * object ({@code object.value}) yields MODEL only.
+ * object ({@code object.value}) yields MODEL only. A SERVED report
+ * ({@code object.report} over a view, FR-044) is documented from its read model
+ * ({@code RestSurfaceGate.restShapeOf}): the row DTO, the read-only repository
+ * seam, the one {@code GET} list route and the filter allowlist, and no MODEL.
+ * A report that is not served yields no unit.
* Templates (iterated via the resolving
* {@code loader.getRoot().getChildren(MetaTemplate.class, true)}): each template yields PAYLOAD / RENDER /
* PROMPT / OUTPUT_PARSER symbols gated by the matching {@code appliesTo}.
@@ -85,10 +89,12 @@ public JavaApiModel build(MetaDataLoader loader, String project) {
// Objects: one unit per object.entity / object.value (entity vs value drives
// which symbol categories appliesTo lets through).
for (MetaObject obj : loader.getMetaObjects()) {
- // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- // It has no generated API to document, and its derived fields do not exist yet.
- if (GeneratorUtil.isReport(obj)) continue;
- ApiUnit unit = buildObjectUnit(obj, loader);
+ // FR-044 Plan 3: a SERVED report (its read source is a view) is documented from
+ // its read model, the same object the generators emit from, so documented ==
+ // generated. A report that is not served generates nothing and gets no unit.
+ MetaObject shape = RestSurfaceGate.restShapeOf(obj);
+ if (shape == null) continue;
+ ApiUnit unit = buildObjectUnit(shape, loader);
if (unit != null) {
units.add(unit);
}
@@ -111,7 +117,8 @@ private ApiUnit buildObjectUnit(MetaObject obj, MetaDataLoader loader) {
String shortName = split[1];
boolean entity = MetaObject.SUBTYPE_ENTITY.equals(obj.getSubType());
boolean projection = MetaObject.SUBTYPE_PROJECTION.equals(obj.getSubType());
- String unitKind = entity ? "entity" : projection ? "projection" : "value";
+ boolean report = GeneratorUtil.isReport(obj); // a served report's read model (see build)
+ String unitKind = entity ? "entity" : projection ? "projection" : report ? "report" : "value";
List symbols = new ArrayList<>();
@@ -119,7 +126,9 @@ private ApiUnit buildObjectUnit(MetaObject obj, MetaDataLoader loader) {
// instantiated, so we do not document a MODEL symbol for them (documented is a
// subset of generated). A concrete value object / projection → MODEL only (plus
// a read DTO for a projection, below).
- if (!IOUtil.isAbstract(obj)) {
+ // A report has NO model symbol: the Java model tier generates no class for one
+ // (GeneratorUtil.getFilteredMetaData drops reports); its row is the DTO below.
+ if (!IOUtil.isAbstract(obj) && !report) {
symbols.add(symbol(
shortName, ApiSymbolKind.MODEL, fqn(javaPkg, shortName),
"class " + shortName,
@@ -138,6 +147,7 @@ shortName, ApiSymbolKind.MODEL, fqn(javaPkg, shortName),
dto, ApiSymbolKind.DTO, fqn(javaPkg, dto),
"record " + dto,
projection ? "the read-only wire / serialization shape"
+ : report ? "the read-only row of the report's view, one component per derived field"
: "the wire / serialization shape",
dtoFields));
@@ -211,6 +221,11 @@ private void addRestSymbols(List symbols, MetaObject obj,
String base = SpringNaming.controllerPath(shortName);
addRest(symbols, controllerFqn, "GET " + base, "list with pagination / sort / filters");
+ // FR-044 — a served report is a list and nothing else (Table G): it has no identity,
+ // so no item route, and the collection POST it refuses is not an operation a caller
+ // can use.
+ if (GeneratorUtil.isReport(obj)) return;
+
// F22 — a read-only projection's controller serves the reads and REFUSES every write
// verb with 405. Documenting it with the writable verb list would be the precise
// drift this builder exists to prevent: the emitted controller has no create path,
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java
index e100399f5..3dd030642 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java
@@ -127,7 +127,11 @@ public void execute(MetaDataLoader loader) {
runtimePackage = getArg(ARG_RUNTIME_PACKAGE, RUNTIME_PACKAGE);
this.loader = loader;
Path outRoot = Paths.get(outDir.getAbsolutePath());
- for (MetaObject entity : loader.getMetaObjects()) {
+ for (MetaObject declared : loader.getMetaObjects()) {
+ // FR-044: a served report is emitted from its read model; any other report
+ // has no shape and emits nothing.
+ MetaObject entity = RestSurfaceGate.restShapeOf(declared);
+ if (entity == null) continue;
if (com.metaobjects.generator.util.GeneratorUtil.isAbstract(entity)) continue;
// FR-017 TPH: a subtype is folded into its base's single table + base controller —
// it emits no standalone controller (it carries no own source.rdb either, so the
@@ -142,7 +146,8 @@ public void execute(MetaDataLoader loader) {
continue;
}
// F22 — a view-only projection gets a READ-ONLY controller: reads served, every
- // write verb answering the cross-port 405 envelope.
+ // write verb answering the cross-port 405 envelope. A served report (FR-044)
+ // takes the same path and, having no identity, comes out keyless.
if (RestSurfaceGate.isReadOnly(entity)) {
emitReadOnly(entity, outRoot);
continue;
@@ -158,7 +163,8 @@ public void execute(MetaDataLoader loader) {
/**
* True iff this generator emits a {@code @RestController} for {@code entity} —
* a WRITABLE object (a concrete table-kind {@code object.entity}, or a write-through
- * entity) or a READ-ONLY view-kind {@code object.projection} (F22). Ask
+ * entity), a READ-ONLY view-kind {@code object.projection} (F22) or a served
+ * {@code object.report} (FR-044, emitted from {@link RestSurfaceGate#restShapeOf}). Ask
* {@link RestSurfaceGate#isReadOnly(MetaObject)} which of the two shapes is emitted.
*
* The predicate itself lives in {@link RestSurfaceGate} and is SHARED with
@@ -535,8 +541,16 @@ private static void appendMapValueValidationLoop(StringBuilder src, String acces
*
The item verbs follow the item GET. A projection's identity is OPTIONAL
* (ADR-0028), and a keyless one mounts no {@code /{id}} read — so it refuses only the
* collection verb, rather than advertising an address it never serves.
+ *
+ * FR-044: a served {@code object.report} is emitted here too, from its read model.
+ * A report has no identity at all, so it is always the keyless shape: the list, the
+ * collection 405, and no {@code /{id}} mapping of any verb.
*/
protected void emitReadOnly(MetaObject entity, Path outRoot) {
+ // What the javadoc and the 405 message call this object. Free prose on the wire
+ // (`message` is not part of the asserted contract), but it must not call a report
+ // a projection.
+ String noun = SpringNaming.readOnlyNoun(entity);
String[] split = SpringNaming.splitFqn(entity.getName());
String pkg = split[0];
String shortName = split[1];
@@ -576,9 +590,15 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) {
src.append("/**\n");
src.append(" * GENERATED — READ-ONLY REST controller for the ").append(shortName)
- .append(" projection.\n");
- src.append(" * Implements the cross-port API contract: GET list + GET by id; every write\n");
- src.append(" * verb answers 405 {\"error\": \"method_not_allowed\"}.\n");
+ .append(" ").append(noun).append(".\n");
+ if (!com.metaobjects.generator.util.GeneratorUtil.isReport(entity)) {
+ src.append(" * Implements the cross-port API contract: GET list + GET by id; every write\n");
+ src.append(" * verb answers 405 {\"error\": \"method_not_allowed\"}.\n");
+ } else {
+ src.append(" * Implements the cross-port API contract: GET list; POST answers\n");
+ src.append(" * 405 {\"error\": \"method_not_allowed\"}. A report has no identity, so no item\n");
+ src.append(" * route is mounted.\n");
+ }
src.append(" *\n");
src.append(" * Auth: these read endpoints are unauthenticated. Require authentication for this path\n");
src.append(" * in your Spring Security config, e.g. {@code http.authorizeHttpRequests(a ->\n");
@@ -625,7 +645,8 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) {
src.append(" private static ResponseEntity> methodNotAllowed(String verb) {\n");
src.append(" return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)\n");
src.append(" .body(Map.of(\"error\", \"method_not_allowed\",\n");
- src.append(" \"message\", verb + \" is not supported on a projection (read-only).\"));\n");
+ src.append(" \"message\", verb + \" is not supported on a ").append(noun)
+ .append(" (read-only).\"));\n");
src.append(" }\n\n");
appendParseSortHelper(src, repoName);
@@ -636,7 +657,7 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) {
GeneratedFileWriter.write(outFile, src.toString());
} catch (IOException e) {
throw new GeneratorException(
- "failed writing " + controllerName + ".java for projection " + entity.getName() + ": " + e, e);
+ "failed writing " + controllerName + ".java for " + noun + " " + entity.getName() + ": " + e, e);
}
}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java
index 07b9444f1..1e49cc613 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java
@@ -116,7 +116,16 @@ public void execute(MetaDataLoader loader) {
// standalone Java enum, so consuming DTOs reference it instead of redeclaring it inline.
emitSharedEnums(loader, outRoot);
boolean emitAbstractShapes = Boolean.parseBoolean(getArg("emitAbstractShapes", "false"));
- for (MetaObject entity : loader.getMetaObjects()) {
+ for (MetaObject declared : loader.getMetaObjects()) {
+ // FR-044: a served report's row DTO is emitted from its read model (one
+ // component per derived field) and nothing else is: no Patch, no stamping
+ // helper, no builder. Any other report has no shape and emits nothing.
+ MetaObject entity = RestSurfaceGate.restShapeOf(declared);
+ if (entity == null) continue;
+ if (GeneratorUtil.isReport(entity)) {
+ emit(entity, outRoot);
+ continue;
+ }
// FR-024: a (read) DTO is emitted for concrete entities AND any
// object.projection (read-only-kind source) — the read model. A proc/
// tableFunction-backed projection DTO may include input @param fields as
@@ -161,7 +170,8 @@ public void execute(MetaDataLoader loader) {
/**
* True iff this generator emits a concrete DTO {@code record} for
- * {@code entity}: any {@code object.entity} that is not {@code abstract}.
+ * {@code entity}: any {@code object.entity} that is not {@code abstract}, any
+ * {@code object.projection}, and a served {@code object.report} (FR-044).
* Unlike the controller/repository, the DTO is emitted for EVERY concrete
* entity regardless of {@code source.rdb} kind (a view-kind entity still gets
* a wire DTO). Abstract entities are excluded here — they only get the opt-in
@@ -170,6 +180,9 @@ public void execute(MetaDataLoader loader) {
* Extracted verbatim from the {@link #execute(MetaDataLoader)} concrete-emit guard.
*/
public static boolean appliesTo(MetaObject entity) {
+ // FR-044: a served report (the declared node or its read model) gets a row DTO,
+ // emitted from RestSurfaceGate.restShapeOf; any other report gets nothing.
+ if (RestSurfaceGate.isServedReport(entity)) return true;
// FR-024: emit a read DTO for any concrete entity OR any object.projection
// (read-only-kind source) — a projection is a read-only wire model; the write
// surfaces skip it. A proc/tableFunction-backed projection DTO may include
@@ -215,7 +228,10 @@ protected void emit(MetaObject entity, Path outRoot) {
// gets the same stamping helpers as any other writable entity's DTO (parity with the
// vanilla contract for a consumer using the subtype DTO directly). The TPH controller's
// own per-subtype create instead stamps via the BASE union DTO's helper — see emitTphUnion.
- boolean writableForAutoSet = SpringRepositoryGenerator.appliesTo(entity) || TphPlan.isTphSubtype(entity);
+ // A report row is read-only and derives no @autoSet field; excluded by name so the
+ // "writable" reading of this flag stays true.
+ boolean writableForAutoSet = !GeneratorUtil.isReport(entity)
+ && (SpringRepositoryGenerator.appliesTo(entity) || TphPlan.isTphSubtype(entity));
List extraBodyMembers =
(writableForAutoSet && AutoSetSupport.hasAutoSetFields(entity))
? autoSetStampHelpers(entity, fields, SpringNaming.dtoName(
@@ -596,7 +612,8 @@ private void emitRecord(MetaObject entity, Path outRoot, List fields,
// enum is NOT nested here — its type is materialized standalone (or @provided externally)
// and merely referenced. Inline enums stay nested (cross-port parity, byte-identical default).
List enumDecls = collectEnumDecls(entity, fields);
- String builder = MetaObject.SUBTYPE_PROJECTION.equals(entity.getSubType())
+ // FR-044: a report row is not constructable either, for the projection's reason.
+ String builder = MetaObject.SUBTYPE_PROJECTION.equals(entity.getSubType()) || GeneratorUtil.isReport(entity)
? "" : SpringRecordBuilder.members(recordName, components);
if (enumDecls.isEmpty() && extraBodyMembers.isEmpty() && builder.isEmpty()) {
src.append(") {}\n");
@@ -945,8 +962,11 @@ private static String netDeserializeAnnotation(MetaField> field) {
*/
private void emitNetBindings(MetaDataLoader loader, Path outRoot) {
java.util.Set packages = new java.util.LinkedHashSet<>();
- for (MetaObject entity : loader.getMetaObjects()) {
- if (!appliesTo(entity)) continue;
+ for (MetaObject declared : loader.getMetaObjects()) {
+ // FR-044: a served report's derived fields are on its read model (a dimension
+ // over a strict field.uri / field.inet binds through MetaNetBindings too).
+ MetaObject entity = RestSurfaceGate.restShapeOf(declared);
+ if (entity == null || !appliesTo(entity)) continue;
for (MetaField field : dtoComponentFields(entity)) {
if (isStrictNetField(field)) {
packages.add(SpringNaming.splitFqn(entity.getName())[0]);
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringFilterAllowlistGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringFilterAllowlistGenerator.java
index 03754a207..18100d446 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringFilterAllowlistGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringFilterAllowlistGenerator.java
@@ -68,6 +68,9 @@ public class SpringFilterAllowlistGenerator extends MultiFileDirectGeneratorBase
/** Metadata attribute marking a field as filterable in the generated allowlist. */
static final String ATTR_FILTERABLE = "filterable";
+ /** {@code Map.of} has overloads up to ten key/value pairs and no more. */
+ private static final int MAP_OF_MAX_PAIRS = 10;
+
@Override
protected Class getFilterClass() {
return MetaObject.class;
@@ -77,7 +80,12 @@ protected Class getFilterClass() {
public void execute(MetaDataLoader loader) {
parseArgs();
Path outRoot = Paths.get(outDir.getAbsolutePath());
- for (MetaObject entity : loader.getMetaObjects()) {
+ for (MetaObject declared : loader.getMetaObjects()) {
+ // FR-044: a served report is emitted from its read model, whose derived fields
+ // all carry @filterable (Table C), so there is no report branch below. Any
+ // other report has no shape and emits nothing.
+ MetaObject entity = RestSurfaceGate.restShapeOf(declared);
+ if (entity == null) continue;
if (TphPlan.isTphSubtype(entity)) continue; // folded into the base — no own allowlist
if (!appliesTo(entity)) continue;
emit(entity, outRoot, loader);
@@ -204,17 +212,24 @@ protected void emit(MetaObject entity, Path outRoot, MetaDataLoader loader) {
if (opsByField.isEmpty()) {
src.append("Map.of();\n");
} else {
- src.append("Map.of(\n");
+ // Map.of stops at ten pairs; past that the same map is spelled with
+ // Map.ofEntries. Ten or fewer keep the Map.of form, so every allowlist that
+ // compiled before is byte-identical. (A served report makes EVERY derived field
+ // filterable, FR-044 Table C, so a wide report crosses ten where a hand-marked
+ // entity rarely did.)
+ boolean entries = opsByField.size() > MAP_OF_MAX_PAIRS;
+ src.append(entries ? "Map.ofEntries(\n" : "Map.of(\n");
int i = 0;
for (Map.Entry> e : opsByField.entrySet()) {
- src.append(" \"").append(e.getKey()).append("\", Set.of(");
+ src.append(" ").append(entries ? "Map.entry(" : "")
+ .append('"').append(e.getKey()).append("\", Set.of(");
boolean firstOp = true;
for (String op : e.getValue()) {
if (!firstOp) src.append(", ");
firstOp = false;
src.append('"').append(op).append('"');
}
- src.append(')');
+ src.append(entries ? "))" : ")");
if (i++ < opsByField.size() - 1) src.append(',');
src.append('\n');
}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java
index bd9ad033a..abf5b5ae6 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java
@@ -27,6 +27,15 @@
*/
public final class SpringNaming {
+ /**
+ * What generated prose (javadoc, the 405 {@code message}, a write-failure message)
+ * calls a read-only object: {@code "report"} for an {@code object.report} (FR-044),
+ * {@code "projection"} for everything else that reaches the read-only emit path.
+ */
+ public static String readOnlyNoun(MetaObject entity) {
+ return MetaObject.SUBTYPE_REPORT.equals(entity.getSubType()) ? "report" : "projection";
+ }
+
private SpringNaming() { /* no instances */ }
/**
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java
index 7b0c75bed..92dfdf47b 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java
@@ -88,10 +88,15 @@ public void execute(MetaDataLoader loader) {
runtimePackage = getArg(ARG_RUNTIME_PACKAGE, RUNTIME_PACKAGE);
this.loader = loader;
Path outRoot = Paths.get(outDir.getAbsolutePath());
- for (MetaObject entity : loader.getMetaObjects()) {
+ for (MetaObject declared : loader.getMetaObjects()) {
+ // FR-044: a served report is emitted from its read model; any other report
+ // has no shape and emits nothing.
+ MetaObject entity = RestSurfaceGate.restShapeOf(declared);
+ if (entity == null) continue;
if (TphPlan.isTphSubtype(entity)) continue; // folded into the base — no own repository
if (!appliesTo(entity)) continue;
- // F22 — a view-only projection gets a READ-ONLY seam: nothing that writes.
+ // F22 — a view-only projection gets a READ-ONLY seam: nothing that writes. A
+ // served report (FR-044) takes the same path: list and count, no findById.
if (RestSurfaceGate.isReadOnly(entity)) emitReadOnly(entity, outRoot);
// FR-017 TPH: the discriminator base gets a polymorphic + per-subtype-scoped repository.
else if (TphPlan.isTphBase(entity, loader)) emitTph(entity, outRoot);
@@ -219,8 +224,12 @@ protected void emit(MetaObject entity, Path outRoot) {
* {@code findById} appears only when the projection is addressable by a
* single-column primary key, matching the controller's {@code /{id}} routes
* ({@link RestSurfaceGate#hasItemRoute}).
+ *
+ * FR-044: a served {@code object.report} is emitted here from its read model. It
+ * has no identity, so its seam is {@code list} and {@code count} only.
*/
protected void emitReadOnly(MetaObject entity, Path outRoot) {
+ String noun = SpringNaming.readOnlyNoun(entity);
String[] split = SpringNaming.splitFqn(entity.getName());
String pkg = split[0];
String shortName = split[1];
@@ -245,7 +254,7 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) {
.append(shortName).append("Controller delegates to this interface.\n");
src.append(" *\n");
src.append(" * READ-ONLY: ").append(shortName)
- .append(" is a projection over a database view, so there is no write method to\n");
+ .append(" is a ").append(noun).append(" over a database view, so there is no write method to\n");
src.append(" * implement. Its controller answers every write verb with 405.
\n");
src.append(" */\n");
src.append("public interface ").append(repoName).append(" {\n\n");
@@ -264,7 +273,7 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) {
GeneratedFileWriter.write(outFile, src.toString());
} catch (IOException e) {
throw new GeneratorException(
- "failed writing " + repoName + ".java for projection " + entity.getName() + ": " + e, e);
+ "failed writing " + repoName + ".java for " + noun + " " + entity.getName() + ": " + e, e);
}
}
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
index 1a32c9c4d..40d61634b 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
@@ -182,7 +182,8 @@ public void theJavaModelTierCompiles() throws Exception {
/**
* The Spring request/response tier: the DTO/Patch pair, the value-object records they
- * bind, the repositories and the filter allowlists.
+ * bind, the repositories and the filter allowlists, including the read-only surface of
+ * every served report.
*
* {@code entity} is deliberately NOT in this selection, and not for tidiness.
* {@code entity} and {@code value-object} BOTH emit a Java type for an
@@ -198,7 +199,14 @@ public void theSpringWebTierCompiles() throws Exception {
selection.put("repository", PLAIN);
selection.put("filter-allowlist", PLAIN);
selection.put("names", PLAIN);
- generateAndCompile("web", selection, 17);
+ // FR-044 Plan 3: the corpus's six view-backed reports are served, so their row
+ // DTOs, repository seams and filter allowlists are in this compile. Named, so a
+ // gate that stops admitting a report fails here instead of compiling less.
+ // ProgramMinutes derives eleven filterable fields, one past Map.of's ten pairs.
+ generateAndCompile("web", selection, 17,
+ "ProgramMinutesDto", "FitnessTotalsDto", "ProgramsByMonthDto", "ProgramsByWeekDto",
+ "RecentProgramsDto", "AssetActivityDto",
+ "ProgramMinutesRepository", "ProgramMinutesFilterAllowlist");
}
/**
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
index 5e7a8d69b..f9033ac94 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
@@ -20,6 +20,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -32,17 +33,21 @@
import static org.junit.Assert.assertTrue;
/**
- * FR-044 Plan 1 — the reporting vocabulary is INERT in every Java generator.
+ * FR-044 — what the reporting vocabulary does and does not change in the Java generators.
*
- *
Plan 1 registers {@code dimension.*}, {@code measure.*}, {@code segment.*} and
- * {@code object.report} and validates them at load, but gives none of them output: a
- * report's lowering lands in Plan 2/3. Until then a model that USES the vocabulary must
- * generate exactly what the same model without it generates, byte for byte, through every
- * generator in {@link GeneratorRegistry}.
+ *
{@code dimension.*}, {@code measure.*}, {@code segment.*} and a report that declares no
+ * view source are INERT: a model that uses them generates exactly what the same model
+ * without them generates, byte for byte, through every generator in
+ * {@link GeneratorRegistry}.
+ *
+ *
Since Plan 3 a report that declares a read-only {@code source.rdb @kind: view} is
+ * SERVED: the REST-surface generators emit its read-only files and nothing else changes.
+ * {@code StoreTotals} is that report, so {@code with/} differs from {@code without/} in
+ * exactly {@link #SERVED_REPORT_FILES} (one file per generator) and one api-docs unit.
+ * {@code DailyRevenue} and {@code ProgramEngagement} declare no source and stay inert.
*
*
The model pair is {@code fixtures/codegen-noop/reporting/{with,without}}, shared with the
- * other four ports' copies of this test. {@code with/} carries a report that declares a
- * read-only {@code source.rdb @kind: view} (R5 allows one) — the shape that leaked in C#.
+ * other four ports' copies of this test.
*/
public class ReportingInertTest extends SharedRegistryTestBase {
@@ -50,6 +55,17 @@ public class ReportingInertTest extends SharedRegistryTestBase {
public TemporaryFolder tempFolder = new TemporaryFolder();
private static final String THREW = "";
+
+ /**
+ * The one file each REST-surface generator adds for the served report {@code StoreTotals}
+ * (contract Table E), by generator stable name. Every generator not named here must emit
+ * byte-identical output with and without the reporting nodes.
+ */
+ private static final Map SERVED_REPORT_FILES = Map.of(
+ "dto", "acme/shop/StoreTotalsDto.java",
+ "repository", "acme/shop/StoreTotalsRepository.java",
+ "filter-allowlist", "acme/shop/StoreTotalsFilterAllowlist.java",
+ "routes", "acme/shop/StoreTotalsController.java");
private static final java.util.regex.Pattern GENERATED_ON =
java.util.regex.Pattern.compile("Generated On:[^\\n]*");
@@ -116,6 +132,21 @@ private static void assertSame(String label, Map expected, Map expected, Map actual,
+ Collection extras) {
+ Map rest = new TreeMap<>(actual);
+ for (String extra : extras) {
+ assertTrue(label + ": the served report's " + extra + " was not emitted; got " + actual.keySet(),
+ rest.remove(extra) != null);
+ assertFalse(label + ": " + extra + " is emitted without any report", expected.containsKey(extra));
+ }
+ assertSame(label, expected, rest);
+ }
+
@Test
public void theWithModelReallyCarriesTheVocabulary() throws Exception {
// Else every comparison below is vacuously green.
@@ -131,18 +162,35 @@ public void theWithModelReallyCarriesTheVocabulary() throws Exception {
}
@Test
- public void everyGeneratorEmitsTheSameFilesWithAndWithoutReportingNodes() throws Exception {
+ public void everyGeneratorEmitsTheSameFilesButTheServedReportsOwn() throws Exception {
// Every generator is compared before anything is asserted, so one red run names
// every leak rather than the first.
List leaks = new ArrayList<>();
+ List seen = new ArrayList<>();
for (GeneratorInfo info : GeneratorRegistry.list().values()) {
+ String extra = SERVED_REPORT_FILES.get(info.stableName());
+ if (extra != null) seen.add(info.stableName());
try {
- assertSame(info.stableName(), emit("without", List.of(info)), emit("with", List.of(info)));
+ assertSameBut(info.stableName(), emit("without", List.of(info)), emit("with", List.of(info)),
+ extra == null ? List.of() : List.of(extra));
} catch (AssertionError e) {
leaks.add(e.getMessage());
}
}
assertTrue(String.join("\n", leaks), leaks.isEmpty());
+ seen.sort(null);
+ assertEquals("every generator SERVED_REPORT_FILES names is registered under that name",
+ new ArrayList<>(new java.util.TreeSet<>(SERVED_REPORT_FILES.keySet())), seen);
+ }
+
+ @Test
+ public void noGeneratorEmitsAnythingForASourcelessReport() throws Exception {
+ for (GeneratorInfo info : GeneratorRegistry.list().values()) {
+ for (String path : emit("with", List.of(info)).keySet()) {
+ assertFalse(info.stableName() + " emitted " + path,
+ path.contains("DailyRevenue") || path.contains("ProgramEngagement"));
+ }
+ }
}
@Test
@@ -177,14 +225,15 @@ public void theModelAndWebTiersInOneRunEmitTheSameFiles() throws Exception {
Map expected = emit("without", suite);
assertFalse(tier + " threw: " + expected.get(THREW), expected.containsKey(THREW));
assertTrue(tier + ": only " + expected.size() + " files — the suite barely ran", expected.size() >= 3);
- assertSame(tier.toString(), expected, emit("with", suite));
+ List extras = tier.stream().map(SERVED_REPORT_FILES::get)
+ .filter(java.util.Objects::nonNull).collect(Collectors.toList());
+ assertSameBut(tier.toString(), expected, emit("with", suite), extras);
}
}
/**
* The api docs surface ({@code mvn metaobjects:docs}): every unit page, the index and the
- * agent page. A report has no generated API to document, and its derived fields do not
- * exist until its lowering lands.
+ * agent page.
*/
private Map apiDocs(String variant) throws Exception {
JavaApiModel model = new JavaApiModelBuilder().build(load(variant), "shop");
@@ -194,15 +243,42 @@ private Map apiDocs(String variant) throws Exception {
pages.put(DocsPaths.docPageOutputPath(DocsPaths.Layout.PACKAGE, unit.pkg(), unit.node()),
renderer.renderUnitPage(unit, null));
}
- pages.put("README.md", renderer.renderIndex(model, DocsPaths.Layout.PACKAGE));
- pages.put("AGENT-API.md", renderer.renderAgentApi(model));
+ pages.put(INDEX_PAGE, renderer.renderIndex(model, DocsPaths.Layout.PACKAGE));
+ pages.put(AGENT_PAGE, renderer.renderAgentApi(model));
return pages;
}
+ private static final String INDEX_PAGE = "README.md";
+ private static final String AGENT_PAGE = "AGENT-API.md";
+
@Test
- public void apiDocsAreTheSameWithAndWithoutReportingNodes() throws Exception {
+ public void apiDocsGainOneUnitForTheServedReportAndNothingElseChanges() throws Exception {
Map expected = apiDocs("without");
assertTrue("only " + expected.size() + " pages — the docs barely ran", expected.size() > 3);
- assertSame("api docs", expected, apiDocs("with"));
+ Map actual = apiDocs("with");
+
+ // Exactly one page is new, and it is the served report's.
+ List added = new ArrayList<>(actual.keySet());
+ added.removeAll(expected.keySet());
+ assertEquals("one new page: " + added, 1, added.size());
+ String reportPage = added.get(0);
+ assertTrue(reportPage, reportPage.contains("StoreTotals"));
+ assertTrue(actual.get(reportPage).contains("GET /api/store_totals"));
+
+ // Every unit page that existed is byte-identical.
+ for (Map.Entry e : expected.entrySet()) {
+ if (INDEX_PAGE.equals(e.getKey()) || AGENT_PAGE.equals(e.getKey())) continue;
+ assertEquals("api docs: " + e.getKey() + " differs once reporting nodes are declared",
+ e.getValue(), actual.get(e.getKey()));
+ }
+ // The two listing pages name the served report and no sourceless one.
+ for (String listing : List.of(INDEX_PAGE, AGENT_PAGE)) {
+ assertFalse(listing + " listed a report before any was declared", expected.get(listing).contains("StoreTotals"));
+ assertTrue(listing + " lists the served report", actual.get(listing).contains("StoreTotals"));
+ }
+ for (String page : actual.values()) {
+ assertFalse("a sourceless report is documented", page.contains("DailyRevenue"));
+ assertFalse("a sourceless report is documented", page.contains("ProgramEngagement"));
+ }
}
}
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java
new file mode 100644
index 000000000..e46ba6792
--- /dev/null
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java
@@ -0,0 +1,323 @@
+package com.metaobjects.generator.spring;
+
+import com.metaobjects.MetaDataException;
+import com.metaobjects.generator.apidocs.ApiSymbol;
+import com.metaobjects.generator.apidocs.ApiSymbolKind;
+import com.metaobjects.generator.apidocs.ApiUnit;
+import com.metaobjects.generator.apidocs.JavaApiModelBuilder;
+import com.metaobjects.generator.util.RestSurfaceGate;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.object.MetaObject;
+import com.metaobjects.registry.SharedRegistryTestBase;
+import com.metaobjects.reporting.ReportReadModel;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * FR-044 Plan 3 — a VIEW-BACKED {@code object.report} gets the read-only Spring surface,
+ * generated from its read model ({@link ReportReadModel}): the wire DTO, a repository seam
+ * with {@code list} and {@code count}, the filter allowlist and a controller that serves
+ * the list and refuses the collection {@code POST}. A report has no identity, so there is
+ * no {@code /{id}} route and no {@code findById}. A sourceless report stays inert.
+ *
+ * The model is the shared {@code fixtures/codegen-noop/reporting/with} corpus:
+ * {@code StoreTotals} declares a view; {@code DailyRevenue} and {@code ProgramEngagement}
+ * declare no source.
+ */
+public class SpringReportRestSurfaceTest extends SharedRegistryTestBase {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private static final String PKG = "acme::shop::";
+
+ private static String withModel() throws Exception {
+ Path model = SpringTestFixtures.findCorpusRoot().getParent()
+ .resolve("codegen-noop/reporting/with/meta.shop.json");
+ return Files.readString(model, StandardCharsets.UTF_8);
+ }
+
+ private MetaDataLoader load(String label, String json) throws Exception {
+ return SpringTestFixtures.loadFixture(tmp.newFolder().toPath(), "report-" + label, json);
+ }
+
+ private static List> surface() {
+ return List.of(
+ new SpringDtoGenerator(),
+ new SpringRepositoryGenerator(),
+ new SpringControllerGenerator(),
+ new SpringFilterAllowlistGenerator());
+ }
+
+ private Path generateAll(String label, String json) throws Exception {
+ Path gen = tmp.newFolder().toPath();
+ MetaDataLoader loader = load(label, json);
+ Map args = new HashMap<>();
+ args.put("outputDir", gen.toString());
+ for (com.metaobjects.generator.direct.MultiFileDirectGeneratorBase> g : surface()) {
+ g.setArgs(args);
+ g.execute(loader);
+ }
+ return gen;
+ }
+
+ private static List filesNamed(Path gen, String prefix) throws Exception {
+ try (Stream s = Files.walk(gen)) {
+ return s.filter(Files::isRegularFile)
+ .map(p -> p.getFileName().toString())
+ .filter(n -> n.startsWith(prefix))
+ .sorted()
+ .collect(Collectors.toList());
+ }
+ }
+
+ private static int occurrences(String haystack, String needle) {
+ int n = 0;
+ for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + needle.length())) n++;
+ return n;
+ }
+
+ // === the gate (Table A) ==================================================
+
+ @Test
+ public void onlyAViewBackedReportIsServed() throws Exception {
+ MetaDataLoader loader = load("gate", withModel());
+ MetaObject totals = loader.getMetaObjectByName(PKG + "StoreTotals");
+ MetaObject daily = loader.getMetaObjectByName(PKG + "DailyRevenue");
+ MetaObject purchase = loader.getMetaObjectByName(PKG + "Purchase");
+
+ assertTrue("a report over a view is served", RestSurfaceGate.isServedReport(totals));
+ assertFalse("a sourceless report is not", RestSurfaceGate.isServedReport(daily));
+ assertFalse("an entity is not a report", RestSurfaceGate.isServedReport(purchase));
+
+ MetaObject shape = RestSurfaceGate.restShapeOf(totals);
+ assertTrue("a served report is generated from its read model", shape instanceof ReportReadModel);
+ assertTrue("the read model is itself a served report", RestSurfaceGate.isServedReport(shape));
+ assertSame("a read model is its own shape", shape, RestSurfaceGate.restShapeOf(shape));
+ assertNull("a sourceless report has no shape", RestSurfaceGate.restShapeOf(daily));
+ assertSame("any other object is its own shape", purchase, RestSurfaceGate.restShapeOf(purchase));
+
+ assertTrue("the declared report is read-only", RestSurfaceGate.isReadOnly(totals));
+ assertTrue("and so is its read model", RestSurfaceGate.isReadOnly(shape));
+ assertFalse("a sourceless report has no surface at all", RestSurfaceGate.emitsRestSurface(daily));
+ assertFalse("a report has no identity, so no item route", RestSurfaceGate.hasItemRoute(shape));
+ }
+
+ @Test
+ public void aReportOverAnyOtherReadOnlyKindIsNotServed() throws Exception {
+ // Table A: the lowering skips these kinds, so no relation with the derived columns
+ // is promised.
+ String json = """
+ { "metadata.root": { "package": "acme::shop", "children": [
+ { "object.entity": { "name": "Sale", "children": [
+ { "source.rdb": { "@table": "sales" } },
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "pk", "@fields": ["id"] } },
+ { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } }
+ ] } },
+ { "object.report": { "name": "Materialized", "@from": "Sale", "@measures": ["sales"], "children": [
+ { "source.rdb": { "@kind": "materializedView", "@materializedView": "mv_sales" } }
+ ] } }
+ ] } }
+ """;
+ MetaDataLoader loader = load("mv", json);
+ MetaObject report = loader.getMetaObjectByName(PKG + "Materialized");
+ assertFalse(RestSurfaceGate.isServedReport(report));
+ assertNull(RestSurfaceGate.restShapeOf(report));
+ assertFalse(RestSurfaceGate.emitsRestSurface(report));
+ assertEquals(List.of(), filesNamed(generateAll("mv-gen", json), "Materialized"));
+ }
+
+ @Test
+ public void controllerRepositoryAndAllowlistAgreeOnEveryObjectAndEveryShape() throws Exception {
+ MetaDataLoader loader = load("lockstep", withModel());
+ List objects = new ArrayList<>(loader.getMetaObjects());
+ objects.add(RestSurfaceGate.restShapeOf(loader.getMetaObjectByName(PKG + "StoreTotals")));
+ for (MetaObject obj : objects) {
+ boolean controller = SpringControllerGenerator.appliesTo(obj);
+ assertEquals(obj.getName() + ": repository must agree with controller",
+ controller, SpringRepositoryGenerator.appliesTo(obj));
+ assertEquals(obj.getName() + ": filter allowlist must agree with controller",
+ controller, SpringFilterAllowlistGenerator.appliesTo(obj));
+ }
+ }
+
+ // === what is emitted (Table E) ===========================================
+
+ @Test
+ public void aServedReportEmitsExactlyItsFourFilesAndASourcelessOneNothing() throws Exception {
+ Path gen = generateAll("files", withModel());
+ assertEquals(
+ List.of("StoreTotalsController.java", "StoreTotalsDto.java",
+ "StoreTotalsFilterAllowlist.java", "StoreTotalsRepository.java"),
+ filesNamed(gen, "StoreTotals"));
+ assertEquals(List.of(), filesNamed(gen, "DailyRevenue"));
+ assertEquals(List.of(), filesNamed(gen, "ProgramEngagement"));
+ }
+
+ @Test
+ public void theDtoIsOneComponentPerDerivedFieldAndIsNotConstructable() throws Exception {
+ String src = Files.readString(generateAll("dto", withModel()).resolve("acme/shop/StoreTotalsDto.java"));
+ assertTrue(src, src.contains("public record StoreTotalsDto("));
+ assertTrue("a count is a required long", src.contains("@NotNull Long purchases"));
+ assertTrue(src.contains("Long buyers"));
+ assertTrue("a sum of currency is integer minor units, nullable", src.contains("Long revenue"));
+ assertFalse("a scoped sum is nullable", src.contains("@NotNull Long revenue"));
+ // A report row arrives from a query; nothing constructs one (the projection rule).
+ assertFalse("no builder", src.contains("builder()"));
+ }
+
+ /**
+ * A derived enum field (an attribute dimension over a {@code field.enum}) is typed by an
+ * enum NESTED IN THE REPORT'S OWN DTO, named {@code }, in the report's
+ * package. It does not reuse the {@code @of} entity's enum type: the read model carries
+ * the members ({@code @values}) and no {@code extends}, so the row DTO stays
+ * self-contained whatever package the {@code @of} entity lives in.
+ */
+ @Test
+ public void aDerivedEnumFieldIsTypedByAnEnumNestedInTheReportsOwnDto() throws Exception {
+ String fitness = Files.readString(
+ SpringTestFixtures.findCorpusRoot().resolve("canonical/meta.fitness.json"), StandardCharsets.UTF_8);
+ Path gen = generateAll("enum", fitness);
+ Path dto;
+ try (Stream s = Files.walk(gen)) {
+ dto = s.filter(p -> p.getFileName().toString().equals("ProgramsByMonthDto.java")).findFirst().orElseThrow();
+ }
+ String src = Files.readString(dto);
+ assertTrue(src, src.contains("ProgramsByMonthStatus status"));
+ assertTrue(src, src.contains("public enum ProgramsByMonthStatus {"));
+ assertTrue("a time dimension at a date grain is a LocalDate", src.contains("java.time.LocalDate createdAtMonth"));
+ }
+
+ @Test
+ public void theRepositoryListsAndCountsAndHasNoIdentity() throws Exception {
+ String src = Files.readString(generateAll("seam", withModel()).resolve("acme/shop/StoreTotalsRepository.java"));
+ assertTrue(src.contains("List list(int limit, int offset, SortClause sort, List filters);"));
+ assertTrue(src.contains("long count(List filters);"));
+ assertFalse("no findById", src.contains("findById"));
+ assertFalse("no Optional import either", src.contains("Optional"));
+ assertFalse(src.contains(" create("));
+ assertFalse(src.contains(" update("));
+ assertFalse(src.contains(" patch("));
+ assertFalse(src.contains(" delete("));
+ assertTrue("it says what it is", src.contains("is a report over a database view"));
+ assertFalse(src.contains("projection"));
+ }
+
+ @Test
+ public void theAllowlistIsEveryDerivedFieldWithItsSubtypeBand() throws Exception {
+ String src = Files.readString(
+ generateAll("allowlist", withModel()).resolve("acme/shop/StoreTotalsFilterAllowlist.java"));
+ assertTrue(src, src.contains("FIELDS = Set.of(\"purchases\", \"buyers\", \"revenue\");"));
+ assertTrue("a measure takes the numeric band",
+ src.contains("\"purchases\", Set.of(\"eq\", \"ne\", \"gt\", \"gte\", \"lt\", \"lte\", \"in\", \"isNull\")"));
+ }
+
+ @Test
+ public void theControllerServesTheListAndRefusesOnlyTheCollectionPost() throws Exception {
+ String src = Files.readString(generateAll("controller", withModel()).resolve("acme/shop/StoreTotalsController.java"));
+ assertTrue("the segment is the report name, snake_cased and pluralized",
+ src.contains("@RequestMapping(\"/api/store_totals\")"));
+ assertEquals("one GET", 1, occurrences(src, "@GetMapping"));
+ assertEquals("one POST", 1, occurrences(src, "@PostMapping"));
+ assertFalse("no item route of any verb", src.contains("/{id}"));
+ assertFalse(src.contains("@PathVariable"));
+ assertFalse(src.contains("RequestMethod"));
+ assertFalse(src.contains("findById"));
+ assertTrue(src.contains("Map.of(\"error\", \"method_not_allowed\""));
+ assertTrue("the 405 says what it refused",
+ src.contains("verb + \" is not supported on a report (read-only).\""));
+ assertFalse("nothing calls a report a projection", src.contains("projection"));
+ assertTrue("every derived field is sortable",
+ src.contains("SORT_ALLOWLIST = Set.of(\"purchases\", \"buyers\", \"revenue\");"));
+ assertTrue(src.contains("StoreTotalsFilterAllowlist.FIELDS"));
+ }
+
+ // === the refusal carried over from Plan 2 ================================
+
+ @Test
+ public void aServedReportOverAFieldObjectStopsEveryGeneratorNamingTheReport() throws Exception {
+ String json = """
+ { "metadata.root": { "package": "acme::shop", "children": [
+ { "object.value": { "name": "Address", "children": [
+ { "field.string": { "name": "city" } }
+ ] } },
+ { "object.entity": { "name": "Sale", "children": [
+ { "source.rdb": { "@table": "sales" } },
+ { "field.long": { "name": "id" } },
+ { "identity.primary": { "name": "pk", "@fields": ["id"] } },
+ { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "jsonb" } },
+ { "dimension.attribute": { "name": "destination", "@of": "Sale.shipTo" } },
+ { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } }
+ ] } },
+ { "object.report": { "name": "SalesByDestination", "@from": "Sale",
+ "@dimensions": ["destination"], "@measures": ["sales"], "children": [
+ { "source.rdb": { "@kind": "view", "@view": "v_sales_by_destination" } }
+ ] } }
+ ] } }
+ """;
+ MetaDataLoader loader = load("object-dim", json);
+ Map args = new HashMap<>();
+ args.put("outputDir", tmp.newFolder().toString());
+ for (com.metaobjects.generator.direct.MultiFileDirectGeneratorBase> g : surface()) {
+ g.setArgs(args);
+ try {
+ g.execute(loader);
+ fail(g.getClass().getSimpleName() + " must refuse a served report over a field.object");
+ } catch (MetaDataException e) {
+ assertTrue(g.getClass().getSimpleName() + ": " + e.getMessage(),
+ e.getMessage().startsWith("report 'SalesByDestination': dimension 'destination'"));
+ }
+ }
+ }
+
+ // === api docs (Table G) ==================================================
+
+ @Test
+ public void apiDocsDocumentAServedReportAndNothingForASourcelessOne() throws Exception {
+ List units = new JavaApiModelBuilder().build(load("docs", withModel()), "shop").units();
+ List names = units.stream().map(ApiUnit::node).collect(Collectors.toList());
+ assertTrue(names.toString(), names.contains("StoreTotals"));
+ assertFalse(names.contains("DailyRevenue"));
+ assertFalse(names.contains("ProgramEngagement"));
+
+ ApiUnit totals = units.stream().filter(u -> u.node().equals("StoreTotals")).findFirst().orElseThrow();
+ assertEquals("report", totals.kind());
+ Map> byKind = new HashMap<>();
+ for (ApiSymbol s : totals.symbols()) {
+ byKind.computeIfAbsent(s.kind(), k -> new ArrayList<>()).add(s.name());
+ }
+ assertEquals("the row model", List.of("StoreTotalsDto"), byKind.get(ApiSymbolKind.DTO));
+ assertEquals("GET the served path and nothing else",
+ List.of("GET /api/store_totals"), byKind.get(ApiSymbolKind.REST));
+ assertEquals(List.of("StoreTotalsRepository"), byKind.get(ApiSymbolKind.DATA_ACCESS));
+ assertEquals(List.of("StoreTotalsFilterAllowlist"), byKind.get(ApiSymbolKind.FILTER));
+ assertNull("no in-memory model class is generated for a report", byKind.get(ApiSymbolKind.MODEL));
+ assertNull("a report is never a create or update body", byKind.get(ApiSymbolKind.VALIDATION));
+
+ ApiSymbol repo = totals.symbols().stream()
+ .filter(s -> s.kind() == ApiSymbolKind.DATA_ACCESS).findFirst().orElseThrow();
+ assertFalse("no findById in the documented seam", repo.signature().contains("findById"));
+ ApiSymbol dto = totals.symbols().stream()
+ .filter(s -> s.kind() == ApiSymbolKind.DTO).findFirst().orElseThrow();
+ assertEquals("one documented field per derived field", 3, dto.fields().size());
+ }
+}
diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java
new file mode 100644
index 000000000..c65ee4695
--- /dev/null
+++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java
@@ -0,0 +1,57 @@
+package com.metaobjects.integration.api;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Locates the FR-044 view-backed-report sub-corpus
+ * ({@code fixtures/api-contract-conformance/report/}) and loads its seed rows.
+ *
+ * {@code seed.json} has two halves. {@code invoices} is the base table, used by the
+ * full-stack lanes. This lane is a SEAM lane: its in-memory repository stands in for the
+ * view, so it is seeded from {@code reports}, which is what the three views return for
+ * those invoices. No SQL for a report is produced or run here (ADR-0015: view SQL is
+ * TypeScript's). A TypeScript test holds the two halves together.
+ */
+final class ReportCorpus {
+ private ReportCorpus() {}
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** The three served reports, in declaration order. */
+ static final List SERVED = List.of("InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals");
+
+ /** The report that declares no view and must generate nothing. */
+ static final String SOURCELESS = "InvoiceDays";
+
+ static Path root() {
+ return ApiContractScenarioLoader.findCorpusRoot().resolve("report");
+ }
+
+ static Path scenariosDir() {
+ return root().resolve("scenarios");
+ }
+
+ /** The rows {@code report}'s view returns: {@code reports.} from {@code report/seed.json}. */
+ @SuppressWarnings("unchecked")
+ static List> seedRows(String report) {
+ try {
+ String text = Files.readString(root().resolve("seed.json"), StandardCharsets.UTF_8);
+ Map parsed = MAPPER.readValue(text, Map.class);
+ if (!(parsed.get("reports") instanceof Map, ?> reports))
+ throw new IllegalStateException("report/seed.json: missing 'reports' object");
+ if (!(reports.get(report) instanceof List> rows))
+ throw new IllegalStateException("report/seed.json: missing 'reports." + report + "' array");
+ return (List>) rows;
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java
new file mode 100644
index 000000000..4c0b16357
--- /dev/null
+++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java
@@ -0,0 +1,82 @@
+package com.metaobjects.integration.api;
+
+import com.metaobjects.integration.api.ApiContractScenarios.ApiRequest;
+import com.metaobjects.integration.api.ApiContractScenarios.ApiScenario;
+import com.metaobjects.integration.api.generated.GeneratedReportControllerHarness;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * FR-044 Plan 3 — the Java GENERATED-controller lane for the view-backed-report
+ * api-contract sub-corpus. Boots the three GENERATED Spring report controllers on one
+ * embedded Tomcat, each behind an in-memory repository seam seeded with what its view
+ * returns, and drives all twelve scenarios.
+ *
+ * Generated lane ONLY, on purpose and on every port (see the sub-corpus README). What
+ * is under test is whether the port's GENERATOR emits a read route for a served report,
+ * and nothing for a sourceless one. A hand-rolled reference server would answer every
+ * scenario by construction.
+ *
+ * Run on-demand:
+ * {@code mvn -f server/java/integration-tests/pom.xml test -Dtest=ReportGeneratedApiContractConformanceTest}
+ */
+@DisplayName("API contract report — GENERATED Spring controllers (codegen-spring) over embedded Tomcat")
+final class ReportGeneratedApiContractConformanceTest {
+
+ private static final List SCENARIOS =
+ ApiContractScenarioLoader.loadScenarios(ReportCorpus.scenariosDir());
+
+ private static GeneratedReportControllerHarness HARNESS;
+
+ @BeforeAll
+ static void setUp() throws Exception {
+ Map>> seed = new LinkedHashMap<>();
+ for (String report : ReportCorpus.SERVED) seed.put(report, ReportCorpus.seedRows(report));
+ Path genDir = Files.createTempDirectory("report-generated-controllers");
+ HARNESS = new GeneratedReportControllerHarness(ReportCorpus.root(), genDir, seed, ReportCorpus.SOURCELESS);
+ }
+
+ @AfterAll
+ static void tearDown() throws Exception {
+ if (HARNESS != null) HARNESS.close();
+ }
+
+ static Stream scenarios() {
+ return SCENARIOS.stream().map(s -> Arguments.of(s.name(), s));
+ }
+
+ @Test
+ void theCorpusCarriesItsTwelveScenarios() {
+ // A scenarios directory that resolved to nothing would leave this lane green and empty.
+ assertEquals(12, SCENARIOS.size());
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("scenarios")
+ void scenario(String name, ApiScenario scenario) {
+ assertDoesNotThrow(() -> {
+ HARNESS.reset();
+ for (ApiRequest req : scenario.requests()) {
+ GeneratedReportControllerHarness.Response res =
+ HARNESS.exchange(req.method(), req.path(), req.body());
+ Object parsed = HARNESS.parseBody(res.body());
+ ApiContractAssertions.assertResponse(scenario.name(), req, res.status(), parsed);
+ }
+ });
+ }
+}
diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedReportControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedReportControllerHarness.java
new file mode 100644
index 000000000..91b0dad40
--- /dev/null
+++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedReportControllerHarness.java
@@ -0,0 +1,232 @@
+package com.metaobjects.integration.api.generated;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.metaobjects.generator.spring.SpringControllerGenerator;
+import com.metaobjects.generator.spring.SpringDtoGenerator;
+import com.metaobjects.generator.spring.SpringFilterAllowlistGenerator;
+import com.metaobjects.generator.spring.SpringRepositoryGenerator;
+import com.metaobjects.integration.api.TomcatHost;
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.loader.uri.URIHelper;
+
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileObject;
+import javax.tools.ToolProvider;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * FR-044 Plan 3 — host the GENERATED Java Spring {@code @RestController} of every served
+ * report in the {@code report/} api-contract sub-corpus over real HTTP (one embedded
+ * Tomcat, {@link TomcatHost}) and drive the scenarios against them. Sibling of
+ * {@link GeneratedProjectionControllerHarness}.
+ *
+ * The artifacts under test are the GENERATED {@code Controller} (the list route
+ * and a 405 on the collection {@code POST}, no item route), the {@code Dto} row,
+ * the {@code FilterAllowlist} and the read-only {@code Repository}
+ * interface. The only hand-written piece is {@link InMemoryReportRepositorySource}, the
+ * consumer seam, which stands in for the report's view and is seeded with what that view
+ * returns. No SQL for a report is produced or run here (ADR-0015).
+ *
+ * The WHOLE model is generated and compiled, the writable {@code Invoice} entity
+ * included, because the interesting failure is a gate that admits one shape and breaks
+ * another. Only the report controllers are MOUNTED: no scenario exercises the entity.
+ */
+public final class GeneratedReportControllerHarness implements AutoCloseable {
+
+ private static final String ENTITY_PKG = "acme.sales";
+
+ /** A served report's generated row type and the constructors a scenario rebuilds it with. */
+ private record Mount(Class> dtoClass, Constructor> repoCtor, Constructor> controllerCtor,
+ List> seedRows) {}
+
+ /**
+ * {@code JavaTimeModule}, with dates as ISO strings: a time dimension at a grain is a
+ * {@code LocalDate} component and must reach the wire as {@code YYYY-MM-DD}. This is the
+ * mapper a Spring Boot application gets by default. Nulls are written (Jackson's
+ * default), so a null measure is a present key.
+ */
+ private final ObjectMapper mapper = new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+
+ private final URLClassLoader classLoader;
+ private final Map mounts = new LinkedHashMap<>();
+
+ private TomcatHost host;
+
+ /**
+ * @param seedByReport the rows each served report's view returns, keyed by report name
+ * @param sourceless a report in the model that declares no view and must generate nothing
+ */
+ public GeneratedReportControllerHarness(Path corpusRoot, Path genDir,
+ Map>> seedByReport,
+ String sourceless) throws Exception {
+ Path srcDir = genDir.resolve("src");
+ Path classesDir = genDir.resolve("classes");
+ Files.createDirectories(srcDir);
+ Files.createDirectories(classesDir);
+
+ MetaDataLoader loader = loadCorpus(corpusRoot.resolve("meta.json"));
+
+ runGenerator(new SpringControllerGenerator(), loader, srcDir);
+ runGenerator(new SpringDtoGenerator(), loader, srcDir);
+ runGenerator(new SpringRepositoryGenerator(), loader, srcDir);
+ runGenerator(new SpringFilterAllowlistGenerator(), loader, srcDir);
+
+ Path pkgDir = srcDir.resolve(ENTITY_PKG.replace('.', '/'));
+
+ // Every served report's controller must have been emitted at all: a silently
+ // skipped generator would otherwise surface as a ClassNotFoundException with no
+ // hint that codegen, not the harness, was the cause.
+ for (String report : seedByReport.keySet()) {
+ Path emitted = pkgDir.resolve(report + "Controller.java");
+ if (!Files.exists(emitted)) {
+ throw new IllegalStateException(
+ "no controller was generated for the served report " + report + " at " + emitted
+ + " — the FR-044 emit gate did not admit a view-backed object.report");
+ }
+ }
+ // ...and a sourceless report must have generated NOTHING. It sits in the model so
+ // that a port which serves every report it finds fails here.
+ try (Stream s = Files.list(pkgDir)) {
+ List leaked = s.map(p -> p.getFileName().toString())
+ .filter(n -> n.startsWith(sourceless))
+ .sorted().collect(Collectors.toList());
+ if (!leaked.isEmpty()) {
+ throw new IllegalStateException(
+ "the sourceless report " + sourceless + " declares no view and must stay inert,"
+ + " but codegen emitted " + leaked);
+ }
+ }
+
+ for (String report : seedByReport.keySet()) {
+ Files.writeString(pkgDir.resolve(InMemoryReportRepositorySource.simpleName(report) + ".java"),
+ InMemoryReportRepositorySource.source(report));
+ }
+
+ compile(srcDir, classesDir);
+
+ this.classLoader = new URLClassLoader(
+ new URL[]{ classesDir.toUri().toURL() }, getClass().getClassLoader());
+ for (Map.Entry>> e : seedByReport.entrySet()) {
+ String report = e.getKey();
+ Class> dtoClass = classLoader.loadClass(ENTITY_PKG + "." + report + "Dto");
+ Class> repoInterface = classLoader.loadClass(ENTITY_PKG + "." + report + "Repository");
+ // (Repository) — no ObjectMapper, no Validator: nothing here binds a body.
+ Constructor> controllerCtor =
+ classLoader.loadClass(ENTITY_PKG + "." + report + "Controller").getDeclaredConstructor(repoInterface);
+ Constructor> repoCtor =
+ classLoader.loadClass(InMemoryReportRepositorySource.fqcn(report)).getDeclaredConstructor(List.class);
+ mounts.put(report, new Mount(dtoClass, repoCtor, controllerCtor, e.getValue()));
+ }
+ }
+
+ /** Re-seed for a scenario: fresh repositories and controllers, all on one Tomcat. */
+ public void reset() throws Exception {
+ List controllers = new ArrayList<>();
+ for (Mount m : mounts.values()) {
+ List dtos = new ArrayList<>();
+ // The seed row becomes the generated row type: "2026-04-01" a LocalDate, "0.4"
+ // a BigDecimal (from the string, no double in between), null a null component.
+ for (Map row : m.seedRows()) dtos.add(mapper.convertValue(row, m.dtoClass()));
+ controllers.add(m.controllerCtor().newInstance(m.repoCtor().newInstance(dtos)));
+ }
+ if (host != null) host.close();
+ this.host = TomcatHost.start(mapper, controllers.toArray());
+ }
+
+ public Response exchange(String method, String path, Object jsonBody) throws Exception {
+ TomcatHost.Response res = host.exchange(method, path, jsonBody == null ? null : mapper.writeValueAsString(jsonBody));
+ return new Response(res.status(), res.body());
+ }
+
+ public Object parseBody(String body) {
+ return TomcatHost.parseBody(mapper, body);
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (host != null) host.close();
+ classLoader.close();
+ }
+
+ public record Response(int status, String body) {}
+
+ // -----------------------------------------------------------------------
+ // setup helpers (mirror GeneratedProjectionControllerHarness)
+ // -----------------------------------------------------------------------
+
+ private static MetaDataLoader loadCorpus(Path metaJson) {
+ URI uri = URIHelper.toURI(
+ "model:file:" + metaJson.toAbsolutePath().toString().replace('\\', '/'));
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, true),
+ MetaDataLoader.SUBTYPE_MANUAL,
+ "api-contract-report-generated");
+ loader.setSourceURIs(List.of(uri));
+ loader.init();
+ return loader;
+ }
+
+ private static void runGenerator(Object generator, MetaDataLoader loader, Path outDir) {
+ Map args = new HashMap<>();
+ args.put("outputDir", outDir.toString());
+ ((com.metaobjects.generator.direct.MultiFileDirectGeneratorBase>) generator).setArgs(args);
+ ((com.metaobjects.generator.direct.MultiFileDirectGeneratorBase>) generator).execute(loader);
+ }
+
+ private static void compile(Path srcDir, Path classesDir) throws Exception {
+ List sources;
+ try (Stream s = Files.walk(srcDir)) {
+ sources = s.filter(p -> p.toString().endsWith(".java"))
+ .map(Path::toFile)
+ .collect(Collectors.toList());
+ }
+ if (sources.isEmpty()) {
+ throw new IllegalStateException("no generated .java sources under " + srcDir);
+ }
+
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ if (javac == null) {
+ throw new IllegalStateException(
+ "JDK (not JRE) required — ToolProvider.getSystemJavaCompiler() returned null");
+ }
+ String cp = System.getProperty("java.class.path");
+ DiagnosticCollector diags = new DiagnosticCollector<>();
+ var fm = javac.getStandardFileManager(diags, null, StandardCharsets.UTF_8);
+ List opts = List.of("-classpath", cp, "-d", classesDir.toString(), "-parameters");
+
+ boolean ok = javac.getTask(null, fm, diags, opts, null,
+ fm.getJavaFileObjectsFromFiles(sources)).call();
+ if (!ok) {
+ StringBuilder sb = new StringBuilder("generated sources failed to compile:\n");
+ for (var d : diags.getDiagnostics()) {
+ sb.append(" ").append(d.getKind()).append(": ").append(d.getMessage(null)).append('\n');
+ if (d.getSource() != null) {
+ sb.append(" at ").append(d.getSource().getName())
+ .append(':').append(d.getLineNumber()).append('\n');
+ }
+ }
+ throw new IllegalStateException(sb.toString());
+ }
+ }
+}
diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java
new file mode 100644
index 000000000..da53e7c7c
--- /dev/null
+++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java
@@ -0,0 +1,192 @@
+package com.metaobjects.integration.api.generated;
+
+/**
+ * The Java SOURCE for an in-memory {@code acme.sales.Repository} impl: ONE generic
+ * template, instantiated once per served report by substituting the report's name. Emitted
+ * alongside the GENERATED controller / DTO / interface so it compiles against them, then
+ * loaded and instantiated reflectively by {@link GeneratedReportControllerHarness}.
+ *
+ * The consumer seam MetaObjects leaves unimplemented, and for a report it is
+ * {@code list} and {@code count} only. If the generator ever emitted {@code findById} or a
+ * write method on a report's repository interface, this class would stop compiling.
+ *
+ * It stands in for the report's SQL view: the seeded rows are what that view returns.
+ * Test scaffolding, not a conformance subject. Its job is to apply the controller-supplied
+ * {@code List}, {@code SortClause} and {@code limit/offset} faithfully, so
+ * the GENERATED controller's qs to predicate to repository translation is exercised end to
+ * end. Envelopes and status codes are the generated controller's job.
+ *
+ * Generic over the row: a column is read through the DTO record's own component
+ * accessor and an operand is coerced by that component's declared type, so nothing here
+ * names a report's fields. A decimal compares as {@link java.math.BigDecimal} (exact, and
+ * scale-blind: {@code 0.4} equals {@code 0.40}), never through a double.
+ */
+final class InMemoryReportRepositorySource {
+
+ private InMemoryReportRepositorySource() {}
+
+ private static final String PKG = "acme.sales";
+ private static final String NAME = "__REPORT__";
+
+ /** Simple name of the emitted impl for {@code report}. */
+ static String simpleName(String report) {
+ return "InMemory" + report + "Repository";
+ }
+
+ /** Fully-qualified name of the emitted impl for {@code report}. */
+ static String fqcn(String report) {
+ return PKG + "." + simpleName(report);
+ }
+
+ /** The impl source for {@code report}. */
+ static String source(String report) {
+ return TEMPLATE.replace(NAME, report);
+ }
+
+ private static final String TEMPLATE = """
+ package acme.sales;
+
+ import com.metaobjects.generator.spring.runtime.FilterPredicate;
+
+ import java.lang.reflect.RecordComponent;
+ import java.math.BigDecimal;
+ import java.time.LocalDate;
+ import java.util.ArrayList;
+ import java.util.Comparator;
+ import java.util.List;
+
+ /**
+ * Hand-written in-memory {@link __REPORT__Repository} (the read-only consumer seam).
+ * Stands in for the report's SQL view. NOT a conformance subject: test scaffolding.
+ */
+ public final class InMemory__REPORT__Repository implements __REPORT__Repository {
+
+ private final List<__REPORT__Dto> rows = new ArrayList<>();
+
+ public InMemory__REPORT__Repository(List<__REPORT__Dto> seed) {
+ rows.addAll(seed);
+ }
+
+ @Override
+ public List<__REPORT__Dto> list(int limit, int offset, SortClause sort, List filters) {
+ List<__REPORT__Dto> out = new ArrayList<>();
+ for (__REPORT__Dto r : rows) if (matchesAll(r, filters)) out.add(r);
+ // No sort: the view's own order, which is the seed's.
+ if (sort != null) out.sort(comparatorFor(sort));
+ int from = Math.min(offset, out.size());
+ int to = Math.min(from + limit, out.size());
+ return new ArrayList<>(out.subList(from, to));
+ }
+
+ @Override
+ public long count(List filters) {
+ long n = 0;
+ for (__REPORT__Dto r : rows) if (matchesAll(r, filters)) n++;
+ return n;
+ }
+
+ // --- the row, read generically ------------------------------------------------
+
+ private static RecordComponent component(String field) {
+ for (RecordComponent c : __REPORT__Dto.class.getRecordComponents()) {
+ if (c.getName().equals(field)) return c;
+ }
+ throw new IllegalStateException("unknown column: " + field);
+ }
+
+ private static Object column(__REPORT__Dto r, String field) {
+ try {
+ return component(field).getAccessor().invoke(r);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("cannot read column " + field, e);
+ }
+ }
+
+ /** A raw query operand as the column's own type, so the comparison is typed. */
+ private static Comparable operand(String field, String raw) {
+ Class> type = component(field).getType();
+ Object value;
+ if (type == Long.class) value = Long.valueOf(raw);
+ else if (type == Integer.class) value = Integer.valueOf(raw);
+ else if (type == Double.class) value = Double.valueOf(raw);
+ else if (type == Float.class) value = Float.valueOf(raw);
+ else if (type == BigDecimal.class) value = new BigDecimal(raw);
+ else if (type == LocalDate.class) value = LocalDate.parse(raw);
+ else if (type == Boolean.class) value = Boolean.valueOf(raw);
+ else if (type == String.class) value = raw;
+ else throw new IllegalStateException("no operand coercion for " + type.getName() + " (" + field + ")");
+ return comparable(value);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Comparable comparable(Object value) {
+ return (Comparable) value;
+ }
+
+ // --- predicate application ---------------------------------------------------
+
+ private static boolean matchesAll(__REPORT__Dto r, List filters) {
+ if (filters == null) return true;
+ for (FilterPredicate p : filters) if (!matches(r, p)) return false; // implicit AND
+ return true;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static boolean matches(__REPORT__Dto r, FilterPredicate p) {
+ Object col = column(r, p.field());
+ switch (p.op()) {
+ case "isNull": {
+ boolean wantNull = Boolean.TRUE.equals(p.value());
+ return wantNull == (col == null);
+ }
+ case "in": {
+ if (col == null) return false;
+ for (String item : (List) p.value()) {
+ if (comparable(col).compareTo(operand(p.field(), item)) == 0) return true;
+ }
+ return false;
+ }
+ case "like": {
+ if (col == null) return false;
+ return sqlLike(String.valueOf(col), (String) p.value());
+ }
+ default: {
+ if (col == null) return false;
+ // compareTo, never equals: BigDecimal.equals is scale-sensitive.
+ int cmp = comparable(col).compareTo(operand(p.field(), (String) p.value()));
+ return switch (p.op()) {
+ case "eq" -> cmp == 0;
+ case "ne" -> cmp != 0;
+ case "gt" -> cmp > 0;
+ case "gte" -> cmp >= 0;
+ case "lt" -> cmp < 0;
+ case "lte" -> cmp <= 0;
+ default -> throw new IllegalStateException("unknown op: " + p.op());
+ };
+ }
+ }
+ }
+
+ /** SQL LIKE with `%` (any run) and `_` (any single char), anchored full-string match. */
+ private static boolean sqlLike(String value, String pattern) {
+ StringBuilder re = new StringBuilder("^");
+ for (int i = 0; i < pattern.length(); i++) {
+ char c = pattern.charAt(i);
+ if (c == '%') re.append(".*");
+ else if (c == '_') re.append('.');
+ else re.append(java.util.regex.Pattern.quote(String.valueOf(c)));
+ }
+ re.append("$");
+ return value.matches(re.toString());
+ }
+
+ private static Comparator<__REPORT__Dto> comparatorFor(SortClause sort) {
+ String field = sort.field();
+ component(field); // an unknown sort column is a harness bug, not an empty sort
+ Comparator<__REPORT__Dto> c = Comparator.comparing(
+ r -> comparable(column(r, field)), Comparator.nullsLast(Comparator.naturalOrder()));
+ return "desc".equalsIgnoreCase(sort.direction()) ? c.reversed() : c;
+ }
+ }
+ """;
+}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java
index e5ae0ec81..6c06543a2 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java
@@ -32,6 +32,7 @@
import com.metaobjects.field.ObjectField;
import com.metaobjects.object.MetaObject;
import com.metaobjects.object.ReportMetaObject;
+import com.metaobjects.query.FilterOps;
import com.metaobjects.source.MetaSource;
import java.util.List;
@@ -72,7 +73,8 @@ public final class ReportReadModel extends ReportMetaObject {
* with the RESOLVING accessor (ADR-0039) so a value the {@code @of} field inherits
* through {@code extends} is carried too. {@code @dbColumnType} and array-ness are
* handled separately. Nothing else is carried: no {@code @column}, {@code @required},
- * {@code @default}, validators or views.
+ * {@code @default}, validators or views. ({@code @required} and {@code @filterable} are
+ * SET on a derived field from the derived shape, never copied from the type source.)
*/
private static final List CARRIED_ATTRS = List.of(
CurrencyField.ATTR_CURRENCY,
@@ -196,8 +198,20 @@ private static MetaField> derivedField(ReportShape.Field f) {
// is still nullable, and a dimension reached by @via is nullable.
field.addMetaAttr(BooleanAttribute.create(MetaField.ATTR_REQUIRED, f.required()));
MetaField> src = f.typeSource();
- if (src == null) return field;
+ if (src != null) carryTypeShape(src, field);
+ // Table C (Plan 3): a report author has no node to put @filterable on, so every
+ // derived field whose subtype has a filter band is filterable (a measure as much as
+ // a dimension). Set on this detached model only: no vocabulary is added and the
+ // declared tree is not touched. Read after the type shape is carried, because the
+ // band is field-level (an int-backed enum drops `like`), and resolving (ADR-0039).
+ if (!FilterOps.opsForField(field).isEmpty()) {
+ field.addMetaAttr(BooleanAttribute.create(MetaField.ATTR_FILTERABLE, true));
+ }
+ return field;
+ }
+ /** Copy the Table B type-shaping attrs and the array-ness of {@code src} onto {@code field}. */
+ private static void carryTypeShape(MetaField> src, MetaField> field) {
for (String name : CARRIED_ATTRS) {
if (src.hasMetaAttr(name)) field.addMetaAttr(copyAttr(src.getMetaAttr(name)));
}
@@ -210,7 +224,6 @@ private static MetaField> derivedField(ReportShape.Field f) {
}
// Array-ness is a native flag, not an attr; isArrayType() is its resolving read.
if (src.isArrayType()) field.setArray(true);
- return field;
}
/**
diff --git a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java
index d9bc51e28..244dbb42d 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java
@@ -27,6 +27,7 @@
import com.metaobjects.loader.MetaDataLoader;
import com.metaobjects.loader.InMemoryStringSource;
import com.metaobjects.object.MetaObject;
+import com.metaobjects.query.FilterOps;
import com.metaobjects.registry.SharedRegistryTestBase;
import com.metaobjects.source.MetaSource;
import org.junit.BeforeClass;
@@ -168,7 +169,45 @@ public void theHourBucketCarriesLocalTimeAndADateBucketCarriesNothing() {
assertEquals(source.hasMetaAttr(CoreDBMetaDataProvider.LOCAL_TIME), hour.hasMetaAttr(CoreDBMetaDataProvider.LOCAL_TIME));
MetaField> week = model.getMetaField("asOfDateWeek");
assertEquals("date", week.getSubType());
- assertEquals("only @required", 1, week.getMetaAttrs().size());
+ assertEquals("only @required and the @filterable every banded derived field gets",
+ 2, week.getMetaAttrs().size());
+ assertTrue(week.hasMetaAttr(MetaField.ATTR_REQUIRED));
+ assertTrue(week.hasMetaAttr(MetaField.ATTR_FILTERABLE));
+ }
+
+ // ---------------------------------------------------------------------------
+ // Table C (Plan 3): every derived field with a filter band is filterable
+ // ---------------------------------------------------------------------------
+
+ @Test
+ public void everyDerivedFieldWithAFilterBandIsFilterable() {
+ int banded = 0;
+ // ADR-0039: own — the root's own children in declaration order (a root has no super).
+ for (MetaObject o : canonical.getChildren(MetaObject.class, false)) {
+ if (!ReportReadModel.isReport(o)) continue;
+ for (MetaField> f : ReportReadModel.of(o, canonical).getMetaFields()) {
+ boolean hasBand = !FilterOps.opsForField(f).isEmpty();
+ String label = o.getShortName() + "." + f.getName() + " (field." + f.getSubType() + ")";
+ assertEquals(label + ": @filterable is set exactly when the subtype has a filter band",
+ hasBand, f.hasMetaAttr(MetaField.ATTR_FILTERABLE));
+ if (hasBand) {
+ banded++;
+ assertEquals(label, Boolean.TRUE, f.getMetaAttr(MetaField.ATTR_FILTERABLE).getValue());
+ }
+ }
+ }
+ assertTrue("the canonical reports derive dimensions and measures: " + banded, banded > 10);
+ }
+
+ @Test
+ public void filterableIsSetOnTheModelOnlyAndNeverOnTheDeclaredTree() {
+ MetaRoot root = loadJson(SALES_MODEL);
+ ReportReadModel model = ReportReadModel.of(object(root, "SalesByRegion"), root);
+ for (String name : List.of("region", "payload", "revenue", "sales")) {
+ assertTrue(name + " (a measure is filterable too)", model.getMetaField(name).hasMetaAttr(MetaField.ATTR_FILTERABLE));
+ }
+ // The @of field declares no @filterable, and deriving the model does not add one.
+ assertFalse(object(root, "Sale").getMetaField("region").hasMetaAttr(MetaField.ATTR_FILTERABLE));
}
private static final String SALES_MODEL = """
From f88462155a1cfff0b4da90e141b18b5cdb824d18 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:43:59 -0400
Subject: [PATCH 11/21] feat(docs): model and API pages for reports in meta
docs (FR-044)
meta docs now documents object.report (Plan 3, Table G), TypeScript.
Model surface: every report gets a page, served or not, built from
reportShape: its @from (linked), its view or 'Not served: declares no
view source', its row scope, and a column table (name, type, nullable,
role, definition). The index lists reports under their own heading. An
entity that declares dimensions, measures or segments, or that a report
reads from, gains a Reporting section.
API surface: one unit for a served report, built from its read model:
the row model, the list query and GET (plus the Hono GET
when wired). No by-id, no write helper, no schema, no hook. A report
that is not served has no unit, and its model page links to none.
A keyless projection (no identity, no id column) no longer documents
GET /:id or findById: the generators stopped emitting both.
Site: the report skip in the link graph and the coverage audit's
deferred bucket are gone. A report is an object page with a Report
section, linked to its @from entity; the entity page gets the same
Reporting section; all reporting kinds and attrs count as rendered.
A model with no report renders every surface byte for byte as before
(pinned by snapshots taken before the change; no existing golden moved).
---
.../cli/test/unit/reporting-inert.test.ts | 138 +-
.../codegen-ts/src/generators/api-model.ts | 99 +-
.../src/generators/docs-data-builder.ts | 15 +-
.../codegen-ts/src/generators/docs-data.ts | 12 +
.../codegen-ts/src/generators/docs-file.ts | 27 +-
.../codegen-ts/src/generators/report-doc.ts | 209 +++
.../embedded-templates.generated.ts | 2 +-
.../templates/docs/entity-page.md.mustache | 12 +
.../__snapshots__/reporting-docs.test.ts.snap | 1537 +++++++++++++++++
.../codegen-ts/test/reporting-docs.test.ts | 375 ++++
.../docs-site/src/builders/object-data.ts | 14 +-
.../docs-site/src/builders/report-data.ts | 207 +++
.../packages/docs-site/src/coverage.ts | 37 +-
.../packages/docs-site/src/link-graph.ts | 21 +-
.../typescript/packages/docs-site/src/site.ts | 2 +
.../docs-site/templates/object.html.mustache | 22 +-
.../__snapshots__/reporting-site.test.ts.snap | 14 +
.../packages/docs-site/test/coverage.test.ts | 18 +-
.../docs-site/test/reporting-site.test.ts | 158 ++
templates/docs/entity-page.md.mustache | 12 +
20 files changed, 2830 insertions(+), 101 deletions(-)
create mode 100644 server/typescript/packages/codegen-ts/src/generators/report-doc.ts
create mode 100644 server/typescript/packages/codegen-ts/test/__snapshots__/reporting-docs.test.ts.snap
create mode 100644 server/typescript/packages/codegen-ts/test/reporting-docs.test.ts
create mode 100644 server/typescript/packages/docs-site/src/builders/report-data.ts
create mode 100644 server/typescript/packages/docs-site/test/__snapshots__/reporting-site.test.ts.snap
create mode 100644 server/typescript/packages/docs-site/test/reporting-site.test.ts
diff --git a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
index d438f66a6..ffcc77765 100644
--- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
+++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
@@ -18,9 +18,11 @@
// `source.rdb @kind: view` (R5 allows one): that is the case that once leaked in C#, where
// it emitted a keyless DbSet, a GET route and a filter allowlist for an object with no fields.
//
-// `meta docs` is still held to the Plan 1 rule here (controller ruling, 2026-10-03): every
-// docs surface — model pages, agent pages, requirements, the HTML site, and the api surface
-// — comes out identical with and without the reporting nodes, bar the one view entry.
+// `meta docs` documents reports since Plan 3 (Table G), and the last describe states the
+// difference exactly: a model page and a site page for every report, served or not; a
+// "Reporting" section on each entity that declares reporting nodes; one api unit, for the
+// served report alone; the schema page's one view entry. `agent/ui.md` does not move: no
+// UI tier is generated for a report. Everything else is byte-identical.
import { describe, test, expect, beforeAll } from "bun:test";
import { mkdtempSync, mkdirSync, copyFileSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -292,7 +294,7 @@ describe("FR-044 a sourceless report is inert in migrate; a view-backed report p
});
});
-describe("FR-044 reporting nodes are inert in meta docs, bar the one view entry", () => {
+describe("FR-044 meta docs differs by exactly the report pages, the Reporting sections and one api unit", () => {
/** Run `meta docs` over a project holding one variant, once per surface flag set, and
* read back everything written. The project directory has the SAME basename for both
* variants: the site stamps it into every page title. */
@@ -311,7 +313,7 @@ describe("FR-044 reporting nodes are inert in meta docs, bar the one view entry"
const files: Record = {};
for (const rel of walkFiles(root)) {
if (rel.split(sep)[0] === "metaobjects") continue;
- files[rel] = readFileSync(join(root, rel), "utf8");
+ files[rel.split(sep).join("/")] = readFileSync(join(root, rel), "utf8");
}
return files;
} finally {
@@ -319,13 +321,93 @@ describe("FR-044 reporting nodes are inert in meta docs, bar the one view entry"
}
}
- test("model, agent, requirements and site output are identical", async () => {
+ const REPORTS = ["DailyRevenue", "ProgramEngagement", "StoreTotals"] as const;
+ /** The entities that declare dimensions, measures or segments in the with-model. */
+ const REPORTING_ENTITIES = ["Purchase", "WorkoutEvent"] as const;
+ const SITE = "out--site/site";
+ const SITE_PKG = `${SITE}/acme/shop`;
+
+ /** The lines of `after` that are not the next unmatched line of `before`: what was
+ * inserted. Throws when `before` is not a subsequence of `after`, i.e. when a line
+ * was removed or rewritten rather than added. */
+ function insertedLines(before: string, after: string): string[] {
+ const want = before.split("\n");
+ const added: string[] = [];
+ let i = 0;
+ for (const line of after.split("\n")) {
+ if (i < want.length && line === want[i]) i++;
+ else added.push(line);
+ }
+ if (i !== want.length) throw new Error(`a line was removed or rewritten: ${JSON.stringify(want[i])}`);
+ return added;
+ }
+
+ test("model pages: a page per report, a Reports index list, a Reporting section on Purchase and WorkoutEvent", async () => {
const expected = await docsOutput("without");
const actual = await docsOutput("with");
- expect(Object.keys(expected).some((p) => p.endsWith(".html"))).toBe(true);
- expect(Object.keys(expected).some((p) => p.endsWith(".md"))).toBe(true);
- expect(Object.keys(actual)).toEqual(Object.keys(expected));
- expect(actual).toEqual(expected);
+ const model = (files: Record): string[] =>
+ Object.keys(files).filter((p) => p.startsWith("out/"));
+
+ expect(model(actual).filter((p) => !(p in expected))).toEqual(REPORTS.map((n) => `out/${n}.md`));
+ expect(model(expected).filter((p) => !(p in actual))).toEqual([]);
+
+ // A served report names its view; a sourceless one says it is not served (answer 7).
+ expect(actual["out/StoreTotals.md"]).toContain("**View:** `v_store_totals`");
+ for (const name of SOURCELESS_REPORTS) {
+ expect(actual[`out/${name}.md`]).toContain("**View:** Not served: declares no view source");
+ }
+
+ for (const path of model(expected)) {
+ const before = expected[path]!;
+ const after = actual[path]!;
+ if (path === "out/README.md") {
+ // The index gains the Reports list and nothing else: no entity entry and no
+ // diagram line moves.
+ expect(insertedLines(before, after)).toEqual(["## Reports", "", ...REPORTS.map((n) => `- [${n}](./${n}.md)`), ""]);
+ } else if (REPORTING_ENTITIES.some((e) => path === `out/${e}.md`)) {
+ // The page is what it was, with the Reporting section appended.
+ expect(after.startsWith(before + "\n## Reporting\n")).toBe(true);
+ } else {
+ expect({ path, content: after }).toEqual({ path, content: before });
+ }
+ }
+ });
+
+ test("agent and requirements output (no gen config) is identical", async () => {
+ const expected = await docsOutput("without");
+ const actual = await docsOutput("with");
+ const other = (files: Record): Record =>
+ Object.fromEntries(Object.entries(files).filter(([p]) => !p.startsWith("out/") && !p.startsWith(`${SITE}/`)));
+ expect(other(actual)).toEqual(other(expected));
+ });
+
+ test("site: a page per report, the Reporting sections, and no page lost", async () => {
+ const expected = await docsOutput("without");
+ const actual = await docsOutput("with");
+ const site = (files: Record): string[] =>
+ Object.keys(files).filter((p) => p.startsWith(`${SITE}/`));
+ expect(site(expected).some((p) => p.endsWith(".html"))).toBe(true);
+
+ expect(site(actual).filter((p) => !(p in expected))).toEqual(REPORTS.map((n) => `${SITE_PKG}/${n}.html`));
+ expect(site(expected).filter((p) => !(p in actual))).toEqual([]);
+
+ expect(actual[`${SITE_PKG}/StoreTotals.html`]).toContain("v_store_totals");
+ for (const name of SOURCELESS_REPORTS) {
+ expect(actual[`${SITE_PKG}/${name}.html`]).toContain("Not served: declares no view source");
+ }
+ for (const name of REPORTING_ENTITIES) {
+ expect(actual[`${SITE_PKG}/${name}.html`]).toContain('id="s-reporting"');
+ expect(expected[`${SITE_PKG}/${name}.html`]).not.toContain('id="s-reporting"');
+ }
+ // An entity with no reporting nodes changes by its sidebar alone: the three report
+ // links, in the package it shares with them.
+ const added = insertedLines(expected[`${SITE_PKG}/Program.html`]!, actual[`${SITE_PKG}/Program.html`]!);
+ expect(added.length).toBe(REPORTS.length);
+ for (const [i, name] of REPORTS.entries()) expect(added[i]).toContain(`${name}.html`);
+ // The stylesheet and script are the same bytes either way.
+ for (const asset of [`${SITE}/assets/site.css`, `${SITE}/assets/site.js`]) {
+ expect(actual[asset]).toBe(expected[asset]!);
+ }
});
/** The GenContext `meta docs` builds, with a full generator suite wired: the Hono
@@ -351,7 +433,7 @@ describe("FR-044 reporting nodes are inert in meta docs, bar the one view entry"
expect(actual).toEqual(expected);
};
- test("the api surface is identical", async () => {
+ test("the api surface gains one unit, for the served report: its row model, list query and GET", async () => {
// `meta docs --api` materializes only with a loadable gen config, which a temp project
// cannot import; so drive the generator with the GenContext `meta docs` builds.
const api = async (metadata: MetaRoot): Promise> => {
@@ -361,7 +443,36 @@ describe("FR-044 reporting nodes are inert in meta docs, bar the one view entry"
};
const expected = await api(withoutReporting);
expect(Object.keys(expected).length).toBeGreaterThan(2);
- compare(expected, await api(withReporting));
+ const actual = await api(withReporting);
+
+ // One new page, and no page for a report that is not served (answer 7).
+ expect(Object.keys(actual).filter((p) => !(p in expected))).toEqual(["api/StoreTotals.md"]);
+ expect(Object.keys(expected).filter((p) => !(p in actual))).toEqual([]);
+
+ // Answer 6: the unit is the row model, the list query function and GET
+ // (once per wired route surface). No by-id, no write, no schema, no hook.
+ const page = actual["api/StoreTotals.md"]!;
+ expect(page.split("\n").filter((l) => l.startsWith("### "))).toEqual([
+ "### `interface StoreTotals`",
+ "### `listStoreTotals(db: Db, opts?: { limit?: number; offset?: number }): Promise`",
+ "### `GET /api/store_totals`",
+ "### `GET /api/store_totals`",
+ ]);
+ expect(page).not.toMatch(/\buse[A-Z]\w*/);
+
+ // Every other page is byte-identical, bar the two indexes, which gain the report's
+ // entry and lose nothing.
+ for (const [path, before] of Object.entries(expected)) {
+ const after = actual[path]!;
+ if (path === "api/README.md" || path === "api/AGENT-API.md") {
+ const added = after.split("\n").filter((l) => !before.split("\n").includes(l));
+ expect(added.length).toBeGreaterThan(0);
+ for (const l of added) expect(l).toContain("StoreTotals");
+ for (const name of SOURCELESS_REPORTS) expect(after).not.toContain(name);
+ continue;
+ }
+ expect({ path, content: after }).toEqual({ path, content: before });
+ }
});
test("the agent surface differs only by the schema page's v_store_totals view, with the UI tier wired", async () => {
@@ -407,5 +518,8 @@ describe("FR-044 reporting nodes are inert in meta docs, bar the one view entry"
const rest = { ...expected };
delete rest[schemaPage];
compare(rest, actual);
+ // Answer 6: no UI tier is generated for a report, so the UI page names none.
+ const uiPage = Object.keys(actual).find((p) => p.endsWith("ui.md"))!;
+ for (const name of ["StoreTotals", ...SOURCELESS_REPORTS]) expect(actual[uiPage]).not.toContain(name);
});
});
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 f1cd745c1..57b374b71 100644
--- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts
@@ -77,6 +77,16 @@
// subtype REST subpaths are NOT YET documented by this builder — that fuller
// TPH modeling is a tracked follow-up (under-documentation, allowed).
//
+// • object.report (FR-044): a unit exists only for a SERVED report (Table A: not
+// abstract, read source of @kind view), built from its read model. It carries the
+// row model, `list` and `GET ` (plus the Hono GET when wired)
+// and nothing else: a report has no identity, so no by-id query and no `/:id`; no
+// write helper; no insert/update schema. No hook is documented for any object here
+// (see DEFERRALS), and none is generated for a report at all (`servesClientTier`).
+// • A KEYLESS projection (no identity and no `id` column) likewise documents no
+// `findById` and no `/:id`: the read-only generators emit neither
+// (`hasItemRoute`).
+//
// NOT modelled here (stated so the gap is known + intentional): a generator's own
// `filter` option. `routesFile({ filter })` narrows what the routes generator emits,
// and this builder reads the MODEL, not the wired generator set, so it cannot see
@@ -121,11 +131,11 @@ import { responseShape } from "../templates/find-inbound.js";
import { isTphSubtype } from "../templates/zod-validators.js";
import { isTphDiscriminatorBase } from "../templates/tph-discriminator.js";
import { isCallableEntity } from "../templates/callable-file.js";
-import { servedPath, servesReadApi } from "../api-surface.js";
+import { hasItemRoute, servedPath, servesReadApi } from "../api-surface.js";
import { isProjection } from "../projection/projection-detector.js";
import { buildPkMap } from "../pk-resolver.js";
import { buildRelationMap, type RelationEntry, type RelationMap } from "../relation-resolver.js";
-import { isReport } from "../source-detect.js";
+import { generatableObjects, isReport } from "../source-detect.js";
import { effectivePackage } from "../docs-paths.js";
import { entityOutputPath, type OutputLayout } from "../import-path.js";
import type { RenderContext } from "../render-context.js";
@@ -297,10 +307,11 @@ export function buildApiModel(root: MetaRoot, ctx: ApiModelContext): ApiModel {
const units: ApiUnitDoc[] = [];
- for (const obj of root.objects()) {
- // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- // It has no generated API to document yet.
- if (isReport(obj)) continue;
+ // FR-044 (Table G): a report has a unit exactly when it is served (Table A), and the
+ // unit is built from its READ MODEL: the declared node has no fields, so the row shape
+ // 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));
}
@@ -380,6 +391,17 @@ function isQueryable(obj: MetaObject): boolean {
return servesReadApi(obj) && !isTphSubtype(obj);
}
+/**
+ * True for a READ-ONLY object the generators give no item surface: no `/:id` route and
+ * no by-id query. That is a projection with no identity and no `id` column, and every
+ * report (FR-044). `hasItemRoute` is the generators' own predicate; it is only meaningful
+ * for the read-only surface, hence the `isProjection` guard (a report's read model
+ * carries a read-only source, so it is one too).
+ */
+function lacksItemSurface(obj: MetaObject): boolean {
+ return isProjection(obj) && !hasItemRoute(obj);
+}
+
function buildEntityUnit(
obj: MetaObject,
ctx: RenderContext,
@@ -410,7 +432,9 @@ function buildEntityUnit(
if (isQueryable(obj)) {
symbols.push(...dataAccessSymbols(obj, ctx, root, layout));
- symbols.push(...validationSymbols(obj, entityMod));
+ // A report's entity module exports a read schema only: no insert or update schema
+ // exists to document (FR-044).
+ if (!isReport(obj)) symbols.push(...validationSymbols(obj, entityMod));
// REST needs no gate of its own: the routes generator's built-in filter is
// `servesReadApi && !isTphSubtype` — exactly isQueryable — so every queryable
// object gets routes. (Its `filter` option can narrow that further; this builder
@@ -483,26 +507,36 @@ function dataAccessSymbols(
const update = updateFnName(name);
const del = deleteByIdFnName(name);
- const reads: ApiSymbol[] = [
- {
- name: find,
- kind: "data-access",
- importPath: mod,
- signature: `${find}(db: Db, ${pk}: ${pkType}): Promise<${name} | null>`,
- params: [`db: Db`, `${pk}: ${pkType}`],
- returns: `Promise<${name} | null>`,
- usage: `Fetch a single ${name} by its primary key; null when not found.`,
- },
- {
- name: list,
- kind: "data-access",
- importPath: mod,
- signature: `${list}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${name}[]>`,
- params: [`db: Db`, `opts?: { limit?: number; offset?: number }`],
- returns: `Promise<${name}[]>`,
- usage: `List ${name} rows with optional limit/offset paging.`,
- },
- ];
+ const findSymbol: ApiSymbol = {
+ name: find,
+ kind: "data-access",
+ importPath: mod,
+ signature: `${find}(db: Db, ${pk}: ${pkType}): Promise<${name} | null>`,
+ params: [`db: Db`, `${pk}: ${pkType}`],
+ returns: `Promise<${name} | null>`,
+ usage: `Fetch a single ${name} by its primary key; null when not found.`,
+ };
+ const listSymbol: ApiSymbol = {
+ name: list,
+ kind: "data-access",
+ importPath: mod,
+ signature: `${list}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${name}[]>`,
+ params: [`db: Db`, `opts?: { limit?: number; offset?: number }`],
+ returns: `Promise<${name}[]>`,
+ usage: `List ${name} rows with optional limit/offset paging.`,
+ };
+
+ // A read-only object with no column to address a row by gets the list alone: the
+ // read-only queries file emits `findById` only when `hasItemRoute` (a keyless
+ // projection and every report have none). Only the read-only surface asks; a writable
+ // entity's queries file emits its by-id helpers unconditionally.
+ const reads: ApiSymbol[] = lacksItemSurface(obj) ? [listSymbol] : [findSymbol, listSymbol];
+
+ // FR-044: a report is served by its list and nothing else. No create, update or delete
+ // exists on any generated seam, so none is documented.
+ if (isReport(obj)) {
+ return reads;
+ }
// A TPH discriminator base emits ONLY the polymorphic reads — the write
// helpers are per concrete subtype (create …), not on the base.
@@ -636,8 +670,12 @@ function restSymbols(
const symbols: ApiSymbol[] = [
ep("GET", path, `List ${name} (supports filter/sort/paging query params).`, modelShape),
- ep("GET", `${path}/:id`, `Fetch a single ${name} by id (404 when not found).`, 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)) {
+ symbols.push(ep("GET", `${path}/:id`, `Fetch a single ${name} by id (404 when not found).`, modelShape));
+ }
if (!readOnly) {
symbols.push(
@@ -842,8 +880,11 @@ function restHonoSymbols(
const symbols: ApiSymbol[] = [
ep("GET", path, `[Hono] List ${name} (filter/sort/paging query params).`, modelShape),
- ep("GET", `${path}/:id`, `[Hono] Fetch a single ${name} by id (404 when not found).`, modelShape),
];
+ // The same rule as the Fastify surface: no `/:id` without an addressable row.
+ if (!lacksItemSurface(obj)) {
+ symbols.push(ep("GET", `${path}/:id`, `[Hono] Fetch a single ${name} by id (404 when not found).`, modelShape));
+ }
if (!readOnly) {
symbols.push(
diff --git a/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts b/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts
index ab14aa422..d5629a252 100644
--- a/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts
@@ -52,7 +52,8 @@ import type { OutputLayout } from "../import-path.js";
import { docPageHref, docPageNode, effectivePackage } from "../docs-paths.js";
import { fieldAnchorHtml } from "./field-anchor.js";
import { enumValues } from "../enum-meta.js";
-import { hasWritableRdbSource } from "../source-detect.js";
+import { hasWritableRdbSource, isReport } from "../source-detect.js";
+import { buildReportBlock, buildReportingBlock } from "./report-doc.js";
import { GENERATED_HEADER } from "../constants.js";
import { renderEntityNeighborhoodErBlock } from "../templates/mermaid-er.js";
// Shape C reuses the SAME walk the stub generator and the requirements index use, so
@@ -843,6 +844,18 @@ export function buildEntityDocData(
data.claimedBy = claimedBy;
data.hasClaimedBy = true;
}
+ // FR-044 (Table G): a report's page gets its "Report" section, and the entity a
+ // report reads from (or that declares reporting members) gets "Reporting". Both are
+ // absent for every other object.
+ if (isReport(entity)) {
+ data.reportBlock = buildReportBlock(entity, root, layout);
+ data.hasReport = true;
+ }
+ const reportingBlock = buildReportingBlock(entity, root, layout);
+ if (reportingBlock !== undefined) {
+ data.reportingBlock = reportingBlock;
+ data.hasReporting = true;
+ }
// Cross-link to the api surfaces — present ONLY when the caller computed the
// hrefs (api surfaces emitted alongside model); model-only runs stay identical.
// `last` flags the final ref so the template renders an inline ` · ` separator.
diff --git a/server/typescript/packages/codegen-ts/src/generators/docs-data.ts b/server/typescript/packages/codegen-ts/src/generators/docs-data.ts
index 47eb7ecaa..c3463d524 100644
--- a/server/typescript/packages/codegen-ts/src/generators/docs-data.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/docs-data.ts
@@ -281,4 +281,16 @@ export interface EntityDocData {
/** Cross-links to this entity's generated-SDK api page, one per api surface
* (per language). Present only when api surfaces are emitted with the model. */
apiRefs?: Array<{ label: string; href: string; last?: boolean }>;
+
+ /** FR-044: the body of an `object.report` page's "Report" section (its `@from`, its
+ * view or why it is not served, its row scope, its derived columns). Pre-rendered
+ * markdown, built by `report-doc.ts`. ABSENT for every other object. */
+ reportBlock?: string;
+ hasReport?: boolean;
+
+ /** FR-044: the body of the "Reporting" section on an entity that declares dimensions,
+ * measures or segments, or that a report names as its `@from`. ABSENT otherwise, so
+ * the page of an entity with no reporting nodes is byte-identical. */
+ reportingBlock?: string;
+ hasReporting?: boolean;
}
diff --git a/server/typescript/packages/codegen-ts/src/generators/docs-file.ts b/server/typescript/packages/codegen-ts/src/generators/docs-file.ts
index 06bf067f4..7336bc5d5 100644
--- a/server/typescript/packages/codegen-ts/src/generators/docs-file.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/docs-file.ts
@@ -35,7 +35,7 @@ import {
import { projectProvider } from "../render-engine/framework-provider.js";
import { renderMermaidErBlock } from "../templates/mermaid-er.js";
import { buildEntityDocData } from "./docs-data-builder.js";
-import { isReport } from "../source-detect.js";
+import { isReport, servedReport } from "../source-detect.js";
import { buildTemplateDocData } from "./template-doc-builder.js";
import type { OutputLayout } from "../import-path.js";
@@ -110,20 +110,22 @@ export const docsFile = function docsFile(opts?: DocsFileOpts): Generator {
// (README.md) can link them via the SAME docPageHref used everywhere else
// (links resolve in flat AND package layout). Grouped entity vs template.
const entityNodes: DocPageNode[] = [];
+ const reportNodes: DocPageNode[] = [];
const templateNodes: DocPageNode[] = [];
const files: EmittedFile[] = ctx.loadedRoot
.objects()
- // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- // Its fields are derived by that lowering, so a page today would show none of them.
- .filter((o) => !isReport(o))
.filter(ctx.matches)
.map((entity: MetaObject) => {
const node = docPageNode(entity);
- entityNodes.push(node);
+ // FR-044: every report gets a page, served or not (its columns come from
+ // `reportShape`). It is listed under "Reports" on the index, not "Entities".
+ (isReport(entity) ? reportNodes : entityNodes).push(node);
const path = docPageOutputPath(layout, node);
placements.push({ path, fqn: entity.resolutionKey() });
- // Cross-link to the sibling api surfaces, when emitted (shared builder).
- const apiRefs = apiRefsFor(path);
+ // Cross-link to the sibling api surfaces, when emitted (shared builder). A
+ // report that is not served has no api unit in any port (Table G), so its page
+ // links to none: the link would point at a page nothing writes.
+ const apiRefs = isReport(entity) && !servedReport(entity) ? undefined : apiRefsFor(path);
const payload = buildEntityDocData(entity, {
dialect: rc.dialect,
layout,
@@ -181,6 +183,7 @@ export const docsFile = function docsFile(opts?: DocsFileOpts): Generator {
entityNodes,
templateNodes,
apiIndexRefs,
+ reportNodes,
);
placements.push({ path: INDEX_FILENAME, fqn: "" });
files.unshift({ path: INDEX_FILENAME, content: indexContent });
@@ -211,6 +214,7 @@ function renderIndexPage(
entityNodes: DocPageNode[],
templateNodes: DocPageNode[],
apiIndexRefs?: Array<{ label: string; href: string }>,
+ reportNodes: DocPageNode[] = [],
): string {
const pkg = root.package;
const out: string[] = [];
@@ -241,6 +245,15 @@ function renderIndexPage(
}
out.push("");
}
+ // FR-044: reports, after the entities they read from. Absent when the model has none.
+ if (reportNodes.length > 0) {
+ out.push("## Reports");
+ out.push("");
+ for (const node of [...reportNodes].sort(byName)) {
+ out.push(`- [${node.name}](${docPageHref(layout, INDEX_NODE, node)})`);
+ }
+ out.push("");
+ }
if (templateNodes.length > 0) {
out.push("## Templates");
out.push("");
diff --git a/server/typescript/packages/codegen-ts/src/generators/report-doc.ts b/server/typescript/packages/codegen-ts/src/generators/report-doc.ts
new file mode 100644
index 000000000..93b07757d
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/src/generators/report-doc.ts
@@ -0,0 +1,209 @@
+// What `meta docs` says about the FR-044 reporting vocabulary on the neutral model
+// surface (Table G of the Plan 3 spec):
+//
+// • a REPORT's page: its `@from`, the view it is read from (or why it is not served),
+// its row scope, and one row per derived column, from `reportShape`;
+// • the `@from` ENTITY's page: a "Reporting" section listing the dimensions, measures
+// and segments it declares and the reports that name it.
+//
+// Everything is read from declared metadata. No SQL is derived here: a definition says
+// what a column means ("sum of `Invoice.amountCents` where segment `paid`"), never how
+// the view computes it. `docs-site` renders the same sentences on the HTML site from its
+// own copy of these rules (it does not depend on this package); its tests hold the two
+// together.
+
+import {
+ type MetaData,
+ type MetaObject,
+ type MetaRoot,
+ type ReportField,
+ DIMENSION_SUBTYPE_TIME,
+ MEASURE_SUBTYPE_RATIO,
+ OBJECT_REPORT_ATTR_FILTER,
+ OBJECT_REPORT_ATTR_SEGMENT,
+ REPORTING_ATTR_AGG,
+ REPORTING_ATTR_DENOMINATOR,
+ REPORTING_ATTR_DISTINCT,
+ REPORTING_ATTR_FILTER,
+ REPORTING_ATTR_GRAINS,
+ REPORTING_ATTR_NUMERATOR,
+ REPORTING_ATTR_OF,
+ REPORTING_ATTR_SEGMENT,
+ REPORTING_ATTR_VIA,
+ SOURCE_KIND_VIEW,
+ TYPE_DIMENSION,
+ TYPE_MEASURE,
+ TYPE_SEGMENT,
+ isMetaObject,
+ reportFrom,
+ reportReadSource,
+ reportShape,
+ resolveObjectRef,
+} from "@metaobjectsdev/metadata";
+import type { OutputLayout } from "../import-path.js";
+import { docPageHref, docPageNode, effectivePackage } from "../docs-paths.js";
+import { isReport } from "../source-detect.js";
+
+/** Inline code. A backtick cannot sit inside a single-backtick span, so it is dropped. */
+function tick(text: string): string {
+ return `\`${text.replace(/`/g, "")}\``;
+}
+
+/** A row-scope filter as authored: compact JSON, in the order it was declared. */
+function filterText(filter: unknown): string {
+ return tick(JSON.stringify(filter));
+}
+
+function stringList(v: unknown): string[] {
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string");
+ return typeof v === "string" ? [v] : [];
+}
+
+/**
+ * "segment `paid` and filter `{…}`": the rows a measure or a report is scoped to, or
+ * undefined when it declares neither. The two combine by AND, which is what the lowering
+ * does with them.
+ */
+export function describeRowScope(segment: unknown, filter: unknown): string | undefined {
+ const parts: string[] = [];
+ if (typeof segment === "string" && segment !== "") parts.push(`segment ${tick(segment)}`);
+ if (filter !== undefined && filter !== null) parts.push(`filter ${filterText(filter)}`);
+ return parts.length > 0 ? parts.join(" and ") : undefined;
+}
+
+/** "`Program.title` via `Purchase.program`": the column a dimension groups by. */
+function dimensionColumn(dim: MetaData): string {
+ // ADR-0039: resolving, so a dimension that extends another reads its effective @of/@via.
+ const of = dim.attr(REPORTING_ATTR_OF);
+ const via = dim.attr(REPORTING_ATTR_VIA);
+ const column = tick(typeof of === "string" ? of : "");
+ return typeof via === "string" && via !== "" ? `${column} via ${tick(via)}` : column;
+}
+
+/** A dimension as its entity declares it: the column, and for a time dimension its grains. */
+export function describeDimension(dim: MetaData): string {
+ const column = dimensionColumn(dim);
+ if (dim.subType !== DIMENSION_SUBTYPE_TIME) return column;
+ return `${column}; grains: ${stringList(dim.attr(REPORTING_ATTR_GRAINS)).join(", ")}`;
+}
+
+/** A dimension as ONE report column: a time dimension is truncated to the report's grain. */
+function describeDimensionColumn(dim: MetaData, grain: string | undefined): string {
+ const column = dimensionColumn(dim);
+ if (grain === undefined) return column;
+ // Reports are UTC only (Plan 3 global constraint): there is no time-zone vocabulary.
+ const joiner = column.includes(" via ") ? "," : "";
+ return `${column}${joiner} truncated to ${grain}, UTC`;
+}
+
+/** A measure in words: the aggregate and its row scope, or the ratio and its null rule. */
+export function describeMeasure(measure: MetaData): string {
+ // ADR-0039: resolving reads throughout, as for a dimension.
+ if (measure.subType === MEASURE_SUBTYPE_RATIO) {
+ const numerator = measure.attr(REPORTING_ATTR_NUMERATOR);
+ const denominator = measure.attr(REPORTING_ATTR_DENOMINATOR);
+ return `${tick(String(numerator ?? ""))} / ${tick(String(denominator ?? ""))}, null when the denominator is 0`;
+ }
+ const columns = stringList(measure.attr(REPORTING_ATTR_OF)).map(tick);
+ const of = columns.length === 1 ? columns[0]! : `(${columns.join(", ")})`;
+ const distinct = measure.attr(REPORTING_ATTR_DISTINCT) === true ? "distinct " : "";
+ const scope = describeRowScope(measure.attr(REPORTING_ATTR_SEGMENT), measure.attr(REPORTING_ATTR_FILTER));
+ return `${String(measure.attr(REPORTING_ATTR_AGG) ?? "")} of ${distinct}${of}${scope !== undefined ? ` where ${scope}` : ""}`;
+}
+
+/** One derived column's definition, from the dimension or measure it comes from. */
+export function describeReportField(field: ReportField): string {
+ if (field.dimension !== undefined) return describeDimensionColumn(field.dimension, field.grain);
+ return field.measure !== undefined ? describeMeasure(field.measure) : "";
+}
+
+/** The neutral logical type of a derived column: its Table B subtype, `[]` for an array. */
+export function reportFieldType(field: ReportField): string {
+ // ADR-0039: resolvedIsArray() is the resolving read of the native array flag.
+ return field.typeSource?.resolvedIsArray() === true ? `${field.subType}[]` : field.subType;
+}
+
+/**
+ * Why a report is not served, or undefined when it is (Table A). The wording is the
+ * spec's for the common case: a report that declares no source at all.
+ */
+export function reportNotServedReason(report: MetaObject): string | undefined {
+ if (report.isAbstract === true) return "Not served: the report is abstract";
+ const source = reportReadSource(report);
+ if (source === undefined) return "Not served: declares no view source";
+ if (source.effectiveKind !== SOURCE_KIND_VIEW) {
+ return `Not served: its source is a ${source.effectiveKind}, not a view`;
+ }
+ return undefined;
+}
+
+/** A markdown table cell: a pipe would end the cell. */
+function cell(text: string): string {
+ return text.replace(/\|/g, "\\|");
+}
+
+/**
+ * The body of a report page's "Report" section: `@from` (linked), the view or the
+ * not-served line, the row scope, and the column table.
+ *
+ * Built from `reportShape`, which resolves for a report with no source, and not from the
+ * read model, which exists only to give a SERVED report ordinary fields.
+ */
+export function buildReportBlock(report: MetaObject, root: MetaRoot, layout: OutputLayout): string {
+ const shape = reportShape(report, root);
+ const lines: string[] = [];
+ const fromHref = docPageHref(layout, docPageNode(report), docPageNode(shape.from));
+ lines.push(`**From:** [${shape.from.name}](${fromHref})`);
+ const notServed = reportNotServedReason(report);
+ lines.push(`**View:** ${notServed ?? tick(reportReadSource(report)?.physicalName ?? "")}`);
+ // ADR-0039: resolving.
+ const scope = describeRowScope(report.attr(OBJECT_REPORT_ATTR_SEGMENT), report.attr(OBJECT_REPORT_ATTR_FILTER));
+ if (scope !== undefined) lines.push(`**Row scope:** ${scope}`);
+ if (shape.fields.length > 0) {
+ lines.push("", "| Column | Type | Nullable | Role | Definition |", "|---|---|---|---|---|");
+ for (const f of shape.fields) {
+ lines.push(
+ `| ${tick(f.name)} | ${tick(reportFieldType(f))} | ${f.required ? "no" : "yes"} | ${f.role} | ${cell(describeReportField(f))} |`,
+ );
+ }
+ }
+ return lines.join("\n");
+}
+
+/** The reports whose `@from` resolves to `entity`, in declaration order. */
+function reportsFrom(entity: MetaObject, root: MetaRoot): MetaObject[] {
+ return root.objects().filter((o) => {
+ if (!isReport(o)) return false;
+ const from = reportFrom(o);
+ if (from === undefined) return false;
+ // A bare @from resolves in the REPORT's package, as `reportShape` resolves it.
+ const target = resolveObjectRef(root, from, effectivePackage(o) ?? "").node;
+ return isMetaObject(target) && target === entity;
+ });
+}
+
+/**
+ * The body of an entity page's "Reporting" section, or undefined when the entity
+ * declares no dimension, measure or segment and no report names it. Undefined, not
+ * empty: the page of an entity that has nothing to do with reporting must not move.
+ */
+export function buildReportingBlock(entity: MetaObject, root: MetaRoot, layout: OutputLayout): string | undefined {
+ if (isReport(entity)) return undefined;
+ // ADR-0039: resolving children(), so a member declared on an abstract base shows on
+ // every entity that inherits it, which is where a report may name it from.
+ const members = entity.children();
+ const groups: Array<[string, string[]]> = [
+ ["Dimensions", members.filter((c) => c.type === TYPE_DIMENSION).map((d) =>
+ `- ${tick(d.name)}${d.subType === DIMENSION_SUBTYPE_TIME ? " (time)" : ""} — ${describeDimension(d)}`)],
+ ["Measures", members.filter((c) => c.type === TYPE_MEASURE).map((m) =>
+ `- ${tick(m.name)} — ${describeMeasure(m)}`)],
+ ["Segments", members.filter((c) => c.type === TYPE_SEGMENT).map((s) =>
+ `- ${tick(s.name)} — ${filterText(s.attr(REPORTING_ATTR_FILTER) ?? {})}`)],
+ ["Reports", reportsFrom(entity, root)
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((r) => `- [${r.name}](${docPageHref(layout, docPageNode(entity), docPageNode(r))})`)],
+ ];
+ const present = groups.filter(([, bullets]) => bullets.length > 0);
+ if (present.length === 0) return undefined;
+ return present.map(([title, bullets]) => `**${title}**\n\n${bullets.join("\n")}`).join("\n\n");
+}
diff --git a/server/typescript/packages/codegen-ts/src/render-engine/embedded-templates.generated.ts b/server/typescript/packages/codegen-ts/src/render-engine/embedded-templates.generated.ts
index 0c2d66421..ee5199bdc 100644
--- a/server/typescript/packages/codegen-ts/src/render-engine/embedded-templates.generated.ts
+++ b/server/typescript/packages/codegen-ts/src/render-engine/embedded-templates.generated.ts
@@ -9,6 +9,6 @@ export const EMBEDDED_FRAMEWORK_TEMPLATES: Record = {
"api/agent-api.md": "{{{generatedMarker}}}\n\n# {{title}}\n\nGenerated API reference for {{project}}; call these exactly as written. {{importNote}}\n{{#hasSetup}}\n\n## Setup\n{{#setup}}\n- `{{handle}}` — {{{note}}} `{{{snippetInline}}}`\n{{/setup}}\n{{/hasSetup}}\n{{#units}}\n\n## {{node}}\n{{#groups}}\n\n`{{importHeader}}`\n{{#symbols}}\n- `{{signature}}` — {{usage}}{{#throwsMarker}} {{throwsMarker}}{{/throwsMarker}}\n{{/symbols}}\n{{/groups}}\n{{#example}}\n\nExample:\n```ts\n{{{example}}}\n```\n{{/example}}\n{{/units}}\n",
"api/entity-api.md": "{{{generatedMarker}}}\n\n# {{node}} API\n{{#modelPageHref}}\n\n**Model / metadata:** [{{node}}]({{modelPageHref}})\n{{/modelPageHref}}\n\n> Import paths are relative to your generated-output directory.\n{{#hasSetup}}\n\n## Setup\n\nObtain the runtime handles the calls below need:\n{{#setup}}\n\n- `{{handle}}` — {{{note}}}\n\n```ts\n{{{snippet}}}\n```\n{{/setup}}\n{{/hasSetup}}\n{{#unitExample}}\n\n## Example\n\n```ts\n{{{unitExample}}}\n```\n{{/unitExample}}\n{{#sections}}\n\n## {{heading}}\n{{#symbols}}\n\n### `{{signature}}`\n\n{{usage}}\n\n```ts\n{{importLine}}\n```\n{{#hasFields}}\n\n{{fieldsCaption}}:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n{{#fieldRows}}\n| `{{field}}` | `{{{type}}}` | {{required}} | {{notes}} |\n{{/fieldRows}}\n{{/hasFields}}\n{{#mountNote}}\n\nMount: {{{mountNote}}}\n{{/mountNote}}\n{{#throws}}\n\nThrows: {{throws}}\n{{/throws}}\n{{#example}}\n\n```ts\n{{{example}}}\n```\n{{/example}}\n{{/symbols}}\n{{/sections}}\n",
"api/index.md": "{{{generatedMarker}}}\n\n# {{title}}\n\n{{intro}}\n{{#hasEntities}}\n\n## Entities\n\n{{#entities}}\n- [{{node}}]({{href}}) — {{summary}} ({{symbolCount}} symbol{{^one}}s{{/one}})\n{{/entities}}\n{{/hasEntities}}\n{{#hasTemplates}}\n\n## Templates\n\n{{#templates}}\n- [{{node}}]({{href}}) — {{summary}} ({{symbolCount}} symbol{{^one}}s{{/one}})\n{{/templates}}\n{{/hasTemplates}}\n",
- "docs/entity-page.md": "{{{generatedMarker}}}\n\n# {{entity.name}}\n{{#summaryLead}}\n\n{{{.}}}\n{{/summaryLead}}\n{{#descriptionQuote}}\n\n{{{.}}}\n{{/descriptionQuote}}\n{{#apiRefs.0}}\n\n**API reference:** {{/apiRefs.0}}{{#apiRefs}}[{{label}}]({{href}}){{^last}} · {{/last}}{{/apiRefs}}{{#apiRefs.0}}\n{{/apiRefs.0}}\n\n{{{preambleHeader}}}\n{{#hasIdentities}}\n\n## Identity\n\n{{#identities}}\n- {{{bullet}}}\n{{/identities}}\n{{/hasIdentities}}\n{{#hasNeighborhoodEr}}\n\n## In context\n\n{{{neighborhoodErBlock}}}\n{{/hasNeighborhoodEr}}\n{{#fields.hasFields}}\n\n## Fields\n\n| Field | Type | Required | Column | Rules |\n|---|---|---|---|---|\n{{#fields.rows}}\n| {{{fieldCell}}} | {{{typeCell}}} | {{requiredCell}} | {{{storageCell}}} | {{{rulesCell}}} |\n{{/fields.rows}}\n{{/fields.hasFields}}\n{{#fieldDetails.hasDetails}}\n\n## Field details\n\n{{#fieldDetails.rows}}\n{{{block}}}\n\n{{/fieldDetails.rows}}\n{{/fieldDetails.hasDetails}}\n{{#hasRelationships}}\n\n## Relationships\n\n{{#relationships}}\n- {{{bullet}}}\n{{/relationships}}\n{{/hasRelationships}}\n{{#hasUsedBy}}\n\n## Used by\n\n{{#usedBy}}\n- {{{bullet}}}\n{{/usedBy}}\n{{/hasUsedBy}}\n{{#hasClaimedBy}}\n\n## Required by\n\n{{#claimedBy}}\n- {{{bullet}}}\n{{/claimedBy}}\n{{/hasClaimedBy}}\n",
+ "docs/entity-page.md": "{{{generatedMarker}}}\n\n# {{entity.name}}\n{{#summaryLead}}\n\n{{{.}}}\n{{/summaryLead}}\n{{#descriptionQuote}}\n\n{{{.}}}\n{{/descriptionQuote}}\n{{#apiRefs.0}}\n\n**API reference:** {{/apiRefs.0}}{{#apiRefs}}[{{label}}]({{href}}){{^last}} · {{/last}}{{/apiRefs}}{{#apiRefs.0}}\n{{/apiRefs.0}}\n\n{{{preambleHeader}}}\n{{#hasReport}}\n\n## Report\n\n{{{reportBlock}}}\n{{/hasReport}}\n{{#hasIdentities}}\n\n## Identity\n\n{{#identities}}\n- {{{bullet}}}\n{{/identities}}\n{{/hasIdentities}}\n{{#hasNeighborhoodEr}}\n\n## In context\n\n{{{neighborhoodErBlock}}}\n{{/hasNeighborhoodEr}}\n{{#fields.hasFields}}\n\n## Fields\n\n| Field | Type | Required | Column | Rules |\n|---|---|---|---|---|\n{{#fields.rows}}\n| {{{fieldCell}}} | {{{typeCell}}} | {{requiredCell}} | {{{storageCell}}} | {{{rulesCell}}} |\n{{/fields.rows}}\n{{/fields.hasFields}}\n{{#fieldDetails.hasDetails}}\n\n## Field details\n\n{{#fieldDetails.rows}}\n{{{block}}}\n\n{{/fieldDetails.rows}}\n{{/fieldDetails.hasDetails}}\n{{#hasRelationships}}\n\n## Relationships\n\n{{#relationships}}\n- {{{bullet}}}\n{{/relationships}}\n{{/hasRelationships}}\n{{#hasUsedBy}}\n\n## Used by\n\n{{#usedBy}}\n- {{{bullet}}}\n{{/usedBy}}\n{{/hasUsedBy}}\n{{#hasClaimedBy}}\n\n## Required by\n\n{{#claimedBy}}\n- {{{bullet}}}\n{{/claimedBy}}\n{{/hasClaimedBy}}\n{{#hasReporting}}\n\n## Reporting\n\n{{{reportingBlock}}}\n{{/hasReporting}}\n",
"docs/template-page.md": "{{{generatedMarker}}}\n\n# {{name}}\n{{#descriptionQuote}}\n\n{{{.}}}\n{{/descriptionQuote}}\n\n**Kind:** {{kind}}\n\n## Output\n{{^isEmail}}\n\n- Format: `{{format}}`\n{{/isEmail}}\n{{#isEmail}}\n\nMultipart email — rendered as the following parts:\n\n| Part | Source | Format | Escaping |\n|---|---|---|---|\n{{#parts}}\n| {{label}} | `{{ref}}` | `{{format}}` | {{#escaped}}escaped{{/escaped}}{{^escaped}}raw{{/escaped}} |\n{{/parts}}\n{{/isEmail}}\n\n## Input\n\n- Payload: [`{{payload.name}}`]({{payload.link}})\n{{#hasRequiredTags}}\n- Required fields:{{#requiredTags}} `{{.}}`{{/requiredTags}}\n{{/hasRequiredTags}}\n\n## Render contract\n\n- Every field referenced by the template is validated against the payload at generation time; an unknown field fails generation.\n{{#maxChars}}\n- Maximum length: {{.}} characters (rendering longer output fails).\n{{/maxChars}}\n{{#hasRequiredTags}}\n- Required tags must be present:{{#requiredTags}} `{{.}}`{{/requiredTags}}\n{{/hasRequiredTags}}\n\n## Source\n\n{{#sourceRefs}}\n- `{{.}}`\n{{/sourceRefs}}\n{{#templateSourceSection}}\n\n{{{.}}}\n{{/templateSourceSection}}\n\n## Capability\n\n{{capability}}\n",
};
diff --git a/server/typescript/packages/codegen-ts/templates/docs/entity-page.md.mustache b/server/typescript/packages/codegen-ts/templates/docs/entity-page.md.mustache
index 43c0d89b6..0102afc73 100644
--- a/server/typescript/packages/codegen-ts/templates/docs/entity-page.md.mustache
+++ b/server/typescript/packages/codegen-ts/templates/docs/entity-page.md.mustache
@@ -15,6 +15,12 @@
{{/apiRefs.0}}
{{{preambleHeader}}}
+{{#hasReport}}
+
+## Report
+
+{{{reportBlock}}}
+{{/hasReport}}
{{#hasIdentities}}
## Identity
@@ -72,3 +78,9 @@
- {{{bullet}}}
{{/claimedBy}}
{{/hasClaimedBy}}
+{{#hasReporting}}
+
+## Reporting
+
+{{{reportingBlock}}}
+{{/hasReporting}}
diff --git a/server/typescript/packages/codegen-ts/test/__snapshots__/reporting-docs.test.ts.snap b/server/typescript/packages/codegen-ts/test/__snapshots__/reporting-docs.test.ts.snap
new file mode 100644
index 000000000..d5a016305
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/test/__snapshots__/reporting-docs.test.ts.snap
@@ -0,0 +1,1537 @@
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`FR-044 no-churn: a model with no report renders every docs page as before model pages are the pre-feature snapshot 1`] = `
+{
+ "Program.md":
+"
+
+# Program
+
+**API reference:** [TypeScript](./api/ts/Program.md)
+
+**Type:** \`object.entity\`
+**Source:** \`meta.shop.json\`
+**Package:** \`acme::shop\`
+
+## Identity
+
+- **Primary key:** \`id\`
+
+## In context
+
+\`\`\`mermaid
+flowchart TB
+ Program["Program"]
+ Purchase["Purchase"]
+ Purchase -->|"programId"| Program
+ click Program "./Program.md"
+ click Purchase "./Purchase.md"
+ classDef focal fill:#dbeafe,stroke:#1e40af,stroke-width:2px,color:#1e293b;
+ classDef same fill:#eff6ff,stroke:#3b82f6,color:#1e293b;
+ classDef external fill:#f3f4f6,stroke:#9ca3af,stroke-dasharray:4 3,color:#374151;
+ classDef vo fill:#faf5ff,stroke:#9333ea,color:#1e293b;
+ class Program focal;
+ class Purchase same;
+\`\`\`
+
+## Fields
+
+| Field | Type | Required | Column | Rules |
+|---|---|---|---|---|
+| 🔑 \`id\` | \`long\` | yes | | |
+| \`title\` | \`string\` | | | |
+| \`createdAt\` | \`timestamp\` | | | |
+
+## Relationships
+
+- \`purchases\` — many → \`Purchase\` (association)
+"
+,
+ "Purchase.md":
+"
+
+# Purchase
+
+**API reference:** [TypeScript](./api/ts/Purchase.md)
+
+**Type:** \`object.entity\`
+**Source:** \`meta.shop.json\`
+**Package:** \`acme::shop\`
+
+## Identity
+
+- **Primary key:** \`id\`
+- **Reference:** \`programId\` → \`Program\`
+
+## In context
+
+\`\`\`mermaid
+flowchart TB
+ Purchase["Purchase"]
+ Program["Program"]
+ Purchase -->|"programId"| Program
+ click Purchase "./Purchase.md"
+ click Program "./Program.md"
+ classDef focal fill:#dbeafe,stroke:#1e40af,stroke-width:2px,color:#1e293b;
+ classDef same fill:#eff6ff,stroke:#3b82f6,color:#1e293b;
+ classDef external fill:#f3f4f6,stroke:#9ca3af,stroke-dasharray:4 3,color:#374151;
+ classDef vo fill:#faf5ff,stroke:#9333ea,color:#1e293b;
+ class Purchase focal;
+ class Program same;
+\`\`\`
+
+## Fields
+
+| Field | Type | Required | Column | Rules |
+|---|---|---|---|---|
+| 🔑 \`id\` | \`long\` | yes | | |
+| 🔗 \`programId\` | \`long\` → \`Program\` | | | |
+| \`customerEmail\` | \`string\` | | | |
+| \`amountCents\` | \`currency\` | | | |
+| \`status\` | \`string\` | | | |
+| \`refunded\` | \`boolean\` | | | |
+| \`purchasedAt\` | \`timestamp\` | | | |
+| \`purchasedOn\` | \`date\` | | | |
+
+## Field details
+
+### \`programId\`
+
+- **Type:** \`long\`
+- **References:** [\`Program.id\`](Program.md)
+
+
+## Relationships
+
+- \`program\` — one → \`Program\` (association)
+"
+,
+ "README.md":
+"# Data Model
+
+Overview of the \`acme::shop\` metadata model — entities, their relationships, and output templates.
+
+## Diagram
+
+\`\`\`mermaid
+erDiagram
+ Program ||--o{ Purchase : "references"
+
+ Program {
+ long id PK
+ string title
+ timestamp createdAt
+ }
+
+ Purchase {
+ long id PK
+ long programId FK
+ string customerEmail
+ currency amountCents
+ string status
+ boolean refunded
+ timestamp purchasedAt
+ date purchasedOn
+ }
+
+ WorkoutEvent {
+ long id PK
+ long programId
+ string customerEmail
+ int weekNumber
+ int dayNumber
+ string eventType
+ timestamp occurredAt
+ }
+\`\`\`
+
+## Entities
+
+- [Program](./Program.md)
+- [Purchase](./Purchase.md)
+- [WorkoutEvent](./WorkoutEvent.md)
+
+## API reference
+
+- [TypeScript](./api/ts/README.md)
+"
+,
+ "WorkoutEvent.md":
+"
+
+# WorkoutEvent
+
+**API reference:** [TypeScript](./api/ts/WorkoutEvent.md)
+
+**Type:** \`object.entity\`
+**Source:** \`meta.shop.json\`
+**Package:** \`acme::shop\`
+
+## Identity
+
+- **Primary key:** \`id\`
+
+## Fields
+
+| Field | Type | Required | Column | Rules |
+|---|---|---|---|---|
+| 🔑 \`id\` | \`long\` | yes | | |
+| \`programId\` | \`long\` | | | |
+| \`customerEmail\` | \`string\` | | | |
+| \`weekNumber\` | \`int\` | | | |
+| \`dayNumber\` | \`int\` | | | |
+| \`eventType\` | \`string\` | | | |
+| \`occurredAt\` | \`timestamp\` | | | |
+"
+,
+}
+`;
+
+exports[`FR-044 no-churn: a model with no report renders every docs page as before api pages are the pre-feature snapshot 1`] = `
+{
+ "api/ts/AGENT-API.md":
+"
+
+# Agent API Reference
+
+Generated API reference for this project; call these exactly as written. Imports are relative to your generated-output directory.
+
+## Setup
+- \`db\` — your Drizzle connection — the generated queries take \`db: Db\` (a \`PgDatabase\` / \`BaseSQLiteDatabase\` alias). Construct one over your own driver and pass any compatible Drizzle instance: \`import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }))\`
+
+## Program
+
+\`import { Program, ProgramInsertSchema, ProgramUpdateSchema } from "./Program"\`
+- \`interface Program { id: number; title?: string; createdAt?: string }\` — The typed shape of a Program row, generated from its metadata.
+- \`ProgramInsertSchema: ZodType<{ id: number; title?: string; createdAt?: string }>\` — Zod schema validating the body of a create / POST request (auto-generated PKs excluded).
+- \`ProgramUpdateSchema: ZodType<{ id?: number; title?: string; createdAt?: string }>\` — Zod schema validating the body of an update / PATCH request (all fields optional).
+
+\`import { findProgramById, listPrograms, createProgram, updateProgram, deleteProgramById } from "./Program.queries"\`
+- \`findProgramById(db: Db, id: number): Promise\` — Fetch a single Program by its primary key; null when not found.
+- \`listPrograms(db: Db, opts?: { limit?: number; offset?: number }): Promise\` — List Program rows with optional limit/offset paging.
+- \`createProgram(db: Db, data: { id: number; title?: string; createdAt?: string }): Promise\` — Validate (via ProgramInsertSchema) and insert a new Program. [throws: ZodError when data fails ProgramInsertSchema validation.]
+- \`updateProgram(db: Db, id: number, patch: { id?: number; title?: string; createdAt?: string }): Promise\` — Partially update a Program by primary key — writes only the assigned fields; null when not found. A renamed/dropped field is a compile error. [throws: ZodError when the patch fails ProgramUpdateSchema validation.]
+- \`deleteProgramById(db: Db, id: number): Promise\` — Delete a Program by primary key; true when a row was removed.
+
+\`import { programRoutes } from "./Program.routes"\`
+- \`GET /api/programs -> { id: number; title?: string; createdAt?: string }\` — List Program (supports filter/sort/paging query params).
+- \`GET /api/programs/:id -> { id: number; title?: string; createdAt?: string }\` — Fetch a single Program by id (404 when not found).
+- \`POST /api/programs body: { id: number; title?: string; createdAt?: string }\` — Create a Program (body validated by ProgramInsertSchema).
+- \`PATCH /api/programs/:id body: { id?: number; title?: string; createdAt?: string }\` — Partially update a Program by id (body validated by ProgramUpdateSchema).
+- \`PUT /api/programs/:id body: { id?: number; title?: string; createdAt?: string }\` — PUT alias of PATCH — the same handler and partial body (validated by ProgramUpdateSchema).
+- \`DELETE /api/programs/:id\` — Delete a Program by id.
+
+\`import { registerProgramRoutes } from "./Program.routes.hono"\`
+- \`GET /api/programs -> { id: number; title?: string; createdAt?: string }\` — [Hono] List Program (filter/sort/paging query params).
+- \`GET /api/programs/:id -> { id: number; title?: string; createdAt?: string }\` — [Hono] Fetch a single Program by id (404 when not found).
+- \`POST /api/programs body: { id: number; title?: string; createdAt?: string }\` — [Hono] Create a Program (body validated by ProgramInsertSchema).
+- \`PATCH /api/programs/:id body: { id?: number; title?: string; createdAt?: string }\` — [Hono] Partially update a Program by id (body validated by ProgramUpdateSchema).
+- \`PUT /api/programs/:id body: { id?: number; title?: string; createdAt?: string }\` — [Hono] PUT alias of PATCH — the same handler and partial body (validated by ProgramUpdateSchema).
+- \`DELETE /api/programs/:id\` — [Hono] Delete a Program by id.
+
+Example:
+\`\`\`ts
+const created = await createProgram(db, { id: 1 });
+const found = await findProgramById(db, created.id);
+const updated = await updateProgram(db, created.id, { id: 1 });
+const removed = await deleteProgramById(db, created.id);
+\`\`\`
+
+## Purchase
+
+\`import { Purchase, PurchaseInsertSchema, PurchaseUpdateSchema, purchasesRelations } from "./Purchase"\`
+- \`interface Purchase { id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — The typed shape of a Purchase row, generated from its metadata.
+- \`PurchaseInsertSchema: ZodType<{ id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }>\` — Zod schema validating the body of a create / POST request (auto-generated PKs excluded).
+- \`PurchaseUpdateSchema: ZodType<{ id?: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }>\` — Zod schema validating the body of an update / PATCH request (all fields optional).
+- \`const purchasesRelations { program?: Program (1:1 / N:1) }\` — Drizzle relations() for Purchase — register it with your schema, then traverse via the relational query API (db.query.purchases.findMany({ with: { … } })).
+
+\`import { findPurchaseById, listPurchases, createPurchase, updatePurchase, deletePurchaseById } from "./Purchase.queries"\`
+- \`findPurchaseById(db: Db, id: number): Promise\` — Fetch a single Purchase by its primary key; null when not found.
+- \`listPurchases(db: Db, opts?: { limit?: number; offset?: number }): Promise\` — List Purchase rows with optional limit/offset paging.
+- \`createPurchase(db: Db, data: { id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }): Promise\` — Validate (via PurchaseInsertSchema) and insert a new Purchase. [throws: ZodError when data fails PurchaseInsertSchema validation.]
+- \`updatePurchase(db: Db, id: number, patch: { id?: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }): Promise\` — Partially update a Purchase by primary key — writes only the assigned fields; null when not found. A renamed/dropped field is a compile error. [throws: ZodError when the patch fails PurchaseUpdateSchema validation.]
+- \`deletePurchaseById(db: Db, id: number): Promise\` — Delete a Purchase by primary key; true when a row was removed.
+
+\`import { purchaseRoutes } from "./Purchase.routes"\`
+- \`GET /api/purchases -> { id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — List Purchase (supports filter/sort/paging query params).
+- \`GET /api/purchases/:id -> { id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — Fetch a single Purchase by id (404 when not found).
+- \`POST /api/purchases body: { id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — Create a Purchase (body validated by PurchaseInsertSchema).
+- \`PATCH /api/purchases/:id body: { id?: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — Partially update a Purchase by id (body validated by PurchaseUpdateSchema).
+- \`PUT /api/purchases/:id body: { id?: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — PUT alias of PATCH — the same handler and partial body (validated by PurchaseUpdateSchema).
+- \`DELETE /api/purchases/:id\` — Delete a Purchase by id.
+
+\`import { registerPurchaseRoutes } from "./Purchase.routes.hono"\`
+- \`GET /api/purchases -> { id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — [Hono] List Purchase (filter/sort/paging query params).
+- \`GET /api/purchases/:id -> { id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — [Hono] Fetch a single Purchase by id (404 when not found).
+- \`POST /api/purchases body: { id: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — [Hono] Create a Purchase (body validated by PurchaseInsertSchema).
+- \`PATCH /api/purchases/:id body: { id?: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — [Hono] Partially update a Purchase by id (body validated by PurchaseUpdateSchema).
+- \`PUT /api/purchases/:id body: { id?: number; programId?: number; customerEmail?: string; amountCents?: number; status?: string; refunded?: boolean; purchasedAt?: string; purchasedOn?: string }\` — [Hono] PUT alias of PATCH — the same handler and partial body (validated by PurchaseUpdateSchema).
+- \`DELETE /api/purchases/:id\` — [Hono] Delete a Purchase by id.
+
+Example:
+\`\`\`ts
+const created = await createPurchase(db, { id: 1 });
+const found = await findPurchaseById(db, created.id);
+const updated = await updatePurchase(db, created.id, { id: 1 });
+const removed = await deletePurchaseById(db, created.id);
+\`\`\`
+
+## WorkoutEvent
+
+\`import { WorkoutEvent, WorkoutEventInsertSchema, WorkoutEventUpdateSchema } from "./WorkoutEvent"\`
+- \`interface WorkoutEvent { id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — The typed shape of a WorkoutEvent row, generated from its metadata.
+- \`WorkoutEventInsertSchema: ZodType<{ id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }>\` — Zod schema validating the body of a create / POST request (auto-generated PKs excluded).
+- \`WorkoutEventUpdateSchema: ZodType<{ id?: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }>\` — Zod schema validating the body of an update / PATCH request (all fields optional).
+
+\`import { findWorkoutEventById, listWorkoutEvents, createWorkoutEvent, updateWorkoutEvent, deleteWorkoutEventById } from "./WorkoutEvent.queries"\`
+- \`findWorkoutEventById(db: Db, id: number): Promise\` — Fetch a single WorkoutEvent by its primary key; null when not found.
+- \`listWorkoutEvents(db: Db, opts?: { limit?: number; offset?: number }): Promise\` — List WorkoutEvent rows with optional limit/offset paging.
+- \`createWorkoutEvent(db: Db, data: { id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }): Promise\` — Validate (via WorkoutEventInsertSchema) and insert a new WorkoutEvent. [throws: ZodError when data fails WorkoutEventInsertSchema validation.]
+- \`updateWorkoutEvent(db: Db, id: number, patch: { id?: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }): Promise\` — Partially update a WorkoutEvent by primary key — writes only the assigned fields; null when not found. A renamed/dropped field is a compile error. [throws: ZodError when the patch fails WorkoutEventUpdateSchema validation.]
+- \`deleteWorkoutEventById(db: Db, id: number): Promise\` — Delete a WorkoutEvent by primary key; true when a row was removed.
+
+\`import { workoutEventRoutes } from "./WorkoutEvent.routes"\`
+- \`GET /api/workout_events -> { id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — List WorkoutEvent (supports filter/sort/paging query params).
+- \`GET /api/workout_events/:id -> { id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — Fetch a single WorkoutEvent by id (404 when not found).
+- \`POST /api/workout_events body: { id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — Create a WorkoutEvent (body validated by WorkoutEventInsertSchema).
+- \`PATCH /api/workout_events/:id body: { id?: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — Partially update a WorkoutEvent by id (body validated by WorkoutEventUpdateSchema).
+- \`PUT /api/workout_events/:id body: { id?: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — PUT alias of PATCH — the same handler and partial body (validated by WorkoutEventUpdateSchema).
+- \`DELETE /api/workout_events/:id\` — Delete a WorkoutEvent by id.
+
+\`import { registerWorkoutEventRoutes } from "./WorkoutEvent.routes.hono"\`
+- \`GET /api/workout_events -> { id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — [Hono] List WorkoutEvent (filter/sort/paging query params).
+- \`GET /api/workout_events/:id -> { id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — [Hono] Fetch a single WorkoutEvent by id (404 when not found).
+- \`POST /api/workout_events body: { id: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — [Hono] Create a WorkoutEvent (body validated by WorkoutEventInsertSchema).
+- \`PATCH /api/workout_events/:id body: { id?: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — [Hono] Partially update a WorkoutEvent by id (body validated by WorkoutEventUpdateSchema).
+- \`PUT /api/workout_events/:id body: { id?: number; programId?: number; customerEmail?: string; weekNumber?: number; dayNumber?: number; eventType?: string; occurredAt?: string }\` — [Hono] PUT alias of PATCH — the same handler and partial body (validated by WorkoutEventUpdateSchema).
+- \`DELETE /api/workout_events/:id\` — [Hono] Delete a WorkoutEvent by id.
+
+Example:
+\`\`\`ts
+const created = await createWorkoutEvent(db, { id: 1 });
+const found = await findWorkoutEventById(db, created.id);
+const updated = await updateWorkoutEvent(db, created.id, { id: 1 });
+const removed = await deleteWorkoutEventById(db, created.id);
+\`\`\`
+"
+,
+ "api/ts/Program.md":
+"
+
+# Program API
+
+**Model / metadata:** [Program](../../Program.md)
+
+> Import paths are relative to your generated-output directory.
+
+## Setup
+
+Obtain the runtime handles the calls below need:
+
+- \`db\` — your Drizzle connection — the generated queries take \`db: Db\` (a \`PgDatabase\` / \`BaseSQLiteDatabase\` alias). Construct one over your own driver and pass any compatible Drizzle instance:
+
+\`\`\`ts
+import { drizzle } from "drizzle-orm/node-postgres";
+import { Pool } from "pg";
+const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));
+\`\`\`
+
+## Example
+
+\`\`\`ts
+import { createProgram, findProgramById, updateProgram, deleteProgramById } from "./Program.queries";
+
+const created = await createProgram(db, { id: 1 });
+const found = await findProgramById(db, created.id);
+const updated = await updateProgram(db, created.id, { id: 1 });
+const removed = await deleteProgramById(db, created.id);
+\`\`\`
+
+## Model
+
+### \`interface Program\`
+
+The typed shape of a Program row, generated from its metadata.
+
+\`\`\`ts
+import { Program } from "./Program"
+\`\`\`
+
+Fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+## Data access
+
+### \`findProgramById(db: Db, id: number): Promise\`
+
+Fetch a single Program by its primary key; null when not found.
+
+\`\`\`ts
+import { findProgramById } from "./Program.queries"
+\`\`\`
+
+### \`listPrograms(db: Db, opts?: { limit?: number; offset?: number }): Promise\`
+
+List Program rows with optional limit/offset paging.
+
+\`\`\`ts
+import { listPrograms } from "./Program.queries"
+\`\`\`
+
+### \`createProgram(db: Db, data: ProgramCreate): Promise\`
+
+Validate (via ProgramInsertSchema) and insert a new Program.
+
+\`\`\`ts
+import { createProgram } from "./Program.queries"
+\`\`\`
+
+Request body (data):
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Throws: ZodError when data fails ProgramInsertSchema validation.
+
+### \`updateProgram(db: Db, id: number, patch: ProgramPatch): Promise\`
+
+Partially update a Program by primary key — writes only the assigned fields; null when not found. A renamed/dropped field is a compile error.
+
+\`\`\`ts
+import { updateProgram } from "./Program.queries"
+\`\`\`
+
+Request body (data):
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Throws: ZodError when the patch fails ProgramUpdateSchema validation.
+
+### \`deleteProgramById(db: Db, id: number): Promise\`
+
+Delete a Program by primary key; true when a row was removed.
+
+\`\`\`ts
+import { deleteProgramById } from "./Program.queries"
+\`\`\`
+
+## REST
+
+### \`GET /api/programs\`
+
+List Program (supports filter/sort/paging query params).
+
+\`\`\`ts
+import { programRoutes } from "./Program.routes"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`await programRoutes(fastify)\`
+
+### \`GET /api/programs/:id\`
+
+Fetch a single Program by id (404 when not found).
+
+\`\`\`ts
+import { programRoutes } from "./Program.routes"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`await programRoutes(fastify)\`
+
+### \`POST /api/programs\`
+
+Create a Program (body validated by ProgramInsertSchema).
+
+\`\`\`ts
+import { programRoutes } from "./Program.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`await programRoutes(fastify)\`
+
+### \`PATCH /api/programs/:id\`
+
+Partially update a Program by id (body validated by ProgramUpdateSchema).
+
+\`\`\`ts
+import { programRoutes } from "./Program.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`await programRoutes(fastify)\`
+
+### \`PUT /api/programs/:id\`
+
+PUT alias of PATCH — the same handler and partial body (validated by ProgramUpdateSchema).
+
+\`\`\`ts
+import { programRoutes } from "./Program.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`await programRoutes(fastify)\`
+
+### \`DELETE /api/programs/:id\`
+
+Delete a Program by id.
+
+\`\`\`ts
+import { programRoutes } from "./Program.routes"
+\`\`\`
+
+Mount: \`await programRoutes(fastify)\`
+
+## REST (Hono)
+
+### \`GET /api/programs\`
+
+[Hono] List Program (filter/sort/paging query params).
+
+\`\`\`ts
+import { registerProgramRoutes } from "./Program.routes.hono"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`registerProgramRoutes(app, { db })\`
+
+### \`GET /api/programs/:id\`
+
+[Hono] Fetch a single Program by id (404 when not found).
+
+\`\`\`ts
+import { registerProgramRoutes } from "./Program.routes.hono"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`registerProgramRoutes(app, { db })\`
+
+### \`POST /api/programs\`
+
+[Hono] Create a Program (body validated by ProgramInsertSchema).
+
+\`\`\`ts
+import { registerProgramRoutes } from "./Program.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`registerProgramRoutes(app, { db })\`
+
+### \`PATCH /api/programs/:id\`
+
+[Hono] Partially update a Program by id (body validated by ProgramUpdateSchema).
+
+\`\`\`ts
+import { registerProgramRoutes } from "./Program.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`registerProgramRoutes(app, { db })\`
+
+### \`PUT /api/programs/:id\`
+
+[Hono] PUT alias of PATCH — the same handler and partial body (validated by ProgramUpdateSchema).
+
+\`\`\`ts
+import { registerProgramRoutes } from "./Program.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+Mount: \`registerProgramRoutes(app, { db })\`
+
+### \`DELETE /api/programs/:id\`
+
+[Hono] Delete a Program by id.
+
+\`\`\`ts
+import { registerProgramRoutes } from "./Program.routes.hono"
+\`\`\`
+
+Mount: \`registerProgramRoutes(app, { db })\`
+
+## Validation
+
+### \`ProgramInsertSchema: ZodType\`
+
+Zod schema validating the body of a create / POST request (auto-generated PKs excluded).
+
+\`\`\`ts
+import { ProgramInsertSchema } from "./Program"
+\`\`\`
+
+Accepted fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+
+### \`ProgramUpdateSchema: ZodType\`
+
+Zod schema validating the body of an update / PATCH request (all fields optional).
+
+\`\`\`ts
+import { ProgramUpdateSchema } from "./Program"
+\`\`\`
+
+Accepted fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`title\` | \`string\` | | |
+| \`createdAt\` | \`string\` | | |
+"
+,
+ "api/ts/Purchase.md":
+"
+
+# Purchase API
+
+**Model / metadata:** [Purchase](../../Purchase.md)
+
+> Import paths are relative to your generated-output directory.
+
+## Setup
+
+Obtain the runtime handles the calls below need:
+
+- \`db\` — your Drizzle connection — the generated queries take \`db: Db\` (a \`PgDatabase\` / \`BaseSQLiteDatabase\` alias). Construct one over your own driver and pass any compatible Drizzle instance:
+
+\`\`\`ts
+import { drizzle } from "drizzle-orm/node-postgres";
+import { Pool } from "pg";
+const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));
+\`\`\`
+
+## Example
+
+\`\`\`ts
+import { createPurchase, findPurchaseById, updatePurchase, deletePurchaseById } from "./Purchase.queries";
+
+const created = await createPurchase(db, { id: 1 });
+const found = await findPurchaseById(db, created.id);
+const updated = await updatePurchase(db, created.id, { id: 1 });
+const removed = await deletePurchaseById(db, created.id);
+\`\`\`
+
+## Model
+
+### \`interface Purchase\`
+
+The typed shape of a Purchase row, generated from its metadata.
+
+\`\`\`ts
+import { Purchase } from "./Purchase"
+\`\`\`
+
+Fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+## Relations
+
+### \`const purchasesRelations: Relations<"purchases", …>\`
+
+Drizzle relations() for Purchase — register it with your schema, then traverse via the relational query API (db.query.purchases.findMany({ with: { … } })).
+
+\`\`\`ts
+import { purchasesRelations } from "./Purchase"
+\`\`\`
+
+Navigations:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`program\` | \`Program (1:1 / N:1)\` | | belongs-to → Program via programId |
+
+## Data access
+
+### \`findPurchaseById(db: Db, id: number): Promise\`
+
+Fetch a single Purchase by its primary key; null when not found.
+
+\`\`\`ts
+import { findPurchaseById } from "./Purchase.queries"
+\`\`\`
+
+### \`listPurchases(db: Db, opts?: { limit?: number; offset?: number }): Promise\`
+
+List Purchase rows with optional limit/offset paging.
+
+\`\`\`ts
+import { listPurchases } from "./Purchase.queries"
+\`\`\`
+
+### \`createPurchase(db: Db, data: PurchaseCreate): Promise\`
+
+Validate (via PurchaseInsertSchema) and insert a new Purchase.
+
+\`\`\`ts
+import { createPurchase } from "./Purchase.queries"
+\`\`\`
+
+Request body (data):
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Throws: ZodError when data fails PurchaseInsertSchema validation.
+
+### \`updatePurchase(db: Db, id: number, patch: PurchasePatch): Promise\`
+
+Partially update a Purchase by primary key — writes only the assigned fields; null when not found. A renamed/dropped field is a compile error.
+
+\`\`\`ts
+import { updatePurchase } from "./Purchase.queries"
+\`\`\`
+
+Request body (data):
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Throws: ZodError when the patch fails PurchaseUpdateSchema validation.
+
+### \`deletePurchaseById(db: Db, id: number): Promise\`
+
+Delete a Purchase by primary key; true when a row was removed.
+
+\`\`\`ts
+import { deletePurchaseById } from "./Purchase.queries"
+\`\`\`
+
+## REST
+
+### \`GET /api/purchases\`
+
+List Purchase (supports filter/sort/paging query params).
+
+\`\`\`ts
+import { purchaseRoutes } from "./Purchase.routes"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`await purchaseRoutes(fastify)\`
+
+### \`GET /api/purchases/:id\`
+
+Fetch a single Purchase by id (404 when not found).
+
+\`\`\`ts
+import { purchaseRoutes } from "./Purchase.routes"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`await purchaseRoutes(fastify)\`
+
+### \`POST /api/purchases\`
+
+Create a Purchase (body validated by PurchaseInsertSchema).
+
+\`\`\`ts
+import { purchaseRoutes } from "./Purchase.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`await purchaseRoutes(fastify)\`
+
+### \`PATCH /api/purchases/:id\`
+
+Partially update a Purchase by id (body validated by PurchaseUpdateSchema).
+
+\`\`\`ts
+import { purchaseRoutes } from "./Purchase.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`await purchaseRoutes(fastify)\`
+
+### \`PUT /api/purchases/:id\`
+
+PUT alias of PATCH — the same handler and partial body (validated by PurchaseUpdateSchema).
+
+\`\`\`ts
+import { purchaseRoutes } from "./Purchase.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`await purchaseRoutes(fastify)\`
+
+### \`DELETE /api/purchases/:id\`
+
+Delete a Purchase by id.
+
+\`\`\`ts
+import { purchaseRoutes } from "./Purchase.routes"
+\`\`\`
+
+Mount: \`await purchaseRoutes(fastify)\`
+
+## REST (Hono)
+
+### \`GET /api/purchases\`
+
+[Hono] List Purchase (filter/sort/paging query params).
+
+\`\`\`ts
+import { registerPurchaseRoutes } from "./Purchase.routes.hono"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`registerPurchaseRoutes(app, { db })\`
+
+### \`GET /api/purchases/:id\`
+
+[Hono] Fetch a single Purchase by id (404 when not found).
+
+\`\`\`ts
+import { registerPurchaseRoutes } from "./Purchase.routes.hono"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`registerPurchaseRoutes(app, { db })\`
+
+### \`POST /api/purchases\`
+
+[Hono] Create a Purchase (body validated by PurchaseInsertSchema).
+
+\`\`\`ts
+import { registerPurchaseRoutes } from "./Purchase.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`registerPurchaseRoutes(app, { db })\`
+
+### \`PATCH /api/purchases/:id\`
+
+[Hono] Partially update a Purchase by id (body validated by PurchaseUpdateSchema).
+
+\`\`\`ts
+import { registerPurchaseRoutes } from "./Purchase.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`registerPurchaseRoutes(app, { db })\`
+
+### \`PUT /api/purchases/:id\`
+
+[Hono] PUT alias of PATCH — the same handler and partial body (validated by PurchaseUpdateSchema).
+
+\`\`\`ts
+import { registerPurchaseRoutes } from "./Purchase.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+Mount: \`registerPurchaseRoutes(app, { db })\`
+
+### \`DELETE /api/purchases/:id\`
+
+[Hono] Delete a Purchase by id.
+
+\`\`\`ts
+import { registerPurchaseRoutes } from "./Purchase.routes.hono"
+\`\`\`
+
+Mount: \`registerPurchaseRoutes(app, { db })\`
+
+## Validation
+
+### \`PurchaseInsertSchema: ZodType\`
+
+Zod schema validating the body of a create / POST request (auto-generated PKs excluded).
+
+\`\`\`ts
+import { PurchaseInsertSchema } from "./Purchase"
+\`\`\`
+
+Accepted fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+
+### \`PurchaseUpdateSchema: ZodType\`
+
+Zod schema validating the body of an update / PATCH request (all fields optional).
+
+\`\`\`ts
+import { PurchaseUpdateSchema } from "./Purchase"
+\`\`\`
+
+Accepted fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`amountCents\` | \`number\` | | |
+| \`status\` | \`string\` | | |
+| \`refunded\` | \`boolean\` | | |
+| \`purchasedAt\` | \`string\` | | |
+| \`purchasedOn\` | \`string\` | | |
+"
+,
+ "api/ts/README.md":
+"
+
+# API Reference
+
+Generated public API surface, one page per entity and output template.
+
+## Entities
+
+- [Program](./Program.md) — model, 5 data access, 6 REST, 6 REST (Hono), 2 validation (20 symbols)
+- [Purchase](./Purchase.md) — model, relations, 5 data access, 6 REST, 6 REST (Hono), 2 validation (21 symbols)
+- [WorkoutEvent](./WorkoutEvent.md) — model, 5 data access, 6 REST, 6 REST (Hono), 2 validation (20 symbols)
+"
+,
+ "api/ts/WorkoutEvent.md":
+"
+
+# WorkoutEvent API
+
+**Model / metadata:** [WorkoutEvent](../../WorkoutEvent.md)
+
+> Import paths are relative to your generated-output directory.
+
+## Setup
+
+Obtain the runtime handles the calls below need:
+
+- \`db\` — your Drizzle connection — the generated queries take \`db: Db\` (a \`PgDatabase\` / \`BaseSQLiteDatabase\` alias). Construct one over your own driver and pass any compatible Drizzle instance:
+
+\`\`\`ts
+import { drizzle } from "drizzle-orm/node-postgres";
+import { Pool } from "pg";
+const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));
+\`\`\`
+
+## Example
+
+\`\`\`ts
+import { createWorkoutEvent, findWorkoutEventById, updateWorkoutEvent, deleteWorkoutEventById } from "./WorkoutEvent.queries";
+
+const created = await createWorkoutEvent(db, { id: 1 });
+const found = await findWorkoutEventById(db, created.id);
+const updated = await updateWorkoutEvent(db, created.id, { id: 1 });
+const removed = await deleteWorkoutEventById(db, created.id);
+\`\`\`
+
+## Model
+
+### \`interface WorkoutEvent\`
+
+The typed shape of a WorkoutEvent row, generated from its metadata.
+
+\`\`\`ts
+import { WorkoutEvent } from "./WorkoutEvent"
+\`\`\`
+
+Fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+## Data access
+
+### \`findWorkoutEventById(db: Db, id: number): Promise\`
+
+Fetch a single WorkoutEvent by its primary key; null when not found.
+
+\`\`\`ts
+import { findWorkoutEventById } from "./WorkoutEvent.queries"
+\`\`\`
+
+### \`listWorkoutEvents(db: Db, opts?: { limit?: number; offset?: number }): Promise\`
+
+List WorkoutEvent rows with optional limit/offset paging.
+
+\`\`\`ts
+import { listWorkoutEvents } from "./WorkoutEvent.queries"
+\`\`\`
+
+### \`createWorkoutEvent(db: Db, data: WorkoutEventCreate): Promise\`
+
+Validate (via WorkoutEventInsertSchema) and insert a new WorkoutEvent.
+
+\`\`\`ts
+import { createWorkoutEvent } from "./WorkoutEvent.queries"
+\`\`\`
+
+Request body (data):
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Throws: ZodError when data fails WorkoutEventInsertSchema validation.
+
+### \`updateWorkoutEvent(db: Db, id: number, patch: WorkoutEventPatch): Promise\`
+
+Partially update a WorkoutEvent by primary key — writes only the assigned fields; null when not found. A renamed/dropped field is a compile error.
+
+\`\`\`ts
+import { updateWorkoutEvent } from "./WorkoutEvent.queries"
+\`\`\`
+
+Request body (data):
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Throws: ZodError when the patch fails WorkoutEventUpdateSchema validation.
+
+### \`deleteWorkoutEventById(db: Db, id: number): Promise\`
+
+Delete a WorkoutEvent by primary key; true when a row was removed.
+
+\`\`\`ts
+import { deleteWorkoutEventById } from "./WorkoutEvent.queries"
+\`\`\`
+
+## REST
+
+### \`GET /api/workout_events\`
+
+List WorkoutEvent (supports filter/sort/paging query params).
+
+\`\`\`ts
+import { workoutEventRoutes } from "./WorkoutEvent.routes"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`await workoutEventRoutes(fastify)\`
+
+### \`GET /api/workout_events/:id\`
+
+Fetch a single WorkoutEvent by id (404 when not found).
+
+\`\`\`ts
+import { workoutEventRoutes } from "./WorkoutEvent.routes"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`await workoutEventRoutes(fastify)\`
+
+### \`POST /api/workout_events\`
+
+Create a WorkoutEvent (body validated by WorkoutEventInsertSchema).
+
+\`\`\`ts
+import { workoutEventRoutes } from "./WorkoutEvent.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`await workoutEventRoutes(fastify)\`
+
+### \`PATCH /api/workout_events/:id\`
+
+Partially update a WorkoutEvent by id (body validated by WorkoutEventUpdateSchema).
+
+\`\`\`ts
+import { workoutEventRoutes } from "./WorkoutEvent.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`await workoutEventRoutes(fastify)\`
+
+### \`PUT /api/workout_events/:id\`
+
+PUT alias of PATCH — the same handler and partial body (validated by WorkoutEventUpdateSchema).
+
+\`\`\`ts
+import { workoutEventRoutes } from "./WorkoutEvent.routes"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`await workoutEventRoutes(fastify)\`
+
+### \`DELETE /api/workout_events/:id\`
+
+Delete a WorkoutEvent by id.
+
+\`\`\`ts
+import { workoutEventRoutes } from "./WorkoutEvent.routes"
+\`\`\`
+
+Mount: \`await workoutEventRoutes(fastify)\`
+
+## REST (Hono)
+
+### \`GET /api/workout_events\`
+
+[Hono] List WorkoutEvent (filter/sort/paging query params).
+
+\`\`\`ts
+import { registerWorkoutEventRoutes } from "./WorkoutEvent.routes.hono"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`registerWorkoutEventRoutes(app, { db })\`
+
+### \`GET /api/workout_events/:id\`
+
+[Hono] Fetch a single WorkoutEvent by id (404 when not found).
+
+\`\`\`ts
+import { registerWorkoutEventRoutes } from "./WorkoutEvent.routes.hono"
+\`\`\`
+
+Response body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`registerWorkoutEventRoutes(app, { db })\`
+
+### \`POST /api/workout_events\`
+
+[Hono] Create a WorkoutEvent (body validated by WorkoutEventInsertSchema).
+
+\`\`\`ts
+import { registerWorkoutEventRoutes } from "./WorkoutEvent.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`registerWorkoutEventRoutes(app, { db })\`
+
+### \`PATCH /api/workout_events/:id\`
+
+[Hono] Partially update a WorkoutEvent by id (body validated by WorkoutEventUpdateSchema).
+
+\`\`\`ts
+import { registerWorkoutEventRoutes } from "./WorkoutEvent.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`registerWorkoutEventRoutes(app, { db })\`
+
+### \`PUT /api/workout_events/:id\`
+
+[Hono] PUT alias of PATCH — the same handler and partial body (validated by WorkoutEventUpdateSchema).
+
+\`\`\`ts
+import { registerWorkoutEventRoutes } from "./WorkoutEvent.routes.hono"
+\`\`\`
+
+Request body:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+Mount: \`registerWorkoutEventRoutes(app, { db })\`
+
+### \`DELETE /api/workout_events/:id\`
+
+[Hono] Delete a WorkoutEvent by id.
+
+\`\`\`ts
+import { registerWorkoutEventRoutes } from "./WorkoutEvent.routes.hono"
+\`\`\`
+
+Mount: \`registerWorkoutEventRoutes(app, { db })\`
+
+## Validation
+
+### \`WorkoutEventInsertSchema: ZodType\`
+
+Zod schema validating the body of a create / POST request (auto-generated PKs excluded).
+
+\`\`\`ts
+import { WorkoutEventInsertSchema } from "./WorkoutEvent"
+\`\`\`
+
+Accepted fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | yes | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+
+### \`WorkoutEventUpdateSchema: ZodType\`
+
+Zod schema validating the body of an update / PATCH request (all fields optional).
+
+\`\`\`ts
+import { WorkoutEventUpdateSchema } from "./WorkoutEvent"
+\`\`\`
+
+Accepted fields:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| \`id\` | \`number\` | | |
+| \`programId\` | \`number\` | | |
+| \`customerEmail\` | \`string\` | | |
+| \`weekNumber\` | \`number\` | | |
+| \`dayNumber\` | \`number\` | | |
+| \`eventType\` | \`string\` | | |
+| \`occurredAt\` | \`string\` | | |
+"
+,
+}
+`;
diff --git a/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts b/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts
new file mode 100644
index 000000000..5219b8042
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts
@@ -0,0 +1,375 @@
+// FR-044 Plan 3, Table G: what `meta docs` shows for a report.
+//
+// model surface a page for EVERY report (served or not), built from `reportShape`,
+// and a "Reporting" section on the `@from` entity's page
+// api surface one unit for a SERVED report: the row model, the list query function
+// and `GET `. No by-id, no write, no validation schema, no
+// hook. No unit for a report that is not served.
+//
+// The model pair is the one every port's FR-044 inert test shares
+// (fixtures/codegen-noop/reporting/). `without/` is the no-churn half: its pages are
+// pinned by a snapshot taken BEFORE this feature landed, so a model with no report
+// renders what it always did.
+
+import { describe, test, expect, beforeAll } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { MetaDataLoader, InMemoryStringSource, type MetaObject, type MetaRoot } from "@metaobjectsdev/metadata";
+import { docsFile } from "../src/generators/docs-file.js";
+import { apiDocsFile } from "../src/generators/api-docs-file.js";
+import { buildApiModel, type ApiUnitDoc } from "../src/generators/api-model.js";
+import { makeRenderContext } from "../src/render-context.js";
+import { buildPkMap } from "../src/pk-resolver.js";
+import { buildRelationMap } from "../src/relation-resolver.js";
+import { entityFile } from "../src/generators/entity-file.js";
+import { queriesFile } from "../src/generators/queries-file.js";
+import { routesFile } from "../src/generators/routes-file.js";
+import { routesFileHono } from "../src/generators/index.js";
+import { generatableObjects } from "../src/source-detect.js";
+import type { GenContext, Generator } from "../src/generator.js";
+import type { OutputLayout } from "../src/import-path.js";
+
+// test → codegen-ts → packages → typescript → server → repo root
+const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..", "..");
+const MODELS = join(REPO_ROOT, "fixtures", "codegen-noop", "reporting");
+
+/** Both variants load under ONE source id: a page prints its source file, so the two
+ * must not differ by the directory they were read from. */
+async function load(variant: "with" | "without"): Promise {
+ const json = readFileSync(join(MODELS, variant, "meta.shop.json"), "utf8");
+ const res = await new MetaDataLoader().load([
+ new InMemoryStringSource(json, { id: "meta.shop.json", format: "json" }),
+ ]);
+ expect(res.errors).toEqual([]);
+ return res.root;
+}
+
+async function loadJson(model: unknown): Promise {
+ const res = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(model), { id: "meta.json", format: "json" }),
+ ]);
+ expect(res.errors).toEqual([]);
+ return res.root;
+}
+
+function ctxFor(root: MetaRoot, layout: OutputLayout = "flat"): GenContext {
+ return {
+ entities: root.objects(),
+ loadedRoot: root,
+ matches: () => true,
+ config: {
+ outDir: "docs", extStyle: "none", dbImport: "", dialect: "postgres", outputLayout: layout,
+ includeHonoRoutes: true, includeUiTier: true,
+ } as never,
+ renderContext: makeRenderContext({
+ dialect: "postgres", loadedRoot: root, outDir: "docs", dbImport: "", apiPrefix: "/api",
+ pkMap: buildPkMap(root), relationMap: buildRelationMap(root),
+ }),
+ warn: () => {},
+ };
+}
+
+const API_SURFACES = [{ label: "TypeScript", subDir: "api/ts" }];
+
+async function modelPages(root: MetaRoot, layout: OutputLayout = "flat"): Promise> {
+ const out: Record = {};
+ for (const f of await docsFile({ apiSurfaces: API_SURFACES }).generate(ctxFor(root, layout))) out[f.path] = f.content;
+ return out;
+}
+
+async function apiPages(root: MetaRoot): Promise> {
+ const out: Record = {};
+ for (const f of await apiDocsFile({ subDir: "api/ts", modelSurface: true }).generate(ctxFor(root))) out[f.path] = f.content;
+ return out;
+}
+
+function apiUnits(root: MetaRoot): ApiUnitDoc[] {
+ return buildApiModel(root, { loadedRoot: root, apiPrefix: "/api", includeHonoRoutes: true }).units;
+}
+
+let withReporting: MetaRoot;
+let withoutReporting: MetaRoot;
+
+beforeAll(async () => {
+ withReporting = await load("with");
+ withoutReporting = await load("without");
+});
+
+describe("FR-044 no-churn: a model with no report renders every docs page as before", () => {
+ test("model pages are the pre-feature snapshot", async () => {
+ expect(await modelPages(withoutReporting)).toMatchSnapshot();
+ });
+
+ test("api pages are the pre-feature snapshot", async () => {
+ expect(await apiPages(withoutReporting)).toMatchSnapshot();
+ });
+
+ test("an entity the with-model leaves without reporting nodes keeps its page byte for byte", async () => {
+ // Program declares no dimension, measure or segment and no report names it.
+ const before = await modelPages(withoutReporting);
+ const after = await modelPages(withReporting);
+ expect(after["Program.md"]).toBe(before["Program.md"]!);
+ });
+});
+
+describe("FR-044 model surface: a page for every report", () => {
+ test("each report has a page, linked from the index under Reports", async () => {
+ const pages = await modelPages(withReporting);
+ for (const name of ["StoreTotals", "ProgramEngagement", "DailyRevenue"]) {
+ expect(Object.keys(pages)).toContain(`${name}.md`);
+ }
+ const index = pages["README.md"]!;
+ expect(index).toContain(
+ "## Reports\n\n- [DailyRevenue](./DailyRevenue.md)\n- [ProgramEngagement](./ProgramEngagement.md)\n- [StoreTotals](./StoreTotals.md)\n",
+ );
+ // A report is not an entity: the entity list is the one the model without reports has.
+ const entities = (md: string): string => {
+ const start = md.indexOf("## Entities");
+ return md.slice(start, md.indexOf("\n## ", start + 1));
+ };
+ expect(entities(index)).toBe(entities((await modelPages(withoutReporting))["README.md"]!));
+ });
+
+ test("a served report names its @from, its view and its columns with their Table B types", async () => {
+ const page = (await modelPages(withReporting))["StoreTotals.md"]!;
+ expect(page).toContain("**Type:** `object.report`");
+ expect(page).toContain("## Report");
+ expect(page).toContain("**From:** [Purchase](./Purchase.md)");
+ expect(page).toContain("**View:** `v_store_totals`");
+ expect(page).not.toContain("Not served");
+ expect(page).toContain(
+ "| Column | Type | Nullable | Role | Definition |\n" +
+ "|---|---|---|---|---|\n" +
+ "| `purchases` | `long` | no | measure | count of `Purchase.id` where segment `active` |\n" +
+ "| `buyers` | `long` | no | measure | count of distinct `Purchase.customerEmail` where segment `active` |\n" +
+ "| `revenue` | `currency` | yes | measure | sum of `Purchase.amountCents` where segment `active` |\n",
+ );
+ // A served report has an api unit, so its page links to it.
+ expect(page).toContain("**API reference:** [TypeScript](./api/ts/StoreTotals.md)");
+ });
+
+ test("a sourceless report says it is not served, and still lists its columns and row scope", async () => {
+ const pages = await modelPages(withReporting);
+ const engagement = pages["ProgramEngagement.md"]!;
+ expect(engagement).toContain("**From:** [WorkoutEvent](./WorkoutEvent.md)");
+ expect(engagement).toContain("**View:** Not served: declares no view source");
+ expect(engagement).toContain("**Row scope:** segment `completions`");
+ expect(engagement).toContain("| `program` | `long` | yes | dimension | `WorkoutEvent.programId` |");
+ expect(engagement).toContain(
+ "| `daysEngaged` | `long` | no | measure | count of distinct (`WorkoutEvent.programId`, `WorkoutEvent.weekNumber`, `WorkoutEvent.dayNumber`) |",
+ );
+ expect(engagement).toContain(
+ "| `avgDaysPerStarter` | `decimal` | yes | measure | `daysEngaged` / `starters`, null when the denominator is 0 |",
+ );
+ expect(engagement).toContain("| `lastActivityAt` | `timestamp` | yes | measure | max of `WorkoutEvent.occurredAt` |");
+ // No api unit exists for it, so nothing links to one.
+ expect(engagement).not.toContain("API reference");
+
+ const daily = pages["DailyRevenue.md"]!;
+ expect(daily).toContain("**View:** Not served: declares no view source");
+ // A filter prints in its canonical form (a bare value is `eq`), as the loader holds it.
+ expect(daily).toContain('**Row scope:** filter `{"purchasedAt":{"gte":{"now":"-P90D"}}}`');
+ expect(daily).toContain("| `purchasedAtDay` | `date` | yes | dimension | `Purchase.purchasedAt` truncated to day, UTC |");
+ expect(daily).not.toContain("API reference");
+ });
+
+ test("the @from entity's page has a Reporting section: dimensions, measures, segments, reports", async () => {
+ const page = (await modelPages(withReporting))["Purchase.md"]!;
+ const section = page.slice(page.indexOf("## Reporting"));
+ expect(section).toBe(
+ "## Reporting\n\n" +
+ "**Dimensions**\n\n" +
+ "- `program` — `Purchase.programId`\n" +
+ "- `programTitle` — `Program.title` via `Purchase.program`\n" +
+ "- `purchasedAt` (time) — `Purchase.purchasedAt`; grains: day, week, month, quarter, year\n" +
+ "- `programCreatedAt` (time) — `Program.createdAt` via `Purchase.program`; grains: month, year\n\n" +
+ "**Measures**\n\n" +
+ "- `refundedPurchases` — count of `Purchase.id` where filter `{\"refunded\":{\"eq\":true}}`\n" +
+ "- `purchases` — count of `Purchase.id` where segment `active`\n" +
+ "- `buyers` — count of distinct `Purchase.customerEmail` where segment `active`\n" +
+ "- `revenue` — sum of `Purchase.amountCents` where segment `active`\n\n" +
+ "**Segments**\n\n" +
+ "- `active` — `{\"status\":{\"eq\":\"active\"}}`\n\n" +
+ "**Reports**\n\n" +
+ "- [DailyRevenue](./DailyRevenue.md)\n" +
+ "- [StoreTotals](./StoreTotals.md)\n",
+ );
+ // Everything above the section is the page the model without reporting nodes has.
+ const before = (await modelPages(withoutReporting))["Purchase.md"]!;
+ expect(page.slice(0, page.indexOf("\n## Reporting"))).toBe(before);
+ });
+
+ test("report and @from links resolve in package layout", async () => {
+ const pages = await modelPages(withReporting, "package");
+ expect(pages["acme/shop/StoreTotals.md"]).toContain("**From:** [Purchase](./Purchase.md)");
+ expect(pages["acme/shop/Purchase.md"]).toContain("- [StoreTotals](./StoreTotals.md)");
+ expect(pages["README.md"]).toContain("- [StoreTotals](./acme/shop/StoreTotals.md)");
+ });
+
+ test("a time dimension reached by @via, and a measure scoped by segment and filter, read in full", async () => {
+ const root = await loadJson({
+ "metadata.root": {
+ package: "acme::billing",
+ children: [
+ { "object.entity": { name: "Customer", children: [
+ { "source.rdb": { "@table": "customers" } },
+ { "field.long": { name: "id" } },
+ { "field.timestamp": { name: "joinedAt" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Invoice", children: [
+ { "source.rdb": { "@table": "invoices" } },
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "customerId" } },
+ { "field.currency": { name: "amountCents" } },
+ { "field.string": { name: "status" } },
+ { "field.boolean": { name: "voided" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "customerRef", "@references": "Customer", "@fields": ["customerId"] } },
+ { "relationship.association": { name: "customer", "@objectRef": "Customer", "@cardinality": "one" } },
+ { "dimension.time": { name: "customerJoinedAt", "@of": "Customer.joinedAt", "@via": "Invoice.customer", "@grains": ["hour", "month"] } },
+ { "measure.aggregate": { name: "paidCents", "@agg": "sum", "@of": "Invoice.amountCents", "@segment": "paid", "@filter": { voided: false } } },
+ { "segment.filter": { name: "paid", "@filter": { status: "paid" } } },
+ ] } },
+ { "object.report": { name: "PaidByCohort", "@from": "Invoice",
+ "@dimensions": ["customerJoinedAt:month"], "@measures": ["paidCents"],
+ "@segment": "paid", "@filter": { voided: false },
+ children: [{ "source.rdb": { "@kind": "view", "@table": "v_paid_by_cohort" } }] } },
+ ],
+ },
+ });
+ const page = (await modelPages(root))["PaidByCohort.md"]!;
+ expect(page).toContain('**Row scope:** segment `paid` and filter `{"voided":{"eq":false}}`');
+ expect(page).toContain(
+ "| `customerJoinedAtMonth` | `date` | yes | dimension | `Customer.joinedAt` via `Invoice.customer`, truncated to month, UTC |",
+ );
+ expect(page).toContain(
+ '| `paidCents` | `currency` | yes | measure | sum of `Invoice.amountCents` where segment `paid` and filter `{"voided":{"eq":false}}` |',
+ );
+ });
+});
+
+describe("FR-044 api surface: one unit for a served report, none for the rest", () => {
+ test("StoreTotals has a unit; the sourceless reports have none", () => {
+ const names = apiUnits(withReporting).map((u) => u.node);
+ expect(names).toContain("StoreTotals");
+ expect(names).not.toContain("ProgramEngagement");
+ expect(names).not.toContain("DailyRevenue");
+ });
+
+ test("the unit is the row model, the list query and GET , per route surface", () => {
+ const unit = apiUnits(withReporting).find((u) => u.node === "StoreTotals")!;
+ expect(unit.symbols.map((s) => [s.kind, s.name])).toEqual([
+ ["model", "StoreTotals"],
+ ["data-access", "listStoreTotals"],
+ ["rest", "GET /api/store_totals"],
+ ["rest-hono", "GET /api/store_totals"],
+ ]);
+ // The row model is the derived shape (the declared node has no fields to read).
+ expect(unit.symbols[0]!.fields).toEqual([
+ { name: "purchases", type: "number", optional: false },
+ { name: "buyers", type: "number", optional: false },
+ { name: "revenue", type: "number", optional: true },
+ ]);
+ // No worked create/find flow: a report has neither.
+ expect(unit.example).toBeUndefined();
+ });
+
+ test("with the Hono variant off, GET is the only REST symbol", () => {
+ const unit = buildApiModel(withReporting, { loadedRoot: withReporting, apiPrefix: "/api" }).units.find((u) => u.node === "StoreTotals")!;
+ expect(unit.symbols.filter((s) => s.kind === "rest" || s.kind === "rest-hono").map((s) => s.name))
+ .toEqual(["GET /api/store_totals"]);
+ });
+
+ test("nothing in the unit is a hook, a by-id, a write or a validation schema", async () => {
+ const unit = apiUnits(withReporting).find((u) => u.node === "StoreTotals")!;
+ for (const s of unit.symbols) {
+ expect(s.name).not.toMatch(/^use[A-Z]/);
+ expect(s.name).not.toMatch(/:id|ById|Schema$|^(create|update|delete)/);
+ }
+ const page = (await apiPages(withReporting))["api/ts/StoreTotals.md"]!;
+ expect(page).toBeDefined();
+ expect(page).not.toMatch(/\buse[A-Z]\w*\(/);
+ expect(page).not.toContain(":id");
+ expect(page).not.toContain("InsertSchema");
+ });
+
+ test("every documented symbol is one the real generators emit for the report", async () => {
+ // The accuracy gate's rule, applied to the report: run the generators over what
+ // `runGen` hands them (the served report swapped for its read model) and find each
+ // documented name in the file that owns it.
+ const ctx: GenContext = { ...ctxFor(withReporting), entities: generatableObjects(withReporting.objects(), withReporting) };
+ const emitted = async (gen: Generator): Promise => {
+ const matches = (o: MetaObject): boolean => (gen.filter ? gen.filter(o) : true);
+ const files = await gen.generate({ ...ctx, entities: ctx.entities.filter(matches), matches });
+ return files.filter((f) => f.path.includes("StoreTotals")).map((f) => f.content).join("\n");
+ };
+ const entity = await emitted(entityFile());
+ const queries = await emitted(queriesFile());
+ const routes = await emitted(routesFile());
+ const hono = await emitted(routesFileHono());
+ expect(entity).toMatch(/export (type|interface) StoreTotals\b/);
+ expect(queries).toContain("export async function listStoreTotals(");
+ expect(queries).not.toContain("ById");
+ expect(routes).toContain("storeTotalsRoutes");
+ expect(routes).toContain("itemRoutes: false");
+ expect(hono).toContain("registerStoreTotalsRoutes");
+ // And the address: the descriptor's $path under the configured prefix.
+ expect(entity).toContain('"/store_totals"');
+ const unit = apiUnits(withReporting).find((u) => u.node === "StoreTotals")!;
+ expect(unit.symbols.find((s) => s.kind === "rest")!.registrar).toBe("storeTotalsRoutes");
+ expect(unit.symbols.find((s) => s.kind === "rest-hono")!.registrar).toBe("registerStoreTotalsRoutes");
+ });
+
+ test("the api pages differ from the no-report model by exactly the report's page and its index entries", async () => {
+ const before = await apiPages(withoutReporting);
+ const after = await apiPages(withReporting);
+ expect(Object.keys(after).filter((p) => !(p in before))).toEqual(["api/ts/StoreTotals.md"]);
+ for (const [path, content] of Object.entries(before)) {
+ if (path.endsWith("README.md") || path.endsWith("AGENT-API.md")) {
+ expect(after[path]).toContain("StoreTotals");
+ expect(after[path]).not.toContain("ProgramEngagement");
+ expect(after[path]).not.toContain("DailyRevenue");
+ continue;
+ }
+ expect({ path, content: after[path] }).toEqual({ path, content });
+ }
+ });
+});
+
+describe("FR-044 answer 4: a keyless projection documents no item surface", () => {
+ const model = (withId: boolean) => ({
+ "metadata.root": {
+ package: "acme::shop",
+ children: [
+ { "object.entity": { name: "Sale", children: [
+ { "source.rdb": { "@table": "sales" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "region" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.projection": { name: "RegionTotals", children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_region_totals", "@unmanaged": true } },
+ ...(withId ? [{ "field.long": { name: "id" } }] : []),
+ { "field.string": { name: "region" } },
+ ] } },
+ ],
+ },
+ });
+ const names = (root: MetaRoot): string[] =>
+ apiUnits(root).find((u) => u.node === "RegionTotals")!.symbols.map((s) => s.name);
+
+ test("no identity and no id column: no /:id endpoint and no by-id query", async () => {
+ const symbols = names(await loadJson(model(false)));
+ expect(symbols).toContain("GET /api/region_totals");
+ expect(symbols).toContain("listRegionTotals");
+ expect(symbols.filter((n) => n.includes(":id"))).toEqual([]);
+ expect(symbols).not.toContain("findRegionTotalsById");
+ });
+
+ test("an id column by convention keeps both (unchanged)", async () => {
+ const symbols = names(await loadJson(model(true)));
+ expect(symbols).toContain("GET /api/region_totals/:id");
+ expect(symbols).toContain("findRegionTotalsById");
+ });
+});
diff --git a/server/typescript/packages/docs-site/src/builders/object-data.ts b/server/typescript/packages/docs-site/src/builders/object-data.ts
index 9beef38c6..4581e1527 100644
--- a/server/typescript/packages/docs-site/src/builders/object-data.ts
+++ b/server/typescript/packages/docs-site/src/builders/object-data.ts
@@ -2,6 +2,10 @@ import type { MetaData } from "@metaobjectsdev/metadata";
import { LinkGraph, fqnOf, type Ref } from "../link-graph.js";
import type { CoverageTracker } from "../coverage.js";
import { esc, badge } from "../badges.js";
+import {
+ REPORT_RENDERED_ATTRS, buildReportSection, buildReportingSection, isReportNode,
+ type ReportSection, type ReportingSection,
+} from "./report-data.js";
import { inheritanceTree, erDiagramRich, flowchartDomain, RICH_MAX, type ErEdge, type ErNode, type ErAttr } from "../mermaid.js";
// capped box attributes for the rich neighborhood ERD: PK → FKs(target) → enums → required, ≤6 + overflow count
@@ -90,6 +94,10 @@ export interface ObjectPageData {
referencedBy: { name: string; href: string; via: string }[];
references: { name: string; href: string; via: string }[]; usedByTemplates: { name: string; href: string }[];
sourceFile: string;
+ /** FR-044: present on an `object.report` page only. */
+ report?: ReportSection | undefined;
+ /** FR-044: present on a page whose object declares reporting members or is a report's `@from`. */
+ reporting?: ReportingSection | undefined;
}
function fieldRow(f: MetaData, ownerHref: string, g: LinkGraph, cov: CoverageTracker, ctxPkg: string): FieldRow {
@@ -173,7 +181,10 @@ export function buildObjectPage(fqn: string, g: LinkGraph, cov: CoverageTracker)
cov.consumeNode(o);
cov.consumeAttr(o, "description");
// consumer/structural attrs authored on the object itself (e.g. @dataflow, @neo4j)
- const objectAttrs = otherAttrs(o, cov, new Set(["description"]));
+ // FR-044: a report's own attrs are rendered by its Report section, not as badges.
+ const report = isReportNode(o) ? buildReportSection(dn, g.root, g, cov) : undefined;
+ const reporting = buildReportingSection(dn, g, cov);
+ const objectAttrs = otherAttrs(o, cov, new Set(["description", ...(report ? REPORT_RENDERED_ATTRS : [])]));
// inheritance hierarchy rows (ancestors nearest-last so level increases downward) + self + direct children
const anc = g.ancestors(fqn); // nearest-first
@@ -348,5 +359,6 @@ export function buildObjectPage(fqn: string, g: LinkGraph, cov: CoverageTracker)
pkg: dn.pkg, href: dn.href, breadcrumbHtml: crumbs.join(" / "), desc: esc(o.attr("description") ?? ""),
tableName, pkHtml, objectAttrs, storageAttrs, layouts, ownFields, inheritedFields, indexes, validators, relations, origins,
hierarchy, inheritanceMermaid, neighborhoodMermaid, neighborhoodLegend, neighborhoodMore, referencedBy, references, usedByTemplates, sourceFile: src,
+ report, reporting,
};
}
diff --git a/server/typescript/packages/docs-site/src/builders/report-data.ts b/server/typescript/packages/docs-site/src/builders/report-data.ts
new file mode 100644
index 000000000..117769830
--- /dev/null
+++ b/server/typescript/packages/docs-site/src/builders/report-data.ts
@@ -0,0 +1,207 @@
+// FR-044 on the HTML site (Table G of the Plan 3 spec):
+//
+// • an `object.report`'s page gets a "Report" section: its `@from` (linked), the view it
+// is read from or why it is not served, its row scope, and one row per derived column
+// from `reportShape`;
+// • the page of an entity that declares dimensions, measures or segments, or that a
+// report names as its `@from`, gets a "Reporting" section.
+//
+// The sentences are the ones the markdown model pages print (codegen-ts
+// `generators/report-doc.ts`). This package does not depend on codegen-ts, so the wording
+// is restated here; `test/reporting-site.test.ts` and codegen-ts's
+// `test/reporting-docs.test.ts` assert the same sentences over the same model.
+
+import type { MetaData, MetaObject, MetaRoot, ReportField } from "@metaobjectsdev/metadata";
+import {
+ DIMENSION_SUBTYPE_TIME,
+ MEASURE_SUBTYPE_RATIO,
+ OBJECT_REPORT_ATTR_DIMENSIONS,
+ OBJECT_REPORT_ATTR_FILTER,
+ OBJECT_REPORT_ATTR_FROM,
+ OBJECT_REPORT_ATTR_MEASURES,
+ OBJECT_REPORT_ATTR_SEGMENT,
+ OBJECT_SUBTYPE_REPORT,
+ REPORTING_ATTR_AGG,
+ REPORTING_ATTR_DENOMINATOR,
+ REPORTING_ATTR_DISTINCT,
+ REPORTING_ATTR_FILTER,
+ REPORTING_ATTR_GRAINS,
+ REPORTING_ATTR_NUMERATOR,
+ REPORTING_ATTR_OF,
+ REPORTING_ATTR_SEGMENT,
+ REPORTING_ATTR_VIA,
+ SOURCE_KIND_VIEW,
+ TYPE_DIMENSION,
+ TYPE_MEASURE,
+ TYPE_OBJECT,
+ TYPE_SEGMENT,
+ reportReadSource,
+ reportShape,
+} from "@metaobjectsdev/metadata";
+import { type DocNode, type LinkGraph, fqnOf } from "../link-graph.js";
+import type { CoverageTracker } from "../coverage.js";
+import { esc } from "../badges.js";
+
+/** The attrs of an `object.report` the Report section renders (so the generic
+ * attribute badges on the page skip them). */
+export const REPORT_RENDERED_ATTRS: ReadonlySet = new Set([
+ OBJECT_REPORT_ATTR_FROM,
+ OBJECT_REPORT_ATTR_DIMENSIONS,
+ OBJECT_REPORT_ATTR_MEASURES,
+ OBJECT_REPORT_ATTR_SEGMENT,
+ OBJECT_REPORT_ATTR_FILTER,
+]);
+
+export function isReportNode(node: MetaData): boolean {
+ return node.type === TYPE_OBJECT && node.subType === OBJECT_SUBTYPE_REPORT;
+}
+
+// ─── Wording (plain text with `code` spans; the same sentences as report-doc.ts) ───
+
+const tick = (text: string): string => `\`${text.replace(/`/g, "")}\``;
+const filterText = (filter: unknown): string => tick(JSON.stringify(filter));
+
+function stringList(v: unknown): string[] {
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string");
+ return typeof v === "string" ? [v] : [];
+}
+
+function describeRowScope(segment: unknown, filter: unknown): string | undefined {
+ const parts: string[] = [];
+ if (typeof segment === "string" && segment !== "") parts.push(`segment ${tick(segment)}`);
+ if (filter !== undefined && filter !== null) parts.push(`filter ${filterText(filter)}`);
+ return parts.length > 0 ? parts.join(" and ") : undefined;
+}
+
+function dimensionColumn(dim: MetaData): string {
+ // ADR-0039: resolving reads, here and in every describe* below.
+ const of = dim.attr(REPORTING_ATTR_OF);
+ const via = dim.attr(REPORTING_ATTR_VIA);
+ const column = tick(typeof of === "string" ? of : "");
+ return typeof via === "string" && via !== "" ? `${column} via ${tick(via)}` : column;
+}
+
+function describeDimension(dim: MetaData): string {
+ const column = dimensionColumn(dim);
+ if (dim.subType !== DIMENSION_SUBTYPE_TIME) return column;
+ return `${column}; grains: ${stringList(dim.attr(REPORTING_ATTR_GRAINS)).join(", ")}`;
+}
+
+function describeDimensionColumn(dim: MetaData, grain: string | undefined): string {
+ const column = dimensionColumn(dim);
+ if (grain === undefined) return column;
+ const joiner = column.includes(" via ") ? "," : "";
+ return `${column}${joiner} truncated to ${grain}, UTC`;
+}
+
+function describeMeasure(measure: MetaData): string {
+ if (measure.subType === MEASURE_SUBTYPE_RATIO) {
+ const numerator = measure.attr(REPORTING_ATTR_NUMERATOR);
+ const denominator = measure.attr(REPORTING_ATTR_DENOMINATOR);
+ return `${tick(String(numerator ?? ""))} / ${tick(String(denominator ?? ""))}, null when the denominator is 0`;
+ }
+ const columns = stringList(measure.attr(REPORTING_ATTR_OF)).map(tick);
+ const of = columns.length === 1 ? columns[0]! : `(${columns.join(", ")})`;
+ const distinct = measure.attr(REPORTING_ATTR_DISTINCT) === true ? "distinct " : "";
+ const scope = describeRowScope(measure.attr(REPORTING_ATTR_SEGMENT), measure.attr(REPORTING_ATTR_FILTER));
+ return `${String(measure.attr(REPORTING_ATTR_AGG) ?? "")} of ${distinct}${of}${scope !== undefined ? ` where ${scope}` : ""}`;
+}
+
+function describeReportField(field: ReportField): string {
+ if (field.dimension !== undefined) return describeDimensionColumn(field.dimension, field.grain);
+ return field.measure !== undefined ? describeMeasure(field.measure) : "";
+}
+
+function notServedReason(report: MetaObject): string | undefined {
+ if (report.isAbstract === true) return "Not served: the report is abstract";
+ const source = reportReadSource(report);
+ if (source === undefined) return "Not served: declares no view source";
+ if (source.effectiveKind !== SOURCE_KIND_VIEW) {
+ return `Not served: its source is a ${source.effectiveKind}, not a view`;
+ }
+ return undefined;
+}
+
+/** Escape, then turn each `code` span into a element (this render lib does not escape). */
+function html(text: string): string {
+ return esc(text).replace(/`([^`]*)`/g, "$1");
+}
+
+/** Mark a node and every attr it authored as rendered. */
+function consume(node: MetaData, cov: CoverageTracker): void {
+ cov.consumeNode(node);
+ // ADR-0039: own — coverage counts the attrs a node DECLARES (`CoverageTracker.report`
+ // walks `ownAttrs()`), so the same layer is what gets marked consumed.
+ for (const [name] of node.ownAttrs()) cov.consumeAttr(node, name);
+}
+
+// ─── A report's page ───────────────────────────────────────────────────────────
+
+export interface ReportColumnRow { name: string; type: string; nullable: string; role: string; definitionHtml: string; }
+export interface ReportSection {
+ fromName: string; fromHref: string;
+ /** The view a served report is read from. Absent when it is not served. */
+ viewName?: string | undefined;
+ /** "Not served: …" (Table A). Absent for a served report. */
+ notServed?: string | undefined;
+ scopeHtml?: string | undefined;
+ columns: ReportColumnRow[];
+}
+
+export function buildReportSection(dn: DocNode, root: MetaRoot, g: LinkGraph, cov: CoverageTracker): ReportSection {
+ // `dn.kind === "object"` and the report subtype are checked by the caller; the
+ // `unknown` bridge is the one link-graph.ts uses for the same narrowing.
+ const report = dn.node as unknown as MetaObject;
+ for (const name of REPORT_RENDERED_ATTRS) if (report.attr(name) !== undefined) cov.consumeAttr(report, name);
+ // From `reportShape`, which resolves for a report with no source too.
+ const shape = reportShape(report, root);
+ const from = g.byFqn(fqnOf(shape.from));
+ const notServed = notServedReason(report);
+ const scope = describeRowScope(report.attr(OBJECT_REPORT_ATTR_SEGMENT), report.attr(OBJECT_REPORT_ATTR_FILTER));
+ return {
+ fromName: shape.from.name,
+ fromHref: from ? g.relHref(dn.href, from.href) : "",
+ viewName: notServed === undefined ? reportReadSource(report)?.physicalName : undefined,
+ notServed,
+ scopeHtml: scope !== undefined ? html(scope) : undefined,
+ columns: shape.fields.map((f) => ({
+ name: f.name,
+ type: f.typeSource?.resolvedIsArray() === true ? `${f.subType}[]` : f.subType,
+ nullable: f.required ? "no" : "yes",
+ role: f.role,
+ definitionHtml: html(describeReportField(f)),
+ })),
+ };
+}
+
+// ─── The @from entity's page ───────────────────────────────────────────────────
+
+export interface ReportingMemberRow { name: string; kind: string; definitionHtml: string; }
+export interface ReportingSection {
+ dimensions: ReportingMemberRow[]; measures: ReportingMemberRow[]; segments: ReportingMemberRow[];
+ reports: { name: string; href: string }[];
+}
+
+/** Undefined when the object declares no reporting member and no report reads from it. */
+export function buildReportingSection(dn: DocNode, g: LinkGraph, cov: CoverageTracker): ReportingSection | undefined {
+ const o = dn.node;
+ if (isReportNode(o)) return undefined;
+ const members = (type: string, describe: (n: MetaData) => string, kind: (n: MetaData) => string): ReportingMemberRow[] =>
+ // Resolving (`childrenOfType`), so a member declared on an abstract base shows on
+ // every entity that inherits it, which is where a report may name it from.
+ o.childrenOfType(type).map((n) => {
+ consume(n, cov);
+ return { name: n.name, kind: kind(n), definitionHtml: html(describe(n)) };
+ });
+ const dimensions = members(TYPE_DIMENSION, describeDimension, (n) => (n.subType === DIMENSION_SUBTYPE_TIME ? "time" : ""));
+ const measures = members(TYPE_MEASURE, describeMeasure, () => "");
+ const segments = members(TYPE_SEGMENT, (n) => filterText(n.attr(REPORTING_ATTR_FILTER) ?? {}), () => "");
+ const reports = g.refsTo(fqnOf(o))
+ .filter((r) => r.kind === "report")
+ .map((r) => g.byFqn(r.from))
+ .filter((r): r is DocNode => r !== undefined)
+ .map((r) => ({ name: r.name, href: g.relHref(dn.href, r.href) }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ if (dimensions.length + measures.length + segments.length + reports.length === 0) return undefined;
+ return { dimensions, measures, segments, reports };
+}
diff --git a/server/typescript/packages/docs-site/src/coverage.ts b/server/typescript/packages/docs-site/src/coverage.ts
index 060b7f7c8..bb7ea0ed0 100644
--- a/server/typescript/packages/docs-site/src/coverage.ts
+++ b/server/typescript/packages/docs-site/src/coverage.ts
@@ -1,29 +1,9 @@
import type { MetaData, MetaRoot } from "@metaobjectsdev/metadata";
-import {
- OBJECT_SUBTYPE_REPORT, TYPE_DIMENSION, TYPE_MEASURE, TYPE_OBJECT, TYPE_SEGMENT,
-} from "@metaobjectsdev/metadata";
-
-/** FR-044 reporting vocabulary — all four types: `dimension.*`, `measure.*`, `segment.*`
- * and `object.report`. A view-backed report is lowered to a view (FR-044 Plan 2), but no
- * site page renders any of this vocabulary until Plan 3, so the site shows none of it BY DESIGN.
- *
- * The audit reports these as DEFERRED, not as "not rendered by any page" and not by
- * silently dropping them: the gap stays visible on the returned report (`deferred`, one
- * `deferred (FR-044 Plan 2/3)` warning per type) without changing a rendered page, because
- * `meta docs` output must be identical with and without the vocabulary until then.
- *
- * DELETE this predicate, `deferred`, and the branch in `walk` when the Plan 2/3 lowering
- * lands and the site renders reports — from then on an unrendered dimension is a real gap. */
-const REPORTING_TYPES: ReadonlySet = new Set([TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT]);
-const isReportingVocabulary = (n: MetaData): boolean =>
- REPORTING_TYPES.has(n.type) || (n.type === TYPE_OBJECT && n.subType === OBJECT_SUBTYPE_REPORT);
export interface CoverageRow { key: string; count: number; consumed: boolean; }
export interface CoverageReport {
kinds: CoverageRow[];
attrs: CoverageRow[];
- /** FR-044 reporting kinds present in the model and deliberately not rendered yet. */
- deferred: CoverageRow[];
warnings: string[];
}
@@ -35,14 +15,7 @@ export class CoverageTracker {
report(root: MetaRoot): CoverageReport {
const kindCount = new Map();
const attrCount = new Map();
- const deferredCount = new Map();
const walk = (n: MetaData) => {
- // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- // Counted as deferred (with its whole subtree), never as a rendering gap.
- if (isReportingVocabulary(n)) {
- deferredCount.set(`${n.type}.${n.subType}`, (deferredCount.get(`${n.type}.${n.subType}`) ?? 0) + 1);
- return;
- }
kindCount.set(`${n.type}.${n.subType}`, (kindCount.get(`${n.type}.${n.subType}`) ?? 0) + 1);
for (const [name] of n.ownAttrs()) {
attrCount.set(`${n.type}:@${name}`, (attrCount.get(`${n.type}:@${name}`) ?? 0) + 1);
@@ -54,11 +27,9 @@ export class CoverageTracker {
[...m.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, count]) => ({ key, count, consumed: seen.has(key) }));
const kinds = rows(kindCount, this.kinds);
const attrs = rows(attrCount, this.attrs);
- const deferred = rows(deferredCount, new Set());
- const warnings = [
- ...[...kinds, ...attrs].filter((r) => !r.consumed).map((r) => `coverage: ${r.key} (${r.count}) not rendered by any page`),
- ...deferred.map((r) => `coverage: ${r.key} (${r.count}) deferred (FR-044 Plan 2/3)`),
- ];
- return { kinds, attrs, deferred, warnings };
+ const warnings = [...kinds, ...attrs]
+ .filter((r) => !r.consumed)
+ .map((r) => `coverage: ${r.key} (${r.count}) not rendered by any page`);
+ return { kinds, attrs, warnings };
}
}
diff --git a/server/typescript/packages/docs-site/src/link-graph.ts b/server/typescript/packages/docs-site/src/link-graph.ts
index 3daf46690..75efd1dd5 100644
--- a/server/typescript/packages/docs-site/src/link-graph.ts
+++ b/server/typescript/packages/docs-site/src/link-graph.ts
@@ -1,13 +1,14 @@
-import type { MetaData, MetaObject, MetaRelationship } from "@metaobjectsdev/metadata";
+import type { MetaData, MetaObject, MetaRelationship, MetaRoot } from "@metaobjectsdev/metadata";
import {
- deriveM2MFields, resolveRelationshipReference, stripPackage, OBJECT_SUBTYPE_REPORT, TYPE_OBJECT,
+ deriveM2MFields, reportFrom, resolveRelationshipReference, stripPackage,
+ OBJECT_REPORT_ATTR_FROM, OBJECT_SUBTYPE_REPORT, TYPE_OBJECT,
} from "@metaobjectsdev/metadata";
import { type LoadedModel, treeOf } from "./load.js";
export interface DocNode { kind: "object" | "prompt" | "output"; name: string; pkg: string; pkgPath: string; href: string; node: MetaData; tree: string; }
export interface Ref {
from: string; to: string; via: string;
- kind: "field" | "fk" | "extends" | "payload" | "response" | "relationship" | "origin";
+ kind: "field" | "fk" | "extends" | "payload" | "response" | "relationship" | "origin" | "report";
cardinality?: "one" | "many" | undefined;
through?: string | undefined; // junction FQN (M:N)
sourceJoinField?: string | undefined; // junction source FK (M:N)
@@ -32,13 +33,12 @@ export class LinkGraph {
private _to = new Map();
private _extBy = new Map();
private _origins = new Map();
+ /** The loaded root the graph was built from (a report's derived columns resolve against it). */
+ readonly root: MetaRoot;
constructor(model: LoadedModel) {
+ this.root = model.root;
for (const o of model.root.ownChildren()) {
- // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3).
- // Dropped from the graph every page, index and nav list is built from: its fields
- // are derived by that lowering, so a page today would show none of them.
- if (o.type === TYPE_OBJECT && o.subType === OBJECT_SUBTYPE_REPORT) continue;
let kind: DocNode["kind"] | undefined;
if (o.type === "object") kind = "object";
else if (o.type === "template") kind = o.subType === "prompt" ? "prompt" : "output";
@@ -147,6 +147,13 @@ export class LinkGraph {
if (to && to !== fqn) addRef({ from: fqn, to, via: `${f.name} (origin)`, kind: "origin" });
}
}
+ // FR-044: a report reads from its @from entity. The edge is what puts the report on
+ // that entity's page and diagrams, and keeps a report from reading as an orphan.
+ if (dn.node.type === TYPE_OBJECT && dn.node.subType === OBJECT_SUBTYPE_REPORT) {
+ const from = reportFrom(dn.node);
+ const to = from !== undefined ? resolveRef(from, dn.pkg) : undefined;
+ if (to) addRef({ from: fqn, to, via: OBJECT_REPORT_ATTR_FROM, kind: "report" });
+ }
const sup = dn.node.superResolved;
if (sup) {
const supFqn = fqnOf(sup);
diff --git a/server/typescript/packages/docs-site/src/site.ts b/server/typescript/packages/docs-site/src/site.ts
index 99e9e3d40..be343d160 100644
--- a/server/typescript/packages/docs-site/src/site.ts
+++ b/server/typescript/packages/docs-site/src/site.ts
@@ -189,6 +189,7 @@ export async function generateSite(opts: SiteOptions): Promise {
const objectTocHtml = (d: ObjectPageData): string => {
const sections: { id: string; label: string; present: boolean }[] = [
{ id: "s-overview", label: "Overview", present: !!d.desc },
+ { id: "s-report", label: "Report", present: d.report !== undefined },
{ id: "s-fields", label: "Fields", present: d.ownFields.length > 0 },
{ id: "s-indexes", label: "Indexes & keys", present: d.indexes.length > 0 },
{ id: "s-validators", label: "Validators", present: d.validators.length > 0 },
@@ -196,6 +197,7 @@ export async function generateSite(opts: SiteOptions): Promise {
{ id: "s-provenance", label: "Field provenance", present: d.origins.length > 0 },
{ id: "s-inheritance", label: "Inheritance", present: !!d.inheritanceMermaid },
{ id: "s-neighborhood", label: "Neighborhood", present: !!d.neighborhoodMermaid },
+ { id: "s-reporting", label: "Reporting", present: d.reporting !== undefined },
{ id: "s-referenced-by", label: "Referenced by", present: d.referencedBy.length > 0 },
];
return sections
diff --git a/server/typescript/packages/docs-site/templates/object.html.mustache b/server/typescript/packages/docs-site/templates/object.html.mustache
index 0978feefc..65c1e16c2 100644
--- a/server/typescript/packages/docs-site/templates/object.html.mustache
+++ b/server/typescript/packages/docs-site/templates/object.html.mustache
@@ -8,8 +8,16 @@
{{#pkHtml}} · pk {{{pkHtml}}}{{/pkHtml}}{{#generation}} · gen {{generation}}{{/generation}}{{#storageAttrs}} · {{name}} {{value}}{{/storageAttrs}}
{{#desc}}{{/desc}}
{{#objectAttrs.length}}attributes {{#objectAttrs}}@{{name}}={{value}} {{/objectAttrs}} {{/objectAttrs.length}}
+{{#report}}
+Report
+from
{{fromName}} {{#viewName}} · view
{{viewName}}{{/viewName}}{{#notServed}} ·
{{notServed}} {{/notServed}}{{#scopeHtml}} · row scope {{{scopeHtml}}}{{/scopeHtml}}
+Column Type Nullable Role Definition
+{{#columns}}{{name}} {{type}} {{nullable}} {{role}} {{{definitionHtml}}}
+{{/columns}}
+
+{{/report}}
-Fields
+{{^report}}Fields
Field Type Description
{{#ownFields}}{{name}}{{#isArray}}[]{{/isArray}} {{type}} {{{badgesHtml}}} {{desc}}
{{#enumValues.length}}{{#enumValues}}{{value}}{{#deflt}} ★{{/deflt}} {{/enumValues}} {{/enumValues.length}}
@@ -18,7 +26,7 @@
{{#inheritedFields.length}}{{/inheritedFields.length}}
+{{/inheritedFields.length}} {{/report}}
{{#indexes.length}}Indexes & keys
Name Kind Fields Detail
@@ -46,6 +54,14 @@
▭ entity · ⬭ value object · ▱ view (ER boxes: dashed = value, dotted = view)
{{#neighborhoodMore}}+{{neighborhoodMore}} more neighbor(s){{#referencedBy.length}} — see
Referenced by {{/referencedBy.length}}.
{{/neighborhoodMore}}{{/neighborhoodMermaid}}
-{{#referencedBy.length}}referenced by {{#referencedBy}}{{name}} {{/referencedBy}} {{/referencedBy.length}}
+{{#reporting}}Reporting
+
+{{#dimensions}}dimension {{name}} {{kind}} {{{definitionHtml}}}
+{{/dimensions}}{{#measures}}measure {{name}} {{kind}} {{{definitionHtml}}}
+{{/measures}}{{#segments}}segment {{name}} {{kind}} {{{definitionHtml}}}
+{{/segments}}
+{{#reports.length}}reports {{#reports}}
{{name}} {{/reports}}
{{/reports.length}}
+
+{{/reporting}}{{#referencedBy.length}}referenced by {{#referencedBy}}{{name}} {{/referencedBy}} {{/referencedBy.length}}
{{#usedByTemplates.length}}used by templates {{#usedByTemplates}}
{{name}} {{/usedByTemplates}}
{{/usedByTemplates.length}}
{{sourceFile}}
diff --git a/server/typescript/packages/docs-site/test/__snapshots__/reporting-site.test.ts.snap b/server/typescript/packages/docs-site/test/__snapshots__/reporting-site.test.ts.snap
new file mode 100644
index 000000000..4f00bae5d
--- /dev/null
+++ b/server/typescript/packages/docs-site/test/__snapshots__/reporting-site.test.ts.snap
@@ -0,0 +1,14 @@
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`FR-044 no-churn: a model with no report renders the site it always did every file of the no-report site hashes to the pre-feature snapshot 1`] = `
+{
+ "acme/shop/Program.html": "1d194f318ee7ae3e7ba8a93d5fcdae3c67d602d8f750781085e7dbc6d854619c",
+ "acme/shop/Purchase.html": "fbacc248ffeb56d5f958137056baa474e5f2f1c0a3cc6dbdf4fb9ed657d69925",
+ "acme/shop/WorkoutEvent.html": "eabbbef84680f72161f56dd85fa42102f51276bceb673d71f2f1c669dbdd9d7d",
+ "acme/shop/index.html": "659e175d771c27b44abf24358400f5dff50a4a478d6019a0c5360533637e894f",
+ "assets/search-index.json": "e196eebb688eef4921346b4f72fb3e44d08ae94fdbb63bfd749ebcdf071dbf7c",
+ "coverage.html": "669758ac5f91d8af1e7e20e484aed61a103414e1ab1bc914a0a9f8ac672a369d",
+ "enums.html": "a018067aa6e52f4e500c8526675161d613132b538d131b5f1a6924b4b2240919",
+ "index.html": "c828cef22198f9d58139c87555368240481e78a746a5dacaf6f11266c8f75d45",
+}
+`;
diff --git a/server/typescript/packages/docs-site/test/coverage.test.ts b/server/typescript/packages/docs-site/test/coverage.test.ts
index 89d9da0d2..572bfcbf6 100644
--- a/server/typescript/packages/docs-site/test/coverage.test.ts
+++ b/server/typescript/packages/docs-site/test/coverage.test.ts
@@ -27,16 +27,20 @@ test("attr consumption is tracked accurately", async () => {
expect(consumedAttr?.consumed).toBe(true);
});
-test("FR-044 reporting vocabulary is reported as deferred, not as a rendering gap", async () => {
- // The inert model pair shared by every port's FR-044 Plan 1 inert test.
+test("FR-044 reporting vocabulary is audited like any other kind: unrendered means a gap", async () => {
+ // The inert model pair shared by every port's FR-044 tests. Nothing is consumed here, so
+ // every reporting kind must surface as "not rendered" (it used to be parked as
+ // "deferred" until the site rendered reports; test/reporting-site.test.ts shows the
+ // real site consumes all of it).
const withReporting = join(import.meta.dir, "..", "..", "..", "..", "..", "fixtures", "codegen-noop", "reporting", "with");
const model = await loadModel([withReporting]);
const rep = new CoverageTracker().report(model.root);
- expect(rep.deferred.map((r) => r.key)).toEqual([
+ expect("deferred" in rep).toBe(false);
+ const reportingKinds = rep.kinds.filter((r) => /^(dimension|measure|segment)\./.test(r.key) || r.key === "object.report");
+ expect(reportingKinds.map((r) => r.key)).toEqual([
"dimension.attribute", "dimension.time", "measure.aggregate", "measure.ratio", "object.report", "segment.filter",
]);
- // Never a "not rendered" row: the site renders none of it by design until Plan 2/3.
- const gapKeys = [...rep.kinds, ...rep.attrs].map((r) => r.key);
- expect(gapKeys.some((k) => /^(dimension|measure|segment)[.:]/.test(k) || k === "object.report")).toBe(false);
- expect(rep.warnings.filter((w) => w.includes("deferred (FR-044 Plan 2/3)")).length).toBe(6);
+ expect(reportingKinds.every((r) => !r.consumed)).toBe(true);
+ expect(rep.warnings).toContain("coverage: object.report (3) not rendered by any page");
+ expect(rep.warnings.some((w) => w.includes("deferred"))).toBe(false);
});
diff --git a/server/typescript/packages/docs-site/test/reporting-site.test.ts b/server/typescript/packages/docs-site/test/reporting-site.test.ts
new file mode 100644
index 000000000..ef6c1e32a
--- /dev/null
+++ b/server/typescript/packages/docs-site/test/reporting-site.test.ts
@@ -0,0 +1,158 @@
+// FR-044 Plan 3, Table G, the HTML site: a page for every report (served or not), an
+// entry for each in its package's object index, a "Reporting" section on the `@from`
+// entity's page, and the reporting vocabulary counted as RENDERED by the coverage audit.
+//
+// The model pair is the one every port's FR-044 inert test shares
+// (fixtures/codegen-noop/reporting/). The sentences asserted here are the same ones
+// codegen-ts's `reporting-docs.test.ts` asserts for the markdown model pages over the same
+// model: the two packages each hold a copy of the wording, and these two tests are what
+// keeps the copies saying the same thing.
+
+import { beforeAll, describe, expect, test } from "bun:test";
+import { createHash } from "node:crypto";
+import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, relative, sep } from "node:path";
+import { generateSite, type SiteResult } from "../src/site";
+
+const MODELS = join(import.meta.dir, "..", "..", "..", "..", "..", "fixtures", "codegen-noop", "reporting");
+
+function walk(root: string, dir = root): string[] {
+ return readdirSync(dir).flatMap((e) => {
+ const p = join(dir, e);
+ return statSync(p).isDirectory() ? walk(root, p) : [relative(root, p).split(sep).join("/")];
+ }).sort();
+}
+
+interface Site { result: SiteResult; files: Record; }
+
+/** Generate the site for one variant. The source dir has the SAME basename for both
+ * variants: a page prints the file its object came from. */
+async function site(variant: "with" | "without"): Promise {
+ const parent = mkdtempSync(join(tmpdir(), "reporting-site-"));
+ try {
+ const src = join(parent, "shop");
+ const out = join(parent, "out");
+ mkdirSync(src);
+ copyFileSync(join(MODELS, variant, "meta.shop.json"), join(src, "meta.shop.json"));
+ const result = await generateSite({ sourceDirs: [src], outDir: out, title: "Shop", stamp: "2026-01-01", commit: "abc1234" });
+ const files: Record = {};
+ for (const rel of walk(out)) files[rel] = readFileSync(join(out, rel), "utf8");
+ return { result, files };
+ } finally {
+ rmSync(parent, { recursive: true, force: true });
+ }
+}
+
+/** The page body with tags dropped and whitespace folded, to assert on what a reader sees. */
+function text(html: string): string {
+ return html.replace(/<\/?code>/g, "").replace(/<[^>]+>/g, " ").replace(/"/g, '"').replace(/&/g, "&").replace(/\s+/g, " ");
+}
+
+let withSite: Site;
+let withoutSite: Site;
+
+beforeAll(async () => {
+ withSite = await site("with");
+ withoutSite = await site("without");
+});
+
+describe("FR-044 no-churn: a model with no report renders the site it always did", () => {
+ test("every file of the no-report site hashes to the pre-feature snapshot", () => {
+ const hashes: Record = {};
+ for (const [path, content] of Object.entries(withoutSite.files)) {
+ // The stylesheet and script are the same bytes for every model; pinning them here
+ // would fail this test on a restyle that has nothing to do with reporting.
+ if (path === "assets/site.css" || path === "assets/site.js") continue;
+ hashes[path] = createHash("sha256").update(content).digest("hex");
+ }
+ expect(hashes).toMatchSnapshot();
+ });
+});
+
+describe("FR-044 the site renders reports", () => {
+ const SHOP = "acme/shop";
+
+ test("each report has a page and a row in its package's object index", () => {
+ const index = withSite.files[`${SHOP}/index.html`]!;
+ for (const name of ["StoreTotals", "ProgramEngagement", "DailyRevenue"]) {
+ expect(Object.keys(withSite.files)).toContain(`${SHOP}/${name}.html`);
+ expect(index).toContain(`href="${name}.html"`);
+ }
+ expect(withSite.result.dangling).toEqual([]);
+ });
+
+ test("a served report names its @from, its view and its columns", () => {
+ const html = withSite.files[`${SHOP}/StoreTotals.html`]!;
+ expect(html).toContain('id="s-report"');
+ expect(html).toContain('Purchase ');
+ const page = text(html);
+ expect(page).toContain("view v_store_totals");
+ expect(page).not.toContain("Not served");
+ // A report declares no fields: its columns are the Report section, not an empty Fields table.
+ expect(html).not.toContain('id="s-fields"');
+ expect(withSite.files[`${SHOP}/Purchase.html`]).toContain('id="s-fields"');
+ expect(page).toContain("purchases long no measure count of Purchase.id where segment active");
+ expect(page).toContain("buyers long no measure count of distinct Purchase.customerEmail where segment active");
+ expect(page).toContain("revenue currency yes measure sum of Purchase.amountCents where segment active");
+ });
+
+ test("a sourceless report is marked not served, and still lists its columns and row scope", () => {
+ const engagement = text(withSite.files[`${SHOP}/ProgramEngagement.html`]!);
+ expect(engagement).toContain("Not served: declares no view source");
+ expect(engagement).toContain("row scope segment completions");
+ expect(engagement).toContain("program long yes dimension WorkoutEvent.programId");
+ expect(engagement).toContain(
+ "daysEngaged long no measure count of distinct (WorkoutEvent.programId, WorkoutEvent.weekNumber, WorkoutEvent.dayNumber)",
+ );
+ expect(engagement).toContain("avgDaysPerStarter decimal yes measure daysEngaged / starters, null when the denominator is 0");
+ expect(engagement).toContain("lastActivityAt timestamp yes measure max of WorkoutEvent.occurredAt");
+
+ const daily = text(withSite.files[`${SHOP}/DailyRevenue.html`]!);
+ expect(daily).toContain("Not served: declares no view source");
+ expect(daily).toContain('row scope filter {"purchasedAt":{"gte":{"now":"-P90D"}}}');
+ expect(daily).toContain("purchasedAtDay date yes dimension Purchase.purchasedAt truncated to day, UTC");
+ });
+
+ test("the @from entity's page has a Reporting section: dimensions, measures, segments, reports", () => {
+ const html = withSite.files[`${SHOP}/Purchase.html`]!;
+ expect(html).toContain('id="s-reporting"');
+ expect(html).toContain('href="#s-reporting"');
+ const section = text(html.slice(html.indexOf('id="s-reporting"')));
+ expect(section).toContain("program Purchase.programId");
+ expect(section).toContain("programTitle Program.title via Purchase.program");
+ expect(section).toContain("purchasedAt time Purchase.purchasedAt; grains: day, week, month, quarter, year");
+ expect(section).toContain('refundedPurchases count of Purchase.id where filter {"refunded":{"eq":true}}');
+ expect(section).toContain("revenue sum of Purchase.amountCents where segment active");
+ expect(section).toContain('active {"status":{"eq":"active"}}');
+ expect(html).toContain('DailyRevenue ');
+ expect(html).toContain('StoreTotals ');
+ // An entity with no reporting nodes has no such section.
+ expect(withSite.files[`${SHOP}/Program.html`]).not.toContain('id="s-reporting"');
+ });
+
+ test("the reporting vocabulary counts as rendered: nothing deferred, no coverage gap", () => {
+ const { coverage } = withSite.result;
+ expect("deferred" in coverage).toBe(false);
+ const reporting = (key: string): boolean => /^(dimension|measure|segment)[.:]/.test(key) || key === "object.report";
+ const kinds = coverage.kinds.filter((r) => reporting(r.key));
+ expect(kinds.map((r) => r.key)).toEqual([
+ "dimension.attribute", "dimension.time", "measure.aggregate", "measure.ratio", "object.report", "segment.filter",
+ ]);
+ expect(kinds.filter((r) => !r.consumed)).toEqual([]);
+ expect(coverage.attrs.filter((r) => reporting(r.key)).length).toBeGreaterThan(0);
+ expect(coverage.attrs.filter((r) => reporting(r.key) && !r.consumed)).toEqual([]);
+ // The report node's own attrs are rendered too (object:@from and friends).
+ expect(coverage.attrs.filter((r) => r.key.startsWith("object:@") && !r.consumed)).toEqual([]);
+ expect(coverage.warnings.filter((w) => /deferred|dimension|measure|segment|object\.report/.test(w))).toEqual([]);
+ // And the model without reports warns about exactly what the model with them does.
+ expect(coverage.warnings).toEqual(withoutSite.result.coverage.warnings);
+ });
+
+ test("a report is not an orphan: it is linked to the entity it reads from", () => {
+ const orphans = withSite.result.anomalies.filter((a) => a.kind === "orphan").map((a) => a.subject);
+ for (const name of ["StoreTotals", "ProgramEngagement", "DailyRevenue"]) expect(orphans).not.toContain(name);
+ // The entity shows its reports among what references it.
+ expect(text(withSite.files[`${SHOP}/WorkoutEvent.html`]!)).toContain("referenced by ProgramEngagement");
+ });
+});
diff --git a/templates/docs/entity-page.md.mustache b/templates/docs/entity-page.md.mustache
index 43c0d89b6..0102afc73 100644
--- a/templates/docs/entity-page.md.mustache
+++ b/templates/docs/entity-page.md.mustache
@@ -15,6 +15,12 @@
{{/apiRefs.0}}
{{{preambleHeader}}}
+{{#hasReport}}
+
+## Report
+
+{{{reportBlock}}}
+{{/hasReport}}
{{#hasIdentities}}
## Identity
@@ -72,3 +78,9 @@
- {{{bullet}}}
{{/claimedBy}}
{{/hasClaimedBy}}
+{{#hasReporting}}
+
+## Reporting
+
+{{{reportingBlock}}}
+{{/hasReporting}}
From 65a2b0af9c3ccccb62982a330b6cda087439a6be Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:48:06 -0400
Subject: [PATCH 12/21] fix(python): keep a projection's by-id route when it
has an id field (FR-044)
---
.../metaobjects/codegen/instance_artifacts.py | 37 +++---
.../tests/codegen/test_projection_compile.py | 11 +-
.../tests/codegen/test_report_router.py | 112 +++++++++++++++---
.../tests/codegen/test_router_generator.py | 16 +--
4 files changed, 125 insertions(+), 51 deletions(-)
diff --git a/server/python/src/metaobjects/codegen/instance_artifacts.py b/server/python/src/metaobjects/codegen/instance_artifacts.py
index 268bea994..ee878d17a 100644
--- a/server/python/src/metaobjects/codegen/instance_artifacts.py
+++ b/server/python/src/metaobjects/codegen/instance_artifacts.py
@@ -7,7 +7,6 @@
field was read by nothing and entity_model never consulted it, so `GenConfig` now
refuses to accept a value it cannot honour.
"""
-from metaobjects.meta.core.identity.identity_constants import IDENTITY_ATTR_FIELDS
from metaobjects.meta.core.object.meta_object import MetaObject
from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT
from metaobjects.meta.core.reporting.report_read_model import report_read_source
@@ -65,18 +64,26 @@ def is_served_report(obj: MetaObject) -> bool:
def has_item_route(entity: MetaObject) -> bool:
- """Whether a read-only object is addressable by key: it has a primary identity over
- EXACTLY ONE field. A report has no identity at all, and a keyless projection has none
- either, so neither gets a ``/{id}`` route or a ``find_by_id`` on its repository seam
- (FR-044 open question 4). A composite identity has no single path parameter to bind."""
- identity = entity.primary_identity()
- if identity is None:
+ """Whether a read-only object gets a ``/{id}`` route, a ``find_by_id`` on its
+ repository seam and the three item-verb refusals (FR-044 open question 4, as ruled).
+
+ Answer 4 removes only what could never serve a row, so the rule is:
+
+ * a report (the declared node or its read model) NEVER has one, even if a derived
+ field is named ``id``: it has no identity, and a row of it is not addressable;
+ * otherwise it has one when it declares a primary identity (a single field, or a
+ composite one, which binds its FIRST field exactly as before this change), or when
+ it declares NO primary identity and has an effective field named ``id`` (the
+ default key ``pk_field_name`` falls back to);
+ * otherwise (no identity and no ``id`` field) the route could only bind an ``int`` it
+ cannot honour, so it is not generated.
+
+ Every projection whose router had a usable item route before FR-044 renders
+ byte-identically. ADR-0039: ``children()`` / ``fields()`` resolve, so an inherited
+ identity or ``id`` field counts.
+ """
+ if entity.sub_type == OBJECT_SUBTYPE_REPORT:
return False
- fields = identity.get_meta_attr(IDENTITY_ATTR_FIELDS) # ADR-0039: resolving (identity attr)
- if isinstance(fields, str):
- names = [n for n in (p.strip() for p in fields.split(",")) if n]
- elif isinstance(fields, (list, tuple)):
- names = [n for n in fields if isinstance(n, str) and n]
- else:
- names = []
- return len(names) == 1
+ if entity.primary_identity() is not None:
+ return True
+ return any(f.name == "id" for f in entity.fields())
diff --git a/server/python/tests/codegen/test_projection_compile.py b/server/python/tests/codegen/test_projection_compile.py
index e5e927962..627fc3a35 100644
--- a/server/python/tests/codegen/test_projection_compile.py
+++ b/server/python/tests/codegen/test_projection_compile.py
@@ -41,7 +41,9 @@ def _view_projection(*, keyed: bool = True) -> MetaObject:
src = MetaSource(TYPE_SOURCE, SOURCE_SUBTYPE_RDB, "")
src.set_attr(SOURCE_ATTR_KIND, SOURCE_KIND_VIEW, sub_type="string")
o.add_child(src)
- o.add_child(_f("id", fc.FIELD_SUBTYPE_INT, required=True))
+ # A keyless projection has neither a primary identity nor a field named `id`: only
+ # then is there nothing a by-id route could bind.
+ o.add_child(_f("id" if keyed else "code", fc.FIELD_SUBTYPE_INT, required=True))
o.add_child(_f("weekCount", fc.FIELD_SUBTYPE_INT)) # non-required derived field
if keyed:
identity = MetaIdentity(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, "pk")
@@ -94,9 +96,10 @@ def test_projection_router_is_read_only() -> None:
def test_keyless_projection_router_has_no_item_routes() -> None:
- """FR-044 open question 4: a projection with no single-field identity has no item
- address, so it gets GET list and the collection POST refusal only, and its repository
- seam has no ``find_by_id``. (Before, it bound an ``id: int`` it could not honour.)"""
+ """FR-044 open question 4 (as ruled): a projection with no primary identity AND no
+ ``id`` field has no item address, so it gets GET list and the collection POST refusal
+ only, and its repository seam has no ``find_by_id``. (Before, it bound an ``id: int``
+ it could not honour.)"""
src = render_router(_view_projection(keyed=False))
assert src is not None
compile(src, "", "exec")
diff --git a/server/python/tests/codegen/test_report_router.py b/server/python/tests/codegen/test_report_router.py
index 4895350d3..54cc525b3 100644
--- a/server/python/tests/codegen/test_report_router.py
+++ b/server/python/tests/codegen/test_report_router.py
@@ -48,10 +48,103 @@ def test_served_report_predicate_follows_table_a() -> None:
assert is_served_report(report_read_model(_obj(root, "InvoiceStatusTotals"), root))
-def test_only_an_object_with_a_single_field_identity_has_an_item_route() -> None:
+def _projection(*, identity: list[str] | None, id_field: bool) -> MetaObject:
+ from metaobjects.meta.core.field.meta_field import MetaField
+ from metaobjects.meta.core.identity.identity_constants import (
+ IDENTITY_ATTR_FIELDS,
+ IDENTITY_SUBTYPE_PRIMARY,
+ )
+ from metaobjects.meta.core.identity.meta_identity import MetaIdentity
+ from metaobjects.meta.persistence.source.meta_source import MetaSource
+ from metaobjects.meta.persistence.source.source_constants import (
+ SOURCE_ATTR_KIND,
+ SOURCE_KIND_VIEW,
+ SOURCE_SUBTYPE_RDB,
+ )
+ from metaobjects.shared.base_types import TYPE_FIELD, TYPE_IDENTITY, TYPE_SOURCE
+
+ obj = MetaObject(TYPE_OBJECT, "projection", "Summary")
+ obj.package = "acme::test"
+ src = MetaSource(TYPE_SOURCE, SOURCE_SUBTYPE_RDB, "")
+ src.set_attr(SOURCE_ATTR_KIND, SOURCE_KIND_VIEW, sub_type="string")
+ obj.add_child(src)
+ names = (["id"] if id_field else []) + ["code", "other"]
+ for n in names:
+ obj.add_child(MetaField(TYPE_FIELD, "int", n))
+ if identity is not None:
+ ident = MetaIdentity(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, "pk")
+ ident.set_attr(IDENTITY_ATTR_FIELDS, identity)
+ obj.add_child(ident)
+ return obj
+
+
+def _has_item_surface(src: str) -> bool:
+ return (
+ '@router.get("/{summary_id}")' in src
+ and "def find_by_id(" in src
+ and src.count('"error": "method_not_allowed",') == 4
+ )
+
+
+def test_a_report_never_has_an_item_route_even_with_a_derived_field_named_id() -> None:
root = _load()
+ model = report_read_model(_obj(root, "InvoiceStatusTotals"), root)
+ assert not has_item_route(model)
+ assert not has_item_route(_obj(root, "InvoiceStatusTotals")) # the declared node too
assert has_item_route(_obj(root, "Invoice"))
- assert not has_item_route(report_read_model(_obj(root, "InvoiceStatusTotals"), root))
+ # A derived field named `id`: build a report whose dimension is `Invoice.id`.
+ import json
+
+ from metaobjects.loader.meta_data_loader import MetaDataLoader
+ from metaobjects.loader.sources import InMemoryStringSource
+
+ meta = json.loads((_CORPUS / "meta.json").read_text())
+ meta["metadata.root"]["children"].append({"object.report": {
+ "name": "ById", "@from": "Invoice", "@dimensions": ["id"], "@measures": ["invoices"],
+ "children": [{"source.rdb": {"@kind": "view", "@view": "v_by_id"}}],
+ }})
+ # A dimension named after the key column: `Invoice.id` needs a dimension node.
+ inv = next(c["object.entity"] for c in meta["metadata.root"]["children"] if "object.entity" in c)
+ inv["children"].append({"dimension.attribute": {"name": "id", "@of": "Invoice.id"}})
+ result = MetaDataLoader().load([InMemoryStringSource(json.dumps(meta), "meta.json")])
+ assert not result.errors, [e.message for e in result.errors]
+ by_id = _obj(result.root, "ById")
+ read_model = report_read_model(by_id, result.root)
+ assert "id" in [f.name for f in read_model.fields()]
+ assert not has_item_route(read_model)
+ src = render_router(read_model)
+ assert src is not None and "find_by_id" not in src and "{" not in "".join(
+ ln for ln in src.splitlines() if ln.startswith("@router.")
+ )
+
+
+def test_a_projection_with_a_single_field_identity_has_the_item_surface() -> None:
+ p = _projection(identity=["id"], id_field=True)
+ assert has_item_route(p)
+ assert _has_item_surface(render_router(p))
+
+
+def test_a_projection_with_an_id_field_and_no_identity_has_the_item_surface() -> None:
+ p = _projection(identity=None, id_field=True)
+ assert has_item_route(p)
+ assert _has_item_surface(render_router(p))
+
+
+def test_a_composite_identity_keeps_its_item_route_bound_to_the_first_field() -> None:
+ p = _projection(identity=["code", "other"], id_field=False)
+ assert has_item_route(p)
+ assert _has_item_surface(render_router(p))
+
+
+def test_a_projection_with_no_identity_and_no_id_field_is_keyless() -> None:
+ p = _projection(identity=None, id_field=False)
+ assert not has_item_route(p)
+ src = render_router(p)
+ assert src is not None
+ decorators = re.findall(r'@router\.(\w+)\(("[^"]*")', src)
+ assert decorators == [("get", '""'), ("post", '""')], decorators
+ assert "find_by_id" not in src
+ assert src.count('"error": "method_not_allowed",') == 1
def test_report_router_has_the_collection_routes_and_no_item_route() -> None:
@@ -97,18 +190,3 @@ def test_run_gen_serves_three_reports_and_nothing_for_the_sourceless_one(tmp_pat
assert f"{snake}_names.py" in files
assert not any(f.startswith("invoice_days") for f in files)
assert not any("invoice_days" in f for f in files)
-
-
-def test_a_composite_identity_has_no_single_path_parameter() -> None:
- from metaobjects.meta.core.identity.identity_constants import (
- IDENTITY_ATTR_FIELDS,
- IDENTITY_SUBTYPE_PRIMARY,
- )
- from metaobjects.meta.core.identity.meta_identity import MetaIdentity
- from metaobjects.shared.base_types import TYPE_IDENTITY
-
- obj = MetaObject(TYPE_OBJECT, "projection", "Pair")
- identity = MetaIdentity(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, "pk")
- identity.set_attr(IDENTITY_ATTR_FIELDS, ["a", "b"])
- obj.add_child(identity)
- assert not has_item_route(obj)
diff --git a/server/python/tests/codegen/test_router_generator.py b/server/python/tests/codegen/test_router_generator.py
index d9cf9a386..517a94843 100644
--- a/server/python/tests/codegen/test_router_generator.py
+++ b/server/python/tests/codegen/test_router_generator.py
@@ -17,11 +17,6 @@
from metaobjects.codegen.runtime.filter_parser import parse_filter
from metaobjects.meta.core.field.meta_field import MetaField
from metaobjects.meta.core.field import field_constants as fc
-from metaobjects.meta.core.identity.identity_constants import (
- IDENTITY_ATTR_FIELDS,
- IDENTITY_SUBTYPE_PRIMARY,
-)
-from metaobjects.meta.core.identity.meta_identity import MetaIdentity
from metaobjects.meta.core.object.meta_object import MetaObject
from metaobjects.meta.persistence.source.meta_source import MetaSource
from metaobjects.meta.persistence.source.source_constants import (
@@ -29,7 +24,7 @@
SOURCE_KIND_VIEW,
SOURCE_SUBTYPE_RDB,
)
-from metaobjects.shared.base_types import TYPE_FIELD, TYPE_IDENTITY, TYPE_OBJECT, TYPE_SOURCE
+from metaobjects.shared.base_types import TYPE_FIELD, TYPE_OBJECT, TYPE_SOURCE
def _entity(
@@ -39,7 +34,6 @@ def _entity(
source_kind: str | None = "table",
package: str | None = None,
subtype: str = "entity",
- pk: bool = False,
) -> MetaObject:
o = MetaObject(TYPE_OBJECT, subtype, name)
o.package = package
@@ -50,12 +44,6 @@ def _entity(
o.add_child(src)
for f in fields:
o.add_child(f)
- if pk:
- # A single-field primary identity: what makes a read-only object addressable by
- # key (FR-044: a keyless one gets no item route).
- identity = MetaIdentity(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, "pk")
- identity.set_attr(IDENTITY_ATTR_FIELDS, ["id"])
- o.add_child(identity)
return o
@@ -142,7 +130,6 @@ def test_view_kind_gets_a_read_only_router() -> None:
[_f("id", fc.FIELD_SUBTYPE_INT, required=True)],
source_kind=SOURCE_KIND_VIEW,
package="acme::blog",
- pk=True,
)
out = render_router(view)
assert out is not None
@@ -179,7 +166,6 @@ def test_projection_subtype_gets_a_read_only_router() -> None:
source_kind=SOURCE_KIND_VIEW,
package="acme::sales",
subtype="projection",
- pk=True,
)
out = render_router(proj)
assert out is not None
From 867360690e2b3a7a9910da2db71338c6d128e171 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:54:06 -0400
Subject: [PATCH 13/21] fix(docs): one home for report wording; document only
the writes a read-only object emits (FR-044)
The sentences meta docs prints about dimensions, measures, segments, row
scopes and why a report is not served now live in one place,
metadata's core/reporting/report-describe.ts, beside reportShape. The
markdown model pages (codegen-ts) and the HTML site (docs-site) both
read them from there; each keeps only its own markup step.
An object's API unit documents create/update/delete, the write REST
verbs and the Insert/Update schemas only when the generators emit them.
The gate is the generators' own dispatch (a read-only-kind source and no
writable one), so a view-backed projection and a report document reads
alone, a keyless one without find-by-id, and a write-through object
keeps its whole write surface. This is a named exception to no-churn:
a read-only projection's API page loses symbols that were never
generated. The accuracy gate now checks a keyed projection, a keyless
projection and a write-through object against the emitted files in
both directions.
The site's coverage audit marks only the attributes the describers
read, so an attribute on a reporting node that no page prints is
reported as a gap. The report page narrows its node with isMetaObject.
---
.../codegen-ts/src/generators/api-model.ts | 50 +++--
.../codegen-ts/src/generators/report-doc.ts | 122 ++---------
.../test/golden/api-docs-accuracy.test.ts | 147 +++++++++++++
.../docs-site/src/builders/report-data.ts | 123 +++--------
.../docs-site/test/reporting-site.test.ts | 28 ++-
.../src/core/reporting/report-describe.ts | 154 +++++++++++++
.../typescript/packages/metadata/src/index.ts | 12 +
.../metadata/test/report-describe.test.ts | 207 ++++++++++++++++++
8 files changed, 618 insertions(+), 225 deletions(-)
create mode 100644 server/typescript/packages/metadata/src/core/reporting/report-describe.ts
create mode 100644 server/typescript/packages/metadata/test/report-describe.test.ts
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 57b374b71..fa5911a3e 100644
--- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts
@@ -83,7 +83,11 @@
// and nothing else: a report has no identity, so no by-id query and no `/:id`; no
// write helper; no insert/update schema. No hook is documented for any object here
// (see DEFERRALS), and none is generated for a report at all (`servesClientTier`).
-// • A KEYLESS projection (no identity and no `id` column) likewise documents no
+// • A READ-ONLY object (a read-only-kind source and no writable one: a view-backed
+// projection, a report's read model) documents reads only: no create/update/delete,
+// no write verb, no insert/update schema, because its generated files carry none
+// (`isReadOnlySurface`). A write-through object is not read-only and keeps them all.
+// • A KEYLESS read-only object (no identity and no `id` column) also documents no
// `findById` and no `/:id`: the read-only generators emit neither
// (`hasItemRoute`).
//
@@ -135,7 +139,7 @@ import { hasItemRoute, servedPath, servesReadApi } from "../api-surface.js";
import { isProjection } from "../projection/projection-detector.js";
import { buildPkMap } from "../pk-resolver.js";
import { buildRelationMap, type RelationEntry, type RelationMap } from "../relation-resolver.js";
-import { generatableObjects, isReport } from "../source-detect.js";
+import { generatableObjects } from "../source-detect.js";
import { effectivePackage } from "../docs-paths.js";
import { entityOutputPath, type OutputLayout } from "../import-path.js";
import type { RenderContext } from "../render-context.js";
@@ -392,14 +396,30 @@ function isQueryable(obj: MetaObject): boolean {
}
/**
- * True for a READ-ONLY object the generators give no item surface: no `/:id` route and
+ * True when the generators emit the READ-ONLY surface for the object: reads only, no
+ * create/update/delete helper, no write verb, no insert or update schema.
+ *
+ * This is `isProjection` from `projection/projection-detector.ts`, the exact test
+ * `entity-file.ts`, `queries-file.ts`, `routes-file.ts` and `routes-file-hono.ts`
+ * dispatch on, and it is a test of SOURCES, not of the `object.projection` subtype: the
+ * object declares a read-only-kind source and no writable one. So it is true for a
+ * view-backed projection and for a report's read model, and FALSE for a write-through
+ * object (a writable table plus a replica view), whose generated files really do carry
+ * the write helpers, the write verbs and both schemas. Documenting writes for a
+ * read-only object published functions and endpoints that were never generated.
+ */
+function isReadOnlySurface(obj: MetaObject): boolean {
+ return isProjection(obj);
+}
+
+/**
+ * True for a read-only object the generators give no item surface: no `/:id` route and
* no by-id query. That is a projection with no identity and no `id` column, and every
- * report (FR-044). `hasItemRoute` is the generators' own predicate; it is only meaningful
- * for the read-only surface, hence the `isProjection` guard (a report's read model
- * carries a read-only source, so it is one too).
+ * report (FR-044). `hasItemRoute` is the generators' own predicate, and only the
+ * read-only surface asks it (a writable entity's by-id helpers are unconditional).
*/
function lacksItemSurface(obj: MetaObject): boolean {
- return isProjection(obj) && !hasItemRoute(obj);
+ return isReadOnlySurface(obj) && !hasItemRoute(obj);
}
function buildEntityUnit(
@@ -432,9 +452,9 @@ function buildEntityUnit(
if (isQueryable(obj)) {
symbols.push(...dataAccessSymbols(obj, ctx, root, layout));
- // A report's entity module exports a read schema only: no insert or update schema
- // exists to document (FR-044).
- if (!isReport(obj)) symbols.push(...validationSymbols(obj, entityMod));
+ // A read-only object's entity module exports a read schema only: no insert or update
+ // schema exists to document.
+ if (!isReadOnlySurface(obj)) symbols.push(...validationSymbols(obj, entityMod));
// REST needs no gate of its own: the routes generator's built-in filter is
// `servesReadApi && !isTphSubtype` — exactly isQueryable — so every queryable
// object gets routes. (Its `filter` option can narrow that further; this builder
@@ -532,9 +552,9 @@ function dataAccessSymbols(
// entity's queries file emits its by-id helpers unconditionally.
const reads: ApiSymbol[] = lacksItemSurface(obj) ? [listSymbol] : [findSymbol, listSymbol];
- // FR-044: a report is served by its list and nothing else. No create, update or delete
- // exists on any generated seam, so none is documented.
- if (isReport(obj)) {
+ // A read-only object is served by its reads and nothing else: the read-only queries
+ // file emits no create, update or delete, so none is documented.
+ if (isReadOnlySurface(obj)) {
return reads;
}
@@ -650,7 +670,7 @@ function restSymbols(
): ApiSymbol[] {
const name = obj.name;
const path = servedPath(obj, apiPrefix);
- const readOnly = isProjection(obj) || isTphDiscriminatorBase(obj, root);
+ const readOnly = isReadOnlySurface(obj) || isTphDiscriminatorBase(obj, root);
// REST endpoints are not importable functions — to WIRE them an adopter
// imports the entity's route registrar (`Routes`) from the routes
@@ -867,7 +887,7 @@ function restHonoSymbols(
): ApiSymbol[] {
const name = obj.name;
const path = servedPath(obj, apiPrefix);
- const readOnly = isProjection(obj);
+ const readOnly = isReadOnlySurface(obj);
const honoMod = entityModulePath(layout, obj, `${name}.routes.hono`);
const registrar = `register${name}Routes`;
diff --git a/server/typescript/packages/codegen-ts/src/generators/report-doc.ts b/server/typescript/packages/codegen-ts/src/generators/report-doc.ts
index 93b07757d..85c962a30 100644
--- a/server/typescript/packages/codegen-ts/src/generators/report-doc.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/report-doc.ts
@@ -6,36 +6,28 @@
// • the `@from` ENTITY's page: a "Reporting" section listing the dimensions, measures
// and segments it declares and the reports that name it.
//
-// Everything is read from declared metadata. No SQL is derived here: a definition says
-// what a column means ("sum of `Invoice.amountCents` where segment `paid`"), never how
-// the view computes it. `docs-site` renders the same sentences on the HTML site from its
-// own copy of these rules (it does not depend on this package); its tests hold the two
-// together.
+// The SENTENCES come from `@metaobjectsdev/metadata` (`core/reporting/report-describe.ts`),
+// which `docs-site` reads too, so the markdown pages and the HTML site cannot disagree.
+// This module only lays them out as markdown.
import {
- type MetaData,
type MetaObject,
type MetaRoot,
- type ReportField,
DIMENSION_SUBTYPE_TIME,
- MEASURE_SUBTYPE_RATIO,
OBJECT_REPORT_ATTR_FILTER,
OBJECT_REPORT_ATTR_SEGMENT,
- REPORTING_ATTR_AGG,
- REPORTING_ATTR_DENOMINATOR,
- REPORTING_ATTR_DISTINCT,
- REPORTING_ATTR_FILTER,
- REPORTING_ATTR_GRAINS,
- REPORTING_ATTR_NUMERATOR,
- REPORTING_ATTR_OF,
- REPORTING_ATTR_SEGMENT,
- REPORTING_ATTR_VIA,
- SOURCE_KIND_VIEW,
TYPE_DIMENSION,
TYPE_MEASURE,
TYPE_SEGMENT,
+ describeDimension,
+ describeMeasure,
+ describeReportField,
+ describeRowScope,
+ describeSegment,
isMetaObject,
+ reportFieldTypeName,
reportFrom,
+ reportNotServedReason,
reportReadSource,
reportShape,
resolveObjectRef,
@@ -44,99 +36,11 @@ import type { OutputLayout } from "../import-path.js";
import { docPageHref, docPageNode, effectivePackage } from "../docs-paths.js";
import { isReport } from "../source-detect.js";
-/** Inline code. A backtick cannot sit inside a single-backtick span, so it is dropped. */
+/** Inline code for a name this module prints itself (the describers return theirs ready). */
function tick(text: string): string {
return `\`${text.replace(/`/g, "")}\``;
}
-/** A row-scope filter as authored: compact JSON, in the order it was declared. */
-function filterText(filter: unknown): string {
- return tick(JSON.stringify(filter));
-}
-
-function stringList(v: unknown): string[] {
- if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string");
- return typeof v === "string" ? [v] : [];
-}
-
-/**
- * "segment `paid` and filter `{…}`": the rows a measure or a report is scoped to, or
- * undefined when it declares neither. The two combine by AND, which is what the lowering
- * does with them.
- */
-export function describeRowScope(segment: unknown, filter: unknown): string | undefined {
- const parts: string[] = [];
- if (typeof segment === "string" && segment !== "") parts.push(`segment ${tick(segment)}`);
- if (filter !== undefined && filter !== null) parts.push(`filter ${filterText(filter)}`);
- return parts.length > 0 ? parts.join(" and ") : undefined;
-}
-
-/** "`Program.title` via `Purchase.program`": the column a dimension groups by. */
-function dimensionColumn(dim: MetaData): string {
- // ADR-0039: resolving, so a dimension that extends another reads its effective @of/@via.
- const of = dim.attr(REPORTING_ATTR_OF);
- const via = dim.attr(REPORTING_ATTR_VIA);
- const column = tick(typeof of === "string" ? of : "");
- return typeof via === "string" && via !== "" ? `${column} via ${tick(via)}` : column;
-}
-
-/** A dimension as its entity declares it: the column, and for a time dimension its grains. */
-export function describeDimension(dim: MetaData): string {
- const column = dimensionColumn(dim);
- if (dim.subType !== DIMENSION_SUBTYPE_TIME) return column;
- return `${column}; grains: ${stringList(dim.attr(REPORTING_ATTR_GRAINS)).join(", ")}`;
-}
-
-/** A dimension as ONE report column: a time dimension is truncated to the report's grain. */
-function describeDimensionColumn(dim: MetaData, grain: string | undefined): string {
- const column = dimensionColumn(dim);
- if (grain === undefined) return column;
- // Reports are UTC only (Plan 3 global constraint): there is no time-zone vocabulary.
- const joiner = column.includes(" via ") ? "," : "";
- return `${column}${joiner} truncated to ${grain}, UTC`;
-}
-
-/** A measure in words: the aggregate and its row scope, or the ratio and its null rule. */
-export function describeMeasure(measure: MetaData): string {
- // ADR-0039: resolving reads throughout, as for a dimension.
- if (measure.subType === MEASURE_SUBTYPE_RATIO) {
- const numerator = measure.attr(REPORTING_ATTR_NUMERATOR);
- const denominator = measure.attr(REPORTING_ATTR_DENOMINATOR);
- return `${tick(String(numerator ?? ""))} / ${tick(String(denominator ?? ""))}, null when the denominator is 0`;
- }
- const columns = stringList(measure.attr(REPORTING_ATTR_OF)).map(tick);
- const of = columns.length === 1 ? columns[0]! : `(${columns.join(", ")})`;
- const distinct = measure.attr(REPORTING_ATTR_DISTINCT) === true ? "distinct " : "";
- const scope = describeRowScope(measure.attr(REPORTING_ATTR_SEGMENT), measure.attr(REPORTING_ATTR_FILTER));
- return `${String(measure.attr(REPORTING_ATTR_AGG) ?? "")} of ${distinct}${of}${scope !== undefined ? ` where ${scope}` : ""}`;
-}
-
-/** One derived column's definition, from the dimension or measure it comes from. */
-export function describeReportField(field: ReportField): string {
- if (field.dimension !== undefined) return describeDimensionColumn(field.dimension, field.grain);
- return field.measure !== undefined ? describeMeasure(field.measure) : "";
-}
-
-/** The neutral logical type of a derived column: its Table B subtype, `[]` for an array. */
-export function reportFieldType(field: ReportField): string {
- // ADR-0039: resolvedIsArray() is the resolving read of the native array flag.
- return field.typeSource?.resolvedIsArray() === true ? `${field.subType}[]` : field.subType;
-}
-
-/**
- * Why a report is not served, or undefined when it is (Table A). The wording is the
- * spec's for the common case: a report that declares no source at all.
- */
-export function reportNotServedReason(report: MetaObject): string | undefined {
- if (report.isAbstract === true) return "Not served: the report is abstract";
- const source = reportReadSource(report);
- if (source === undefined) return "Not served: declares no view source";
- if (source.effectiveKind !== SOURCE_KIND_VIEW) {
- return `Not served: its source is a ${source.effectiveKind}, not a view`;
- }
- return undefined;
-}
-
/** A markdown table cell: a pipe would end the cell. */
function cell(text: string): string {
return text.replace(/\|/g, "\\|");
@@ -163,7 +67,7 @@ export function buildReportBlock(report: MetaObject, root: MetaRoot, layout: Out
lines.push("", "| Column | Type | Nullable | Role | Definition |", "|---|---|---|---|---|");
for (const f of shape.fields) {
lines.push(
- `| ${tick(f.name)} | ${tick(reportFieldType(f))} | ${f.required ? "no" : "yes"} | ${f.role} | ${cell(describeReportField(f))} |`,
+ `| ${tick(f.name)} | ${tick(reportFieldTypeName(f))} | ${f.required ? "no" : "yes"} | ${f.role} | ${cell(describeReportField(f))} |`,
);
}
}
@@ -198,7 +102,7 @@ export function buildReportingBlock(entity: MetaObject, root: MetaRoot, layout:
["Measures", members.filter((c) => c.type === TYPE_MEASURE).map((m) =>
`- ${tick(m.name)} — ${describeMeasure(m)}`)],
["Segments", members.filter((c) => c.type === TYPE_SEGMENT).map((s) =>
- `- ${tick(s.name)} — ${filterText(s.attr(REPORTING_ATTR_FILTER) ?? {})}`)],
+ `- ${tick(s.name)} — ${describeSegment(s)}`)],
["Reports", reportsFrom(entity, root)
.sort((a, b) => a.name.localeCompare(b.name))
.map((r) => `- [${r.name}](${docPageHref(layout, docPageNode(entity), docPageNode(r))})`)],
diff --git a/server/typescript/packages/codegen-ts/test/golden/api-docs-accuracy.test.ts b/server/typescript/packages/codegen-ts/test/golden/api-docs-accuracy.test.ts
index b03b6e569..41eba0252 100644
--- a/server/typescript/packages/codegen-ts/test/golden/api-docs-accuracy.test.ts
+++ b/server/typescript/packages/codegen-ts/test/golden/api-docs-accuracy.test.ts
@@ -1089,3 +1089,150 @@ describe("api-docs ACCURACY gate (T5) — relations / callable / prompt / Hono",
}
});
});
+
+// ---------------------------------------------------------------------------
+// Read-only vs write-through: a unit documents WRITES only when the generators
+// emit them.
+//
+// The read-only surface (a read-only-kind source and no writable one) is emitted
+// with reads alone, and with no by-id read when no column addresses a row. A
+// write-through object (a writable table plus a replica view) is NOT read-only:
+// its files carry the write helpers, the write verbs and both schemas. The unit
+// used to document create/update/delete and the Insert/Update schemas for every
+// one of them. Both directions are checked against the real generated files.
+// ---------------------------------------------------------------------------
+
+const READ_WRITE_FIXTURE = JSON.stringify({
+ "metadata.root": {
+ package: "acme::orders",
+ children: [
+ { "object.entity": { name: "Customer", children: [
+ { "source.rdb": { "@table": "customers" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "name", "@required": true, "@maxLength": 80 } },
+ { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } },
+ ] } },
+ // Write-through: writes hit the table, reads go through the replica view.
+ { "object.entity": { name: "Order", children: [
+ { "source.rdb": { "@role": "primary", "@table": "orders" } },
+ { "source.rdb": { "@role": "replica", "@kind": "view", "@table": "v_order_with_customer" } },
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "customerId", "@required": true } },
+ { "field.string": { name: "customerName", children: [
+ { "origin.passthrough": { "@from": "Customer.name", "@via": "Order.customer" } },
+ ] } },
+ { "relationship.association": { name: "customer", "@objectRef": "Customer", "@cardinality": "one" } },
+ { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } },
+ { "identity.reference": { name: "fkCustomer", "@fields": "customerId", "@references": "Customer" } },
+ ] } },
+ // Read-only, keyed: an `id` column by convention addresses a row.
+ { "object.projection": { name: "CustomerCard", children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_customer_card", "@unmanaged": true } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "name" } },
+ ] } },
+ // Read-only, keyless: no identity and no `id` column.
+ { "object.projection": { name: "RegionTotal", children: [
+ { "source.rdb": { "@kind": "view", "@table": "v_region_total", "@unmanaged": true } },
+ { "field.string": { name: "region" } },
+ { "field.long": { name: "orders" } },
+ ] } },
+ ],
+ },
+});
+
+describe("api-docs ACCURACY gate — writes are documented only where the generators emit them", () => {
+ let model: ApiModel;
+ let entityFiles: EmittedFile[];
+ let queriesFiles: EmittedFile[];
+ let routesFiles: EmittedFile[];
+ let honoFiles: EmittedFile[];
+
+ const unit = (name: string) => {
+ const u = model.units.find((x) => x.node === name);
+ if (u === undefined) throw new Error(`no unit ${name}`);
+ return u;
+ };
+ const names = (name: string, kind: ApiSymbol["kind"]): string[] =>
+ unit(name).symbols.filter((s) => s.kind === kind).map((s) => s.name);
+ const content = (files: EmittedFile[], suffix: string): string => {
+ const f = fileFor(files, suffix);
+ if (f === undefined) throw new Error(`no emitted file ${suffix}`);
+ return f.content;
+ };
+ /** Every `export [async] function (` in a file. */
+ const exportedFns = (src: string): string[] =>
+ [...src.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g)].map((m) => m[1]!);
+ const WRITE_VERBS = /^(POST|PATCH|PUT|DELETE) /;
+
+ test("setup: run the real generators + build the ApiModel on the same root", async () => {
+ const res = await new MetaDataLoader().load([
+ new InMemoryStringSource(READ_WRITE_FIXTURE, { id: "orders.json", format: "json" }),
+ ]);
+ expect(res.errors).toEqual([]);
+ const projectRoot = mkdtempSync(join(tmpdir(), "api-docs-accuracy-rw-"));
+ entityFiles = await runGenerator(entityFile(), res.root, projectRoot);
+ queriesFiles = await runGenerator(queriesFile(), res.root, projectRoot);
+ routesFiles = await runGenerator(routesFile(), res.root, projectRoot);
+ honoFiles = await runGenerator(routesFileHono(), res.root, projectRoot);
+ model = buildApiModel(res.root, { loadedRoot: res.root, includeHonoRoutes: true });
+ });
+
+ for (const name of ["CustomerCard", "RegionTotal", "Order", "Customer"]) {
+ test(`${name}: data-access symbols are EXACTLY the functions its queries file exports`, () => {
+ // Both directions at once: nothing documented that was not emitted, and for these
+ // shapes nothing emitted that is not documented.
+ const emitted = exportedFns(content(queriesFiles, `${name}.queries.ts`));
+ expect(names(name, "data-access").sort()).toEqual(
+ // Reverse finders (`findsBy`) are a documented deferral of this builder.
+ emitted.filter((fn) => !/^find\w+sBy[A-Z]/.test(fn) || fn.endsWith("ById")).sort(),
+ );
+ });
+
+ test(`${name}: Insert/Update schemas are documented exactly when the entity file exports them`, () => {
+ const entity = content(entityFiles, `${name}.ts`);
+ for (const schema of [`${name}InsertSchema`, `${name}UpdateSchema`]) {
+ expect({ schema, documented: names(name, "validation").includes(schema) })
+ .toEqual({ schema, documented: hasExportedDecl(entity, schema) });
+ }
+ });
+
+ for (const [kind, files, suffix] of [
+ ["rest", () => routesFiles, ".routes.ts"],
+ ["rest-hono", () => honoFiles, ".routes.hono.ts"],
+ ] as const) {
+ test(`${name}: ${kind} documents write verbs and /:id exactly as the routes file mounts them`, () => {
+ const routes = content(files(), `${name}${suffix}`);
+ const readOnlyMount = /mountReadOnlyCrudRoutes|mountReadOnlyHonoCrudRoutes|ReadOnly/.test(routes);
+ const documented = names(name, kind);
+ expect(documented.length).toBeGreaterThan(0);
+ expect(documented.some((n) => WRITE_VERBS.test(n))).toBe(!readOnlyMount);
+ const itemRoutes = !routes.includes("itemRoutes: false");
+ expect(documented.some((n) => n.includes("/:id"))).toBe(itemRoutes);
+ });
+ }
+ }
+
+ test("the read-only shapes read as intended: keyed has list + by-id, keyless has list alone", () => {
+ expect(names("CustomerCard", "data-access")).toEqual(["findCustomerCardById", "listCustomerCards"]);
+ expect(names("CustomerCard", "rest")).toEqual(["GET /customer_cards", "GET /customer_cards/:id"]);
+ expect(names("CustomerCard", "validation")).toEqual([]);
+ expect(unit("CustomerCard").example).toBeUndefined();
+
+ expect(names("RegionTotal", "data-access")).toEqual(["listRegionTotals"]);
+ expect(names("RegionTotal", "rest")).toEqual(["GET /region_totals"]);
+ expect(names("RegionTotal", "rest-hono")).toEqual(["GET /region_totals"]);
+ expect(names("RegionTotal", "validation")).toEqual([]);
+ });
+
+ test("a write-through object keeps its whole write surface", () => {
+ expect(names("Order", "data-access")).toEqual([
+ "findOrderById", "listOrders", "createOrder", "updateOrder", "deleteOrderById",
+ ]);
+ expect(names("Order", "validation")).toEqual(["OrderInsertSchema", "OrderUpdateSchema"]);
+ expect(names("Order", "rest")).toEqual([
+ "GET /orders", "GET /orders/:id", "POST /orders", "PATCH /orders/:id", "PUT /orders/:id", "DELETE /orders/:id",
+ ]);
+ expect(unit("Order").example).toBeDefined();
+ });
+});
diff --git a/server/typescript/packages/docs-site/src/builders/report-data.ts b/server/typescript/packages/docs-site/src/builders/report-data.ts
index 117769830..244aa73b6 100644
--- a/server/typescript/packages/docs-site/src/builders/report-data.ts
+++ b/server/typescript/packages/docs-site/src/builders/report-data.ts
@@ -6,37 +6,34 @@
// • the page of an entity that declares dimensions, measures or segments, or that a
// report names as its `@from`, gets a "Reporting" section.
//
-// The sentences are the ones the markdown model pages print (codegen-ts
-// `generators/report-doc.ts`). This package does not depend on codegen-ts, so the wording
-// is restated here; `test/reporting-site.test.ts` and codegen-ts's
-// `test/reporting-docs.test.ts` assert the same sentences over the same model.
+// The sentences come from `@metaobjectsdev/metadata` (`core/reporting/report-describe.ts`),
+// the same describers the markdown model pages use, so the two surfaces cannot disagree.
+// This module only turns their `code` spans into HTML.
-import type { MetaData, MetaObject, MetaRoot, ReportField } from "@metaobjectsdev/metadata";
+import type { MetaData, MetaRoot } from "@metaobjectsdev/metadata";
import {
DIMENSION_SUBTYPE_TIME,
- MEASURE_SUBTYPE_RATIO,
OBJECT_REPORT_ATTR_DIMENSIONS,
OBJECT_REPORT_ATTR_FILTER,
OBJECT_REPORT_ATTR_FROM,
OBJECT_REPORT_ATTR_MEASURES,
OBJECT_REPORT_ATTR_SEGMENT,
OBJECT_SUBTYPE_REPORT,
- REPORTING_ATTR_AGG,
- REPORTING_ATTR_DENOMINATOR,
- REPORTING_ATTR_DISTINCT,
- REPORTING_ATTR_FILTER,
- REPORTING_ATTR_GRAINS,
- REPORTING_ATTR_NUMERATOR,
- REPORTING_ATTR_OF,
- REPORTING_ATTR_SEGMENT,
- REPORTING_ATTR_VIA,
- SOURCE_KIND_VIEW,
TYPE_DIMENSION,
TYPE_MEASURE,
TYPE_OBJECT,
TYPE_SEGMENT,
+ describeDimension,
+ describeMeasure,
+ describeReportField,
+ describeRowScope,
+ describeSegment,
+ isMetaObject,
+ reportFieldTypeName,
+ reportNotServedReason,
reportReadSource,
reportShape,
+ reportingDescribedAttrs,
} from "@metaobjectsdev/metadata";
import { type DocNode, type LinkGraph, fqnOf } from "../link-graph.js";
import type { CoverageTracker } from "../coverage.js";
@@ -56,83 +53,22 @@ export function isReportNode(node: MetaData): boolean {
return node.type === TYPE_OBJECT && node.subType === OBJECT_SUBTYPE_REPORT;
}
-// ─── Wording (plain text with `code` spans; the same sentences as report-doc.ts) ───
-
-const tick = (text: string): string => `\`${text.replace(/`/g, "")}\``;
-const filterText = (filter: unknown): string => tick(JSON.stringify(filter));
-
-function stringList(v: unknown): string[] {
- if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string");
- return typeof v === "string" ? [v] : [];
-}
-
-function describeRowScope(segment: unknown, filter: unknown): string | undefined {
- const parts: string[] = [];
- if (typeof segment === "string" && segment !== "") parts.push(`segment ${tick(segment)}`);
- if (filter !== undefined && filter !== null) parts.push(`filter ${filterText(filter)}`);
- return parts.length > 0 ? parts.join(" and ") : undefined;
-}
-
-function dimensionColumn(dim: MetaData): string {
- // ADR-0039: resolving reads, here and in every describe* below.
- const of = dim.attr(REPORTING_ATTR_OF);
- const via = dim.attr(REPORTING_ATTR_VIA);
- const column = tick(typeof of === "string" ? of : "");
- return typeof via === "string" && via !== "" ? `${column} via ${tick(via)}` : column;
-}
-
-function describeDimension(dim: MetaData): string {
- const column = dimensionColumn(dim);
- if (dim.subType !== DIMENSION_SUBTYPE_TIME) return column;
- return `${column}; grains: ${stringList(dim.attr(REPORTING_ATTR_GRAINS)).join(", ")}`;
-}
-
-function describeDimensionColumn(dim: MetaData, grain: string | undefined): string {
- const column = dimensionColumn(dim);
- if (grain === undefined) return column;
- const joiner = column.includes(" via ") ? "," : "";
- return `${column}${joiner} truncated to ${grain}, UTC`;
-}
-
-function describeMeasure(measure: MetaData): string {
- if (measure.subType === MEASURE_SUBTYPE_RATIO) {
- const numerator = measure.attr(REPORTING_ATTR_NUMERATOR);
- const denominator = measure.attr(REPORTING_ATTR_DENOMINATOR);
- return `${tick(String(numerator ?? ""))} / ${tick(String(denominator ?? ""))}, null when the denominator is 0`;
- }
- const columns = stringList(measure.attr(REPORTING_ATTR_OF)).map(tick);
- const of = columns.length === 1 ? columns[0]! : `(${columns.join(", ")})`;
- const distinct = measure.attr(REPORTING_ATTR_DISTINCT) === true ? "distinct " : "";
- const scope = describeRowScope(measure.attr(REPORTING_ATTR_SEGMENT), measure.attr(REPORTING_ATTR_FILTER));
- return `${String(measure.attr(REPORTING_ATTR_AGG) ?? "")} of ${distinct}${of}${scope !== undefined ? ` where ${scope}` : ""}`;
-}
-
-function describeReportField(field: ReportField): string {
- if (field.dimension !== undefined) return describeDimensionColumn(field.dimension, field.grain);
- return field.measure !== undefined ? describeMeasure(field.measure) : "";
-}
-
-function notServedReason(report: MetaObject): string | undefined {
- if (report.isAbstract === true) return "Not served: the report is abstract";
- const source = reportReadSource(report);
- if (source === undefined) return "Not served: declares no view source";
- if (source.effectiveKind !== SOURCE_KIND_VIEW) {
- return `Not served: its source is a ${source.effectiveKind}, not a view`;
- }
- return undefined;
-}
-
/** Escape, then turn each `code` span into a element (this render lib does not escape). */
function html(text: string): string {
return esc(text).replace(/`([^`]*)`/g, "$1");
}
-/** Mark a node and every attr it authored as rendered. */
-function consume(node: MetaData, cov: CoverageTracker): void {
+/**
+ * Mark a dimension, measure or segment as rendered, with exactly the attrs its describer
+ * prints (`reportingDescribedAttrs`). Anything else authored on the node stays unconsumed,
+ * so the coverage audit reports it as a gap instead of claiming a page shows it.
+ */
+function consumeDescribed(node: MetaData, cov: CoverageTracker): void {
cov.consumeNode(node);
- // ADR-0039: own — coverage counts the attrs a node DECLARES (`CoverageTracker.report`
- // walks `ownAttrs()`), so the same layer is what gets marked consumed.
- for (const [name] of node.ownAttrs()) cov.consumeAttr(node, name);
+ for (const name of reportingDescribedAttrs(node)) {
+ // ADR-0039: resolving, the read the describer itself makes.
+ if (node.attr(name) !== undefined) cov.consumeAttr(node, name);
+ }
}
// ─── A report's page ───────────────────────────────────────────────────────────
@@ -149,14 +85,13 @@ export interface ReportSection {
}
export function buildReportSection(dn: DocNode, root: MetaRoot, g: LinkGraph, cov: CoverageTracker): ReportSection {
- // `dn.kind === "object"` and the report subtype are checked by the caller; the
- // `unknown` bridge is the one link-graph.ts uses for the same narrowing.
- const report = dn.node as unknown as MetaObject;
+ const report = dn.node;
+ if (!isMetaObject(report)) throw new Error(`not a report object: ${fqnOf(report)}`);
for (const name of REPORT_RENDERED_ATTRS) if (report.attr(name) !== undefined) cov.consumeAttr(report, name);
// From `reportShape`, which resolves for a report with no source too.
const shape = reportShape(report, root);
const from = g.byFqn(fqnOf(shape.from));
- const notServed = notServedReason(report);
+ const notServed = reportNotServedReason(report);
const scope = describeRowScope(report.attr(OBJECT_REPORT_ATTR_SEGMENT), report.attr(OBJECT_REPORT_ATTR_FILTER));
return {
fromName: shape.from.name,
@@ -166,7 +101,7 @@ export function buildReportSection(dn: DocNode, root: MetaRoot, g: LinkGraph, co
scopeHtml: scope !== undefined ? html(scope) : undefined,
columns: shape.fields.map((f) => ({
name: f.name,
- type: f.typeSource?.resolvedIsArray() === true ? `${f.subType}[]` : f.subType,
+ type: reportFieldTypeName(f),
nullable: f.required ? "no" : "yes",
role: f.role,
definitionHtml: html(describeReportField(f)),
@@ -190,12 +125,12 @@ export function buildReportingSection(dn: DocNode, g: LinkGraph, cov: CoverageTr
// Resolving (`childrenOfType`), so a member declared on an abstract base shows on
// every entity that inherits it, which is where a report may name it from.
o.childrenOfType(type).map((n) => {
- consume(n, cov);
+ consumeDescribed(n, cov);
return { name: n.name, kind: kind(n), definitionHtml: html(describe(n)) };
});
const dimensions = members(TYPE_DIMENSION, describeDimension, (n) => (n.subType === DIMENSION_SUBTYPE_TIME ? "time" : ""));
const measures = members(TYPE_MEASURE, describeMeasure, () => "");
- const segments = members(TYPE_SEGMENT, (n) => filterText(n.attr(REPORTING_ATTR_FILTER) ?? {}), () => "");
+ const segments = members(TYPE_SEGMENT, describeSegment, () => "");
const reports = g.refsTo(fqnOf(o))
.filter((r) => r.kind === "report")
.map((r) => g.byFqn(r.from))
diff --git a/server/typescript/packages/docs-site/test/reporting-site.test.ts b/server/typescript/packages/docs-site/test/reporting-site.test.ts
index ef6c1e32a..cce03f90b 100644
--- a/server/typescript/packages/docs-site/test/reporting-site.test.ts
+++ b/server/typescript/packages/docs-site/test/reporting-site.test.ts
@@ -3,14 +3,12 @@
// entity's page, and the reporting vocabulary counted as RENDERED by the coverage audit.
//
// The model pair is the one every port's FR-044 inert test shares
-// (fixtures/codegen-noop/reporting/). The sentences asserted here are the same ones
-// codegen-ts's `reporting-docs.test.ts` asserts for the markdown model pages over the same
-// model: the two packages each hold a copy of the wording, and these two tests are what
-// keeps the copies saying the same thing.
+// (fixtures/codegen-noop/reporting/). The sentences come from `@metaobjectsdev/metadata`'s
+// describers, which the markdown model pages (codegen-ts) print too.
import { beforeAll, describe, expect, test } from "bun:test";
import { createHash } from "node:crypto";
-import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative, sep } from "node:path";
import { generateSite, type SiteResult } from "../src/site";
@@ -28,13 +26,14 @@ interface Site { result: SiteResult; files: Record; }
/** Generate the site for one variant. The source dir has the SAME basename for both
* variants: a page prints the file its object came from. */
-async function site(variant: "with" | "without"): Promise {
+async function site(variant: "with" | "without", edit?: (json: string) => string): Promise {
const parent = mkdtempSync(join(tmpdir(), "reporting-site-"));
try {
const src = join(parent, "shop");
const out = join(parent, "out");
mkdirSync(src);
- copyFileSync(join(MODELS, variant, "meta.shop.json"), join(src, "meta.shop.json"));
+ const model = readFileSync(join(MODELS, variant, "meta.shop.json"), "utf8");
+ writeFileSync(join(src, "meta.shop.json"), edit ? edit(model) : model);
const result = await generateSite({ sourceDirs: [src], outDir: out, title: "Shop", stamp: "2026-01-01", commit: "abc1234" });
const files: Record = {};
for (const rel of walk(out)) files[rel] = readFileSync(join(out, rel), "utf8");
@@ -149,6 +148,21 @@ describe("FR-044 the site renders reports", () => {
expect(coverage.warnings).toEqual(withoutSite.result.coverage.warnings);
});
+ test("an attr a reporting node carries that no page prints is reported as not rendered", async () => {
+ // The audit marks only the attrs the describers read. `@description` on a measure is
+ // legal (a documentation attr of any node) and the Reporting section does not print it.
+ const edited = await site("with", (json) => {
+ const needle = '"name": "revenue",';
+ expect(json.split(needle).length).toBe(2);
+ return json.replace(needle, `${needle} "@description": "Gross takings.",`);
+ });
+ const row = edited.result.coverage.attrs.find((r) => r.key === "measure:@description");
+ expect(row).toEqual({ key: "measure:@description", count: 1, consumed: false });
+ expect(edited.result.coverage.warnings).toContain("coverage: measure:@description (1) not rendered by any page");
+ // The attrs the section does print are still counted as rendered.
+ expect(edited.result.coverage.attrs.find((r) => r.key === "measure:@agg")?.consumed).toBe(true);
+ });
+
test("a report is not an orphan: it is linked to the entity it reads from", () => {
const orphans = withSite.result.anomalies.filter((a) => a.kind === "orphan").map((a) => a.subject);
for (const name of ["StoreTotals", "ProgramEngagement", "DailyRevenue"]) expect(orphans).not.toContain(name);
diff --git a/server/typescript/packages/metadata/src/core/reporting/report-describe.ts b/server/typescript/packages/metadata/src/core/reporting/report-describe.ts
new file mode 100644
index 000000000..965a4d731
--- /dev/null
+++ b/server/typescript/packages/metadata/src/core/reporting/report-describe.ts
@@ -0,0 +1,154 @@
+// The reporting vocabulary in words (FR-044): what a dimension groups by, what a measure
+// computes, which rows a segment or report is scoped to, and why a report is not served.
+//
+// One home for the wording. `meta docs` prints these sentences on the markdown model pages
+// (codegen-ts) and on the HTML site (docs-site); both read them from here, so the two
+// surfaces cannot say different things about the same node.
+//
+// Every function returns PLAIN TEXT whose identifiers sit in single-backtick spans
+// ("sum of `Invoice.amountCents` where segment `paid`"). A caller turns the spans into
+// its own markup. Nothing here derives SQL: a description says what a column means, never
+// how the view computes it.
+//
+// Browser-safe: no Node-only import.
+
+import type { MetaData } from "../../shared/meta-data.js";
+import { TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT } from "../../shared/base-types.js";
+import { SOURCE_KIND_VIEW } from "../../persistence/source/source-constants.js";
+import type { MetaObject } from "../object/meta-object.js";
+import {
+ DIMENSION_SUBTYPE_TIME,
+ MEASURE_SUBTYPE_RATIO,
+ REPORTING_ATTR_AGG,
+ REPORTING_ATTR_DENOMINATOR,
+ REPORTING_ATTR_DISTINCT,
+ REPORTING_ATTR_FILTER,
+ REPORTING_ATTR_GRAINS,
+ REPORTING_ATTR_NUMERATOR,
+ REPORTING_ATTR_OF,
+ REPORTING_ATTR_SEGMENT,
+ REPORTING_ATTR_VIA,
+} from "./reporting-constants.js";
+import { reportReadSource } from "./report-read-model.js";
+import type { ReportField } from "./report-shape.js";
+
+/** Inline code. A backtick cannot sit inside a single-backtick span, so it is dropped. */
+function tick(text: string): string {
+ return `\`${text.replace(/`/g, "")}\``;
+}
+
+function stringList(v: unknown): string[] {
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string");
+ return typeof v === "string" ? [v] : [];
+}
+
+/** A row-scope filter as the loader holds it: compact JSON in declared order, in a code span. */
+export function describeFilter(filter: unknown): string {
+ return tick(JSON.stringify(filter ?? {}));
+}
+
+/**
+ * "segment `paid` and filter `{…}`": the rows a measure or a report is scoped to, or
+ * undefined when it declares neither. The two combine by AND, as the lowering combines them.
+ */
+export function describeRowScope(segment: unknown, filter: unknown): string | undefined {
+ const parts: string[] = [];
+ if (typeof segment === "string" && segment !== "") parts.push(`segment ${tick(segment)}`);
+ if (filter !== undefined && filter !== null) parts.push(`filter ${describeFilter(filter)}`);
+ return parts.length > 0 ? parts.join(" and ") : undefined;
+}
+
+/** "`Program.title` via `Purchase.program`": the column a dimension groups by. */
+function dimensionColumn(dim: MetaData): string {
+ // ADR-0039: resolving reads, here and in every describer below, so a node that
+ // `extends` another is described by its effective attributes.
+ const of = dim.attr(REPORTING_ATTR_OF);
+ const via = dim.attr(REPORTING_ATTR_VIA);
+ const column = tick(typeof of === "string" ? of : "");
+ return typeof via === "string" && via !== "" ? `${column} via ${tick(via)}` : column;
+}
+
+/** A dimension as its entity declares it: the column, and for a time dimension its grains. */
+export function describeDimension(dim: MetaData): string {
+ const column = dimensionColumn(dim);
+ if (dim.subType !== DIMENSION_SUBTYPE_TIME) return column;
+ return `${column}; grains: ${stringList(dim.attr(REPORTING_ATTR_GRAINS)).join(", ")}`;
+}
+
+/**
+ * A dimension as ONE report column. A time dimension is truncated to the grain the report
+ * picked; reports are UTC only (there is no time-zone vocabulary). After a `via` clause
+ * the truncation is set off by a comma, so it reads as applying to the column and not to
+ * the relationship path.
+ */
+export function describeDimensionColumn(dim: MetaData, grain?: string): string {
+ const column = dimensionColumn(dim);
+ if (grain === undefined || grain === "") return column;
+ const joiner = column.includes(" via ") ? "," : "";
+ return `${column}${joiner} truncated to ${grain}, UTC`;
+}
+
+/** A measure in words: the aggregate and its row scope, or the ratio and its null rule. */
+export function describeMeasure(measure: MetaData): string {
+ if (measure.subType === MEASURE_SUBTYPE_RATIO) {
+ const numerator = measure.attr(REPORTING_ATTR_NUMERATOR);
+ const denominator = measure.attr(REPORTING_ATTR_DENOMINATOR);
+ return `${tick(String(numerator ?? ""))} / ${tick(String(denominator ?? ""))}, null when the denominator is 0`;
+ }
+ const columns = stringList(measure.attr(REPORTING_ATTR_OF)).map(tick);
+ const of = columns.length === 1 ? columns[0]! : `(${columns.join(", ")})`;
+ const distinct = measure.attr(REPORTING_ATTR_DISTINCT) === true ? "distinct " : "";
+ const scope = describeRowScope(measure.attr(REPORTING_ATTR_SEGMENT), measure.attr(REPORTING_ATTR_FILTER));
+ return `${String(measure.attr(REPORTING_ATTR_AGG) ?? "")} of ${distinct}${of}${scope !== undefined ? ` where ${scope}` : ""}`;
+}
+
+/** A segment in words: its filter. */
+export function describeSegment(segment: MetaData): string {
+ return describeFilter(segment.attr(REPORTING_ATTR_FILTER));
+}
+
+/** One derived report column's definition, from the dimension or measure it comes from. */
+export function describeReportField(field: ReportField): string {
+ if (field.dimension !== undefined) return describeDimensionColumn(field.dimension, field.grain);
+ return field.measure !== undefined ? describeMeasure(field.measure) : "";
+}
+
+/** The neutral logical type of a derived column: its Table B subtype, `[]` for an array. */
+export function reportFieldTypeName(field: ReportField): string {
+ // ADR-0039: resolvedIsArray() is the resolving read of the native array flag.
+ return field.typeSource?.resolvedIsArray() === true ? `${field.subType}[]` : field.subType;
+}
+
+/**
+ * Why a report is not served, or undefined when it is (Plan 3 Table A: served means not
+ * abstract, with a read source of `@kind: view`).
+ */
+export function reportNotServedReason(report: MetaObject): string | undefined {
+ if (report.isAbstract === true) return "Not served: the report is abstract";
+ const source = reportReadSource(report);
+ if (source === undefined) return "Not served: declares no view source";
+ if (source.effectiveKind !== SOURCE_KIND_VIEW) {
+ return `Not served: its source is a ${source.effectiveKind}, not a view`;
+ }
+ return undefined;
+}
+
+const DIMENSION_ATTRS = [REPORTING_ATTR_OF, REPORTING_ATTR_VIA] as const;
+const TIME_DIMENSION_ATTRS = [...DIMENSION_ATTRS, REPORTING_ATTR_GRAINS] as const;
+const AGGREGATE_ATTRS = [
+ REPORTING_ATTR_AGG, REPORTING_ATTR_OF, REPORTING_ATTR_DISTINCT, REPORTING_ATTR_SEGMENT, REPORTING_ATTR_FILTER,
+] as const;
+const RATIO_ATTRS = [REPORTING_ATTR_NUMERATOR, REPORTING_ATTR_DENOMINATOR] as const;
+const SEGMENT_ATTRS = [REPORTING_ATTR_FILTER] as const;
+
+/**
+ * The attributes the describer for `node` reads, so a documentation coverage audit can
+ * mark exactly those as rendered and no others. Kept beside the describers: a describer
+ * that starts reading a new attribute adds it here. Empty for any other node.
+ */
+export function reportingDescribedAttrs(node: MetaData): readonly string[] {
+ if (node.type === TYPE_DIMENSION) return node.subType === DIMENSION_SUBTYPE_TIME ? TIME_DIMENSION_ATTRS : DIMENSION_ATTRS;
+ if (node.type === TYPE_MEASURE) return node.subType === MEASURE_SUBTYPE_RATIO ? RATIO_ATTRS : AGGREGATE_ATTRS;
+ if (node.type === TYPE_SEGMENT) return SEGMENT_ATTRS;
+ return [];
+}
diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts
index 19364d602..d60d550c6 100644
--- a/server/typescript/packages/metadata/src/index.ts
+++ b/server/typescript/packages/metadata/src/index.ts
@@ -65,6 +65,18 @@ export {
type ReportShape,
} from "./core/reporting/report-shape.js";
export { reportReadModel, reportReadSource } from "./core/reporting/report-read-model.js";
+export {
+ describeDimension,
+ describeDimensionColumn,
+ describeFilter,
+ describeMeasure,
+ describeReportField,
+ describeRowScope,
+ describeSegment,
+ reportFieldTypeName,
+ reportNotServedReason,
+ reportingDescribedAttrs,
+} from "./core/reporting/report-describe.js";
// Shared `@implementedBy` resolution — one resolver for the CLI's requirement
// checks and codegen's requirement-test fan-out (FR-038).
export {
diff --git a/server/typescript/packages/metadata/test/report-describe.test.ts b/server/typescript/packages/metadata/test/report-describe.test.ts
new file mode 100644
index 000000000..ac81807d3
--- /dev/null
+++ b/server/typescript/packages/metadata/test/report-describe.test.ts
@@ -0,0 +1,207 @@
+// The reporting vocabulary in words (FR-044): the one home of the sentences `meta docs`
+// prints on the markdown model pages and on the HTML site.
+
+import { describe, expect, test } from "bun:test";
+import {
+ InMemoryStringSource,
+ MetaDataLoader,
+ describeDimension,
+ describeDimensionColumn,
+ describeFilter,
+ describeMeasure,
+ describeReportField,
+ describeRowScope,
+ describeSegment,
+ reportFieldTypeName,
+ reportNotServedReason,
+ reportShape,
+ reportingDescribedAttrs,
+ type MetaData,
+ type MetaObject,
+ type MetaRoot,
+} from "../src/index.js";
+
+const view = (name: string, kind = "view") => ({ "source.rdb": { "@kind": kind, "@table": name } });
+
+const MODEL = {
+ "metadata.root": {
+ package: "acme::billing",
+ children: [
+ { "object.entity": { name: "Customer", children: [
+ { "source.rdb": { "@table": "customers" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "region" } },
+ { "field.timestamp": { name: "joinedAt" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ ] } },
+ { "object.entity": { name: "Invoice", children: [
+ { "source.rdb": { "@table": "invoices" } },
+ { "field.long": { name: "id" } },
+ { "field.long": { name: "customerId" } },
+ { "field.currency": { name: "amountCents" } },
+ { "field.string": { name: "status" } },
+ { "field.string": { name: "tags", isArray: true } },
+ { "field.boolean": { name: "voided" } },
+ { "field.date": { name: "issuedOn" } },
+ { "identity.primary": { name: "id", "@fields": ["id"] } },
+ { "identity.reference": { name: "customerRef", "@references": "Customer", "@fields": ["customerId"] } },
+ { "relationship.association": { name: "customer", "@objectRef": "Customer", "@cardinality": "one" } },
+ { "dimension.attribute": { name: "status", "@of": "Invoice.status" } },
+ { "dimension.attribute": { name: "tags", "@of": "Invoice.tags" } },
+ { "dimension.attribute": { name: "region", "@of": "Customer.region", "@via": "Invoice.customer" } },
+ { "dimension.time": { name: "issuedOn", "@of": "Invoice.issuedOn", "@grains": ["day", "month"] } },
+ { "dimension.time": { name: "customerJoinedAt", "@of": "Customer.joinedAt", "@via": "Invoice.customer", "@grains": ["hour", "month"] } },
+ { "measure.aggregate": { name: "invoices", "@agg": "count", "@of": "Invoice.id" } },
+ { "measure.aggregate": { name: "customers", "@agg": "count", "@distinct": true, "@of": ["Invoice.customerId", "Invoice.status"] } },
+ { "measure.aggregate": { name: "paidInvoices", "@agg": "count", "@of": "Invoice.id", "@segment": "paid" } },
+ { "measure.aggregate": { name: "liveCents", "@agg": "sum", "@of": "Invoice.amountCents", "@filter": { voided: false } } },
+ { "measure.aggregate": { name: "paidLiveCents", "@agg": "sum", "@of": "Invoice.amountCents", "@segment": "paid", "@filter": { voided: false } } },
+ { "measure.ratio": { name: "paidShare", "@numerator": "paidInvoices", "@denominator": "invoices" } },
+ { "segment.filter": { name: "paid", "@filter": { status: "paid" } } },
+ ] } },
+ { "object.report": { name: "ByMonth", "@from": "Invoice",
+ "@dimensions": ["issuedOn:month", "customerJoinedAt:month", "region", "tags"],
+ "@measures": ["invoices", "paidShare"], children: [view("v_by_month")] } },
+ { "object.report": { name: "Sourceless", "@from": "Invoice", "@measures": ["invoices"] } },
+ { "object.report": { name: "Materialized", "@from": "Invoice", "@measures": ["invoices"],
+ children: [view("mv_invoices", "materializedView")] } },
+ { "object.report": { name: "AbstractReport", abstract: true, "@from": "Invoice", "@measures": ["invoices"],
+ children: [view("v_abstract")] } },
+ ],
+ },
+};
+
+async function load(): Promise {
+ const res = await new MetaDataLoader().load([
+ new InMemoryStringSource(JSON.stringify(MODEL), { id: "meta.json", format: "json" }),
+ ]);
+ expect(res.errors).toEqual([]);
+ return res.root;
+}
+const object = (root: MetaRoot, name: string): MetaObject => {
+ const found = root.objects().find((o) => o.name === name);
+ if (found === undefined) throw new Error(`no object ${name}`);
+ return found;
+};
+const member = (root: MetaRoot, type: string, name: string): MetaData => {
+ const found = object(root, "Invoice").children().find((c) => c.type === type && c.name === name);
+ if (found === undefined) throw new Error(`no ${type} ${name}`);
+ return found;
+};
+
+describe("describing a dimension", () => {
+ test("an attribute dimension is its column; @via is appended", async () => {
+ const root = await load();
+ expect(describeDimension(member(root, "dimension", "status"))).toBe("`Invoice.status`");
+ expect(describeDimension(member(root, "dimension", "region"))).toBe("`Customer.region` via `Invoice.customer`");
+ });
+
+ test("a time dimension lists its grains", async () => {
+ const root = await load();
+ expect(describeDimension(member(root, "dimension", "issuedOn"))).toBe("`Invoice.issuedOn`; grains: day, month");
+ expect(describeDimension(member(root, "dimension", "customerJoinedAt")))
+ .toBe("`Customer.joinedAt` via `Invoice.customer`; grains: hour, month");
+ });
+
+ test("as a report column: truncated to the grain, UTC; a comma sets it off after @via", async () => {
+ const root = await load();
+ expect(describeDimensionColumn(member(root, "dimension", "issuedOn"), "month"))
+ .toBe("`Invoice.issuedOn` truncated to month, UTC");
+ expect(describeDimensionColumn(member(root, "dimension", "customerJoinedAt"), "month"))
+ .toBe("`Customer.joinedAt` via `Invoice.customer`, truncated to month, UTC");
+ // No grain (an attribute dimension): the column alone.
+ expect(describeDimensionColumn(member(root, "dimension", "region"))).toBe("`Customer.region` via `Invoice.customer`");
+ });
+});
+
+describe("describing a measure, a segment and a row scope", () => {
+ test("an aggregate: plain, distinct over a tuple, by segment, by filter, by both", async () => {
+ const root = await load();
+ const m = (name: string): string => describeMeasure(member(root, "measure", name));
+ expect(m("invoices")).toBe("count of `Invoice.id`");
+ expect(m("customers")).toBe("count of distinct (`Invoice.customerId`, `Invoice.status`)");
+ expect(m("paidInvoices")).toBe("count of `Invoice.id` where segment `paid`");
+ // A filter prints in the canonical form the loader holds (a bare value is `eq`).
+ expect(m("liveCents")).toBe('sum of `Invoice.amountCents` where filter `{"voided":{"eq":false}}`');
+ expect(m("paidLiveCents")).toBe('sum of `Invoice.amountCents` where segment `paid` and filter `{"voided":{"eq":false}}`');
+ });
+
+ test("a ratio names both sides and its null rule", async () => {
+ const root = await load();
+ expect(describeMeasure(member(root, "measure", "paidShare")))
+ .toBe("`paidInvoices` / `invoices`, null when the denominator is 0");
+ });
+
+ test("a segment is its filter", async () => {
+ const root = await load();
+ expect(describeSegment(member(root, "segment", "paid"))).toBe('`{"status":{"eq":"paid"}}`');
+ });
+
+ test("a row scope: segment, filter, both, neither", () => {
+ expect(describeRowScope("paid", undefined)).toBe("segment `paid`");
+ expect(describeRowScope(undefined, { a: 1 })).toBe('filter `{"a":1}`');
+ expect(describeRowScope("paid", { a: 1 })).toBe('segment `paid` and filter `{"a":1}`');
+ expect(describeRowScope(undefined, undefined)).toBeUndefined();
+ expect(describeRowScope("", null)).toBeUndefined();
+ });
+
+ test("a backtick in a value cannot break out of the code span", () => {
+ expect(describeFilter({ note: "a`b" })).toBe('`{"note":"ab"}`');
+ });
+});
+
+describe("describing a report's columns", () => {
+ test("each derived field is described from its dimension or measure, with its type", async () => {
+ const root = await load();
+ const rows = reportShape(object(root, "ByMonth"), root).fields
+ .map((f) => [f.name, reportFieldTypeName(f), describeReportField(f)]);
+ expect(rows).toEqual([
+ ["issuedOnMonth", "date", "`Invoice.issuedOn` truncated to month, UTC"],
+ ["customerJoinedAtMonth", "date", "`Customer.joinedAt` via `Invoice.customer`, truncated to month, UTC"],
+ ["region", "string", "`Customer.region` via `Invoice.customer`"],
+ ["tags", "string[]", "`Invoice.tags`"],
+ ["invoices", "long", "count of `Invoice.id`"],
+ ["paidShare", "decimal", "`paidInvoices` / `invoices`, null when the denominator is 0"],
+ ]);
+ });
+});
+
+describe("why a report is not served (Plan 3 Table A)", () => {
+ test("a view-backed report is served", async () => {
+ const root = await load();
+ expect(reportNotServedReason(object(root, "ByMonth"))).toBeUndefined();
+ });
+
+ test("no source, a source that is not a view, and an abstract report each say why", async () => {
+ const root = await load();
+ expect(reportNotServedReason(object(root, "Sourceless"))).toBe("Not served: declares no view source");
+ expect(reportNotServedReason(object(root, "Materialized")))
+ .toBe("Not served: its source is a materializedView, not a view");
+ // Abstract wins over the source it declares.
+ expect(reportNotServedReason(object(root, "AbstractReport"))).toBe("Not served: the report is abstract");
+ });
+});
+
+describe("the attrs a describer reads", () => {
+ test("per node kind, and nothing for any other node", async () => {
+ const root = await load();
+ expect(reportingDescribedAttrs(member(root, "dimension", "status"))).toEqual(["of", "via"]);
+ expect(reportingDescribedAttrs(member(root, "dimension", "issuedOn"))).toEqual(["of", "via", "grains"]);
+ expect(reportingDescribedAttrs(member(root, "measure", "invoices"))).toEqual(["agg", "of", "distinct", "segment", "filter"]);
+ expect(reportingDescribedAttrs(member(root, "measure", "paidShare"))).toEqual(["numerator", "denominator"]);
+ expect(reportingDescribedAttrs(member(root, "segment", "paid"))).toEqual(["filter"]);
+ expect(reportingDescribedAttrs(object(root, "Invoice"))).toEqual([]);
+ });
+
+ test("every attr the vocabulary lets an author set on these nodes is one a describer reads", async () => {
+ // If a new reporting attr is registered and no describer prints it, this fails here
+ // rather than surfacing as a silent gap on a docs page.
+ const root = await load();
+ for (const node of object(root, "Invoice").children()) {
+ const described = reportingDescribedAttrs(node);
+ if (described.length === 0) continue;
+ // ADR-0039: own — the assertion is about what THIS node declares.
+ for (const [name] of node.ownAttrs()) expect({ node: node.name, name, read: described.includes(name) }).toEqual({ node: node.name, name, read: true });
+ }
+ });
+});
From 27419f41c8ede6a10c80a1d250ea739e786b4e63 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 18:56:06 -0400
Subject: [PATCH 14/21] feat(kotlin): read-only Spring controller for a
view-backed report (FR-044)
A served object.report (read source @kind: view) now gets its row data class,
filter allowlist and a keyless read-only Spring controller, beside the Exposed
table it already had. Each generator reaches the report through
RestSurfaceGate.restShapeOf, replacing the interim skips, and the table's
served check is RestSurfaceGate.isServedReport. A report that is not served
generates nothing.
- The row's enum property is typed by the enum of the entity the item reads,
the class the table types the column by (KotlinGenUtil.reportEnumClasses is
the one answer for the table, the row and the controller).
- The row carries no builder and no validation annotation: it is read from the
view, never bound.
- Api docs document a served report as a unit of kind "report": its row, its
table, GET and its allowlist.
- New generated lane over the shared report/ api-contract corpus (12 scenarios).
Two controller defects fixed, both in emitters shared with entities and
projections, both byte-neutral for a model that does not hit them:
- A filter on a field.decimal or field.float column threw ClassCastException
(a 500): the value was coerced to a Double and then cast to the column's
BigDecimal or Float. Each now has its own coercer, emitted only when such a
column exists.
- The row mapper and the filter dispatch named a column by its field name, but
the table declares a field named after a member of Exposed's Table under a
Column suffix (source is sourceColumn), so that controller did not compile.
Both now use KotlinNaming.safeColumnProperty, as the sort dispatch did.
---
.../generator/kotlin/KotlinEntityGenerator.kt | 52 ++-
.../kotlin/KotlinExposedTableGenerator.kt | 47 +--
.../kotlin/KotlinFilterAllowlistGenerator.kt | 11 +-
.../generator/kotlin/KotlinGenUtil.kt | 50 +++
.../generator/kotlin/KotlinNaming.kt | 8 +
.../kotlin/KotlinSpringControllerGenerator.kt | 66 ++-
.../kotlin/apidocs/KotlinApiModel.kt | 2 +-
.../kotlin/apidocs/KotlinApiModelBuilder.kt | 25 +-
.../kotlin/CodegenCompileConformanceTest.kt | 8 +-
.../kotlin/KotlinReportRestSurfaceTest.kt | 399 ++++++++++++++++++
.../generator/kotlin/ReportingInertTest.kt | 88 ++--
...portGeneratedApiContractConformanceTest.kt | 107 +++++
.../GeneratedReportControllerHarness.kt | 185 ++++++++
13 files changed, 954 insertions(+), 94 deletions(-)
create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt
create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt
create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt
index 05a9c231c..708873be5 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt
@@ -49,6 +49,8 @@ import java.io.PrintWriter
import java.nio.file.Path
import java.nio.file.Paths
import com.metaobjects.generator.util.GeneratedFileWriter
+import com.metaobjects.generator.util.RestSurfaceGate
+import com.metaobjects.reporting.ReportReadModel
/**
* Generator: one plain Kotlin data class (Jackson-compatible) per `object.entity` and
@@ -99,8 +101,11 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() {
// typed property to resolve.
// Local-only abstract check (own attribute, not inherited) so concrete subtypes
// extending an abstract base still emit normally.
- for (obj in loader.metaObjects) {
- if (obj.subType !in EMITTED_SUBTYPES) continue
+ for (declared in loader.metaObjects) {
+ // FR-044: a served report is emitted from its read model (one field per derived
+ // column); any other report maps to null and emits nothing.
+ val obj = RestSurfaceGate.restShapeOf(declared) ?: continue
+ if (!emitsDataClass(obj)) continue
if (KotlinGenUtil.isAbstractEntity(obj)) {
if (emitAbstractShapes) emitAbstractShape(obj, outRoot, loader, emittedEnumFqns)
continue
@@ -130,6 +135,8 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() {
private fun emitNetJsonSupport(loader: MetaDataLoader, outRoot: Path) {
val packages = linkedSetOf()
for (obj in loader.metaObjects) {
+ // A report's row carries no deserializer (it is never bound from a request), so
+ // a report is not counted here.
if (obj.subType !in EMITTED_SUBTYPES) continue
if (KotlinGenUtil.isAbstractEntity(obj)) continue
if (KotlinTphPlan.isTphSubtype(obj)) continue
@@ -151,9 +158,41 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() {
}
protected open fun emit(obj: MetaObject, outRoot: Path, loader: MetaDataLoader, emittedEnumFqns: MutableSet) {
+ // FR-044: a report's row gets no enum of its own. A derived enum field is typed by
+ // the class already emitted for the entity it reads from, the same class the
+ // report's Exposed table types the column by, so the generated row mapper compiles.
+ // Empty for every other object.
+ reportEnumClasses = KotlinGenUtil.reportEnumClasses(obj, loader)
+ try {
+ emitDataClass(obj, outRoot, loader, emittedEnumFqns)
+ } finally {
+ reportEnumClasses = emptyMap()
+ }
+ }
+
+ /**
+ * The enum classes of the report row being emitted, by derived field name, for the
+ * duration of one [emit] call; empty outside it and for any object that is not a report.
+ * It reaches [resolveElementType] this way, not as a parameter, because that function is
+ * `protected open` and an adopter's subclass may override it.
+ */
+ private var reportEnumClasses: Map = emptyMap()
+
+ /**
+ * Whether [obj] gets a data class: an entity, value object or projection, and the read
+ * model of a served report (FR-044), whose row a query returns. A declared report node
+ * never reaches here: [execute] maps it to its read model or drops it.
+ */
+ private fun emitsDataClass(obj: MetaObject): Boolean =
+ obj.subType in EMITTED_SUBTYPES || obj is ReportReadModel
+
+ private fun emitDataClass(obj: MetaObject, outRoot: Path, loader: MetaDataLoader, emittedEnumFqns: MutableSet) {
+ val reportRow = obj is ReportReadModel
// Emit one Kotlin enum class file per `field.enum` child BEFORE the data class
// so the resolved property type (a ClassName) points at a real file. Deduped per run.
for (field in obj.metaFields) {
+ // A report's enum field references an entity's class; nothing is materialized.
+ if (reportRow) break
// FR-019: a @provided shared enum is referenced externally, never materialized — skip it.
if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field))
KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns)
@@ -228,7 +267,10 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() {
.build()
ctorBuilder.addParameter(param)
val propBuilder = PropertySpec.builder(propName, propType).initializer(propName)
- if (!tphBase && !derivedReadOnly && !serverOwned) {
+ // FR-044: a report's row carries no constraint either. Nothing binds or validates
+ // it (it is read from the view, never sent), and a `@Size(min = 1)` on a required
+ // dimension would claim a rule the view does not have: an empty string is a group.
+ if (!tphBase && !derivedReadOnly && !serverOwned && !reportRow) {
for (annotation in validationAnnotations(field)) {
propBuilder.addAnnotation(annotation)
}
@@ -387,6 +429,8 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() {
// FR-019: a @provided shared enum is referenced at its configured external namespace
// (.E) instead of the materialized in-package class; an unresolved namespace throws.
Fr019SharedEnum.providedClassName(field, fr019Config)?.let { return it }
+ // FR-044: a report's derived enum field → the class of the entity field it reads.
+ if (owner is ReportReadModel) reportEnumClasses[field.name]?.let { return it }
// field.enum → typed enum class generated alongside this entity.
KotlinTypeMapper.enumTypeName(field, owner)?.let { return it }
if (field is ObjectField) {
@@ -787,6 +831,8 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() {
// into a type KotlinProjectionCompileTest requires to be immutable, which is how this
// exclusion was found rather than reasoned about.
if (obj.subType == MetaObject.SUBTYPE_PROJECTION) return
+ // Nor on a report's row (FR-044), for the same reason: it arrives from the view.
+ if (obj is ReportReadModel) return
val builderClass = ClassName(className.packageName, className.simpleName, "Builder")
val builder = TypeSpec.classBuilder("Builder")
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
index a6293244c..acd3be3d3 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
@@ -44,6 +44,7 @@ import java.nio.file.Path
import java.nio.file.Paths
import org.slf4j.LoggerFactory
import com.metaobjects.generator.util.GeneratedFileWriter
+import com.metaobjects.generator.util.RestSurfaceGate
/**
* Generator: one Exposed Table `object` per `object.entity` that has a `source.rdb` child.
@@ -367,12 +368,13 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase,
packagesNeedingUuidStringHelper: MutableSet,
) {
- if (KotlinGenUtil.isAbstractEntity(report)) return
+ // Table A, from the one JVM predicate every REST-surface generator asks: a report
+ // is served only when it is concrete and its read source is `@kind: view`.
+ if (!RestSurfaceGate.isServedReport(report)) return
// The source the report is READ from, by the rule that names the lowered view.
val source = ReportShape.readSource(report) as? RdbSource ?: return
- if (source.effectiveKind != MetaSource.KIND_VIEW) return
// Table B, derived ONCE for the report; everything below reads this one shape.
val shape = ReportShape.of(report, loader.root)
@@ -395,8 +398,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase, entity: MetaObject): ClassName? =
reportPlans[entity]?.enumClasses?.get(field.name) ?: KotlinTypeMapper.enumTypeName(field, entity)
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt
index 6a63257fe..7b56ce7d1 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt
@@ -63,12 +63,11 @@ open class KotlinFilterAllowlistGenerator : MultiFileDirectGeneratorBase): Boolean =
field is com.metaobjects.field.EnumField && field.hasMetaAttr(com.metaobjects.field.EnumField.ATTR_INT_VALUE_MAP)
+ /**
+ * FR-044: the generated enum class each derived enum field of a served report is typed
+ * by, keyed by derived field name. A report gets no enum of its own: its enum field
+ * carries the values of the field the dimension (or min/max measure) reads, and is typed
+ * by THAT field's class, the one [KotlinEntityGenerator] emits for the entity the item
+ * reads from. Without `@via` that is the report's `@from` entity (which is how a field
+ * `@from` inherits from an abstract base still names a class that exists); with `@via` it
+ * is the entity the `@of` reference names. [ReportShape.ofEntity] answers both, by the
+ * rule that derived the field.
+ *
+ * The ONE answer for every generator that names the class: the Exposed table types the
+ * column by it, the data class types the property by it, and the controller's int-backed
+ * filter arm resolves a member through it. Three answers would be a row mapper that does
+ * not compile.
+ *
+ * @throws GeneratorException naming the report and the item when the entity does not
+ * resolve. The shape resolved the same reference to derive the field, so a loaded model
+ * cannot reach this; it guards a tree built in code, where typing the column by a
+ * guessed class would compile against the wrong enum.
+ */
+ fun reportEnumClasses(shape: ReportShape): Map {
+ val report = shape.report()
+ val out = LinkedHashMap()
+ for (f in shape.fields()) {
+ if (f.typeSource !is com.metaobjects.field.EnumField) continue
+ val item = "${f.role.wireName()} \"${f.name}\""
+ // The entity the field is read from, by the rule that derived the field: nothing
+ // about packages or @via is restated here.
+ val owner = shape.ofEntity(f)
+ ?: throw GeneratorException(
+ "report \"${report.shortName}\": its dimension \"${f.dimension?.shortName}\" reads the enum " +
+ "\"${f.dimension?.of}\" through @via, and the entity that reference names does not " +
+ "resolve, so the generated column has no enum class to be typed by."
+ )
+ out[f.name] = KotlinTypeMapper.enumTypeName(f.typeSource, owner)
+ ?: throw GeneratorException(
+ "report \"${report.shortName}\": its $item is an enum with no generated enum class."
+ )
+ }
+ return out
+ }
+
+ /** [reportEnumClasses] for a report's read model; empty for any other object. */
+ fun reportEnumClasses(obj: MetaObject, loader: MetaDataLoader): Map {
+ val report = (obj as? ReportReadModel)?.report() ?: return emptyMap()
+ return reportEnumClasses(ReportShape.of(report, loader.root))
+ }
+
/**
* Whether the generated data-class property for [field] is nullable (with a `null` default),
* outside the TPH-base and write-through relaxations [KotlinEntityGenerator] layers on top.
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt
index bdbbf4569..ba4ab410f 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt
@@ -84,6 +84,14 @@ object KotlinNaming {
return if (reserved) name + "Column" else name
}
+ /**
+ * [KotlinSpringControllerGenerator] and the api docs: what a read-only object is called in
+ * generated prose, `"report"` for an `object.report` (the declared node or its read model)
+ * and `"projection"` otherwise. Prose only: no contract asserts it.
+ */
+ fun readOnlyNoun(obj: com.metaobjects.`object`.MetaObject): String =
+ if (obj.subType == com.metaobjects.`object`.MetaObject.SUBTYPE_REPORT) "report" else "projection"
+
/** [KotlinSpringControllerGenerator]: `shortName + "Controller"`. */
fun controllerName(shortName: String): String = shortName + "Controller"
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
index b9755ff65..efe286803 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
@@ -112,12 +112,11 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase
out.append("coerce${shortName}Long(op, raw)\n")
- FloatField.SUBTYPE_FLOAT, DoubleField.SUBTYPE_DOUBLE, DecimalField.SUBTYPE_DECIMAL ->
+ DoubleField.SUBTYPE_DOUBLE ->
out.append("coerce${shortName}Double(op, raw)\n")
+ // The dispatch arm casts the coerced value to the COLUMN's Kotlin type
+ // (`p.value as Float`, `p.value as BigDecimal`), so the coercer must produce
+ // exactly that type. Both used to ride on the Double coercer, and a boxed
+ // Double is neither: every filter on a float or decimal column threw
+ // ClassCastException out of the handler, a 500 on a request the allowlist
+ // had admitted.
+ FloatField.SUBTYPE_FLOAT ->
+ out.append("coerce${shortName}Float(op, raw)\n")
+ DecimalField.SUBTYPE_DECIMAL ->
+ out.append("coerce${shortName}Decimal(op, raw)\n")
BooleanField.SUBTYPE_BOOLEAN ->
out.append("coerce${shortName}Boolean(op, raw)\n")
DateField.SUBTYPE_DATE ->
@@ -1425,6 +1434,13 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase when (p.op) {\n")
out.append(" \"eq\" -> $member?.let { $col eq it } ?: Op.FALSE\n")
out.append(" \"ne\" -> $member?.let { $col neq it } ?: $col.isNotNull()\n")
@@ -1576,8 +1597,8 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase(TextColumnType())"
- else "${tableObjectName}.${fieldName}"
+ val col = if (isEnum) "$column.castTo(TextColumnType())"
+ else column
out.append(" \"$fieldName\" -> when (p.op) {\n")
out.append(" \"eq\" -> $col eq (p.value as $elementType)\n")
if (FilterOps.FILTER_OP_NE in band) {
@@ -1602,10 +1623,10 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase(TextColumnType())"
+ else "$column.castTo(TextColumnType())"
out.append(" \"like\" -> $likeCol like (p.value as String)\n")
}
- out.append(" \"isNull\" -> if (p.value as Boolean) ${tableObjectName}.${fieldName}.isNull() else ${tableObjectName}.${fieldName}.isNotNull()\n")
+ out.append(" \"isNull\" -> if (p.value as Boolean) $column.isNull() else $column.isNotNull()\n")
out.append(" else -> throw IllegalStateException(\"unsupported op for $fieldName: \" + p.op)\n")
out.append(" }\n")
}
@@ -1861,10 +1882,21 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase = entity.metaFields
.filterNot { it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it) }
- .map { ScalarFieldSpec(it.name, it.subType, columnElementType(it)) }
+ .map {
+ ScalarFieldSpec(
+ it.name, it.subType, columnElementType(it),
+ if (KotlinGenUtil.isIntBackedEnum(it)) reportEnums[it.name]?.canonicalName else null,
+ )
+ }
val allowlistName = "${shortName}FilterAllowlist"
+ // "report" or "projection", for generated prose only.
+ val noun = KotlinNaming.readOnlyNoun(entity)
val source = buildString {
if (pkg.isNotEmpty()) {
@@ -1934,7 +1966,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase`) are skipped — the data class defaults them.
if (field is MapField || (field is ObjectField && !isJsonbObjectColumn(field))) continue
- append(" ${field.name} = row[${readObj}.${field.name}],\n")
+ // The column PROPERTY, which the table generator renames when the field name is a
+ // member of Exposed's Table (`source` is declared `sourceColumn`). Identity otherwise.
+ append(" ${field.name} = row[${readObj}.${KotlinNaming.safeColumnProperty(field.name, exposedApi())}],\n")
}
append(")\n\n")
}
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModel.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModel.kt
index 2716cd6b5..e933459d6 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModel.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModel.kt
@@ -19,7 +19,7 @@ data class ApiUnit(
val node: String,
/** The unit's metadata package (`acme::shop`) — drives the doc-page layout path. */
val pkg: String,
- /** `"entity"` | `"value"` | `"template"` — drives the index's entities-vs-templates split. */
+ /** `"entity"` | `"value"` | `"projection"` | `"report"` | `"template"` — drives the index's entities-vs-templates split. */
val kind: String,
val symbols: List,
)
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
index 2176d1f43..890117192 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
@@ -12,6 +12,7 @@ import com.metaobjects.generator.kotlin.KotlinTypeMapper
import com.metaobjects.generator.kotlin.PackageMapping
import com.metaobjects.loader.MetaDataLoader
import com.metaobjects.generator.util.RestSurfaceGate
+import com.metaobjects.reporting.ReportReadModel
import com.metaobjects.`object`.MetaObject
import com.metaobjects.source.MetaSource
import com.metaobjects.template.MetaTemplate
@@ -39,7 +40,10 @@ import com.metaobjects.template.TemplateConstants
* traversal) / FILTER (the `FilterAllowlist` object). A read-only `object.projection` →
* MODEL + a single read-only DATA_ACCESS surface (the Exposed `Table` for a view kind, or the
* `Proc` callable for a storedProc kind); no write surfaces. A value object → MODEL only. An
- * abstract object / a TPH subtype yields no instance artifacts.
+ * abstract object / a TPH subtype yields no instance artifacts. A SERVED report (`object.report`
+ * over a view, FR-044) is documented from its read model: MODEL (its row), DATA_ACCESS (the
+ * read-only Exposed `Table`), one REST symbol (`GET `) and FILTER. A report that is not
+ * served yields no unit.
* - **Templates** (both subtypes): each → PAYLOAD / RENDER, since every renderable template gets
* a render helper (ADR-0052); a RESPONDING `template.prompt` adds PROMPT / OUTPUT_PARSER /
* EXTRACTOR — each gated by the matching generator's applies-predicate.
@@ -68,10 +72,15 @@ class KotlinApiModelBuilder {
// Objects: one unit per object.entity / object.value / object.projection (the subtype +
// each generator's gate drive which symbol categories are let through — a projection is a
// read-only model, documented as MODEL + its read-only data-access surface only).
- for (obj in loader.metaObjects) {
+ for (declared in loader.metaObjects) {
+ // FR-044 Plan 3: a SERVED report (its read source is a view) is documented from
+ // its read model, the object its data class, table, allowlist and controller are
+ // generated from. A report that is not served generates nothing and gets no unit.
+ val obj = RestSurfaceGate.restShapeOf(declared) ?: continue
if (obj.subType != MetaObject.SUBTYPE_ENTITY &&
obj.subType != MetaObject.SUBTYPE_VALUE &&
- obj.subType != MetaObject.SUBTYPE_PROJECTION
+ obj.subType != MetaObject.SUBTYPE_PROJECTION &&
+ obj !is ReportReadModel
) {
continue
}
@@ -97,9 +106,11 @@ class KotlinApiModelBuilder {
val (pkg, shortName) = PackageMapping.splitFqn(obj.name)
val entity = obj.subType == MetaObject.SUBTYPE_ENTITY
val projection = obj.subType == MetaObject.SUBTYPE_PROJECTION
+ val report = obj is ReportReadModel // a served report's read model (see build)
val unitKind = when {
entity -> "entity"
projection -> "projection"
+ report -> "report"
else -> "value"
}
@@ -120,6 +131,7 @@ class KotlinApiModelBuilder {
usage = when {
entity -> "the entity model (plain Kotlin data class, Jackson-compatible; also the controller request/response body)"
projection -> "the read-model (plain Kotlin data class, Jackson-compatible; read-only projection of query results)"
+ report -> "the report row (plain Kotlin data class, Jackson-compatible; read-only, one property per derived field)"
else -> "the value-object model (plain Kotlin data class, Jackson-compatible)"
},
)
@@ -129,7 +141,8 @@ class KotlinApiModelBuilder {
// A projection's data-access surface IS what codegen-kotlin emits for its source
// @kind: a read-only Exposed Table object (view / materializedView) or a stored-proc
// callable object (storedProc).
- if (projection && !KotlinGenUtil.isAbstractEntity(obj)) {
+ // A served report's is the read-only Exposed Table object over its view (FR-044).
+ if ((projection || report) && !KotlinGenUtil.isAbstractEntity(obj)) {
projectionDataAccess(obj)?.let { symbols.add(it) }
}
@@ -296,6 +309,10 @@ class KotlinApiModelBuilder {
)
}
rest("GET $base", "list with pagination / sort / filters")
+ // FR-044: a served report is a list and nothing else (Table G). It has no identity,
+ // so no item route; its controller answers POST with 405, which is a refusal and not
+ // an operation of the API.
+ if (obj is ReportReadModel) return
// F22 — a read-only projection serves the reads and REFUSES every write verb with
// 405. Documenting it with the writable verb list would be the precise drift this
// builder exists to prevent; and a keyless projection has no /{id} route at all.
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/CodegenCompileConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/CodegenCompileConformanceTest.kt
index 70bcdc2d5..05abe589d 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/CodegenCompileConformanceTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/CodegenCompileConformanceTest.kt
@@ -161,7 +161,13 @@ class CodegenCompileConformanceTest {
"KotlinRelationsGenerator" to KotlinRelationsGenerator(),
"KotlinFilterAllowlistGenerator" to KotlinFilterAllowlistGenerator(),
"KotlinValidatorGenerator" to KotlinValidatorGenerator(),
- ), minFiles = 17)
+ ), minFiles = 17, expectedFiles = listOf(
+ // FR-044 Plan 3: a view-backed report's row and allowlist compile beside its
+ // table. ProgramsByMonth carries an enum typed by Program's class; ProgramMinutes
+ // derives eleven fields, the arity that broke the Java allowlist.
+ "ProgramsByMonth.kt", "ProgramsByMonthTable.kt", "ProgramsByMonthFilterAllowlist.kt",
+ "ProgramMinutes.kt", "ProgramMinutesTable.kt", "ProgramMinutesFilterAllowlist.kt",
+ ))
}
/**
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt
new file mode 100644
index 000000000..9bd2d0de6
--- /dev/null
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt
@@ -0,0 +1,399 @@
+package com.metaobjects.generator.kotlin
+
+import com.metaobjects.MetaDataException
+import com.metaobjects.generator.Generator
+import com.metaobjects.generator.kotlin.apidocs.ApiSymbolKind
+import com.metaobjects.generator.kotlin.apidocs.KotlinApiModelBuilder
+import com.metaobjects.loader.MetaDataLoader
+import com.metaobjects.metadata.ktx.loadDirectory
+import com.metaobjects.metadata.ktx.loadString
+import com.tschuchort.compiletesting.KotlinCompilation
+import com.tschuchort.compiletesting.SourceFile
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.util.TreeMap
+import kotlin.io.path.isRegularFile
+import kotlin.io.path.readText
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * FR-044 Plan 3: a SERVED `object.report` (its read source is `@kind: view`) gets the
+ * keyless read-only REST surface in Kotlin: its row data class, its filter allowlist and a
+ * read-only Spring controller, beside the Exposed table Plan 2 already emits. A report that
+ * is not served gets none of them.
+ *
+ * The four files must agree, because the controller names the other three: it maps a
+ * `ResultRow` of `Table` into `` and gates filters through `FilterAllowlist`. So
+ * every generator reaches the report through `RestSurfaceGate.restShapeOf`, and an enum
+ * column is typed by one class everywhere.
+ *
+ * This module has Exposed on its test classpath and no Spring, so the data class, table and
+ * allowlist are COMPILED here and the controller is asserted as text. The controller is
+ * compiled and driven over HTTP by the report lane in `integration-tests-kotlin`.
+ */
+@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class)
+class KotlinReportRestSurfaceTest {
+
+ private fun fixtureDir(relative: String): Path {
+ var cur: Path? = Paths.get("").toAbsolutePath()
+ while (cur != null) {
+ val candidate = cur.resolve(relative)
+ if (Files.isDirectory(candidate)) return candidate
+ cur = cur.parent
+ }
+ throw IllegalStateException("Could not locate $relative")
+ }
+
+ private fun restSurface(): List = listOf(
+ KotlinEntityGenerator(), KotlinExposedTableGenerator(),
+ KotlinFilterAllowlistGenerator(), KotlinSpringControllerGenerator(),
+ )
+
+ /** Run [generators] over [loader] into one directory; relative path to contents. */
+ private fun emit(
+ loader: MetaDataLoader,
+ generators: List = restSurface(),
+ args: Map = emptyMap(),
+ ): Map {
+ val outDir = Files.createTempDirectory("report-rest-")
+ try {
+ for (gen in generators) {
+ gen.setArgs(mapOf("outputDir" to outDir.toString()) + args)
+ gen.execute(loader)
+ }
+ val files = TreeMap()
+ Files.walk(outDir).use { s ->
+ s.filter { it.isRegularFile() }.forEach { files[outDir.relativize(it).toString()] = it.readText() }
+ }
+ return files
+ } finally {
+ outDir.toFile().deleteRecursively()
+ }
+ }
+
+ private fun withModel() = loadDirectory("report-rest-with", fixtureDir("fixtures/codegen-noop/reporting/with"))
+
+ private fun canonical() =
+ loadDirectory("report-rest-canonical", fixtureDir("fixtures/persistence-conformance/canonical"))
+
+ /** Compile everything but the controllers: Spring is not on this module's classpath. */
+ private fun assertCompilesWithoutControllers(files: Map) {
+ val sources = files.filterKeys { it.endsWith(".kt") && !it.endsWith("Controller.kt") }
+ .map { (path, text) -> SourceFile.kotlin(path.replace('/', '_'), text) }
+ val result = KotlinCompilation().apply {
+ this.sources = sources
+ inheritClassPath = true // Exposed and jakarta.validation, off the test classpath
+ messageOutputStream = System.out
+ }.compile()
+ assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages)
+ }
+
+ // --- Which reports are served (Table A) ---------------------------------------------
+
+ @Test
+ fun `a served report emits exactly its data class, table, allowlist and controller`() {
+ val files = emit(withModel())
+ assertEquals(
+ listOf(
+ "acme/shop/StoreTotals.kt", "acme/shop/StoreTotalsController.kt",
+ "acme/shop/StoreTotalsFilterAllowlist.kt", "acme/shop/StoreTotalsTable.kt",
+ ),
+ files.keys.filter { "StoreTotals" in it },
+ )
+ }
+
+ @Test
+ fun `a sourceless report emits nothing`() {
+ val files = emit(withModel())
+ for (report in listOf("ProgramEngagement", "DailyRevenue")) {
+ val hits = files.filter { (path, text) -> report in path || report in text }.keys
+ assertTrue(hits.isEmpty(), "$report leaked into $hits")
+ }
+ }
+
+ private fun saleTotals(reportAttrs: String = "", source: String? = VIEW): String {
+ val children = source?.let { """, "children": [ $it ]""" } ?: ""
+ return """{
+ "metadata.root": { "package": "acme::shop", "children": [
+ { "object.entity": { "name": "Sale", "children": [
+ { "source.rdb": { "@table": "sales" } },
+ { "field.long": { "name": "id" } },
+ { "field.string": { "name": "channel", "@maxLength": 20, "@required": true } },
+ { "field.decimal": { "name": "weight", "@precision": 10, "@scale": 2 } },
+ { "field.float": { "name": "score" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } },
+ { "dimension.attribute": { "name": "source", "@of": "Sale.channel" } },
+ { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } },
+ { "measure.aggregate": { "name": "heaviest", "@agg": "max", "@of": "Sale.weight" } },
+ { "measure.aggregate": { "name": "topScore", "@agg": "max", "@of": "Sale.score" } },
+ { "measure.aggregate": { "name": "avgWeight", "@agg": "avg", "@of": "Sale.weight" } }
+ ] } },
+ { "object.report": { "name": "SaleTotals", $reportAttrs "@from": "Sale", "@dimensions": ["source"],
+ "@measures": ["sales", "heaviest", "topScore", "avgWeight"]$children } }
+ ] }
+ }"""
+ }
+
+ private fun saleTotalsFiles(json: String): Map =
+ emit(loadString("report-rest-model", json)).filterKeys { "SaleTotals" in it }
+
+ @Test
+ fun `only a concrete report over a view is served`() {
+ assertEquals(4, saleTotalsFiles(saleTotals()).size)
+ assertEquals(emptyMap(), saleTotalsFiles(saleTotals(source = null)), "sourceless")
+ assertEquals(emptyMap(), saleTotalsFiles(saleTotals(reportAttrs = """"abstract": true,""")), "abstract")
+ for (kind in listOf(
+ """"@kind": "materializedView", "@materializedView": "mv_sale_totals"""",
+ """"@kind": "storedProc", "@procedure": "sale_totals"""",
+ """"@kind": "tableFunction", "@function": "sale_totals"""",
+ )) {
+ assertEquals(emptyMap(), saleTotalsFiles(saleTotals(source = """{ "source.rdb": { $kind } }""")), kind)
+ }
+ }
+
+ // --- The row data class -------------------------------------------------------------
+
+ @Test
+ fun `the row is an immutable data class with one property per derived field, nullable by Table B`() {
+ val src = emit(withModel()).getValue("acme/shop/StoreTotals.kt")
+ assertTrue("public data class StoreTotals(" in src, src)
+ assertTrue(" public val purchases: Long,\n" in src, src)
+ assertTrue(" public val buyers: Long,\n" in src, src)
+ assertTrue(" public val revenue: Long? = null,\n" in src, src)
+ // Read from the view, never constructed or bound: no builder, no constraint.
+ assertFalse("Builder" in src, src)
+ assertFalse(Regex("""\bvar\s+\w""").containsMatchIn(src), src)
+ assertFalse("jakarta" in src, src)
+ }
+
+ @Test
+ fun `a derived enum field is typed by the enum of the entity it reads, the class its table uses`() {
+ val files = emit(canonical(), listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator()))
+ val row = files.getValue("fitness/ProgramsByMonth.kt")
+ val table = files.getValue("fitness/ProgramsByMonthTable.kt")
+ assertTrue(" public val status: ProgramStatus" in row, row)
+ assertTrue("ProgramStatus::class" in table, table)
+ // No generator materializes an enum for the report.
+ assertFalse(files.keys.any { "ProgramsByMonthStatus" in it }, files.keys.toString())
+ assertFalse("ProgramsByMonthStatus" in row, row)
+ }
+
+ @Test
+ fun `a time bucket is a date and a ratio is a decimal`() {
+ val files = emit(canonical(), listOf(KotlinEntityGenerator()))
+ assertTrue(" public val createdAtMonth: LocalDate,\n" in files.getValue("fitness/ProgramsByMonth.kt"))
+ assertTrue(" public val longShare: BigDecimal? = null,\n" in files.getValue("fitness/ProgramMinutes.kt"))
+ }
+
+ @Test
+ fun `a served report over a field object is refused by the row generator, naming the report`() {
+ val json = """{
+ "metadata.root": { "package": "acme::shop", "children": [
+ { "object.value": { "name": "Address", "children": [ { "field.string": { "name": "city" } } ] } },
+ { "object.entity": { "name": "Sale", "children": [
+ { "source.rdb": { "@table": "sales" } },
+ { "field.long": { "name": "id" } },
+ { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "jsonb" } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } },
+ { "dimension.attribute": { "name": "destination", "@of": "Sale.shipTo" } },
+ { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } }
+ ] } },
+ { "object.report": { "name": "SaleTotals", "@from": "Sale", "@dimensions": ["destination"],
+ "@measures": ["sales"], "children": [ $VIEW ] } }
+ ] }
+ }"""
+ for (gen in listOf(KotlinEntityGenerator(), KotlinFilterAllowlistGenerator(), KotlinSpringControllerGenerator())) {
+ val e = assertFailsWith(gen.javaClass.simpleName) {
+ emit(loadString("report-rest-object", json), listOf(gen))
+ }
+ assertTrue("report 'SaleTotals'" in e.message.orEmpty() && "field.object" in e.message.orEmpty(), e.message)
+ }
+ }
+
+ // --- The allowlist (Table C) --------------------------------------------------------
+
+ @Test
+ fun `every derived field with a filter band is in the allowlist, with its subtype's operators`() {
+ assertEquals(
+ """
+ |package acme.shop
+ |
+ |/**
+ | * GENERATED — per-entity FR-009 filter allowlist for StoreTotals.
+ | * FIELDS lists the filterable field names; OPS_BY_FIELD constrains the
+ | * operator vocabulary for each field by its subtype.
+ | */
+ |object StoreTotalsFilterAllowlist {
+ | val FIELDS: Set = setOf(
+ | "purchases",
+ | "buyers",
+ | "revenue",
+ | )
+ |
+ | val OPS_BY_FIELD: Map> = mapOf(
+ | "purchases" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"),
+ | "buyers" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"),
+ | "revenue" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull")
+ | )
+ |}
+ |""".trimMargin(),
+ emit(withModel()).getValue("acme/shop/StoreTotalsFilterAllowlist.kt"),
+ )
+ }
+
+ @Test
+ fun `a report with more than ten filterable fields emits one allowlist entry per field`() {
+ // The Java allowlist stopped at ten pairs (Map.of); Kotlin's mapOf is vararg. Pinned
+ // on the report that found it: ProgramMinutes derives eleven fields.
+ val src = emit(canonical(), listOf(KotlinFilterAllowlistGenerator()))
+ .getValue("fitness/ProgramMinutesFilterAllowlist.kt")
+ assertEquals(11, Regex("""" to setOf\(""").findAll(src).count(), src)
+ }
+
+ // --- The controller (Table B) -------------------------------------------------------
+
+ @Test
+ fun `the controller serves the list, refuses POST with 405 and mounts no item route`() {
+ val src = emit(withModel()).getValue("acme/shop/StoreTotalsController.kt")
+ assertTrue("@RequestMapping(\"/api/store_totals\")" in src, src)
+ assertEquals(1, Regex("@GetMapping").findAll(src).count(), src)
+ assertTrue(" @PostMapping\n fun create(): ResponseEntity = methodNotAllowed()\n" in src, src)
+ assertTrue("\"error\" to \"method_not_allowed\"" in src, src)
+ assertFalse("{id}" in src, src)
+ assertFalse("PathVariable" in src || "RequestMethod" in src, src)
+ // Reads the generated table object into the generated row, through the allowlist.
+ assertTrue("private fun rowToStoreTotals(row: ResultRow): StoreTotals = StoreTotals(" in src, src)
+ assertTrue(" revenue = row[StoreTotalsTable.revenue],\n" in src, src)
+ assertTrue("StoreTotalsFilterAllowlist.FIELDS" in src, src)
+ // Every derived field is sortable.
+ assertTrue(
+ "private val StoreTotalsSortAllowlist = setOf(\n \"purchases\",\n \"buyers\",\n \"revenue\",\n)" in src,
+ src,
+ )
+ // No write path, and the prose names what it is.
+ for (write in listOf(".insert", ".update", "deleteWhere", "RequestBody", "Validator")) {
+ assertFalse(write in src, "$write in:\n$src")
+ }
+ assertTrue("READ-ONLY REST controller for the StoreTotals report." in src, src)
+ assertTrue("writes are not supported on a report (read-only)." in src, src)
+ assertFalse("projection" in src, src)
+ }
+
+ @Test
+ fun `a projection's read-only controller still says projection`() {
+ val json = """{
+ "metadata.root": { "package": "acme::report", "children": [
+ { "object.projection": { "name": "SalesReport", "children": [
+ { "field.long": { "name": "total" } },
+ { "source.rdb": { "@table": "v_sales_report", "@kind": "view" } }
+ ] } }
+ ] }
+ }"""
+ val src = emit(loadString("report-rest-projection", json), listOf(KotlinSpringControllerGenerator()))
+ .getValue("acme/report/SalesReportController.kt")
+ assertTrue("READ-ONLY REST controller for the SalesReport projection." in src, src)
+ assertTrue("writes are not supported on a projection (read-only)." in src, src)
+ }
+
+ @Test
+ fun `the controller reads a column the table renamed through the renamed property`() {
+ // `source` is a member of Exposed's Table, so the table declares `sourceColumn`.
+ val files = saleTotalsFiles(saleTotals())
+ assertTrue(" val sourceColumn = varchar(\"source\", 20)" in files.getValue("acme/shop/SaleTotalsTable.kt"))
+ val src = files.getValue("acme/shop/SaleTotalsController.kt")
+ assertTrue(" source = row[SaleTotalsTable.sourceColumn],\n" in src, src)
+ assertTrue("\"eq\" -> SaleTotalsTable.sourceColumn eq (p.value as String)" in src, src)
+ assertTrue("\"source\" -> SaleTotalsTable.sourceColumn\n" in src, src)
+ assertFalse("SaleTotalsTable.source]" in src || "SaleTotalsTable.source " in src, src)
+ }
+
+ @Test
+ fun `a decimal and a float filter value are coerced to the column's own type`() {
+ // The dispatch arm casts to the column type, so a Double would be a ClassCastException.
+ val src = saleTotalsFiles(saleTotals()).getValue("acme/shop/SaleTotalsController.kt")
+ assertTrue(" \"heaviest\" -> coerceSaleTotalsDecimal(op, raw)\n" in src, src)
+ assertTrue(" \"avgWeight\" -> coerceSaleTotalsDecimal(op, raw)\n" in src, src)
+ assertTrue(" \"topScore\" -> coerceSaleTotalsFloat(op, raw)\n" in src, src)
+ assertTrue("runCatching { java.math.BigDecimal(s) }" in src, src)
+ assertTrue("runCatching { java.lang.Float.parseFloat(s) }" in src, src)
+ assertTrue("\"gt\" -> SaleTotalsTable.avgWeight greater (p.value as BigDecimal)" in src, src)
+ }
+
+ @Test
+ fun `a model with no float or decimal column gets neither coercer`() {
+ val src = emit(withModel()).getValue("acme/shop/StoreTotalsController.kt")
+ assertFalse("coerceStoreTotalsDecimal" in src || "coerceStoreTotalsFloat" in src, src)
+ }
+
+ @Test
+ fun `an int-backed enum dimension filters through the enum class its table is typed by`() {
+ val json = """{
+ "metadata.root": { "package": "acme::shop", "children": [
+ { "object.entity": { "name": "Sale", "children": [
+ { "source.rdb": { "@table": "sales" } },
+ { "field.long": { "name": "id" } },
+ { "field.enum": { "name": "channel", "@values": ["WEB", "SHOP"], "@intValueMap": { "WEB": 1, "SHOP": 2 }, "@required": true } },
+ { "identity.primary": { "name": "id", "@fields": ["id"] } },
+ { "dimension.attribute": { "name": "channel", "@of": "Sale.channel" } },
+ { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } }
+ ] } },
+ { "object.report": { "name": "SalesByChannel", "@from": "Sale",
+ "@dimensions": ["channel"], "@measures": ["sales"], "children": [ $VIEW ] } }
+ ] }
+ }"""
+ val files = emit(loadString("report-rest-int-enum", json))
+ assertTrue(" public val channel: SaleChannel,\n" in files.getValue("acme/shop/SalesByChannel.kt"))
+ val src = files.getValue("acme/shop/SalesByChannelController.kt")
+ assertTrue("acme.shop.SaleChannel.entries.firstOrNull { it.name == (p.value as String) }" in src, src)
+ assertFalse("SalesByChannelChannel" in files.toString(), "a per-report enum leaked")
+ assertCompilesWithoutControllers(files)
+ }
+
+ // --- The emitted tree builds --------------------------------------------------------
+
+ @Test
+ fun `the served report's row, table and allowlist compile together`() {
+ assertCompilesWithoutControllers(emit(withModel()))
+ }
+
+ @Test
+ fun `the six canonical reports' rows, tables and allowlists compile beside their entities`() {
+ val files = emit(canonical(), args = mapOf("columnNaming" to "literal"))
+ for (report in listOf(
+ "ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity",
+ )) {
+ for (suffix in listOf("", "Table", "FilterAllowlist", "Controller")) {
+ assertTrue("fitness/$report$suffix.kt" in files, "$report$suffix missing from ${files.keys}")
+ }
+ }
+ assertCompilesWithoutControllers(files)
+ }
+
+ // --- Api docs (Table G) -------------------------------------------------------------
+
+ @Test
+ fun `api docs document a served report as a list and nothing else`() {
+ val model = KotlinApiModelBuilder().build(withModel(), "shop")
+ val unit = model.units.single { it.node == "StoreTotals" }
+ assertEquals("report", unit.kind)
+ assertEquals(
+ listOf(
+ ApiSymbolKind.MODEL to "data class StoreTotals",
+ ApiSymbolKind.DATA_ACCESS to "object StoreTotalsTable : Table",
+ ApiSymbolKind.REST to "GET /api/store_totals",
+ ApiSymbolKind.FILTER to "object StoreTotalsFilterAllowlist",
+ ),
+ unit.symbols.map { it.kind to it.signature },
+ )
+ assertFalse(model.units.any { it.node == "DailyRevenue" || it.node == "ProgramEngagement" })
+ }
+
+ private companion object {
+ const val VIEW = """{ "source.rdb": { "@kind": "view", "@view": "v_sale_totals" } }"""
+ }
+}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
index 315c8c569..815f0458b 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
@@ -23,11 +23,13 @@ import kotlin.test.assertTrue
* `dimension.*`, `measure.*` and `segment.*` generate nothing anywhere. An `object.report`
* with no read-only source generates nothing anywhere. A report that declares a read-only
* `source.rdb @kind: view` is lowered to that view (by TypeScript migrate), and Kotlin
- * generates exactly one thing for it: its read-only Exposed table, from
- * [KotlinExposedTableGenerator]. Every other generator in [GENERATOR_REGISTRY] — entity,
- * names, relations, repository, controller, filter allowlist, the docs tier — emits for a
- * model that USES the vocabulary exactly what it emits for the same model without it, byte
- * for byte.
+ * generates exactly four files for it (FR-044 Plan 3), one from each of four generators:
+ * its read-only Exposed table ([KotlinExposedTableGenerator]), its row data class
+ * ([KotlinEntityGenerator]), its filter allowlist ([KotlinFilterAllowlistGenerator]) and its
+ * read-only controller ([KotlinSpringControllerGenerator]). Every other generator in
+ * [GENERATOR_REGISTRY] — names, relations, repository, validator, the prompt tier — emits
+ * for a model that USES the vocabulary exactly what it emits for the same model without it,
+ * byte for byte, and those four emit nothing else new.
*
* The model pair is `fixtures/codegen-noop/reporting/{with,without}`, shared with the other
* four ports' copies of this test. `with/` carries two sourceless reports
@@ -87,26 +89,27 @@ class ReportingInertTest {
}
/**
- * Null when [actual] is [expected] plus exactly the files in [added] (path to contents,
- * empty for a generator that must stay inert); else what leaked.
+ * Null when [actual] is [expected] plus exactly the paths in [added] (empty for a
+ * generator that must stay inert), every [expected] file unchanged; else what leaked.
+ * What an added file CONTAINS is pinned elsewhere: the table below, the rest in
+ * [KotlinReportRestSurfaceTest].
*/
private fun sameOrLeak(
label: String,
expected: Map,
actual: Map,
- added: Map = emptyMap(),
+ added: Set = emptySet(),
): String? {
- val wanted = TreeMap(expected).apply { putAll(added) }
- if (wanted.keys.toList() != actual.keys.toList()) {
- return "$label: emitted file set ${wanted.keys} became ${actual.keys}"
+ val wanted = (expected.keys + added).sorted()
+ if (wanted != actual.keys.toList()) {
+ return "$label: emitted file set $wanted became ${actual.keys}"
}
- val differing = wanted.keys.filter { wanted[it] != actual[it] }
+ val differing = expected.keys.filter { expected[it] != actual[it] }
return if (differing.isEmpty()) null else "$label: $differing differ once reporting nodes are declared"
}
- /** What a generator may add for the with-model: the view-backed report's table, and only from `exposed-table`. */
- private fun allowedFor(info: GeneratorInfo): Map =
- if (info.name == EXPOSED_TABLE) mapOf(STORE_TOTALS_TABLE_PATH to STORE_TOTALS_TABLE) else emptyMap()
+ /** What a generator may add for the with-model: one file of the view-backed report, from four generators. */
+ private fun allowedFor(info: GeneratorInfo): Set = setOfNotNull(STORE_TOTALS_FILES[info.name])
@Test
fun `the with-model really carries the vocabulary`() {
@@ -120,7 +123,7 @@ class ReportingInertTest {
}
@Test
- fun `only the Exposed table generator emits for a report, and only the view-backed one's table`() {
+ fun `only four generators emit for a report, each one file, for the view-backed one`() {
// Every generator is compared before anything is asserted, so one red run names
// every leak rather than the first.
val leaks = GENERATOR_REGISTRY.values.mapNotNull { info ->
@@ -137,6 +140,16 @@ class ReportingInertTest {
assertEquals(mapOf(STORE_TOTALS_TABLE_PATH to STORE_TOTALS_TABLE), added)
}
+ @Test
+ fun `the view-backed report emits exactly one file from each of the four generators`() {
+ // Else the allowance above is vacuous: each file really is emitted.
+ for ((name, path) in STORE_TOTALS_FILES) {
+ val info = GENERATOR_REGISTRY.getValue(name)
+ val added = emit("with", listOf(info)).keys - emit("without", listOf(info)).keys
+ assertEquals(setOf(path), added, name)
+ }
+ }
+
@Test
fun `a sourceless report appears in no generated file`() {
val files = emit("with", GENERATOR_REGISTRY.values.toList())
@@ -145,9 +158,9 @@ class ReportingInertTest {
val hits = files.filter { (path, text) -> report in path || report in text }.keys
assertTrue(hits.isEmpty(), "$report leaked into $hits")
}
- // The view-backed report is named by its table and by nothing else.
+ // The view-backed report is named by its four files and by nothing else.
val hits = files.filter { (path, text) -> "StoreTotals" in path || "StoreTotals" in text }.keys
- assertEquals(setOf(STORE_TOTALS_TABLE_PATH), hits)
+ assertEquals(STORE_TOTALS_FILES.values.toSet(), hits)
}
@Test
@@ -165,14 +178,14 @@ class ReportingInertTest {
val expected = emit("without", runnable)
assertFalse(THREW in expected, "the combined suite threw: ${expected[THREW]}")
assertTrue(expected.size > 10, "only ${expected.size} files — the suite barely ran")
- val leak = sameOrLeak(
- "combined", expected, emit("with", runnable), mapOf(STORE_TOTALS_TABLE_PATH to STORE_TOTALS_TABLE))
+ val leak = sameOrLeak("combined", expected, emit("with", runnable), STORE_TOTALS_FILES.values.toSet())
assertTrue(leak == null, leak)
}
/**
- * The api docs surface: every unit page, the index and the agent page. A report has no
- * generated API to document — no route, repository or DTO — whether or not it has a view.
+ * The api docs surface: every unit page, the index and the agent page. A served report
+ * has a generated API to document (its row, table, list route and allowlist); a
+ * sourceless one has none.
*/
private fun apiDocs(variant: String): Map {
val model = KotlinApiModelBuilder().build(load(variant), "shop")
@@ -188,11 +201,28 @@ class ReportingInertTest {
}
@Test
- fun `api docs are the same with and without reporting nodes`() {
+ fun `api docs gain exactly the view-backed report's page`() {
val expected = apiDocs("without")
+ val actual = apiDocs("with")
assertTrue(expected.size > 3, "only ${expected.size} pages — the docs barely ran")
- val leak = sameOrLeak("api docs", expected, apiDocs("with"))
- assertTrue(leak == null, leak)
+ val added = actual.keys - expected.keys
+ assertEquals(1, added.size, added.toString())
+ assertTrue("StoreTotals" in added.single(), added.toString())
+ assertEquals(emptySet(), expected.keys - actual.keys)
+ // Every other unit page is byte-identical; only the two pages that LIST units move.
+ val listings = setOf("README.md", "AGENT-API.md")
+ val differing = expected.keys.filter { it !in listings && expected[it] != actual[it] }
+ assertTrue(differing.isEmpty(), "$differing differ once reporting nodes are declared")
+ for (page in listings) {
+ // Dropping every line that names the report leaves the page it was (blank
+ // separator lines aside: the report's section brings its own).
+ val lines = actual.getValue(page).lines()
+ .filterNot { it.isBlank() || "StoreTotals" in it || "store_totals" in it }
+ assertEquals(expected.getValue(page).lines().filterNot { it.isBlank() }, lines, page)
+ }
+ for (report in listOf("ProgramEngagement", "DailyRevenue")) {
+ assertFalse(actual.any { (path, text) -> report in path || report in text }, "$report is documented")
+ }
}
private companion object {
@@ -203,6 +233,14 @@ class ReportingInertTest {
const val STORE_TOTALS_TABLE_PATH = "acme/shop/StoreTotalsTable.kt"
+ /** Registry id of each generator that emits for a served report, to the one file it adds. */
+ val STORE_TOTALS_FILES = mapOf(
+ EXPOSED_TABLE to STORE_TOTALS_TABLE_PATH,
+ "entity" to "acme/shop/StoreTotals.kt",
+ "filter-allowlist" to "acme/shop/StoreTotalsFilterAllowlist.kt",
+ "routes" to "acme/shop/StoreTotalsController.kt",
+ )
+
/** `StoreTotals`: three measures over `Purchase` — two counts and a sum of a currency. */
val STORE_TOTALS_TABLE = """
|package acme.shop
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt
new file mode 100644
index 000000000..14d0ae8a7
--- /dev/null
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt
@@ -0,0 +1,107 @@
+package com.metaobjects.integration.kotlin.api.report
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.metaobjects.integration.kotlin.api.ApiContractAssertions
+import com.metaobjects.integration.kotlin.api.ApiContractScenarioLoader
+import com.metaobjects.integration.kotlin.api.ApiContractScenarios.ApiScenario
+import com.metaobjects.integration.kotlin.api.report.generated.GeneratedReportControllerHarness
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertDoesNotThrow
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.util.stream.Stream
+
+/**
+ * FR-044 Plan 3: the Kotlin GENERATED-controller lane for the view-backed-report
+ * api-contract sub-corpus. Drives the three generated read-only report controllers over an
+ * embedded Tomcat: GET list with the FR-009 filter and sort allowlists over the report's
+ * DERIVED fields, `POST` answering `405 {"error": "method_not_allowed"}`, and no `/{id}`.
+ *
+ * Generated lane ONLY, on purpose and on every port (see the sub-corpus README): what is
+ * under test is whether the port's GENERATOR emits a route for a served report. A
+ * hand-rolled reference controller would answer every scenario by construction.
+ */
+@DisplayName("API contract report (FR-044) — GENERATED Kotlin Spring controllers over embedded Tomcat")
+internal class ReportGeneratedApiContractConformanceTest {
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("scenarios")
+ fun scenario(name: String, scenario: ApiScenario) {
+ assertDoesNotThrow {
+ HARNESS.reset() // fresh H2 + stand-in tables + seed per scenario
+ for (req in scenario.requests) {
+ val res = HARNESS.exchange(req.method, req.path, req.body)
+ val parsed = HARNESS.parseBody(res.body)
+ ApiContractAssertions.assertResponse(scenario.name, req, res.status, parsed)
+ }
+ }
+ }
+
+ @Test
+ fun `the corpus has its twelve scenarios`() {
+ // A lane that silently ran fewer would still be green.
+ assertEquals(12, scenarios().count())
+ }
+
+ @Test
+ fun `the sourceless report generates nothing`() {
+ // InvoiceDays declares no view. A port that served every report it found would emit
+ // a table no schema has and a route no scenario calls.
+ val leaked = HARNESS.emittedFiles.filter { "InvoiceDays" in it }
+ assertTrue(leaked.isEmpty(), "InvoiceDays leaked into $leaked")
+ for (report in GeneratedReportControllerHarness.SERVED) {
+ for (suffix in listOf("", "Table", "FilterAllowlist", "Controller")) {
+ assertTrue("acme/sales/$report$suffix.kt" in HARNESS.emittedFiles, "$report$suffix was not generated")
+ }
+ }
+ }
+
+ companion object {
+ private val MAPPER = ObjectMapper()
+
+ /**
+ * The `reports` half of the seed: what the three views return for the seeded
+ * invoices. This lane's H2 tables stand in for the views (no view SQL is written in
+ * Kotlin), so they are seeded with the views' rows, not with the base table's.
+ */
+ @Suppress("UNCHECKED_CAST")
+ private val SEED: Map>> by lazy {
+ val corpus = ApiContractScenarioLoader.findCorpusRoot()
+ val text = Files.readString(corpus.resolve("report/seed.json"), StandardCharsets.UTF_8)
+ val parsed = MAPPER.readValue(text, Map::class.java) as Map
+ (parsed["reports"] as? Map>>)
+ ?: error("report/seed.json: missing 'reports'")
+ }
+
+ private lateinit var HARNESS: GeneratedReportControllerHarness
+
+ @BeforeAll
+ @JvmStatic
+ fun setUp() {
+ val corpus = ApiContractScenarioLoader.findCorpusRoot()
+ val genDir = Files.createTempDirectory("fr044-generated-kotlin-report-controller")
+ HARNESS = GeneratedReportControllerHarness(corpus, genDir, SEED)
+ }
+
+ @AfterAll
+ @JvmStatic
+ fun tearDown() {
+ if (::HARNESS.isInitialized) HARNESS.close()
+ }
+
+ @JvmStatic
+ fun scenarios(): Stream {
+ val corpus = ApiContractScenarioLoader.findCorpusRoot()
+ val scenarios = ApiContractScenarioLoader.loadScenarios(corpus.resolve("report/scenarios"))
+ return scenarios.stream().map { Arguments.of(it.name, it) }
+ }
+ }
+}
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt
new file mode 100644
index 000000000..e97b70939
--- /dev/null
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt
@@ -0,0 +1,185 @@
+package com.metaobjects.integration.kotlin.api.report.generated
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
+import com.fasterxml.jackson.module.kotlin.registerKotlinModule
+import com.metaobjects.generator.kotlin.KotlinEntityGenerator
+import com.metaobjects.generator.kotlin.KotlinExposedTableGenerator
+import com.metaobjects.generator.kotlin.KotlinFilterAllowlistGenerator
+import com.metaobjects.generator.kotlin.KotlinNamesGenerator
+import com.metaobjects.generator.kotlin.KotlinSpringControllerGenerator
+import com.metaobjects.integration.kotlin.api.TomcatHost
+import com.metaobjects.loader.uri.URIHelper
+import com.metaobjects.metadata.ktx.loadUris
+import com.tschuchort.compiletesting.KotlinCompilation
+import com.tschuchort.compiletesting.SourceFile
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.SchemaUtils
+import org.jetbrains.exposed.sql.Table
+import org.jetbrains.exposed.sql.transactions.transaction
+import java.net.URI
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.io.path.isRegularFile
+import kotlin.io.path.readText
+
+/**
+ * FR-044 Plan 3: host the GENERATED Kotlin Spring controllers of the three served reports
+ * of the `report/` corpus over real HTTP (an embedded Tomcat). Mirrors
+ * [com.metaobjects.integration.kotlin.api.projection.generated.GeneratedProjectionControllerHarness].
+ *
+ * Mechanism:
+ * 1. Load `fixtures/api-contract-conformance/report/meta.json`.
+ * 2. Run the codegen-kotlin generators. The emitted controllers are hosted UNMODIFIED: a
+ * failing scenario is a generator bug, never something fixed by hand-editing emitted code.
+ * 3. Compile every emitted `.kt` together. This is the only thing that proves the emitted
+ * report controller COMPILES against its row, table and allowlist: the codegen-compile
+ * gate excludes the framework-bound route tier in every port by design.
+ * 4. Per scenario: a fresh in-memory H2 (PostgreSQL mode), `SchemaUtils.create(...)` on the
+ * three generated report table objects, then the `reports` half of `seed.json`.
+ * 5. Serve the three controllers from one embedded Tomcat ([TomcatHost]).
+ *
+ * No view SQL is written here (ADR-0015: TypeScript owns it). H2 creates each generated
+ * `Table` as a plain table that STANDS IN for the view, and the seed's `reports` half is
+ * what the three views return for the seeded invoices. What this lane proves is the
+ * generated route over the generated binding, not the lowering.
+ */
+@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class)
+class GeneratedReportControllerHarness(
+ corpusRoot: Path,
+ genDir: Path,
+ /** `seed.json`'s `reports`: report name to the rows its view returns. */
+ private val reportRows: Map>>,
+) : AutoCloseable {
+
+ private val mapper: ObjectMapper = ObjectMapper()
+ .registerKotlinModule()
+ // A date reaches the wire as YYYY-MM-DD (Table D), not as an array or a number.
+ .registerModule(JavaTimeModule())
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+
+ private val controllerClasses: List>
+ private val tables: Map
+ private val dbSeq = AtomicInteger(0)
+ private var host: TomcatHost? = null
+
+ /** Every generated file, by path relative to the output root. */
+ val emittedFiles: Set
+
+ init {
+ val metaJson = corpusRoot.resolve("report/meta.json")
+ val uri: URI = URIHelper.toURI("model:file:" + metaJson.toAbsolutePath().toString().replace('\\', '/'))
+ val loader = loadUris("api-contract-report-generated", listOf(uri))
+
+ val srcDir = genDir.resolve("src")
+ Files.createDirectories(srcDir)
+ for (g in listOf(
+ KotlinEntityGenerator(),
+ KotlinNamesGenerator(),
+ KotlinExposedTableGenerator(),
+ KotlinFilterAllowlistGenerator(),
+ KotlinSpringControllerGenerator(),
+ )) {
+ g.setArgs(mapOf("outputDir" to srcDir.toString(), "useNames" to "true"))
+ g.execute(loader)
+ }
+
+ val paths = Files.walk(srcDir).use { stream ->
+ stream.filter { it.isRegularFile() && it.toString().endsWith(".kt") }.toList()
+ }
+ emittedFiles = paths.map { srcDir.relativize(it).toString().replace('\\', '/') }.toSet()
+
+ // A served report's controller must have been emitted at all; otherwise this fails
+ // downstream as a ClassNotFoundException with no hint that the emit gate was the cause.
+ for (report in SERVED) {
+ check("acme/sales/${report}Controller.kt" in emittedFiles) {
+ "no controller was generated for the $report report; emitted=$emittedFiles"
+ }
+ }
+
+ val result = KotlinCompilation().apply {
+ this.sources = paths.map { SourceFile.kotlin(srcDir.relativize(it).toString().replace('/', '_'), it.readText()) }
+ inheritClassPath = true
+ messageOutputStream = System.out
+ }.compile()
+ check(result.exitCode == KotlinCompilation.ExitCode.OK) {
+ "generated Kotlin failed to compile:\n${result.messages}"
+ }
+
+ controllerClasses = SERVED.map { result.classLoader.loadClass("$ENTITY_PKG.${it}Controller") }
+ tables = SERVED.associateWith {
+ result.classLoader.loadClass("$ENTITY_PKG.${it}Table").getDeclaredField("INSTANCE").get(null) as Table
+ }
+ }
+
+ /** Rebuild a fresh in-memory H2, the three stand-in tables, the seed, and Tomcat. */
+ fun reset() {
+ val dbName = "report_invoice_${dbSeq.incrementAndGet()}"
+ val db = Database.connect("jdbc:h2:mem:$dbName;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver")
+ transaction(db) {
+ for ((report, table) in tables) {
+ SchemaUtils.create(table)
+ val rows = reportRows[report] ?: error("report/seed.json: no 'reports.$report' rows")
+ for (row in rows) {
+ val fields = row.keys.toList()
+ val colList = fields.joinToString(", ") { identity(column(table, it)) }
+ val values = fields.joinToString(", ") { literal(row[it]) }
+ exec("INSERT INTO ${identity(table)} ($colList) VALUES ($values)")
+ }
+ }
+ }
+
+ host?.close()
+ host = TomcatHost.start(mapper, *controllerClasses.map { it.getDeclaredConstructor().newInstance() }.toTypedArray())
+ }
+
+ /**
+ * The generated Exposed column for a derived field name. Looked up by the PHYSICAL name
+ * the generator chose (snake_case is Kotlin's default), so the seed follows whatever the
+ * generator emitted rather than a spelling hardcoded here that could drift from it.
+ */
+ private fun column(table: Table, field: String) =
+ table.columns.firstOrNull { it.name == snakeCase(field) }
+ ?: error("no column for derived field '$field' on ${table.tableName}; columns=${table.columns.map { it.name }}")
+
+ fun exchange(method: String, path: String, jsonBody: Any?): Response {
+ val server = host ?: error("reset() must be called before exchange(...)")
+ val res = server.exchange(method, path, jsonBody?.let { mapper.writeValueAsString(it) })
+ return Response(res.status, res.body)
+ }
+
+ fun parseBody(body: String?): Any? = TomcatHost.parseBody(mapper, body)
+
+ override fun close() {
+ host?.close() // H2 in-mem is reclaimed at JVM exit (DB_CLOSE_DELAY=-1).
+ }
+
+ data class Response(val status: Int, val body: String)
+
+ companion object {
+ const val ENTITY_PKG = "acme.sales"
+
+ /** The three reports of the corpus that declare a view. `InvoiceDays` declares none. */
+ val SERVED = listOf("InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals")
+
+ private fun snakeCase(s: String): String = buildString {
+ for (c in s) {
+ if (c.isUpperCase()) { if (isNotEmpty()) append('_'); append(c.lowercaseChar()) } else append(c)
+ }
+ }
+
+ /**
+ * A seed value as a SQL literal. A date (`"2026-04-01"`) and the ratio (`"0.4"`, a
+ * string in the seed so no float sits between it and the column's decimal) are quoted
+ * strings that H2 converts to the column's own type on insert.
+ */
+ private fun literal(v: Any?): String = when (v) {
+ null -> "NULL"
+ is Number -> v.toString()
+ is Boolean -> v.toString()
+ else -> "'" + v.toString().replace("'", "''") + "'"
+ }
+ }
+}
From 48fe87219ff15d17775a8d78a930240d370ca2d3 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 4 Oct 2026 19:09:47 -0400
Subject: [PATCH 15/21] docs(reporting): report routes, the report api-contract
corpus, and the per-port generators (FR-044)
Docs, agent skills, CHANGELOG, conformance counts (api-contract 61 to 73)
and the Plan 3 document brought in line with what was built. The CHANGELOG
names five corrections that reach models with no report. The project's own
requirements ledger is unchanged: no entry is about serving a report.
---
.claude/rules/cross-language-porting.md | 2 +-
AGENTS.md | 2 +-
CHANGELOG.md | 95 +++++++++-
README.md | 2 +-
.../skills/metaobjects-authoring/SKILL.md | 10 +-
.../references/reporting.md | 24 ++-
.../metaobjects-codegen/references/csharp.md | 12 ++
.../metaobjects-codegen/references/java.md | 11 ++
.../metaobjects-codegen/references/kotlin.md | 12 ++
.../metaobjects-codegen/references/python.md | 16 +-
.../references/typescript.md | 23 ++-
.../skills/metaobjects-runtime-ui/SKILL.md | 2 +
docs/CONFORMANCE.md | 21 ++-
docs/features/api-contract.md | 51 ++++-
docs/features/codegen-data-shapes.md | 5 +
docs/features/reporting.md | 175 +++++++++++++++---
docs/ports/csharp.md | 8 +-
docs/ports/java.md | 14 ++
docs/ports/kotlin.md | 19 +-
docs/ports/python.md | 12 ++
docs/ports/typescript.md | 22 +++
...-10-04-fr-044-plan-3-report-read-routes.md | 38 +++-
.../skills/metaobjects-authoring/SKILL.md | 10 +-
.../references/reporting.md | 24 ++-
.../metaobjects-codegen/references/java.md | 11 ++
.../metaobjects-codegen/references/kotlin.md | 12 ++
.../skills/metaobjects-runtime-ui/SKILL.md | 2 +
.../skills/metaobjects-authoring/SKILL.md | 10 +-
.../references/reporting.md | 24 ++-
.../metaobjects-codegen/references/java.md | 11 ++
.../skills/metaobjects-runtime-ui/SKILL.md | 2 +
.../skills/metaobjects-authoring/SKILL.md | 10 +-
.../references/reporting.md | 24 ++-
.../metaobjects-codegen/references/python.md | 16 +-
.../skills/metaobjects-runtime-ui/SKILL.md | 2 +
.../skills/metaobjects-authoring/SKILL.md | 10 +-
.../references/reporting.md | 24 ++-
.../references/typescript.md | 23 ++-
.../skills/metaobjects-runtime-ui/SKILL.md | 2 +
.../skills/metaobjects-authoring/SKILL.md | 10 +-
.../references/reporting.md | 24 ++-
.../references/typescript.md | 23 ++-
.../skills/metaobjects-runtime-ui/SKILL.md | 2 +
fixtures/api-contract-conformance/README.md | 12 ++
.../api-contract-conformance/report/README.md | 17 +-
fixtures/codegen-noop/reporting/README.md | 39 ++--
46 files changed, 788 insertions(+), 132 deletions(-)
diff --git a/.claude/rules/cross-language-porting.md b/.claude/rules/cross-language-porting.md
index ccd8b7eef..8a7206904 100644
--- a/.claude/rules/cross-language-porting.md
+++ b/.claude/rules/cross-language-porting.md
@@ -17,7 +17,7 @@ Preserve the following contracts exactly across all language ports:
**Metamodel subtype vocabularies (must be identical across languages):** the `registry-conformance` gate (`fixtures/registry-conformance/`) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to `expected-registry.json`. **All five ports (TS / C# / Java / Kotlin / Python) are live + green** (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See `fixtures/registry-conformance/README.md`.
- Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull`
-- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and, since FR-044 Plan 2, **lowered only when it declares a read-only `source.rdb` of `@kind: view`**: `meta migrate` creates that view (TypeScript only, ADR-0015), every port reads it (persistence corpus, `report-shapes.json`), C# generates its typed row and Kotlin its Exposed table object, and everything else (routes, typed clients, filter allowlists, api-docs, and a report with no view source at all) stays inert, gated by the 27 `reporting` conformance fixtures and the `codegen-noop` corpus. See [docs/features/reporting.md](docs/features/reporting.md).
+- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and, since FR-044 Plan 2, **lowered only when it declares a read-only `source.rdb` of `@kind: view`**: `meta migrate` creates that view (TypeScript only, ADR-0015), every port reads it (persistence corpus, `report-shapes.json`), C# generates its typed row and Kotlin its Exposed table object, and, since FR-044 Plan 3, every port's generators **serve it as a keyless read-only projection** (GET list with filter/sort/paging on every derived field that has a filter band, `POST` 405, no `/{id}`; row type + filter allowlist + route/controller per port, gated by the api-contract `report/` sub-corpus in the generated lane). The client UI tier (hooks, grids, forms) and a report with no view source at all stay inert, gated by the 27 `reporting` conformance fixtures and the `codegen-noop` corpus. A served report is the predicate `servedReport` (TS) / `ReportRows.IsViewBacked` (C#) / `RestSurfaceGate.isServedReport` (Java, Kotlin) / `is_served_report` (Python): concrete, read source `@kind: view`. See [docs/features/reporting.md](docs/features/reporting.md).
- Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`.
- Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210).
- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N / **junction-unpairable** → `ERR_INVALID_RELATIONSHIP`. **Unpairable means the junction declares its two references but neither resolves to the navigating entity, or neither to the `@objectRef` target** — declaring two references is NOT enough, and the loader now checks WHAT they point at (owner ruling 2026-09-20). It does so by running the real FK derivation and converting its failure, never a parallel re-implementation, so the loader and the derivation cannot drift; scope mirrors codegen's own iteration exactly — every CONCRETE, non-projection object crossed with its EFFECTIVE relationships, NOT deduped by declaration, because pairing is a property of the navigating entity and an inherited M:N can pair from one subtype and not another. Before the ruling this loaded clean and then diverged: TS and C# warned and emitted no traversal route (a silent 404), while Java, Kotlin and Python failed the build. Gated by `fixtures/conformance/error-relationship-m2m-junction-unpairable/`.
diff --git a/AGENTS.md b/AGENTS.md
index e73efe755..fe538eaf6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,7 +82,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
- YAML / verify corpora green across the ports that ship those layers.
- **Codegen-compile gate** (all five ports; a GATE, not a corpus — it has no fixtures of its own and no row in the matrix). Every corpus above gates BEHAVIOUR; none asks whether the emitted code BUILDS, which is how four "generated code does not compile" defects shipped in 1.0.4 with the whole matrix green — `gen` exits 0 in all four cases and the adopter's build is the first thing that disagrees. Each port generates from `fixtures/persistence-conformance/canonical/meta.fitness.json` (reused deliberately: a second kitchen sink would drift from the one the other corpora already maintain) and compiles the emitted tree with its real compiler — `ts.createProgram` / Roslyn / `javac` / `KotlinCompilation` / (Python, having no static compiler) importing the generated package plus `ruff` F821. **Every port excludes its framework-bound route tier** (TS `routesFile`, C# `RoutesGenerator`, Java `SpringControllerGenerator`, Kotlin `KotlinSpringControllerGenerator`): those imports are not on an in-memory compile's classpath and stubbing them drowns the signal, so that tier is proven by the api-contract integration lane instead. One cross-port rule, not four local concessions. Found 5 further real defects on first run. Boundary detail: `docs/CONFORMANCE.md` → "Split coverage".
-**Key cross-language features shipped:** FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core `UsingEntity` / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal `GET //{id}/` + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete `field.*` subtype write+read round-trips cross-port via the persistence `op: roundtrip` gate; cut `field.byte`/`field.short`/`field.class` non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen); FR-044 Plan 1 (the reporting vocabulary — `dimension.attribute`/`dimension.time`, `measure.aggregate`/`measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value — registered and loader-validated in all five ports behind 27 conformance fixtures + the `codegen-noop` corpus; a report that declares a read-only `source.rdb @kind: view` is lowered to a SQL view by `meta migrate` and read by every port (Plan 2), and no route or typed client is generated for a report yet, see [docs/features/reporting.md](docs/features/reporting.md)).
+**Key cross-language features shipped:** FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core `UsingEntity` / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal `GET //{id}/` + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete `field.*` subtype write+read round-trips cross-port via the persistence `op: roundtrip` gate; cut `field.byte`/`field.short`/`field.class` non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen); FR-044 Plan 1 (the reporting vocabulary — `dimension.attribute`/`dimension.time`, `measure.aggregate`/`measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value — registered and loader-validated in all five ports behind 27 conformance fixtures + the `codegen-noop` corpus; a report that declares a read-only `source.rdb @kind: view` is lowered to a SQL view by `meta migrate` and read by every port (Plan 2), and served by a generated read-only list route in every port, gated by the api-contract `report/` sub-corpus (Plan 3); no client hook, grid or form is generated for a report yet, see [docs/features/reporting.md](docs/features/reporting.md)).
**Latest release: 1.0.13** (2026-10-03) — npm `1.0.13`, PyPI `1.0.13`, NuGet `1.0.13`, Maven Central `8.0.13`. A PATCH: an already-plural entity name (`Stats`, `Settings`) no longer doubles in API-surface names (REST paths, hooks, finders, DbSets) in any port, while default physical table names stay frozen on the old rule; two entities that would share one API name are now a generation error; two Kotlin controller compile fixes (`field.inet` filter ops, `@dbColumnType: uuid` on a string field). Gated by a private `1.0.13-rc.1` build on the adopter estate (`rc-gate.sh` 7/7) and a full `--strict-toolchains` local CI run. The previous release, 1.0.12 (2026-10-02), added `fmt` in every CLI, a deprecated-reference `verify` advisory, the `onLocate` extract hook, and an Exposed 1.x Kotlin output mode.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f15964e7..f6a970a5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -71,8 +71,8 @@ it until 1.1 ships._
`ObjectManager` read a view-backed report (list and count, with filter, sort and limit on the
derived fields; by-id and writes are refused); C# generates a keyless EF Core row type and
`DbContext` mapping for it and Kotlin an Exposed table object. `meta docs` lists the view on
- the agent schema page. **Still absent:** no route, typed client, filter allowlist or api-docs
- entry for a report in any port, no `measure.derived`, and no query-time grouping. Six shared
+ the agent schema page. **Still absent:** no `measure.derived` and no query-time grouping (the
+ next entry says how a report is served). Six shared
persistence scenarios (`report-*.yaml`) and `report-shapes.json` hold the ports to the same
columns; the `metaobjects-authoring` skill now teaches reports (`references/reporting.md`).
Anyone who declared a view-sourced report under the unreleased 1.1 vocabulary will now see a
@@ -84,6 +84,97 @@ it until 1.1 ships._
(`Sale.total`) and reads the same as the bare name in every port. Java OQL
(`executeQuery`) with a report as its result class builds rows from the report's derived
fields.
+- **A view-backed report is served by a generated list route in every port (FR-044).** A
+ concrete `object.report` whose read source is a `source.rdb` of `@kind: view` now gets the
+ read-only REST surface of a keyless projection: `GET //` lists it with the
+ standard `?filter[...]`, `?sort=`, `limit`, `offset` and `withCount=1` (`total` counts groups
+ after filtering), `POST` answers `405 {"error": "method_not_allowed"}`, and `/{id}` is not
+ mounted (a report has no identity). `` is the existing rule, the name snake_cased
+ then pluralized: `InvoicesByMonth` is `/invoices_by_months`. Every derived field whose type
+ has filter operators is filterable and sortable, dimension and measure alike, and a report
+ cannot narrow that set; a field of the `@from` entity the report does not expose is refused
+ with a `400`. No request parameter picks dimensions, measures or a grain. A report with no
+ source, an abstract one, and one over a `materializedView`, `storedProc` or `tableFunction`
+ mount nothing. No vocabulary is added and `metamodelVersion` stays `1.1`. What each port
+ generates for a report ``: TypeScript `.ts`, `.queries.ts` (list only),
+ `.routes.ts` / `.routes.hono.ts`, `.names.ts` and the barrel export; C#
+ `Routes.g.cs` and `