From 2622e7daf0ed710dd0b328e73e63c20b3388b4b2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 9 Oct 2026 11:10:48 -0400 Subject: [PATCH 1/4] feat(reporting): list hook for a served report, correct nested average, string ratio on SQLite (FR-044) Three corrections to FR-044 reporting for 1.1, found when a first real adopter mapped its report pages onto the feature. 1. TypeScript: a served report gets the generated TanStack list hook. The hook generator now gates on a new exported predicate, servesClientHooks (servesReadApi, true for a served report), and writes .hooks.ts plus the .meta.ts descriptor it imports: useList typed with the report's row, filter and sort, and no detail or mutation hook. The grid, grid-hook, Angular and form tiers keep servesClientTier, which is still false for a report. agent/ui.md lists the report. No other port has a client tier, so none gains a file. An owned hook generator keeps its old gate and emits no report hook until resynced; the upgrade guide and CHANGELOG say so. 2. The spec's worked example for the nested average counted the distinct days anyone did, not the days each customer did. daysEngaged is now a distinct count of (programId, customerEmail, weekNumber, dayNumber). The same declaration in the positive conformance fixture, its expected.json, the codegen-noop model and the error fixtures derived from it is corrected, with the per-port accessor tests. A value test on real SQLite and Postgres pins 5 customer-days over 3 starters = 1.667 and the 3 / 3 slip it replaces. 3. A report's decimal fields (avg, ratio, sum of a decimal) reach the wire as strings on SQLite, as on Postgres. SQLite computes a REAL, which the generated route sent as a JSON number although the row type says string. The report routes pass decimalColumns to the read-only mount (Fastify and Hono), which sends a number under one of those keys as its string. Gated by a new TypeScript SQLite lane that boots the emitted report routes and runs the thirteen shared report scenarios plus the decimal's type. Projections on SQLite are unchanged. --- CHANGELOG.md | 72 +++++++++-- .../references/reporting.md | 2 +- .../references/typescript.md | 15 ++- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../migrations/upgrading-within-1.0.md | 10 ++ docs/features/reporting.md | 26 ++-- docs/ports/typescript.md | 14 +- ...-10-04-fr-044-plan-3-report-read-routes.md | 7 +- ...2026-10-02-fr-044-core-reporting-design.md | 12 +- .../codegen/generators/routes.ts | 6 +- .../codegen/runtime/decimal-wire.ts | 32 +++++ .../codegen/runtime/drizzle-fastify/index.ts | 1 + .../drizzle-fastify/mount-read-only.ts | 13 +- .../showcase/codegen/generators/routes.ts | 6 +- .../showcase/codegen/runtime/decimal-wire.ts | 32 +++++ .../codegen/runtime/drizzle-fastify/index.ts | 1 + .../drizzle-fastify/mount-read-only.ts | 13 +- .../references/reporting.md | 2 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../references/reporting.md | 2 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../references/reporting.md | 2 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../references/reporting.md | 2 +- .../references/typescript.md | 15 ++- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../references/reporting.md | 2 +- .../references/typescript.md | 15 ++- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../api-contract-conformance/report/README.md | 7 +- fixtures/codegen-noop/reporting/README.md | 7 +- .../reporting/with/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../input/meta.shop.json | 1 + .../reporting-vocabulary/expected.json | 1 + .../reporting-vocabulary/input/meta.shop.json | 1 + .../ReportingAccessorsTests.cs | 2 +- .../loader/ReportingValidationTest.java | 4 +- .../metaobjects/reporting/ReportingTest.java | 6 +- .../tests/unit/test_reporting_accessors.py | 1 + .../cli/test/unit/reporting-inert.test.ts | 34 +++-- .../src/reference/hooks.ts | 4 +- .../codegen-ts-tanstack/src/tanstack-query.ts | 4 +- .../test/co-emitted-output-compiles.test.ts | 22 ++++ ...ui-tier.test.ts => report-ui-tier.test.ts} | 101 ++++++++++++--- .../packages/codegen-ts/src/api-surface.ts | 52 ++++++-- .../src/generators/agent-ui-page.ts | 27 ++-- .../codegen-ts/src/generators/api-model.ts | 2 +- .../packages/codegen-ts/src/index.ts | 2 +- .../codegen-ts/src/reference/routes-hono.ts | 6 +- .../codegen-ts/src/reference/routes.ts | 6 +- .../src/templates/routes-file-hono.ts | 7 +- .../codegen-ts/src/templates/routes-file.ts | 7 +- .../projection/extract-report-spec.test.ts | 6 +- .../test/projection/routes-file.test.ts | 77 ++++++++++- .../codegen-ts/test/reporting-docs.test.ts | 2 +- .../docs-site/test/reporting-site.test.ts | 2 +- .../src/api-contract-report-sqlite-server.ts | 121 ++++++++++++++++++ .../test/api-contract-report-sqlite.test.ts | 118 +++++++++++++++++ .../test/report-views-pg.test.ts | 58 +++++++++ .../test/report-views-sqlite.test.ts | 53 ++++++++ .../test/reporting-validation.test.ts | 2 + .../packages/runtime-ts/src/decimal-wire.ts | 32 +++++ .../runtime-ts/src/drizzle-fastify/index.ts | 1 + .../src/drizzle-fastify/mount-read-only.ts | 13 +- .../packages/runtime-ts/src/hono/index.ts | 1 + .../runtime-ts/src/hono/mount-read-only.ts | 13 +- .../runtime-ts/test/decimal-wire.test.ts | 108 ++++++++++++++++ .../packages/test-generators/src/routes.ts | 6 +- 92 files changed, 1125 insertions(+), 149 deletions(-) create mode 100644 examples/advanced-modeling/codegen/runtime/decimal-wire.ts create mode 100644 examples/showcase/codegen/runtime/decimal-wire.ts rename server/typescript/packages/codegen-ts-tanstack/test/{report-no-ui-tier.test.ts => report-ui-tier.test.ts} (68%) create mode 100644 server/typescript/packages/integration-tests/src/api-contract-report-sqlite-server.ts create mode 100644 server/typescript/packages/integration-tests/test/api-contract-report-sqlite.test.ts create mode 100644 server/typescript/packages/runtime-ts/src/decimal-wire.ts create mode 100644 server/typescript/packages/runtime-ts/test/decimal-wire.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2260842..807ec81ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -178,7 +178,7 @@ it until 1.1 ships._ nothing for a report before. Java `gen` now joins Kotlin and C# in refusing a served report with a derived field over a `field.object`. In C# a report's enum dimension is sortable (an entity's enum field still is not). A decimal column (`avg`, a ratio, a `sum` of a decimal) - has no cross-port JSON spelling: each port sends its own, and TypeScript sends a string. + has no cross-port JSON spelling: each port sends its own, and TypeScript sends a string (on SQLite as well as Postgres: see **Fixed**). Gated by a new api-contract sub-corpus, `fixtures/api-contract-conformance/report/` (13 scenarios, generated lane, all five ports; the corpus goes from 61 scenarios to 78, the `projection/` sub-corpus gaining four), which @@ -196,7 +196,8 @@ it until 1.1 ships._ routes generator passes them, and its output fails typecheck against the older `MountReadOnlyOptions`. A hand-written generator that gates on `servesReadApi`, or on `!isAbstract` as the `meta generator` scaffold does, now receives a served report's read - model: gate UI output on `servesClientTier`. + model: gate grid or form output on `servesClientTier` (a hook is wanted for a report: see the + list hook below). - **Reports have model and API pages in `meta docs` (FR-044).** Every report gets a model page (its `@from`, its view or "Not served" with the reason, its row scope, and a column table with a definition per column) listed under `## Reports` on the model index, and a page on the @@ -206,14 +207,26 @@ it until 1.1 ships._ gets none. `EntityDocData` gains four optional keys for an owned `docs/entity-page.md` template: `hasReport`, `reportBlock`, `hasReporting`, `reportingBlock`. A model with no reporting nodes renders the same model pages as before. -- **No client hook or other UI-tier output is generated for a report yet (FR-044).** No - TanStack hook, grid, grid hook or form, no Angular service or grid, and `agent/ui.md` lists - no report. In TypeScript the UI-tier generators now gate on a new exported predicate, - `servesClientTier` (`servesReadApi` and not a report); `servesReadApi` is true for a served - report so that its routes and queries emit. **If you own an ejected hook or grid generator - that gates on `servesReadApi`, it will emit for every served report: switch it to - `servesClientTier`.** `hasItemRoute`, `isReport`, `servedReport` and `generatableObjects` are - exported from `@metaobjectsdev/codegen-ts` beside it. +- **A served report gets a generated TanStack list hook in TypeScript (FR-044).** `tanstackQuery()` + writes `.hooks.ts` and the `.meta.ts` descriptor it imports for a served report: + `useList` (or `uses` when the name is not already plural), typed with the report's row + type, its filter type and its sort, and the `Keys` query-key factory with `all`, `lists` and + `list`. It writes no detail hook, no mutation hook, no form and no grid: a report has no item + route and no write, and a grid, form or dashboard over reports waits for the `reporting` + library. `agent/ui.md` lists the report, with a line saying so. An adopter whose pages read + everything through generated hooks no longer swaps a generated hook for a hand-written one + when it replaces a projection with a report. TypeScript is the only port with a generated + client tier, so no other port gains a file. Two predicates, both exported from + `@metaobjectsdev/codegen-ts`: the hook generator gates on `servesClientHooks` (`servesReadApi`, + true for a served report) and the grid generators, the Angular service and grid and the + Angular barrel gate on `servesClientTier` (`servesReadApi` and not a report). `hasItemRoute`, + `isReport`, `servedReport` and `generatableObjects` are exported beside them. + **Upgrading an owned `hooks` generator.** A copy ejected before this release keeps the gate it + was copied with, so it writes no hook for a report until you resync it (`meta eject hooks + --force`, or merge the reference by hand: its filter is now `servesClientHooks(e)`). Nothing + else breaks: an owned `grid`, `grid-hook` or `form` copy needs no change, and a hand-written + generator that gates on `servesReadApi` receives a served report's read model and emits + its list hook, which is what a hook generator wants; gate a grid or form on `servesClientTier`. ### Changed @@ -348,6 +361,45 @@ until you regenerate. ### Fixed +- **TypeScript: a report's decimal fields reach the wire as strings on SQLite, as on Postgres + (FR-044).** A ratio is typed `decimal`, the TypeScript read schema types a decimal as + `string`, and SQLite has no decimal: the view computes a `REAL`, which the driver hands the + route as a JS number. A generated report route on SQLite therefore answered + `"paidShare": 0.4` where Postgres answers `"paidShare": "0.4"`, and the row type said + `string` for both, so `.toFixed(1)` compiled on neither and ran on one. The route is now + corrected, not the type. **Behaviour note:** on SQLite a report's `avg`, ratio and + `sum` of a decimal field arrive as strings (`"0.4"`, `"1.6666666666666667"`, null stays + null); convert with `Number(x)` where you did arithmetic or formatting on the number. The + generated report route passes the decimal field names to the mount as `decimalColumns` + (`mountReadOnlyCrudRoutes` in the Fastify and Hono adapters, new optional option), which sends + a number under one of them as its string. Filtering and sorting on the column are unchanged + and stay numeric, because the view column is still a REAL. The digits are JavaScript's + shortest spelling of the double, not Postgres' fixed scale, and a decimal's digits stay + outside the cross-port contract. Postgres and MySQL are untouched, and so is a projection on + SQLite (released behaviour; its decimals still arrive as numbers). **Upgrading an owned + `routes` or `routes-hono` generator:** a copy ejected before this release passes no + `decimalColumns`, so a SQLite report keeps answering a number until you resync it + (`meta eject routes --force`, `meta eject routes-hono --force`, which also refreshes the + adapter copy under `codegen/runtime/`). Gated by a new TypeScript lane, + `integration-tests/test/api-contract-report-sqlite.test.ts`, which boots the emitted report + routes on a real SQLite and runs the thirteen shared report scenarios as well as the + decimal's wire type; the cross-port corpus still runs TypeScript on Postgres only. +- **The spec's worked example for the nested average counted the wrong thing (FR-044).** + `daysEngaged` was declared as a distinct count of `(programId, weekNumber, dayNumber)` and + `avgDaysPerStarter` as `daysEngaged / starters`. Grouped by program, that numerator is the + number of distinct days anyone did, not the sum over customers of the days each did: for one + customer with three days and two customers sharing one day it gives 3 / 3 = 1.0 where the + answer is (3 + 1 + 1) / 3 = 1.667. The tuple now includes the customer: + `(programId, customerEmail, weekNumber, dayNumber)`. `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` + states why. The same declaration was copied into the conformance fixtures, so the positive + fixture `reporting-vocabulary`, its canonical `expected.json`, the `codegen-noop/reporting` + model and the error-fixture models derived from it carry the four-column tuple, and the + per-port accessor tests and the Java tuple-size message ("@of lists 4 columns") follow. No + loader rule changes, no vocabulary moves and `metamodelVersion` stays `1.1`; a model that + used the three-column form still loads, and keeps counting the distinct days anyone did. + A value test on a real SQLite and a real Postgres holds the example's numbers (5 customer-days + over 3 starters, 1.667), and its slip form (3 / 3) as the thing it replaces. + - **TypeScript, Kotlin, C#: a read-only projection whose key is renamed and whose identity omits `@fields` now serves its item route.** The shape: a view-only `object.projection` that passes the base entity's key through on a field with another name (`regNo` extending `Invoice.id`) and diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index 9709ac661..be0bc0003 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -98,4 +98,4 @@ The files each port generates are in the `metaobjects-codegen` skill's reference ## What a report does not have -No client hook, grid or form is generated for a report in any port yet; you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. +In TypeScript a served report gets a generated list hook; no port generates a grid or a form for a report, and the other ports have no client tier, so there you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index 9a41d39f6..13cfc12ac 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript.md @@ -179,12 +179,15 @@ served like a keyless projection, from a detached read model of its derived fiel view binding, Zod read schema, row type, descriptor, filter and sort allowlists), `.queries.ts` (`list…` only, no by-id), `.routes.ts` (and `.routes.hono.ts` from `routesFileHono()`): GET list, `POST` answers 405, no `/:id`; `.names.ts`; and the barrel -export. Every derived field with filter operators is filterable and sortable. Nothing -from the UI tier is written (no hooks, grid, grid hook or form), and `agent/ui.md` lists -no report: those generators gate on `servesClientTier`, which is false for a report, -while `servesReadApi` is true. An ejected hook generator that still gates on -`servesReadApi` emits a list hook for a report; switch it to `servesClientTier`. A -report with no view source, or an abstract one, generates nothing. The route and +export. Every derived field with filter operators is filterable and sortable. The UI tier +writes the list hook and nothing else: `.hooks.ts` (`useList`, typed with the report's +filter, no detail or mutation hook) and its `.meta.ts` descriptor, and `agent/ui.md` lists +the report. The hook generator gates on `servesClientHooks`, which is true for a served +report; the grid generators gate on `servesClientTier`, which is false, so there is no grid, +grid hook or form. An ejected hook generator keeps the gate it was copied with and writes no +report hook until you resync it (`meta eject`). On SQLite a decimal field of a report (an +`avg`, a ratio) is sent as a string, as on Postgres: the route passes `decimalColumns` to the +mount. A report with no view source, or an abstract one, generates nothing. The route and contract: `references/reporting.md` in the `metaobjects-authoring` skill. The `CREATE VIEW` DDL is generated by `meta migrate` diff --git a/agent-context/skills/metaobjects-runtime-ui/SKILL.md b/agent-context/skills/metaobjects-runtime-ui/SKILL.md index f33c7d1e3..609999dbe 100644 --- a/agent-context/skills/metaobjects-runtime-ui/SKILL.md +++ b/agent-context/skills/metaobjects-runtime-ui/SKILL.md @@ -103,8 +103,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at `/order_summaries` either way. The generated web-client hooks and grids build their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. -A view-backed `object.report` has a generated list route at the same rule and no -generated hook, grid or form yet. +A view-backed `object.report` has a generated list route at the same rule and, in +TypeScript, a generated list hook (`useList`); it has no grid, form or detail +hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/docs/features/migrations/upgrading-within-1.0.md b/docs/features/migrations/upgrading-within-1.0.md index 982b98bb2..93e6589b9 100644 --- a/docs/features/migrations/upgrading-within-1.0.md +++ b/docs/features/migrations/upgrading-within-1.0.md @@ -56,5 +56,15 @@ cross. `origin.*` `@via` path follows is now the one the metadata names. A view that used to join on the other key is rewritten by the next `meta migrate`, and it can return different rows. Read that migration before applying it. +- **Reports (1.1).** An owned (ejected) generator keeps the logic it was copied with, so a copy + from before 1.1 produces no report output, or the wrong output, until you resync it. For a + served `object.report` that means: `entity`, `queries`, `names` and `barrel` write no report + files; `routes` and `routes-hono` mount `GET /:id` and the item refusals on a report, and on + SQLite send a ratio as a number; `hooks` writes no report hook, so a page that calls + `useList` has nothing to import. `meta eject --list` shows which copies differ; + `meta eject --force` takes the new reference, or three-way merge a copy you edited. An + owned `mount-read-only.ts` under `codegen/runtime/` needs the same resync (`itemRoutes`, + `resource` and `decimalColumns`). Only TypeScript has a generated client tier; the other + ports gain a route and a row type and no client file. - **New advisories (1.0.9).** `meta verify` lists foreign keys with no covering index. It never fails a build; add an `index.lookup` (and a migration) where the join matters. diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 6920b65a4..ab4455eff 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -338,24 +338,29 @@ encodings"), applied to the column types above: | `currency` | integer minor units | | `date` (a time dimension at `day`, `week`, `month`, `quarter` or `year`) | `YYYY-MM-DD`, the first day of the bucket | | `timestamp` (a time dimension at `hour`) | an instant with `Z`, or naive without for a `@localTime` field | -| `decimal` (`avg`, a ratio, a `sum` of a decimal) | **the port's own decimal spelling.** Not part of the contract: precision is the engine's, and the ports do not agree on one JSON form (TypeScript sends a string) | +| `decimal` (`avg`, a ratio, a `sum` of a decimal) | **the port's own decimal spelling.** Not part of the contract: precision is the engine's, and the ports do not agree on one JSON form (TypeScript sends a string, on SQLite as on Postgres: SQLite computes the value as a REAL and the generated route sends it as its string) | | a null value | `null`, with the key present | **What each port generates for a served report.** `` is the report's name. | Port | Generated | |---|---| -| TypeScript | `.ts` (Drizzle view binding, Zod read schema, row type, descriptor, filter and sort allowlists), `.queries.ts` (the list query only), `.routes.ts` (and `.routes.hono.ts` from the Hono routes generator), `.names.ts`, the barrel export | +| TypeScript | `.ts` (Drizzle view binding, Zod read schema, row type, descriptor, filter and sort allowlists), `.queries.ts` (the list query only), `.routes.ts` (and `.routes.hono.ts` from the Hono routes generator), `.names.ts`, `.hooks.ts` (the TanStack list hook only) with the `.meta.ts` descriptor it imports, the barrel export | | C# | `.g.cs` (keyless row class) and its `DbContext` mapping, `Routes.g.cs`, `FilterAllowlist.g.cs` | | Java | `Dto`, `Repository` (`list` and `count` only), `FilterAllowlist`, `Controller` | | Kotlin | `Table` (Exposed), the `` data class, `FilterAllowlist`, `Controller` | | Python | `.py` (Pydantic row model), `_filter_allowlist.py`, `_router.py`, `_names.py` | No port generates a by-id query, a `findById` on a repository seam, a create or update -schema, a write method, a form, a grid or a client hook for a report. In TypeScript the UI -tier asks a separate predicate, `servesClientTier`, which is false for a report while -`servesReadApi` is true; a hook generator you own that still gates on `servesReadApi` will -emit a list hook for a served report, so switch it to `servesClientTier`. TypeScript and +schema, a write method, a form or a grid for a report. Only TypeScript has a generated client +tier, so only TypeScript generates a hook: the list hook, `useList` (or `uses` when the +name is not already plural), typed with the report's row, its filter and its sort. It has no +detail hook and no mutation hook, because the report has no item route and no write. The +hook generator asks `servesClientHooks`, which is true for a served report; the grid +generators ask `servesClientTier`, which is false for one. A hook generator you own was +copied with the predicate it had then and emits no report hook until you resync it +(`meta eject hooks`, then merge your edits); the other UI generators you own need +nothing. TypeScript and Python write a names artifact because their read model flows through the names generator; C#, Java and Kotlin bind the view and its columns by literal. These are reference helpers, not core: copy and own them with your port's `eject`. @@ -423,8 +428,9 @@ the bodies are valid under MySQL's default `ONLY_FULL_GROUP_BY`. router drops only `object` fields, so it would list a `map` one. The corpus has no array or map dimension, so none of this is asserted: do not rely on filtering or sorting one across ports. -- **No generated client.** A served report has a route and a row type; a hook, grid or form - for it is yours to write until the UI tier covers reports. +- **A list hook and nothing else of a client.** A served report has a route, a row type and, + in TypeScript, the list hook. A grid, a form or a dashboard over reports is yours to write + until the `reporting` library covers it. - **With `@via`, `@of` must name an entity that has the field** (declared on it or inherited by it); naming a base of the reached entity for a field only the subtype declares loads and then fails `meta migrate`. The quiet form of the same rule: a `@via` dimension reads its field from @@ -451,7 +457,9 @@ on a measure and an enum dimension, paging over groups, the three field-naming ` `405` on `POST`, and `404` on every verb at `/{id}`. The corpus model carries one sourceless report, so a port that serves every report it finds fails. No scenario asserts a decimal's spelling or a timestamp literal. TypeScript and C# run the scenarios against the real views on -Postgres; Java, Kotlin and Python serve seeded rows behind their repository seam, and a +Postgres, and TypeScript runs the same thirteen against SQLite as well +(`api-contract-report-sqlite.test.ts`), where it also holds that a ratio reaches the wire as a +string, as it does on Postgres; Java, Kotlin and Python serve seeded rows behind their repository seam, and a TypeScript test holds those rows equal to what the views return. ## The rules the loader enforces diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index 6e6bdad56..f854c81fe 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -311,14 +311,18 @@ like a keyless read-only projection. For a report `` the generators you have | `queriesFile()` | `.queries.ts` | `list` only. No by-id query | | `routesFile()` / `routesFileHono()` | `.routes.ts` / `.routes.hono.ts` | GET list; `POST` answers 405; no `/:id` route | | `namesFile()` | `.names.ts` | the view and column names | +| `tanstackQuery()` | `.hooks.ts`, `.meta.ts` | the list hook (`useList`, with the report's filter type) and its descriptor. No detail hook, no mutation | | `barrel()` | `index.ts` | one export | Every derived field with filter operators is filterable and sortable. A decimal column (an -`avg`, a ratio) is a `string` in the read schema and on the wire. The UI tier writes nothing -for a report (no hooks, grid, grid hook or form) and `agent/ui.md` lists none: those -generators gate on `servesClientTier`, which is false for a report, while `servesReadApi` is -true. If you own an ejected hook generator that gates on `servesReadApi`, switch it to -`servesClientTier`, or it emits a list hook for every served report. A report with no view +`avg`, a ratio) is a `string` in the read schema and on the wire, on SQLite too: SQLite +computes it as a REAL, so on that dialect the generated route passes the decimal field names to +the mount as `decimalColumns` and the mount sends each as its string. The UI tier writes the +list hook and nothing else for a report (no grid, grid hook or form), and `agent/ui.md` lists +it: the hook generator gates on `servesClientHooks`, which is true for a served report, and +the grid generators on `servesClientTier`, which is false. An ejected hook generator keeps +the gate it was copied with and writes no report hook until you resync it with `meta eject`. +A report with no view source, or an abstract one, generates nothing. The contract is in [reporting](../features/reporting.md#how-a-report-is-served). 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 029bc3449..f9b6a2aae 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 @@ -1182,16 +1182,17 @@ Ruled 2026-10-04, before execution. The questions are kept below as asked. 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. +6. **Different from the plan as first written, and reversed for the list hook before 1.1 (see the amendment below).** 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. ### As built What execution changed or added, beyond the answers above. The tables and tasks were edited to match. -- **A. No UI tier for a report (answer 6).** TypeScript gates the UI tier (the TanStack generators and the source-only Angular generators) on a new `servesClientTier` predicate, while `servesReadApi` is true for a served report. `agent/ui.md` lists no report. An adopter-owned (ejected) hook generator that still gates on `servesReadApi` would emit for a report; the CHANGELOG tells owners to switch to `servesClientTier`. +- **A. No UI tier for a report (answer 6). Amended before the 1.1 release: the list hook is back.** A first real adopter mapped its report pages onto the feature and found that every page reads through generated hooks, so a projection replaced by a report lost a generated hook and gained a hand-written one. The TanStack hook generator now gates on a new `servesClientHooks` (`servesReadApi`, true for a served report) and emits the list hook and its `.meta.ts` descriptor; the grid, grid-hook, Angular and form generators still gate on `servesClientTier`, and `agent/ui.md` lists the report. The text below is the decision as it was taken. +- **A (as first decided). No UI tier for a report (answer 6).** TypeScript gates the UI tier (the TanStack generators and the source-only Angular generators) on a new `servesClientTier` predicate, while `servesReadApi` is true for a served report. `agent/ui.md` lists no report. An adopter-owned (ejected) hook generator that still gates on `servesReadApi` would emit for a report; the CHANGELOG tells owners to switch to `servesClientTier`. - **B. The item-route rule (answer 4).** TypeScript `hasItemRoute` and Python `has_item_route`: false for every report; otherwise true when the by-id column exists on the object (a declared primary identity, or no identity and a field named `id`); false otherwise. Only a read-only projection with no identity and no `id` field loses its `/{id}` routes, by-id query and, in TypeScript, its detail hook. This differs from C#, Java and Kotlin, which require a declared single-column identity and are unchanged. -- **C. Decimal (answer 5).** A `field.decimal` in a TypeScript view read schema is `z.string()`, for reports and projections. +- **C. Decimal (answer 5).** A `field.decimal` in a TypeScript view read schema is `z.string()`, for reports and projections. On SQLite the generated report route sends a report's decimal as that string too (`decimalColumns` on the read-only mount, added before the 1.1 release): the view computes a REAL there, and the route used to send it as a number. - **D. API docs accuracy (new, behaviour changes 4 and 5).** A read-only object's API unit documents exactly what is generated. In TypeScript a read-only projection's page loses the write and (when keyless) by-id symbols that were never generated. In Python a read-only projection's unit, which had only its model symbol, gains the repository seam, `GET `, `GET /{id}` when it has an item route, and the filter allowlist. - **E. Per-port fixes found on the way.** C#: a report's enum dimension is sortable (report-only; an entity's enum field is still not sortable in C#). Java: the filter-allowlist generator handles more than ten filterable fields (`Map.ofEntries`, behaviour change 7). Kotlin: a filter on a decimal or float column no longer throws, which changes the generated controller of any object with such a field, filterable or not, and of a TPH base for a float only (behaviour change 6); reads of a field whose name needs `safeColumnProperty` compile, byte-neutral for every other name. - **F. A non-`id` key on a read-only projection (new, behaviour change 8; found by the whole-branch review).** TypeScript's routes templates pass `idColumn` to the read-only mount when the by-id field is not `id`, through a new `itemRouteField` (`hasItemRoute` is now "it is defined"). Both read-only mounts answer `404 not_found` when the view has no column under `idColumn`. C#, Java, Kotlin and Python are not changed by this. diff --git a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md index 81c63777c..cc8b10d1b 100644 --- a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md +++ b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md @@ -183,8 +183,8 @@ because time owns `@grains` and truncation behaviour that attribute dimensions d - measure.aggregate: { name: buyers, "@agg": count, "@distinct": true, "@of": "Purchase.customerEmail", "@segment": active } - measure.aggregate: { name: daysEngaged, "@agg": count, "@distinct": true, - "@of": ["WorkoutEvent.programId", "WorkoutEvent.weekNumber", - "WorkoutEvent.dayNumber"] } + "@of": ["WorkoutEvent.programId", "WorkoutEvent.customerEmail", + "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber"] } - measure.aggregate: { name: revenue, "@agg": sum, "@of": "Purchase.amountCents", "@segment": active } - measure.aggregate: { name: starters, "@agg": count, "@distinct": true, @@ -195,6 +195,14 @@ because time owns `@grains` and truncation behaviour that attribute dimensions d - measure.derived: { name: netRevenue, "@expr": { fn: sub, args: [ ... ] } } ``` +`daysEngaged` counts customer-days: the tuple includes the customer, so a report grouped by +program counts each customer's distinct days and sums them over customers. Without +`customerEmail` the tuple would count the distinct days that *anyone* did. Take one customer +with three days and two customers who share one day: the right numerator is 3 + 1 + 1 = 5, so +`avgDaysPerStarter` is 5 / 3 = 1.667, where the tuple without the customer gives 3 / 3 = 1.0. +`programId` stays in the tuple so the measure is also right in a report that is not grouped by +program. + - `measure.aggregate` — `@agg` in `count | sum | avg | min | max`; `@of` one column or a list (a list is legal only with `@distinct: true` and means a distinct count of the tuple); optional `@filter` (`attr.filter`) or `@segment` (a named segment, R3). diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 6be087bd2..425fcfcdb 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -73,6 +73,7 @@ import { isWriteThrough, isReport, itemRouteField, + reportDecimalColumns, DEFAULT_ID_FIELD, servesReadApi, formatTs, @@ -154,12 +155,15 @@ function renderRoutes( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. + // A decimal a SQLite report computes is a REAL; the mount sends it as the string it is elsewhere. + const decimalColumns = reportDecimalColumns(entity, ctx.dialect); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + - (report ? `\n${indent}resource: "report",` : ""); + (report ? `\n${indent}resource: "report",` : "") + + (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — diff --git a/examples/advanced-modeling/codegen/runtime/decimal-wire.ts b/examples/advanced-modeling/codegen/runtime/decimal-wire.ts new file mode 100644 index 000000000..c71e7034c --- /dev/null +++ b/examples/advanced-modeling/codegen/runtime/decimal-wire.ts @@ -0,0 +1,32 @@ +// The wire spelling of a field.decimal that a read-only mount SENDS. +// +// A decimal is a string on the wire: it is the string Postgres `numeric` and MySQL `DECIMAL` +// really return, and the string the TypeScript read schema types it as. SQLite has no +// decimal. A decimal field on a table is a `text` column and stays a string, but the value +// a report's view computes (a ratio, an average, a sum of a decimal field) is a REAL, which +// a SQLite driver hands back as a JS number. Sent as it is, the same route answers +// `"paidShare": "0.4"` on Postgres and `"paidShare": 0.4` on SQLite, and the type says +// `string` for both. +// +// So a mount takes the NAMES of its decimal fields (`decimalColumns`, which the generated +// report route passes on SQLite) and sends a number among them as its string. Which keys are +// decimals comes from the caller, never from the shape of a value: an integer column is not +// a decimal because it is a number. A string, a null and anything else pass through. The +// digits are JavaScript's own shortest round-trip spelling of the double (`1.6666666666666667`); +// like every decimal's spelling on the wire, they are the engine's and not part of the contract. + +/** Rewrite a number under any of `columns` to its string. Returns `undefined` when there is nothing to do. */ +export function decimalWire( + columns: readonly string[] | undefined, +): ((row: unknown) => unknown) | undefined { + if (columns === undefined || columns.length === 0) return undefined; + return (row) => { + if (row === null || typeof row !== "object") return row; + const out = { ...(row as Record) }; + for (const c of columns) { + const v = out[c]; + if (typeof v === "number") out[c] = String(v); + } + return out; + }; +} diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts index e323c2914..734594217 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts @@ -34,6 +34,7 @@ import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js"; export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js"; +export { decimalWire } from "../decimal-wire.js"; // --------------------------------------------------------------------------- // Loose types — we don't bind to a specific Drizzle backend so the helper 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 4424c8dad..b4f926228 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 @@ -6,6 +6,7 @@ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; +import { decimalWire } from "../decimal-wire.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views @@ -49,6 +50,14 @@ export interface MountReadOnlyOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; + /** + * The names of the view's decimal fields, for a dialect that has no decimal. SQLite hands + * a computed decimal (a report's ratio, average or sum) back as a REAL, a JS number, where + * Postgres and MySQL return the string the read schema types it as. A number under one of + * these keys is sent as its string, so the route answers the same on every engine. The + * generated route passes it for a report on SQLite and omits it otherwise. Default none. + */ + readonly decimalColumns?: readonly string[]; } const rejectMutation = (resource: string) => async ( @@ -150,7 +159,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const viewName = resolveViewName(view); const useRawSql = isEmptyColumnView(view) && !!viewName; // The raw-SQL branch has no declared columns, so nothing names a timestamp there. - const toWire = timestampWire(view); + const timestamps = timestampWire(view); + const decimals = decimalWire(opts.decimalColumns); + const toWire = decimals === undefined ? timestamps : (row: unknown) => decimals(timestamps(row)); // ── List ────────────────────────────────────────────────────────────────── fastify.get(path, ro, async (req, reply) => { diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index 6be087bd2..425fcfcdb 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -73,6 +73,7 @@ import { isWriteThrough, isReport, itemRouteField, + reportDecimalColumns, DEFAULT_ID_FIELD, servesReadApi, formatTs, @@ -154,12 +155,15 @@ function renderRoutes( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. + // A decimal a SQLite report computes is a REAL; the mount sends it as the string it is elsewhere. + const decimalColumns = reportDecimalColumns(entity, ctx.dialect); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + - (report ? `\n${indent}resource: "report",` : ""); + (report ? `\n${indent}resource: "report",` : "") + + (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — diff --git a/examples/showcase/codegen/runtime/decimal-wire.ts b/examples/showcase/codegen/runtime/decimal-wire.ts new file mode 100644 index 000000000..c71e7034c --- /dev/null +++ b/examples/showcase/codegen/runtime/decimal-wire.ts @@ -0,0 +1,32 @@ +// The wire spelling of a field.decimal that a read-only mount SENDS. +// +// A decimal is a string on the wire: it is the string Postgres `numeric` and MySQL `DECIMAL` +// really return, and the string the TypeScript read schema types it as. SQLite has no +// decimal. A decimal field on a table is a `text` column and stays a string, but the value +// a report's view computes (a ratio, an average, a sum of a decimal field) is a REAL, which +// a SQLite driver hands back as a JS number. Sent as it is, the same route answers +// `"paidShare": "0.4"` on Postgres and `"paidShare": 0.4` on SQLite, and the type says +// `string` for both. +// +// So a mount takes the NAMES of its decimal fields (`decimalColumns`, which the generated +// report route passes on SQLite) and sends a number among them as its string. Which keys are +// decimals comes from the caller, never from the shape of a value: an integer column is not +// a decimal because it is a number. A string, a null and anything else pass through. The +// digits are JavaScript's own shortest round-trip spelling of the double (`1.6666666666666667`); +// like every decimal's spelling on the wire, they are the engine's and not part of the contract. + +/** Rewrite a number under any of `columns` to its string. Returns `undefined` when there is nothing to do. */ +export function decimalWire( + columns: readonly string[] | undefined, +): ((row: unknown) => unknown) | undefined { + if (columns === undefined || columns.length === 0) return undefined; + return (row) => { + if (row === null || typeof row !== "object") return row; + const out = { ...(row as Record) }; + for (const c of columns) { + const v = out[c]; + if (typeof v === "number") out[c] = String(v); + } + return out; + }; +} diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/index.ts b/examples/showcase/codegen/runtime/drizzle-fastify/index.ts index e323c2914..734594217 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/index.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/index.ts @@ -34,6 +34,7 @@ import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js"; export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js"; +export { decimalWire } from "../decimal-wire.js"; // --------------------------------------------------------------------------- // Loose types — we don't bind to a specific Drizzle backend so the helper 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 4424c8dad..b4f926228 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts @@ -6,6 +6,7 @@ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; +import { decimalWire } from "../decimal-wire.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views @@ -49,6 +50,14 @@ export interface MountReadOnlyOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; + /** + * The names of the view's decimal fields, for a dialect that has no decimal. SQLite hands + * a computed decimal (a report's ratio, average or sum) back as a REAL, a JS number, where + * Postgres and MySQL return the string the read schema types it as. A number under one of + * these keys is sent as its string, so the route answers the same on every engine. The + * generated route passes it for a report on SQLite and omits it otherwise. Default none. + */ + readonly decimalColumns?: readonly string[]; } const rejectMutation = (resource: string) => async ( @@ -150,7 +159,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const viewName = resolveViewName(view); const useRawSql = isEmptyColumnView(view) && !!viewName; // The raw-SQL branch has no declared columns, so nothing names a timestamp there. - const toWire = timestampWire(view); + const timestamps = timestampWire(view); + const decimals = decimalWire(opts.decimalColumns); + const toWire = decimals === undefined ? timestamps : (row: unknown) => decimals(timestamps(row)); // ── List ────────────────────────────────────────────────────────────────── fastify.get(path, ro, async (req, reply) => { diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9709ac661..be0bc0003 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -98,4 +98,4 @@ The files each port generates are in the `metaobjects-codegen` skill's reference ## What a report does not have -No client hook, grid or form is generated for a report in any port yet; you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. +In TypeScript a served report gets a generated list hook; no port generates a grid or a form for a report, and the other ports have no client tier, so there you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index f33c7d1e3..609999dbe 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -103,8 +103,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at `/order_summaries` either way. The generated web-client hooks and grids build their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. -A view-backed `object.report` has a generated list route at the same rule and no -generated hook, grid or form yet. +A view-backed `object.report` has a generated list route at the same rule and, in +TypeScript, a generated list hook (`useList`); it has no grid, form or detail +hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9709ac661..be0bc0003 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -98,4 +98,4 @@ The files each port generates are in the `metaobjects-codegen` skill's reference ## What a report does not have -No client hook, grid or form is generated for a report in any port yet; you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. +In TypeScript a served report gets a generated list hook; no port generates a grid or a form for a report, and the other ports have no client tier, so there you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index f33c7d1e3..609999dbe 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -103,8 +103,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at `/order_summaries` either way. The generated web-client hooks and grids build their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. -A view-backed `object.report` has a generated list route at the same rule and no -generated hook, grid or form yet. +A view-backed `object.report` has a generated list route at the same rule and, in +TypeScript, a generated list hook (`useList`); it has no grid, form or detail +hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9709ac661..be0bc0003 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -98,4 +98,4 @@ The files each port generates are in the `metaobjects-codegen` skill's reference ## What a report does not have -No client hook, grid or form is generated for a report in any port yet; you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. +In TypeScript a served report gets a generated list hook; no port generates a grid or a form for a report, and the other ports have no client tier, so there you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index f33c7d1e3..609999dbe 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -103,8 +103,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at `/order_summaries` either way. The generated web-client hooks and grids build their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. -A view-backed `object.report` has a generated list route at the same rule and no -generated hook, grid or form yet. +A view-backed `object.report` has a generated list route at the same rule and, in +TypeScript, a generated list hook (`useList`); it has no grid, form or detail +hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9709ac661..be0bc0003 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -98,4 +98,4 @@ The files each port generates are in the `metaobjects-codegen` skill's reference ## What a report does not have -No client hook, grid or form is generated for a report in any port yet; you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. +In TypeScript a served report gets a generated list hook; no port generates a grid or a form for a report, and the other ports have no client tier, so there you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md index 9a41d39f6..13cfc12ac 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -179,12 +179,15 @@ served like a keyless projection, from a detached read model of its derived fiel view binding, Zod read schema, row type, descriptor, filter and sort allowlists), `.queries.ts` (`list…` only, no by-id), `.routes.ts` (and `.routes.hono.ts` from `routesFileHono()`): GET list, `POST` answers 405, no `/:id`; `.names.ts`; and the barrel -export. Every derived field with filter operators is filterable and sortable. Nothing -from the UI tier is written (no hooks, grid, grid hook or form), and `agent/ui.md` lists -no report: those generators gate on `servesClientTier`, which is false for a report, -while `servesReadApi` is true. An ejected hook generator that still gates on -`servesReadApi` emits a list hook for a report; switch it to `servesClientTier`. A -report with no view source, or an abstract one, generates nothing. The route and +export. Every derived field with filter operators is filterable and sortable. The UI tier +writes the list hook and nothing else: `.hooks.ts` (`useList`, typed with the report's +filter, no detail or mutation hook) and its `.meta.ts` descriptor, and `agent/ui.md` lists +the report. The hook generator gates on `servesClientHooks`, which is true for a served +report; the grid generators gate on `servesClientTier`, which is false, so there is no grid, +grid hook or form. An ejected hook generator keeps the gate it was copied with and writes no +report hook until you resync it (`meta eject`). On SQLite a decimal field of a report (an +`avg`, a ratio) is sent as a string, as on Postgres: the route passes `decimalColumns` to the +mount. A report with no view source, or an abstract one, generates nothing. The route and contract: `references/reporting.md` in the `metaobjects-authoring` skill. The `CREATE VIEW` DDL is generated by `meta migrate` diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index f33c7d1e3..609999dbe 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -103,8 +103,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at `/order_summaries` either way. The generated web-client hooks and grids build their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. -A view-backed `object.report` has a generated list route at the same rule and no -generated hook, grid or form yet. +A view-backed `object.report` has a generated list route at the same rule and, in +TypeScript, a generated list hook (`useList`); it has no grid, form or detail +hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9709ac661..be0bc0003 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -98,4 +98,4 @@ The files each port generates are in the `metaobjects-codegen` skill's reference ## What a report does not have -No client hook, grid or form is generated for a report in any port yet; you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. +In TypeScript a served report gets a generated list hook; no port generates a grid or a form for a report, and the other ports have no client tier, so there you get the route and the row type. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md index 9a41d39f6..13cfc12ac 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -179,12 +179,15 @@ served like a keyless projection, from a detached read model of its derived fiel view binding, Zod read schema, row type, descriptor, filter and sort allowlists), `.queries.ts` (`list…` only, no by-id), `.routes.ts` (and `.routes.hono.ts` from `routesFileHono()`): GET list, `POST` answers 405, no `/:id`; `.names.ts`; and the barrel -export. Every derived field with filter operators is filterable and sortable. Nothing -from the UI tier is written (no hooks, grid, grid hook or form), and `agent/ui.md` lists -no report: those generators gate on `servesClientTier`, which is false for a report, -while `servesReadApi` is true. An ejected hook generator that still gates on -`servesReadApi` emits a list hook for a report; switch it to `servesClientTier`. A -report with no view source, or an abstract one, generates nothing. The route and +export. Every derived field with filter operators is filterable and sortable. The UI tier +writes the list hook and nothing else: `.hooks.ts` (`useList`, typed with the report's +filter, no detail or mutation hook) and its `.meta.ts` descriptor, and `agent/ui.md` lists +the report. The hook generator gates on `servesClientHooks`, which is true for a served +report; the grid generators gate on `servesClientTier`, which is false, so there is no grid, +grid hook or form. An ejected hook generator keeps the gate it was copied with and writes no +report hook until you resync it (`meta eject`). On SQLite a decimal field of a report (an +`avg`, a ratio) is sent as a string, as on Postgres: the route passes `decimalColumns` to the +mount. A report with no view source, or an abstract one, generates nothing. The route and contract: `references/reporting.md` in the `metaobjects-authoring` skill. The `CREATE VIEW` DDL is generated by `meta migrate` diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index f33c7d1e3..609999dbe 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -103,8 +103,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at `/order_summaries` either way. The generated web-client hooks and grids build their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. -A view-backed `object.report` has a generated list route at the same rule and no -generated hook, grid or form yet. +A view-backed `object.report` has a generated list route at the same rule and, in +TypeScript, a generated list hook (`useList`); it has no grid, form or detail +hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/api-contract-conformance/report/README.md b/fixtures/api-contract-conformance/report/README.md index 599094661..9f40ecade 100644 --- a/fixtures/api-contract-conformance/report/README.md +++ b/fixtures/api-contract-conformance/report/README.md @@ -32,8 +32,9 @@ object uses (`InvoicesByMonth` is `/api/invoices_by_months`). `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. +- **No grid or form is generated for a report in any port.** The read route and its row type + are the surface in every port; TypeScript, the one port with a client tier, also generates + the list hook. The corpus gates the route, not the hook. - **An enum dimension sorts and filters like any other.** `Invoice.status` is a `field.enum` (`OPEN`, `PAID`, `VOID`, declared in alphabetical order so the stored text and the declared order agree), so `InvoiceStatusTotals.status` is an enum dimension: `?sort=status:asc|desc` is @@ -43,6 +44,8 @@ object uses (`InvoicesByMonth` is `/api/invoices_by_months`). - **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. + TypeScript sends a string, on SQLite as well as Postgres, and holds that in its own SQLite lane + (`integration-tests/test/api-contract-report-sqlite.test.ts`), outside this cross-port corpus. - 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. diff --git a/fixtures/codegen-noop/reporting/README.md b/fixtures/codegen-noop/reporting/README.md index 37f679069..ffffd658a 100644 --- a/fixtures/codegen-noop/reporting/README.md +++ b/fixtures/codegen-noop/reporting/README.md @@ -21,8 +21,8 @@ these places and no others: `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. + `StoreTotals.names.ts`. From the client UI tier it writes the list hook `StoreTotals.hooks.ts` + and its descriptor `StoreTotals.meta.ts` and nothing else (no grid, grid hook or form). - C# codegen (`dotnet meta gen`) writes three extra files: the keyless row class `StoreTotals.g.cs`, `StoreTotalsRoutes.g.cs` (GET list, `POST` answers 405, no `{id}` route) and `StoreTotalsFilterAllowlist.g.cs`; and two extra lines in `AppDbContext.g.cs` (a `DbSet` @@ -44,7 +44,8 @@ these places and no others: What stays inert: a report with no read-only source (`ProgramEngagement`, `DailyRevenue`), in every generator, migration and runtime (its only output is the model page above); the client -UI tier for every report, in every port. No port but TypeScript emits SQL for a report +UI tier for every unserved report, and every port's client tier but the TypeScript list hook +(no other port has one). No port but TypeScript emits SQL for a report (ADR-0015). The per-port tests that hold this: | Port | Test | diff --git a/fixtures/codegen-noop/reporting/with/meta.shop.json b/fixtures/codegen-noop/reporting/with/meta.shop.json index 1c3b18b9a..b55b953ac 100644 --- a/fixtures/codegen-noop/reporting/with/meta.shop.json +++ b/fixtures/codegen-noop/reporting/with/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-dimension-grain-on-date/input/meta.shop.json b/fixtures/conformance/error-dimension-grain-on-date/input/meta.shop.json index 69af9ad6d..c68c9885f 100644 --- a/fixtures/conformance/error-dimension-grain-on-date/input/meta.shop.json +++ b/fixtures/conformance/error-dimension-grain-on-date/input/meta.shop.json @@ -283,6 +283,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-dimension-of-unresolved/input/meta.shop.json b/fixtures/conformance/error-dimension-of-unresolved/input/meta.shop.json index dc259d733..fa57959e4 100644 --- a/fixtures/conformance/error-dimension-of-unresolved/input/meta.shop.json +++ b/fixtures/conformance/error-dimension-of-unresolved/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-dimension-time-not-temporal/input/meta.shop.json b/fixtures/conformance/error-dimension-time-not-temporal/input/meta.shop.json index 2cfd0d84a..839baacf9 100644 --- a/fixtures/conformance/error-dimension-time-not-temporal/input/meta.shop.json +++ b/fixtures/conformance/error-dimension-time-not-temporal/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-dimension-via-to-many/input/meta.shop.json b/fixtures/conformance/error-dimension-via-to-many/input/meta.shop.json index ccfda5cde..53269be03 100644 --- a/fixtures/conformance/error-dimension-via-to-many/input/meta.shop.json +++ b/fixtures/conformance/error-dimension-via-to-many/input/meta.shop.json @@ -280,6 +280,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-measure-distinct-not-count/input/meta.shop.json b/fixtures/conformance/error-measure-distinct-not-count/input/meta.shop.json index 318d0aca9..fc603cd59 100644 --- a/fixtures/conformance/error-measure-distinct-not-count/input/meta.shop.json +++ b/fixtures/conformance/error-measure-distinct-not-count/input/meta.shop.json @@ -274,6 +274,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-measure-of-foreign/input/meta.shop.json b/fixtures/conformance/error-measure-of-foreign/input/meta.shop.json index c8d2b8aa8..d7dcccd01 100644 --- a/fixtures/conformance/error-measure-of-foreign/input/meta.shop.json +++ b/fixtures/conformance/error-measure-of-foreign/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-measure-segment-unresolved/input/meta.shop.json b/fixtures/conformance/error-measure-segment-unresolved/input/meta.shop.json index 4ad29abc6..7b53438aa 100644 --- a/fixtures/conformance/error-measure-segment-unresolved/input/meta.shop.json +++ b/fixtures/conformance/error-measure-segment-unresolved/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-measure-sum-non-numeric/input/meta.shop.json b/fixtures/conformance/error-measure-sum-non-numeric/input/meta.shop.json index d5d7d81af..44e3809a8 100644 --- a/fixtures/conformance/error-measure-sum-non-numeric/input/meta.shop.json +++ b/fixtures/conformance/error-measure-sum-non-numeric/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-measure-tuple-without-distinct/input/meta.shop.json b/fixtures/conformance/error-measure-tuple-without-distinct/input/meta.shop.json index 97a575f68..69b0cfdb0 100644 --- a/fixtures/conformance/error-measure-tuple-without-distinct/input/meta.shop.json +++ b/fixtures/conformance/error-measure-tuple-without-distinct/input/meta.shop.json @@ -272,6 +272,7 @@ "@agg": "count", "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-ratio-operand-not-aggregate/input/meta.shop.json b/fixtures/conformance/error-ratio-operand-not-aggregate/input/meta.shop.json index 5ad4d31c9..c25478e83 100644 --- a/fixtures/conformance/error-ratio-operand-not-aggregate/input/meta.shop.json +++ b/fixtures/conformance/error-ratio-operand-not-aggregate/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-relative-date-bad-duration/input/meta.shop.json b/fixtures/conformance/error-relative-date-bad-duration/input/meta.shop.json index 580cbac4a..720722fe3 100644 --- a/fixtures/conformance/error-relative-date-bad-duration/input/meta.shop.json +++ b/fixtures/conformance/error-relative-date-bad-duration/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-relative-date-datagrid-host/input/meta.shop.json b/fixtures/conformance/error-relative-date-datagrid-host/input/meta.shop.json index ac712d11e..92e30fc06 100644 --- a/fixtures/conformance/error-relative-date-datagrid-host/input/meta.shop.json +++ b/fixtures/conformance/error-relative-date-datagrid-host/input/meta.shop.json @@ -298,6 +298,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json b/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json index 7d064af7d..22bc0a0b0 100644 --- a/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json +++ b/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-relative-date-origin-host/input/meta.shop.json b/fixtures/conformance/error-relative-date-origin-host/input/meta.shop.json index 35708e927..8237ccbdc 100644 --- a/fixtures/conformance/error-relative-date-origin-host/input/meta.shop.json +++ b/fixtures/conformance/error-relative-date-origin-host/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-relative-date-wrong-host/input/meta.shop.json b/fixtures/conformance/error-relative-date-wrong-host/input/meta.shop.json index e6bba0cfe..43f801112 100644 --- a/fixtures/conformance/error-relative-date-wrong-host/input/meta.shop.json +++ b/fixtures/conformance/error-relative-date-wrong-host/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-declares-field/input/meta.shop.json b/fixtures/conformance/error-report-declares-field/input/meta.shop.json index 9c289ba39..c4d71ee03 100644 --- a/fixtures/conformance/error-report-declares-field/input/meta.shop.json +++ b/fixtures/conformance/error-report-declares-field/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-dimension-grain/input/meta.shop.json b/fixtures/conformance/error-report-dimension-grain/input/meta.shop.json index c04ba260f..04d877cc6 100644 --- a/fixtures/conformance/error-report-dimension-grain/input/meta.shop.json +++ b/fixtures/conformance/error-report-dimension-grain/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-field-collision/input/meta.shop.json b/fixtures/conformance/error-report-field-collision/input/meta.shop.json index a2a9ff847..f61fe496c 100644 --- a/fixtures/conformance/error-report-field-collision/input/meta.shop.json +++ b/fixtures/conformance/error-report-field-collision/input/meta.shop.json @@ -280,6 +280,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-foreign-measure/input/meta.shop.json b/fixtures/conformance/error-report-foreign-measure/input/meta.shop.json index 90a4eea5a..8188c5858 100644 --- a/fixtures/conformance/error-report-foreign-measure/input/meta.shop.json +++ b/fixtures/conformance/error-report-foreign-measure/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-from-not-entity/input/meta.shop.json b/fixtures/conformance/error-report-from-not-entity/input/meta.shop.json index 93c9872e7..571a6e63b 100644 --- a/fixtures/conformance/error-report-from-not-entity/input/meta.shop.json +++ b/fixtures/conformance/error-report-from-not-entity/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-measure-unresolved/input/meta.shop.json b/fixtures/conformance/error-report-measure-unresolved/input/meta.shop.json index 8d504a293..c4c63085c 100644 --- a/fixtures/conformance/error-report-measure-unresolved/input/meta.shop.json +++ b/fixtures/conformance/error-report-measure-unresolved/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-segment-unresolved/input/meta.shop.json b/fixtures/conformance/error-report-segment-unresolved/input/meta.shop.json index aa54a59d9..28d76833f 100644 --- a/fixtures/conformance/error-report-segment-unresolved/input/meta.shop.json +++ b/fixtures/conformance/error-report-segment-unresolved/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-two-unresolved-dimensions/input/meta.shop.json b/fixtures/conformance/error-report-two-unresolved-dimensions/input/meta.shop.json index e02d4cdbe..c8d91e5e6 100644 --- a/fixtures/conformance/error-report-two-unresolved-dimensions/input/meta.shop.json +++ b/fixtures/conformance/error-report-two-unresolved-dimensions/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-report-writable-source/input/meta.shop.json b/fixtures/conformance/error-report-writable-source/input/meta.shop.json index e2d3c5d38..21d63f1b2 100644 --- a/fixtures/conformance/error-report-writable-source/input/meta.shop.json +++ b/fixtures/conformance/error-report-writable-source/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/error-segment-filter-bad-field/input/meta.shop.json b/fixtures/conformance/error-segment-filter-bad-field/input/meta.shop.json index 1150ad8fb..ee83eaa95 100644 --- a/fixtures/conformance/error-segment-filter-bad-field/input/meta.shop.json +++ b/fixtures/conformance/error-segment-filter-bad-field/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/reporting-vocabulary/expected.json b/fixtures/conformance/reporting-vocabulary/expected.json index 245dd1945..b61dea5ec 100644 --- a/fixtures/conformance/reporting-vocabulary/expected.json +++ b/fixtures/conformance/reporting-vocabulary/expected.json @@ -287,6 +287,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/fixtures/conformance/reporting-vocabulary/input/meta.shop.json b/fixtures/conformance/reporting-vocabulary/input/meta.shop.json index 00edb4205..83eb05fe4 100644 --- a/fixtures/conformance/reporting-vocabulary/input/meta.shop.json +++ b/fixtures/conformance/reporting-vocabulary/input/meta.shop.json @@ -273,6 +273,7 @@ "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber" ] diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs index c0d32100f..de9524756 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs @@ -71,7 +71,7 @@ public void MeasureOfColumns_bare_string_is_a_one_element_list_and_a_list_is_the var days = Assert.IsType(Root(r, "WorkoutEvent").Children().Single(c => c.Name == "daysEngaged")); Assert.Equal( - ["WorkoutEvent.programId", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber"], + ["WorkoutEvent.programId", "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber"], days.OfColumns()); Assert.True(days.Distinct()); diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java index 2ee6b9041..558412406 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java @@ -158,7 +158,7 @@ public void everyErrorFixtureYieldsTheReferenceCodeAndMessage() throws IOExcepti expect.put("error-measure-sum-non-numeric", new String[]{"ERR_INVALID_MEASURE", "measure 'revenue' on entity 'acme::shop::Purchase': @agg 'sum' needs a numeric field (field.int, long, double, float, decimal or currency), but 'Purchase.status' is field.string."}); expect.put("error-measure-tuple-without-distinct", new String[]{"ERR_INVALID_MEASURE", - "measure 'daysEngaged' on entity 'acme::shop::WorkoutEvent': @of lists 3 columns; a tuple is legal only with @agg: count and @distinct: true (a distinct count of the tuple)."}); + "measure 'daysEngaged' on entity 'acme::shop::WorkoutEvent': @of lists 4 columns; a tuple is legal only with @agg: count and @distinct: true (a distinct count of the tuple)."}); expect.put("error-ratio-operand-not-aggregate", new String[]{"ERR_INVALID_MEASURE", "measure 'ratioOfRatio' on entity 'acme::shop::WorkoutEvent': @numerator 'avgDaysPerStarter' is a measure.ratio; a ratio's operands must be measure.aggregate (a ratio of ratios is not supported)."}); expect.put("error-relative-date-bad-duration", new String[]{"ERR_BAD_ATTR_FILTER", @@ -227,7 +227,7 @@ public void tupleWithDistinctButSumIsOneM2Error() throws IOException { member(doc, "WorkoutEvent", "measure.aggregate", "daysEngaged").addProperty("@agg", "sum"); List got = ValidationPhase.validateReporting(loadJson(doc)); assertEquals(List.of("ERR_INVALID_MEASURE"), codes(got)); - assertTrue(got.get(0).getMessage(), got.get(0).getMessage().contains("@of lists 3 columns")); + assertTrue(got.get(0).getMessage(), got.get(0).getMessage().contains("@of lists 4 columns")); } @Test diff --git a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java index 5d1c401f7..41b1cac53 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java @@ -163,7 +163,8 @@ public void aBareStringWithACommaStaysOneItemAndAJsonArrayStillSplitsIntoItems() assertEquals(List.of("Purchase.amountCents,Purchase.id"), member(purchase, MetaMeasure.class, "revenue").getOfColumns()); // A JSON-array @of keeps its items. - assertEquals(List.of("WorkoutEvent.programId", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber"), + assertEquals(List.of("WorkoutEvent.programId", "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber"), member(object(loader.getRoot(), "WorkoutEvent"), MetaMeasure.class, "daysEngaged").getOfColumns()); } @@ -171,7 +172,8 @@ public void aBareStringWithACommaStaysOneItemAndAJsonArrayStillSplitsIntoItems() public void aListOfIsTheTupleForm() throws IOException { MetaObject workout = object(loadFixture("reporting-vocabulary"), "WorkoutEvent"); MetaMeasure days = member(workout, MetaMeasure.class, "daysEngaged"); - assertEquals(List.of("WorkoutEvent.programId", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber"), + assertEquals(List.of("WorkoutEvent.programId", "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber"), days.getOfColumns()); assertTrue(days.isDistinct()); assertFalse(days.isRatio()); diff --git a/server/python/tests/unit/test_reporting_accessors.py b/server/python/tests/unit/test_reporting_accessors.py index 0b024c9d1..503932078 100644 --- a/server/python/tests/unit/test_reporting_accessors.py +++ b/server/python/tests/unit/test_reporting_accessors.py @@ -146,6 +146,7 @@ def test_measure_accessors(vocab: MetaData) -> None: assert isinstance(days, MetaMeasure) assert days.of_columns() == [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber", ] 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 ffcc77765..821bb8694 100644 --- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts +++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts @@ -13,6 +13,10 @@ // (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 one UI-tier exception is the list hook (and the `.meta.ts` descriptor it imports): +// the hook generator emits `StoreTotals.hooks.ts` and `StoreTotals.meta.ts`, and no other UI +// generator emits anything for a report. +// // 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 once leaked in C#, where @@ -21,8 +25,8 @@ // `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. +// served report alone; the schema page's one view entry; one section in `agent/ui.md` for +// the served report's list hook. Everything else is byte-identical. import { describe, test, expect, beforeAll } from "bun:test"; import { mkdtempSync, mkdirSync, copyFileSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs"; @@ -112,11 +116,14 @@ const SERVED_REPORT_FILES: Readonly> = { queries: [`${OUT}/StoreTotals.queries.ts`], routes: [`${OUT}/StoreTotals.routes.ts`], "routes-hono": [`${OUT}/StoreTotals.routes.hono.ts`], + // The list hook, and the DB-free descriptor module it imports. + hooks: [`${OUT}/StoreTotals.hooks.ts`, `${OUT}/StoreTotals.meta.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; +/** The client UI generators that emit NOTHING for a report: a report has a list hook and no + * grid, grid hook or form. */ +const UI_TIER = ["grid", "grid-hook", "form"] as const; const SOURCELESS_REPORTS = ["ProgramEngagement", "DailyRevenue"] as const; /** Assert `actual` is `expected` plus exactly `added`, with every shared file @@ -184,12 +191,12 @@ describe("FR-044 a sourceless report is inert; a served report emits exactly its 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. + // Not vacuous for the form: it emits 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") { + if (name === "form") { expect(Object.keys(actual).some((p) => p.includes("Program"))).toBe(true); } expect(Object.keys(actual).filter((p) => p.includes("StoreTotals"))).toEqual([]); @@ -515,11 +522,20 @@ describe("FR-044 meta docs differs by exactly the report pages, the Reporting se expect(actual[schemaPage]).toContain(entry); expect(actual[schemaPage]!.replace(entry, "")).toBe(expected[schemaPage]!); delete actual[schemaPage]; + // The UI page gains one section: the served report's list hook. A sourceless report + // is not served and so is not on it. + const uiPage = Object.keys(actual).find((p) => p.endsWith("ui.md"))!; + const uiActual = actual[uiPage]!; + delete actual[uiPage]; const rest = { ...expected }; delete rest[schemaPage]; + delete rest[uiPage]; 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); + const section = uiActual.split("\n## ").find((s) => s.startsWith("`acme::shop::StoreTotals`")); + expect(section).toBeDefined(); + expect(section).toContain("list only"); + expect(section).toContain("no form, grid or detail view"); + expect(uiActual.replace(`\n## ${section}`, "").replace(/\n+$/, "\n")).toBe(expected[uiPage]!); + for (const name of SOURCELESS_REPORTS) expect(uiActual).not.toContain(name); }); }); 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 a58c9d6fe..70881aa34 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, - servesClientTier, + servesClientHooks, 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) => servesClientTier(e) && !isTphSubtype(e) && userFilter(e), + filter: (e: MetaObject) => servesClientHooks(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-query.ts b/server/typescript/packages/codegen-ts-tanstack/src/tanstack-query.ts index 402162ba3..982291e69 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, servesClientTier, isTphSubtype, +import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesClientHooks, 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) => servesClientTier(e) && !isTphSubtype(e) && userFilter(e), + filter: (e: MetaObject) => servesClientHooks(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/test/co-emitted-output-compiles.test.ts b/server/typescript/packages/codegen-ts-tanstack/test/co-emitted-output-compiles.test.ts index c360c8c7e..03fddb561 100644 --- a/server/typescript/packages/codegen-ts-tanstack/test/co-emitted-output-compiles.test.ts +++ b/server/typescript/packages/codegen-ts-tanstack/test/co-emitted-output-compiles.test.ts @@ -54,6 +54,22 @@ const META = JSON.stringify({ { "field.string": { name: "name", extends: "Author.name" } }, { "identity.primary": { name: "pk", extends: "Author.pk" } }, ] } }, + // Served report (FR-044) — a route, a row type and a LIST hook, and no grid. The row + // that proves the report's hook imports only names its siblings really export. + { "object.entity": { name: "Sale", children: [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "region" } }, + { "field.decimal": { name: "amount", "@precision": 12, "@scale": 2 } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { name: "region", "@of": "Sale.region" } }, + { "measure.aggregate": { name: "sales", "@agg": "count", "@of": "Sale.id" } }, + { "measure.aggregate": { name: "avgAmount", "@agg": "avg", "@of": "Sale.amount" } }, + ] } }, + { "object.report": { name: "SalesByRegion", "@from": "Sale", "@dimensions": ["region"], + "@measures": ["sales", "avgAmount"], children: [ + { "source.rdb": { "@kind": "view", "@table": "v_sales_by_region" } }, + ] } }, // object.value — a pure shape. No identity, no source, ever (ADR-0028). { "object.value": { name: "NotePayload", children: [ { "field.string": { name: "text" } }, @@ -106,6 +122,12 @@ describe("co-emitted generated output", () => { // over-broad fix. It has a source, so a route exists to read from. expect(files).toContain("AuthorSummary.hooks.ts"); + // A served report gets the list hook and no grid: it is a client of a route, and has + // no grid to feed. A report with no source would get nothing, as the sourceless rows do. + expect(files).toContain("SalesByRegion.hooks.ts"); + expect(files).not.toContain("SalesByRegion.columns.tsx"); + expect(files).not.toContain("SalesByRegion.grid.ts"); + // Nothing that has no source gets a client for a route it does not have. for (const name of ["NotePayload", "Sourceless", "AuthorCard"]) { expect(files).not.toContain(`${name}.hooks.ts`); 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-ui-tier.test.ts similarity index 68% rename from server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts rename to server/typescript/packages/codegen-ts-tanstack/test/report-ui-tier.test.ts index 6265a9ae0..f22928681 100644 --- a/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts +++ b/server/typescript/packages/codegen-ts-tanstack/test/report-ui-tier.test.ts @@ -1,20 +1,21 @@ -// 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. +// FR-044 — a served report has a route, a row type and a LIST HOOK, and no grid, grid hook +// or form; 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. +// route and queries generators emit). The hook generator gates on `servesClientHooks`, +// which a report passes; the grid generators gate on `servesClientTier`, which it fails. +// A grid, grid hook or form over reports belongs to the later `reporting` library. +// `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 { mkdtempSync, readFileSync, 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, + servesClientHooks, servesClientTier, servesReadApi, } from "@metaobjectsdev/codegen-ts"; import type { Generator } from "@metaobjectsdev/codegen-ts"; import { tanstackGrid, tanstackGridHook, tanstackQuery } from "../src/index.js"; @@ -48,31 +49,73 @@ async function emittedPaths(generators: Generator[]): Promise { } } -describe("no UI-tier generator emits for a served report", () => { - test("the report is served and has no client tier", async () => { +describe("a served report gets a list hook and no other UI-tier file", () => { + test("the report is served, has a hook and has no grid 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(servesClientHooks(o)).toBe(true); expect(servesClientTier(o)).toBe(false); } }); - for (const [name, generators] of [ - ["built-in", () => [tanstackQuery(), tanstackGrid(), tanstackGridHook()]], - ["reference", () => [refHooks(), refGrid(), refGridHook()]], + for (const [name, hooks, others] 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. + test(`${name} hook generator emits the report's list hook and its descriptor`, async () => { + const paths = await emittedPaths(hooks()); expect(paths).toContain("Program.hooks.ts"); + expect(paths.filter((p) => p.startsWith("StoreTotals")).sort()).toEqual([ + "StoreTotals.hooks.ts", + "StoreTotals.meta.ts", + ]); + // A sourceless report is not served, so it has no hook. + for (const sourceless of ["ProgramEngagement", "DailyRevenue"]) { + expect(paths.filter((p) => p.startsWith(sourceless))).toEqual([]); + } + }); + + test(`${name} grid and grid hook generators emit nothing named for the report`, async () => { + const paths = await emittedPaths(others()); expect(paths.filter((p) => p.startsWith("StoreTotals"))).toEqual([]); for (const sourceless of ["ProgramEngagement", "DailyRevenue"]) { expect(paths.filter((p) => p.startsWith(sourceless))).toEqual([]); } }); } + + for (const [name, make] of [ + ["built-in", tanstackQuery], + ["reference", refHooks], + ] as const) { + test(`${name} hooks file for a report: the list hook and its keys, no detail hook, no mutations`, async () => { + const dir = mkdtempSync(join(tmpdir(), "report-hooks-")); + try { + await runGen({ + config: defineConfig({ outDir: dir, extStyle: "none", dbImport: "../db", dialect: "postgres", generators: [make()] }), + metadata: await loadWith(), + }); + const out = readFileSync(join(dir, "StoreTotals.hooks.ts"), "utf8"); + // `Totals` is already plural, so the list hook does not double it (hookListNameSegment). + expect(out).toContain("export function useStoreTotalsList("); + expect(out).toContain("filter?: StoreTotalsFilter"); + expect(out).toContain("type StoreTotals as StoreTotalsRow"); + expect(out).toContain("lists:"); + expect(out).not.toContain("details:"); + expect(out).not.toContain("detail:"); + expect(out).not.toContain("useCreate"); + expect(out).not.toContain("useUpdate"); + expect(out).not.toContain("useDelete"); + expect(out).not.toContain("/${id}"); + expect(out).not.toContain("useMutation"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + } }); describe("a keyless projection gets a list hook and no detail hook", () => { @@ -164,7 +207,7 @@ describe("a keyless projection gets a list hook and no detail hook", () => { // 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", () => { +describe("each grid-tier gate refuses a served report that passes its other gates", () => { async function reportWithGrid() { const json = JSON.stringify({ "metadata.root": { package: "test", children: [ { @@ -203,10 +246,8 @@ describe("each UI-tier gate refuses a served report that passes its other gates" } for (const [name, make] of [ - ["tanstackQuery", tanstackQuery], ["tanstackGrid", tanstackGrid], ["tanstackGridHook", tanstackGridHook], - ["reference hooks", refHooks], ["reference grid", refGrid], ["reference grid-hook", refGridHook], ] as const) { @@ -223,3 +264,25 @@ describe("each UI-tier gate refuses a served report that passes its other gates" }); } }); + +// The hook generator admits a served report and still refuses what is not served: the same +// filter, asked of a report with no view, answers false. Reverting it to `servesClientTier` +// turns the first row red; widening it past `servesReadApi` turns the second red. +describe("the hook gate", () => { + for (const [name, make] of [ + ["tanstackQuery", tanstackQuery], + ["reference hooks", refHooks], + ] as const) { + test(name, async () => { + const root = await loadWith(); + const filter = make().filter; + if (!filter) throw new Error(`${name} has no filter`); + const served = root.objects().find((o) => o.name === "StoreTotals"); + const sourceless = root.objects().find((o) => o.name === "DailyRevenue"); + if (!served || !sourceless) throw new Error("fixture reports not found"); + expect(filter(served)).toBe(true); + expect(filter(reportReadModel(served, root))).toBe(true); + expect(filter(sourceless)).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 91ff5978e..48aa49594 100644 --- a/server/typescript/packages/codegen-ts/src/api-surface.ts +++ b/server/typescript/packages/codegen-ts/src/api-surface.ts @@ -18,12 +18,13 @@ // 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. +// Three questions since FR-044: `servesReadApi` asks whether a read endpoint exists (the +// route and queries generators), `servesClientHooks` asks whether a typed client hook is +// generated for it, and `servesClientTier` asks whether the rest of the client UI tier (a +// grid, its grid hook, the Angular service) is. They differ for a served report, which has +// a route and a list hook and no grid, form or detail hook. -import type { MetaObject } from "@metaobjectsdev/metadata"; +import { FIELD_SUBTYPE_DECIMAL, 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"; @@ -42,8 +43,9 @@ import { tphRouteSegment } from "./templates/tph-discriminator.js"; * * 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. The UI tier asks `servesClientTier`, which - * is this answer minus reports. + * `routesFile` / `routesFileHono` gate on. The hook generator asks `servesClientHooks` + * (this answer) and the grid generators ask `servesClientTier`, which is this answer + * minus reports. */ export function servesReadApi(entity: MetaObject): boolean { // FR-044 Plan 3: a report is served exactly when Table A says so (`servedReport`: not @@ -95,14 +97,44 @@ export function itemRouteField(entity: MetaObject): string | undefined { } /** - * 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. + * True when a typed client hook is generated for the object: `servesReadApi(entity)`. + * A served report is included. It gets the list hook (`uses`, with the report's + * filter and sort types) and nothing else: `hasItemRoute` is false for it, so no detail + * hook, and it has no writable source, so no mutation hooks. `agent/ui.md` lists the + * objects this answers true for. + */ +export function servesClientHooks(entity: MetaObject): boolean { + return servesReadApi(entity); +} + +/** + * True when the rest of the client UI tier (a grid, its grid hook, the Angular service) + * is generated for the object: `servesReadApi(entity)` and not a report. A served report + * has a route and a list hook (`servesClientHooks`) and no grid; a grid over reports + * belongs to the later `reporting` library. */ export function servesClientTier(entity: MetaObject): boolean { return servesReadApi(entity) && !isReport(entity); } +/** + * The decimal fields a report's read-only mount must send as strings: a report on SQLite. + * + * A decimal is a string on the wire. SQLite has no decimal, so the REAL a report's view + * computes (a ratio, an average, a sum of a decimal field) reaches the route as a JS number + * where Postgres and MySQL return a string, and the read schema says string for all of them. + * The generated route passes these names to the mount as `decimalColumns` and the mount sends + * a number under one of them as its string. Empty for every other object and dialect, which + * keeps their routes byte-identical. A projection on SQLite is not covered: it is released + * behaviour and its decimals keep reaching the wire as numbers. + */ +export function reportDecimalColumns(entity: MetaObject, dialect: string): readonly string[] { + if (dialect !== "sqlite" || !isReport(entity)) return []; + // ADR-0039: resolving. A report read model's fields are its own, but the resolving call is + // the rule everywhere a field set is read. + return entity.fields().filter((f) => f.subType === FIELD_SUBTYPE_DECIMAL).map((f) => f.name); +} + /** * True when the object is served by generated WRITE endpoints — so a form has * somewhere to submit. Requires a WRITABLE source: the same predicate the entity-file 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 bfc8ea09d..16104907f 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,8 @@ import { } from "@metaobjectsdev/metadata"; import type { MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata"; import { GENERATED_HEADER } from "../constants.js"; -import { hasGeneratedForm, restPath, servedPath, servesClientTier } from "../api-surface.js"; +import { generatableObjects, isReport } from "../source-detect.js"; +import { hasGeneratedForm, restPath, servedPath, servesClientHooks } from "../api-surface.js"; import { buildEntityUiDescriptor, type UiFieldDescriptor, @@ -120,6 +121,14 @@ function flag(value: unknown): string { */ function endpointLine(obj: MetaObject, root: MetaRoot, apiPrefix: string): string { const endpoint = servedPath(obj, apiPrefix); + // A served report: a list route, a list hook, and no form, grid, detail hook or writes. + if (isReport(obj)) { + return ( + `Endpoint \`${endpoint}\` — list only. The generated client is the list hook; ` + + "**no form, grid or detail view is generated** for a report. The fields below " + + "describe the row and the filters." + ); + } if (hasGeneratedForm(obj)) return `Endpoint \`${endpoint}\`.`; // `isTphDiscriminatorBase`, which requires at least one CONCRETE subtype — the same // predicate `routes-file.ts` switches on. `@discriminator` with no subtype yet is a @@ -155,11 +164,10 @@ function dataGrids(obj: MetaObject): MetaData[] { /** * True when a UI generator would emit for this object. * - * `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`. + * `servesClientHooks` — the api-surface predicate the hook generator itself gates 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 a list hook, so it is listed. * * 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 @@ -168,7 +176,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 servesClientTier(obj); + return servesClientHooks(obj); } function gridSection(obj: MetaObject, grid: MetaData): string[] { @@ -197,8 +205,9 @@ function gridSection(obj: MetaObject, grid: MetaData): string[] { * then emits no FILE, so a headless project sees nothing rather than an empty page. */ export function renderAgentUiPage(root: MetaRoot, apiPrefix = ""): string { - const objects = root.objects(); - const withUi = objects.filter(hasUiSurface); + // A served report declares no fields; its row shape lives on its read model, which is + // what the hook and the route are generated from, so the page describes that. + const withUi = generatableObjects(root.objects(), root).filter(hasUiSurface); if (withUi.length === 0) return ""; const out: string[] = []; 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 fa5911a3e..57b820121 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts @@ -82,7 +82,7 @@ // 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`). +// (see DEFERRALS); a report does get a generated list hook (`servesClientHooks`). // • 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 diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 92edc1ea4..e679a5b6b 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, hasItemRoute, itemRouteField, restPath, servesClientTier, servesReadApi, servesWriteApi } from "./api-surface.js"; +export { hasGeneratedForm, hasItemRoute, itemRouteField, reportDecimalColumns, restPath, servesClientHooks, 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. 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 995d0f3a5..ef12b20c8 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts @@ -58,6 +58,7 @@ import { isWriteThrough, isReport, itemRouteField, + reportDecimalColumns, DEFAULT_ID_FIELD, servesReadApi, formatTs, @@ -135,12 +136,15 @@ function renderRoutesHono( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. + // A decimal a SQLite report computes is a REAL; the mount sends it as the string it is elsewhere. + const decimalColumns = reportDecimalColumns(entity, ctx.dialect); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + - (report ? `\n${indent}resource: "report",` : ""); + (report ? `\n${indent}resource: "report",` : "") + + (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); const HonoSym = imp("t:Hono@hono"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index 6be087bd2..425fcfcdb 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -73,6 +73,7 @@ import { isWriteThrough, isReport, itemRouteField, + reportDecimalColumns, DEFAULT_ID_FIELD, servesReadApi, formatTs, @@ -154,12 +155,15 @@ function renderRoutes( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. + // A decimal a SQLite report computes is a REAL; the mount sends it as the string it is elsewhere. + const decimalColumns = reportDecimalColumns(entity, ctx.dialect); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + - (report ? `\n${indent}resource: "report",` : ""); + (report ? `\n${indent}resource: "report",` : "") + + (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — 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 6cc7b08b0..5a228a127 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 @@ -31,7 +31,7 @@ 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 { itemRouteField } from "../api-surface.js"; +import { itemRouteField, reportDecimalColumns } from "../api-surface.js"; import { DEFAULT_ID_FIELD } from "./queries.js"; import { authSeamJsDoc, type CrudVerb, exposeLine } from "../routes-expose.js"; import { effectivePackage } from "../docs-paths.js"; @@ -94,12 +94,15 @@ export function renderRoutesFileHono( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. + // A decimal a SQLite report computes is a REAL; the mount sends it as the string it is elsewhere. + const decimalColumns = reportDecimalColumns(entity, ctx.dialect); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + - (report ? `\n${indent}resource: "report",` : ""); + (report ? `\n${indent}resource: "report",` : "") + + (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); const HonoSym = imp("t:Hono@hono"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); 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 84b03bf90..aed8c61cc 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts @@ -29,7 +29,7 @@ import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants import { routesHandlerName } from "../naming.js"; import { isProjection, isWriteThrough } from "../projection/projection-detector.js"; import { isReport } from "../source-detect.js"; -import { itemRouteField } from "../api-surface.js"; +import { itemRouteField, reportDecimalColumns } from "../api-surface.js"; import { DEFAULT_ID_FIELD } from "./queries.js"; import type { RelationEntry } from "../relation-resolver.js"; import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js"; @@ -93,12 +93,15 @@ export function renderRoutesFile( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. + // A decimal a SQLite report computes is a REAL; the mount sends it as the string it is elsewhere. + const decimalColumns = reportDecimalColumns(entity, ctx.dialect); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + - (report ? `\n${indent}resource: "report",` : ""); + (report ? `\n${indent}resource: "report",` : "") + + (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — diff --git a/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts b/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts index 15ec7690d..3e72c8ea1 100644 --- a/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts @@ -197,7 +197,7 @@ describe("extractReportSpec", () => { const s = await spec("ProgramEngagement"); const days = s.columns.find((c) => c.fieldName === "daysEngaged")!; if (days.kind !== "aggregate") throw new Error("expected an aggregate"); - expect(days.aggregate.refs).toEqual(["w.program_id", "w.week_number", "w.day_number"]); + expect(days.aggregate.refs).toEqual(["w.program_id", "w.customer_email", "w.week_number", "w.day_number"]); expect(days.aggregate.distinct).toBe(true); expect(days.aggregate.cast).toBeUndefined(); // The report's @segment scopes the whole report, not each measure. @@ -208,7 +208,7 @@ describe("extractReportSpec", () => { const s = await spec("ProgramEngagement"); const ratio = s.columns.find((c) => c.fieldName === "avgDaysPerStarter")!; if (ratio.kind !== "ratio") throw new Error("expected a ratio"); - expect(ratio.numerator.refs).toHaveLength(3); + expect(ratio.numerator.refs).toHaveLength(4); expect(ratio.numerator.distinct).toBe(true); expect(ratio.denominator.refs).toEqual(["w.customer_email"]); expect(ratio.denominator.distinct).toBe(true); @@ -231,7 +231,7 @@ describe("extractReportSpec", () => { if (same.kind !== "ratio") throw new Error("expected a ratio"); expect(ratio.numerator).toEqual(same.numerator); expect(ratio.denominator).toEqual(same.denominator); - expect(ratio.numerator.refs).toHaveLength(3); + expect(ratio.numerator.refs).toHaveLength(4); expect(ratio.denominator.refs).toEqual(["w.customer_email"]); }); 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 933345022..5efc28265 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 @@ -10,7 +10,7 @@ import { MetaDataLoader, InMemoryStringSource, loadUris, reportReadModel } from 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, itemRouteField, servesClientTier, servesReadApi } from "../../src/api-surface.js"; +import { hasGeneratedForm, hasItemRoute, itemRouteField, reportDecimalColumns, servesClientHooks, servesClientTier, servesReadApi } from "../../src/api-surface.js"; import { renderQueriesFile } from "../../src/templates/queries-file.js"; import { servedReport } from "../../src/source-detect.js"; import { hasUiSurface } from "../../src/generators/agent-ui-page.js"; @@ -663,6 +663,74 @@ describe("renderRoutesFile — a served report (FR-044 Plan 3)", () => { } }); + describe("a decimal a SQLite report computes is sent as a string (decimalColumns)", () => { + // A ratio is typed decimal, SQLite has no decimal, and the view hands the route a REAL. + const ratioModel = () => loadMetadata([ + { + "object.entity": { + name: "Invoice", + children: [ + { "source.rdb": { "@table": "invoices" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "status" } }, + { "field.decimal": { name: "amount", "@precision": 12, "@scale": 2 } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { "dimension.attribute": { name: "status", "@of": "Invoice.status" } }, + { "measure.aggregate": { name: "invoices", "@agg": "count", "@of": "Invoice.id" } }, + { "measure.aggregate": { name: "paidInvoices", "@agg": "count", "@of": "Invoice.id", "@filter": { status: "PAID" } } }, + { "measure.aggregate": { name: "avgAmount", "@agg": "avg", "@of": "Invoice.amount" } }, + { "measure.ratio": { name: "paidShare", "@numerator": "paidInvoices", "@denominator": "invoices" } }, + ], + }, + }, + { + "object.report": { + name: "InvoiceTotals", + "@from": "Invoice", + "@dimensions": ["status"], + "@measures": ["invoices", "avgAmount", "paidShare"], + children: [{ "source.rdb": { "@kind": "view", "@table": "v_invoice_totals" } }], + }, + }, + ]); + const sqliteCtx = (root: MetaRoot) => makeRenderContext({ + dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db", + pkMap: buildPkMap(root), relationMap: buildRelationMap(root), + }); + + test("every decimal field of the report is named, on both route flavours and on SQLite only", async () => { + const root = await ratioModel(); + const model = reportReadModel(declared(root, "InvoiceTotals"), root); + // Not vacuous: the ratio and the average really are decimal fields of the read model. + expect(model.findField("paidShare")?.subType).toBe("decimal"); + expect(model.findField("avgAmount")?.subType).toBe("decimal"); + expect(reportDecimalColumns(model, "sqlite")).toEqual(["avgAmount", "paidShare"]); + const ctx = sqliteCtx(root); + for (const out of [renderRoutesFile(model, ctx), renderRoutesFileHono(model, ctx)]) { + expect(out).toContain('decimalColumns: ["avgAmount", "paidShare"],'); + } + for (const dialect of ["postgres", "mysql"] as const) { + expect(reportDecimalColumns(model, dialect)).toEqual([]); + const other = makeRenderContext({ + dialect, loadedRoot: root, outDir: "/x", dbImport: "~/db", + pkMap: buildPkMap(root), relationMap: buildRelationMap(root), + }); + expect(renderRoutesFile(model, other)).not.toContain("decimalColumns"); + expect(renderRoutesFileHono(model, other)).not.toContain("decimalColumns"); + } + }); + + test("a report with no decimal field, a projection and an entity pass none", async () => { + const root = await loadReportingModel(); + const storeTotals = reportReadModel(declared(root, "StoreTotals"), root); + // StoreTotals' measures are counts and a currency sum: no decimal. + expect(reportDecimalColumns(storeTotals, "sqlite")).toEqual([]); + expect(renderRoutesFile(storeTotals, sqliteCtx(root))).not.toContain("decimalColumns"); + // A projection on SQLite is released behaviour and is not covered. + expect(reportDecimalColumns(declared(root, "Program"), "sqlite")).toEqual([]); + }); + }); + 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(); @@ -676,15 +744,16 @@ describe("renderRoutesFile — a served report (FR-044 Plan 3)", () => { } }); - test("a served report has a read API and no client tier; an unserved one has neither", async () => { + test("a served report has a read API and a list hook and no grid tier; an unserved one has none of them", 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(servesClientHooks(o)).toBe(true); expect(servesClientTier(o)).toBe(false); - expect(hasUiSurface(o)).toBe(false); + expect(hasUiSurface(o)).toBe(true); expect(hasGeneratedForm(o)).toBe(false); expect(hasItemRoute(o)).toBe(false); } @@ -692,11 +761,13 @@ describe("renderRoutesFile — a served report (FR-044 Plan 3)", () => { const sourceless = declared(root, name); expect(servedReport(sourceless)).toBe(false); expect(servesReadApi(sourceless)).toBe(false); + expect(servesClientHooks(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(servesClientHooks(program)).toBe(true); expect(servesClientTier(program)).toBe(true); }); diff --git a/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts b/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts index 09910b3bc..eba6c3cc4 100644 --- a/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts +++ b/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts @@ -156,7 +156,7 @@ describe("FR-044 model surface: a page for every report", () => { 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`) |", + "| `daysEngaged` | `long` | no | measure | count of distinct (`WorkoutEvent.programId`, `WorkoutEvent.customerEmail`, `WorkoutEvent.weekNumber`, `WorkoutEvent.dayNumber`) |", ); expect(engagement).toContain( "| `avgDaysPerStarter` | `decimal` | yes | measure | `daysEngaged` / `starters`, null when the denominator is 0 |", 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 cce03f90b..fc3a27e89 100644 --- a/server/typescript/packages/docs-site/test/reporting-site.test.ts +++ b/server/typescript/packages/docs-site/test/reporting-site.test.ts @@ -102,7 +102,7 @@ describe("FR-044 the site renders reports", () => { 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)", + "daysEngaged long no measure count of distinct (WorkoutEvent.programId, WorkoutEvent.customerEmail, 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"); diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-sqlite-server.ts b/server/typescript/packages/integration-tests/src/api-contract-report-sqlite-server.ts new file mode 100644 index 000000000..cc5df6e7b --- /dev/null +++ b/server/typescript/packages/integration-tests/src/api-contract-report-sqlite-server.ts @@ -0,0 +1,121 @@ +// api-contract-report-sqlite-server.ts — boots the GENERATED report routes on SQLite +// (bun:sqlite) and drives them over HTTP. The Postgres twin is +// api-contract-report-generated-server.ts; this one exists because the TypeScript view +// read schema types a ratio (a decimal) as a string, SQLite has no decimal and returns a +// REAL, and the corpus runs TypeScript on Postgres only. What the route answers for a +// ratio there is gated by test/api-contract-report-sqlite.test.ts. +// +// Same shape as the Postgres server: run the real codegen over report/meta.json +// (dialect sqlite), provision `invoices` by hand in the EMITTED snake_case spelling, +// create each view from buildReportViews (the real lowering), import the EMITTED +// .routes.ts files unmodified and mount them. + +import Fastify, { type FastifyInstance } from "fastify"; +import type { Database } from "bun:sqlite"; +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 { loadMetadataFile } from "./load-metadata.ts"; +import type { ReportSeed } from "./api-contract-report-generated-server.ts"; + +export interface SqliteReportServerHandle { + baseUrl: string; + applySeed(seed: ReportSeed): Promise; + close(): Promise; +} + +const SERVED_REPORTS = [ + { name: "InvoiceStatusTotals", registrar: "invoiceStatusTotalsRoutes" }, + { name: "InvoicesByMonth", registrar: "invoicesByMonthRoutes" }, + { name: "InvoiceTotals", registrar: "invoiceTotalsRoutes" }, +] as const; + +export async function startSqliteReportServer(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-sqlite-")); + + const root = await loadMetadataFile(metaPath); + const lr = await runGen({ + config: defineConfig({ + outDir: tmp, + extStyle: "none", + dbImport: "./db", + dialect: "sqlite", + 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) { + rmSync(tmp, { recursive: true, force: true }); + throw new Error(`codegen produced warnings: ${lr.warnings.join("; ")}`); + } + + // The emitted routes import `db` from "./db"; one in-memory database serves them and the + // seed. A REAL read back from SQLite is the same JS number on every SQLite driver. + const dbModule = ` +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +export const client = new Database(":memory:"); +export const db = drizzle(client); +`; + writeFileSync(join(tmp, "db.ts"), dbModule, "utf8"); + const dbMod = (await import(pathToFileURL(join(tmp, "db.ts")).href)) as { client: Database }; + + const views = buildReportViews(root, { + dialect: "sqlite", + columnNamingStrategy: DEFAULT_COLUMN_NAMING_STRATEGY, + }); + dbMod.client.run(` + CREATE TABLE "invoices" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "reference" TEXT NOT NULL, + "status" TEXT NOT NULL, + "amount_cents" INTEGER NOT NULL, + "issued_on" TEXT NOT NULL + )`); + for (const v of views) { + if (v.sql === undefined) throw new Error(`view ${v.name} has no SQL`); + dbMod.client.run(`CREATE VIEW "${v.name}" AS ${v.sql}`); + } + + 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); + } + await fastify.ready(); + const baseUrl = await fastify.listen({ host: "127.0.0.1", port: 0 }); + + return { + baseUrl, + applySeed: async (seed: ReportSeed) => { + dbMod.client.run(`DELETE FROM "invoices"`); + for (const i of seed.invoices) { + dbMod.client.run( + `INSERT INTO "invoices" ("id","reference","status","amount_cents","issued_on") VALUES (?,?,?,?,?)`, + [i.id, i.reference, i.status, i.amountCents, i.issuedOn], + ); + } + }, + close: async () => { + await fastify.close(); + dbMod.client.close(); + rmSync(tmp, { recursive: true, force: true }); + }, + }; +} diff --git a/server/typescript/packages/integration-tests/test/api-contract-report-sqlite.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report-sqlite.test.ts new file mode 100644 index 000000000..52570ef86 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/api-contract-report-sqlite.test.ts @@ -0,0 +1,118 @@ +// FR-044 view-backed-report api-contract conformance, GENERATED lane, on SQLite. +// +// The cross-port corpus (api-contract-report.test.ts) runs TypeScript against Postgres +// only, and its README does not assert a decimal's spelling. That left a hole on SQLite: +// a ratio is typed `decimal`, the TypeScript read schema types a decimal as `string`, and +// SQLite has no decimal, so the view hands the route a REAL. Unmodified, the generated +// route answered `"paidShare": 0.4` (a number) where Postgres answers `"paidShare": "0.4"`. +// +// This file boots the EMITTED report routes unmodified on a real SQLite (bun:sqlite) with +// the views the real lowering produces, and holds two things: +// 1. the type: a decimal is a JSON string on SQLite as it is on Postgres, null stays null, +// and everything that is not a decimal keeps its type; +// 2. the shared scenarios: every scenario the Postgres lane runs also passes here, so +// filtering, sorting and paging a decimal measure behave the same on both engines. + +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 type { ReportSeed } from "../src/api-contract-report-generated-server.ts"; +import { startSqliteReportServer, type SqliteReportServerHandle } from "../src/api-contract-report-sqlite-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"); + +async function withServer(fn: (server: SqliteReportServerHandle) => Promise, seed: ReportSeed = SEED): Promise { + const server = await startSqliteReportServer(META_PATH); + try { + await server.applySeed(seed); + await fn(server); + } finally { + await server.close(); + } +} + +async function getJson(server: SqliteReportServerHandle, path: string): Promise { + const res = await fetch(server.baseUrl + path); + expect(res.status).toBe(200); + return res.json(); +} + +describe("api contract report (FR-044) — GENERATED routes on SQLite", () => { + test("a ratio is a JSON string, as it is on Postgres, and the integers around it stay numbers", async () => { + await withServer(async (server) => { + const rows = (await getJson(server, "/api/invoice_totals")) as Array>; + expect(rows.length).toBe(1); + const row = rows[0]!; + expect(typeof row["paidShare"]).toBe("string"); + expect(Number(row["paidShare"])).toBe(0.4); + expect(row["invoices"]).toBe(5); + expect(row["totalCents"]).toBe(315500); + }); + }); + + test("a ratio is a string in the withCount envelope too", async () => { + await withServer(async (server) => { + const body = (await getJson(server, "/api/invoice_totals?withCount=1")) as { + rows: Array>; total: number; + }; + expect(body.total).toBe(1); + expect(typeof body.rows[0]!["paidShare"]).toBe("string"); + }); + }); + + test("a ratio over zero rows is null, not the string 'null' or 0", async () => { + await withServer(async (server) => { + const rows = (await getJson(server, "/api/invoice_totals")) as Array>; + expect(rows).toEqual([{ invoices: 0, totalCents: null, paidShare: null }]); + }, { invoices: [] }); + }); + + test("a report with no decimal field is untouched: every value keeps its type", async () => { + await withServer(async (server) => { + expect(await getJson(server, "/api/invoice_status_totals?sort=status:asc")).toEqual( + SEED.reports!["InvoiceStatusTotals"], + ); + expect(await getJson(server, "/api/invoices_by_months?sort=issuedOnMonth:asc")).toEqual( + SEED.reports!["InvoicesByMonth"], + ); + }); + }); + + test("the filter and the sort on a ratio still compare numerically after the wire change", async () => { + await withServer(async (server) => { + const hit = async (path: string): Promise => ((await getJson(server, path)) as unknown[]).length; + expect(await hit("/api/invoice_totals?filter[paidShare][gt]=0.3")).toBe(1); + expect(await hit("/api/invoice_totals?filter[paidShare][gt]=0.5")).toBe(0); + // 0.4 < 0.5 numerically; as TEXT '0.5' would sort the other way against '0.45'-style values. + expect(await hit("/api/invoice_totals?filter[paidShare][lt]=0.5")).toBe(1); + expect(await hit("/api/invoice_totals?filter[paidShare][eq]=0.4")).toBe(1); + expect(await hit("/api/invoice_totals?sort=paidShare:desc")).toBe(1); + }); + }); + + for (const scenario of loadScenarios(API_CONTRACT_REPORT_SCENARIOS_DIR)) { + test(`shared scenario: ${scenario.name}`, async () => { + await withServer((server) => runScenario(scenario, server)); + }); + } +}); + +async function runScenario(scenario: ApiScenario, server: SqliteReportServerHandle): 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); + } +} diff --git a/server/typescript/packages/integration-tests/test/report-views-pg.test.ts b/server/typescript/packages/integration-tests/test/report-views-pg.test.ts index 7921e357a..745cb9eb8 100644 --- a/server/typescript/packages/integration-tests/test/report-views-pg.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-pg.test.ts @@ -364,6 +364,40 @@ function metricModel(measures: string[]): string { ]}}); } +/** + * The spec's nested average (FR-044 design, R2): `avgDaysPerStarter = daysEngaged / starters`, + * grouped by program. `daysEngaged` is a distinct count of a tuple, and the tuple has to + * include the customer: without it the numerator is the number of distinct days ANYONE did, + * not the sum over customers of the days each did. + */ +function engagementModel(tuple: readonly string[]): string { + return JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "WorkoutEvent", children: [ + { "source.rdb": { "@table": "workout_events" } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "programId", "@required": true } }, + { "field.string": { name: "customerEmail", "@required": true } }, + { "field.int": { name: "weekNumber", "@required": true } }, + { "field.int": { name: "dayNumber", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { name: "program", "@of": "WorkoutEvent.programId" } }, + { "measure.aggregate": { name: "starters", "@agg": "count", "@distinct": true, "@of": "WorkoutEvent.customerEmail" } }, + { "measure.aggregate": { name: "daysEngaged", "@agg": "count", "@distinct": true, "@of": tuple.map((c) => `WorkoutEvent.${c}`) } }, + { "measure.ratio": { name: "avgDaysPerStarter", "@numerator": "daysEngaged", "@denominator": "starters" } }, + ] } }, + { "object.report": { name: "ProgramEngagement", "@from": "WorkoutEvent", "@dimensions": ["program"], + "@measures": ["starters", "daysEngaged", "avgDaysPerStarter"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_program_engagement" } } ] } }, + ]}}); +} + +/** One customer with three days, two customers who share one day: 5 customer-days, 3 starters. */ +const ENGAGEMENT_ROWS = ` + INSERT INTO "workout_events" ("programId","customerEmail","weekNumber","dayNumber") VALUES + (1, 'a@x.test', 1, 1), (1, 'a@x.test', 1, 2), (1, 'a@x.test', 1, 3), + (1, 'b@x.test', 1, 1), (1, 'c@x.test', 1, 1), + (1, 'a@x.test', 1, 1)`; + describe("report views — inline models on real Postgres", () => { test("UTC BUCKETS (Review Focus 3): a report at recordedAt:day puts 23:30 New York on the next UTC day", async () => { const root = await loadInline(EVENT_MODEL); @@ -434,6 +468,30 @@ describe("report views — inline models on real Postgres", () => { expect(again.up.trim()).toBe(""); }, 60_000); + test("NESTED AVERAGE: the tuple includes the customer, so 3 + 1 + 1 customer-days over 3 starters is 5 / 3", async () => { + const root = await loadInline(engagementModel(["programId", "customerEmail", "weekNumber", "dayNumber"])); + const { expected, unmanagedNames } = await migrate(root); + await assertConverged(expected, unmanagedNames); + await applyRaw(ENGAGEMENT_ROWS + ";"); + const [row] = await select( + `SELECT "program", "starters", "daysEngaged", "avgDaysPerStarter"::float8 AS "avg" FROM "v_program_engagement"`, + ); + expect(row).toMatchObject({ program: "1", starters: "3", daysEngaged: "5" }); + expect(Number(row!.avg)).toBeCloseTo(5 / 3, 9); + }, 60_000); + + test("NESTED AVERAGE, the slip it replaces: a tuple without the customer counts days anyone did, 3 / 3", async () => { + const root = await loadInline(engagementModel(["programId", "weekNumber", "dayNumber"])); + const { expected, unmanagedNames } = await migrate(root); + await assertConverged(expected, unmanagedNames); + await applyRaw(ENGAGEMENT_ROWS + ";"); + const [row] = await select( + `SELECT "program", "starters", "daysEngaged", "avgDaysPerStarter"::float8 AS "avg" FROM "v_program_engagement"`, + ); + expect(row).toMatchObject({ program: "1", starters: "3", daysEngaged: "3" }); + expect(Number(row!.avg)).toBe(1); + }, 60_000); + test("a tuple with a NULL component is not counted, read through the lowered view", async () => { // The canonical tuple's components are both required, so only an inline model can put a // NULL through the FILTER guard on an engine. diff --git a/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts b/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts index af9744695..015969ba1 100644 --- a/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts @@ -267,6 +267,40 @@ describe("report views — canonical model on real SQLite", () => { }); }); +/** + * The spec's nested average (FR-044 design, R2): `avgDaysPerStarter = daysEngaged / starters`, + * grouped by program. `daysEngaged` is a distinct count of a tuple, and the tuple has to + * include the customer: without it the numerator is the number of distinct days ANYONE did, + * not the sum over customers of the days each did. + */ +function engagementModel(tuple: readonly string[]): string { + return JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "WorkoutEvent", children: [ + { "source.rdb": { "@table": "workout_events" } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "programId", "@required": true } }, + { "field.string": { name: "customerEmail", "@required": true } }, + { "field.int": { name: "weekNumber", "@required": true } }, + { "field.int": { name: "dayNumber", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { name: "program", "@of": "WorkoutEvent.programId" } }, + { "measure.aggregate": { name: "starters", "@agg": "count", "@distinct": true, "@of": "WorkoutEvent.customerEmail" } }, + { "measure.aggregate": { name: "daysEngaged", "@agg": "count", "@distinct": true, "@of": tuple.map((c) => `WorkoutEvent.${c}`) } }, + { "measure.ratio": { name: "avgDaysPerStarter", "@numerator": "daysEngaged", "@denominator": "starters" } }, + ] } }, + { "object.report": { name: "ProgramEngagement", "@from": "WorkoutEvent", "@dimensions": ["program"], + "@measures": ["starters", "daysEngaged", "avgDaysPerStarter"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_program_engagement" } } ] } }, + ]}}); +} + +/** One customer with three days, two customers who share one day: 5 customer-days, 3 starters. */ +const ENGAGEMENT_ROWS = ` + INSERT INTO "workout_events" ("programId","customerEmail","weekNumber","dayNumber") VALUES + (1, 'a@x.test', 1, 1), (1, 'a@x.test', 1, 2), (1, 'a@x.test', 1, 3), + (1, 'b@x.test', 1, 1), (1, 'c@x.test', 1, 1), + (1, 'a@x.test', 1, 1)`; + describe("report views — inline model on real SQLite", () => { /** A Stamp table with date and naive-timestamp columns for the quarter / year grains. */ const STAMP_MODEL = JSON.stringify({ "metadata.root": { package: "acme", children: [ @@ -298,6 +332,25 @@ describe("report views — inline model on real SQLite", () => { { "source.rdb": { "@kind": "view", "@view": "v_pair_totals" } } ] } }, ]}}); + test("NESTED AVERAGE: the tuple includes the customer, so 3 + 1 + 1 customer-days over 3 starters is 5 / 3", async () => { + const root = await loadInline(engagementModel(["programId", "customerEmail", "weekNumber", "dayNumber"])); + const { expected } = await migrate(root); + await assertConverged(expected); + await applyRaw(ENGAGEMENT_ROWS); + const [row] = await select(`SELECT * FROM "v_program_engagement"`); + expect(row).toMatchObject({ program: 1, starters: 3, daysEngaged: 5 }); + expect(row!.avgDaysPerStarter as number).toBeCloseTo(5 / 3, 9); + }); + + test("NESTED AVERAGE, the slip it replaces: a tuple without the customer counts days anyone did, 3 / 3", async () => { + const root = await loadInline(engagementModel(["programId", "weekNumber", "dayNumber"])); + const { expected } = await migrate(root); + await assertConverged(expected); + await applyRaw(ENGAGEMENT_ROWS); + const [row] = await select(`SELECT * FROM "v_program_engagement"`); + expect(row).toMatchObject({ program: 1, starters: 3, daysEngaged: 3, avgDaysPerStarter: 1 }); + }); + test("a tuple with a NULL component is not counted, read through the lowered view", async () => { const root = await loadInline(PAIR_MODEL); expect(viewSql(root, "v_pair_totals")).toContain( diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts index 32a8a493b..21d68a664 100644 --- a/server/typescript/packages/metadata/test/reporting-validation.test.ts +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -159,6 +159,7 @@ function fullReportingModel(): Model { "@distinct": true, "@of": [ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber", ], @@ -1093,6 +1094,7 @@ describe("report accessors", () => { const days = event.children().find((c) => c.name === "daysEngaged") as MetaMeasure; expect(days.ofColumns()).toEqual([ "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber", ]); diff --git a/server/typescript/packages/runtime-ts/src/decimal-wire.ts b/server/typescript/packages/runtime-ts/src/decimal-wire.ts new file mode 100644 index 000000000..c71e7034c --- /dev/null +++ b/server/typescript/packages/runtime-ts/src/decimal-wire.ts @@ -0,0 +1,32 @@ +// The wire spelling of a field.decimal that a read-only mount SENDS. +// +// A decimal is a string on the wire: it is the string Postgres `numeric` and MySQL `DECIMAL` +// really return, and the string the TypeScript read schema types it as. SQLite has no +// decimal. A decimal field on a table is a `text` column and stays a string, but the value +// a report's view computes (a ratio, an average, a sum of a decimal field) is a REAL, which +// a SQLite driver hands back as a JS number. Sent as it is, the same route answers +// `"paidShare": "0.4"` on Postgres and `"paidShare": 0.4` on SQLite, and the type says +// `string` for both. +// +// So a mount takes the NAMES of its decimal fields (`decimalColumns`, which the generated +// report route passes on SQLite) and sends a number among them as its string. Which keys are +// decimals comes from the caller, never from the shape of a value: an integer column is not +// a decimal because it is a number. A string, a null and anything else pass through. The +// digits are JavaScript's own shortest round-trip spelling of the double (`1.6666666666666667`); +// like every decimal's spelling on the wire, they are the engine's and not part of the contract. + +/** Rewrite a number under any of `columns` to its string. Returns `undefined` when there is nothing to do. */ +export function decimalWire( + columns: readonly string[] | undefined, +): ((row: unknown) => unknown) | undefined { + if (columns === undefined || columns.length === 0) return undefined; + return (row) => { + if (row === null || typeof row !== "object") return row; + const out = { ...(row as Record) }; + for (const c of columns) { + const v = out[c]; + if (typeof v === "number") out[c] = String(v); + } + return out; + }; +} diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts index e323c2914..734594217 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts @@ -34,6 +34,7 @@ import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js"; export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js"; +export { decimalWire } from "../decimal-wire.js"; // --------------------------------------------------------------------------- // Loose types — we don't bind to a specific Drizzle backend so the helper 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 4424c8dad..b4f926228 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 @@ -6,6 +6,7 @@ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; +import { decimalWire } from "../decimal-wire.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views @@ -49,6 +50,14 @@ export interface MountReadOnlyOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; + /** + * The names of the view's decimal fields, for a dialect that has no decimal. SQLite hands + * a computed decimal (a report's ratio, average or sum) back as a REAL, a JS number, where + * Postgres and MySQL return the string the read schema types it as. A number under one of + * these keys is sent as its string, so the route answers the same on every engine. The + * generated route passes it for a report on SQLite and omits it otherwise. Default none. + */ + readonly decimalColumns?: readonly string[]; } const rejectMutation = (resource: string) => async ( @@ -150,7 +159,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const viewName = resolveViewName(view); const useRawSql = isEmptyColumnView(view) && !!viewName; // The raw-SQL branch has no declared columns, so nothing names a timestamp there. - const toWire = timestampWire(view); + const timestamps = timestampWire(view); + const decimals = decimalWire(opts.decimalColumns); + const toWire = decimals === undefined ? timestamps : (row: unknown) => decimals(timestamps(row)); // ── List ────────────────────────────────────────────────────────────────── fastify.get(path, ro, async (req, reply) => { diff --git a/server/typescript/packages/runtime-ts/src/hono/index.ts b/server/typescript/packages/runtime-ts/src/hono/index.ts index 8de51ee7e..93b6a1298 100644 --- a/server/typescript/packages/runtime-ts/src/hono/index.ts +++ b/server/typescript/packages/runtime-ts/src/hono/index.ts @@ -53,6 +53,7 @@ import { guardRoute, readJsonBody } from "./route-guard.js"; // shared (deprecated) helper so the three adapters can't silently diverge. export { parseId } from "../drizzle-fastify/util.js"; export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js"; +export { decimalWire } from "../decimal-wire.js"; // --------------------------------------------------------------------------- // Loose types — we don't bind to a specific Drizzle backend so the helper 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 7274e6643..b598469dd 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 @@ -13,6 +13,7 @@ import type { } from "../drizzle-fastify/filter-allowlist.js"; import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode, viewBaseConfig } from "../drizzle-fastify/util.js"; import { timestampWire } from "../timestamp-wire.js"; +import { decimalWire } from "../decimal-wire.js"; // An unexpected error on a mounted route answers `500 { error: "internal" }`. import { guardRoute } from "./route-guard.js"; @@ -48,6 +49,14 @@ export interface MountReadOnlyOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; + /** + * The names of the view's decimal fields, for a dialect that has no decimal. SQLite hands + * a computed decimal (a report's ratio, average or sum) back as a REAL, a JS number, where + * Postgres and MySQL return the string the read schema types it as. A number under one of + * these keys is sent as its string, so the route answers the same on every engine. The + * generated route passes it for a report on SQLite and omits it otherwise. Default none. + */ + readonly decimalColumns?: readonly string[]; } function resolveViewName(view: AnyView): string | undefined { @@ -129,7 +138,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const viewName = resolveViewName(view); const useRawSql = isEmptyColumnView(view) && !!viewName; // The raw-SQL branch has no declared columns, so nothing names a timestamp there. - const toWire = timestampWire(view); + const timestamps = timestampWire(view); + const decimals = decimalWire(opts.decimalColumns); + const toWire = decimals === undefined ? timestamps : (row: unknown) => decimals(timestamps(row)); // ── List ────────────────────────────────────────────────────────────────── app.get(path, guardRoute(async (c) => { diff --git a/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts new file mode 100644 index 000000000..0ca91fd7e --- /dev/null +++ b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts @@ -0,0 +1,108 @@ +// `decimalColumns` on the read-only mounts: a SQLite REAL under a declared decimal key is +// sent as its string, exactly the spelling Postgres `numeric` and MySQL `DECIMAL` already +// give. Both adapters (Fastify and Hono), plus the pure function they share. + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import Fastify, { type FastifyInstance } from "fastify"; +import { Hono } from "hono"; +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { sqliteView, integer, text } from "drizzle-orm/sqlite-core"; +import { decimalWire } from "../src/decimal-wire.js"; +import { mountReadOnlyCrudRoutes as mountFastify } from "../src/drizzle-fastify/mount-read-only.js"; +import { mountReadOnlyCrudRoutes as mountHono } from "../src/hono/mount-read-only.js"; + +describe("decimalWire", () => { + test("nothing to do when no column is named", () => { + expect(decimalWire(undefined)).toBeUndefined(); + expect(decimalWire([])).toBeUndefined(); + }); + + test("a number under a named key becomes its string; everything else passes through", () => { + const wire = decimalWire(["share", "avg"])!; + expect(wire({ share: 0.4, avg: 1.6666666666666667, n: 5, label: "a" })).toEqual({ + share: "0.4", avg: "1.6666666666666667", n: 5, label: "a", + }); + expect(wire({ share: null, avg: "2.50", n: 5 })).toEqual({ share: null, avg: "2.50", n: 5 }); + expect(wire({ n: 5 })).toEqual({ n: 5 }); + expect(wire(null)).toBeNull(); + }); + + test("an integer-valued REAL keeps no decimal point, and a key not named stays a number", () => { + expect(decimalWire(["share"])!({ share: 1, other: 1 })).toEqual({ share: "1", other: 1 }); + }); +}); + +describe("read-only mounts send a named decimal as a string", () => { + let client: ReturnType; + let fastify: FastifyInstance; + let hono: Hono; + let plainFastify: FastifyInstance; + + const view = () => sqliteView("v_shares", { + kind: text("kind").notNull(), + n: integer("n").notNull(), + share: text("share"), + }).existing(); + + beforeAll(async () => { + client = createClient({ url: ":memory:" }); + // `share` is a computed REAL, the way a report's ratio is: SQLite gives it back as a number. + await client.execute(`CREATE TABLE t (kind TEXT NOT NULL, hit INTEGER NOT NULL)`); + await client.execute(`INSERT INTO t (kind, hit) VALUES ('a', 1), ('a', 0), ('b', 0)`); + await client.execute( + `CREATE VIEW v_shares AS SELECT kind, COUNT(*) AS n, CAST(SUM(hit) AS REAL) / NULLIF(COUNT(*), 0) AS share FROM t GROUP BY kind`, + ); + const db = drizzle(client); + const base = { path: "/shares", db, view: view(), filterAllowlist: {}, sortAllowlist: {}, dialect: "sqlite" as const, itemRoutes: false, resource: "report" as const }; + + fastify = Fastify(); + mountFastify({ fastify, ...base, decimalColumns: ["share"] }); + await fastify.ready(); + + plainFastify = Fastify(); + mountFastify({ fastify: plainFastify, ...base }); + await plainFastify.ready(); + + hono = new Hono(); + mountHono({ app: hono, ...base, decimalColumns: ["share"] }); + }); + + afterAll(async () => { + await fastify.close(); + await plainFastify.close(); + client.close(); + }); + + const expected = [{ kind: "a", n: 2, share: "0.5" }, { kind: "b", n: 1, share: "0" }]; + const byKind = (rows: Array<{ kind: string }>) => [...rows].sort((x, y) => x.kind.localeCompare(y.kind)); + + test("fastify", async () => { + const res = await fastify.inject({ method: "GET", url: "/shares" }); + expect(byKind(JSON.parse(res.body))).toEqual(expected); + }); + + test("fastify, withCount envelope", async () => { + const res = await fastify.inject({ method: "GET", url: "/shares?withCount=1" }); + const body = JSON.parse(res.body) as { rows: Array<{ kind: string }>; total: number }; + expect(body.total).toBe(2); + expect(byKind(body.rows)).toEqual(expected); + }); + + test("hono", async () => { + const res = await hono.request("/shares"); + expect(byKind((await res.json()) as Array<{ kind: string }>)).toEqual(expected); + }); + + test("hono, withCount envelope", async () => { + const res = await hono.request("/shares?withCount=1"); + const body = (await res.json()) as { rows: Array<{ kind: string }>; total: number }; + expect(body.total).toBe(2); + expect(byKind(body.rows)).toEqual(expected); + }); + + test("without decimalColumns the mount is unchanged: the REAL reaches the wire as a number", async () => { + const res = await plainFastify.inject({ method: "GET", url: "/shares" }); + expect(byKind(JSON.parse(res.body))).toEqual([{ kind: "a", n: 2, share: 0.5 }, { kind: "b", n: 1, share: 0 }]); + }); +}); diff --git a/server/typescript/packages/test-generators/src/routes.ts b/server/typescript/packages/test-generators/src/routes.ts index 6be087bd2..425fcfcdb 100644 --- a/server/typescript/packages/test-generators/src/routes.ts +++ b/server/typescript/packages/test-generators/src/routes.ts @@ -73,6 +73,7 @@ import { isWriteThrough, isReport, itemRouteField, + reportDecimalColumns, DEFAULT_ID_FIELD, servesReadApi, formatTs, @@ -154,12 +155,15 @@ function renderRoutes( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. + // A decimal a SQLite report computes is a REAL; the mount sends it as the string it is elsewhere. + const decimalColumns = reportDecimalColumns(entity, ctx.dialect); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + - (report ? `\n${indent}resource: "report",` : ""); + (report ? `\n${indent}resource: "report",` : "") + + (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — From 07988b3b54c8545f094d7ab173bf1c59778b5388 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 9 Oct 2026 11:58:32 -0400 Subject: [PATCH 2/4] test(runtime-ts): type the decimal-wire test's row sorter so the workspace typecheck passes --- server/typescript/packages/runtime-ts/test/decimal-wire.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts index 0ca91fd7e..5835273a5 100644 --- a/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts +++ b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts @@ -75,7 +75,7 @@ describe("read-only mounts send a named decimal as a string", () => { }); const expected = [{ kind: "a", n: 2, share: "0.5" }, { kind: "b", n: 1, share: "0" }]; - const byKind = (rows: Array<{ kind: string }>) => [...rows].sort((x, y) => x.kind.localeCompare(y.kind)); + const byKind = (rows: T[]): T[] => [...rows].sort((x, y) => x.kind.localeCompare(y.kind)); test("fastify", async () => { const res = await fastify.inject({ method: "GET", url: "/shares" }); From afaa196563f260a7ad38337382a6e72b1b3c4564 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 9 Oct 2026 12:16:45 -0400 Subject: [PATCH 3/4] test(runtime-ts): type the parsed body in the decimal-wire mount test --- server/typescript/packages/runtime-ts/test/decimal-wire.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts index 5835273a5..28d48b542 100644 --- a/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts +++ b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts @@ -103,6 +103,6 @@ describe("read-only mounts send a named decimal as a string", () => { test("without decimalColumns the mount is unchanged: the REAL reaches the wire as a number", async () => { const res = await plainFastify.inject({ method: "GET", url: "/shares" }); - expect(byKind(JSON.parse(res.body))).toEqual([{ kind: "a", n: 2, share: 0.5 }, { kind: "b", n: 1, share: 0 }]); + expect(byKind(JSON.parse(res.body) as Array<{ kind: string; n: number; share: number }>)).toEqual([{ kind: "a", n: 2, share: 0.5 }, { kind: "b", n: 1, share: 0 }]); }); }); From c3e88515784f20553bd4da25bbf17cfe61f9a6aa Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 9 Oct 2026 14:42:34 -0400 Subject: [PATCH 4/4] no-mistakes(document): Fixed stale documentation: README.md and metaobjects-authoring/SKILL.md updated to reflect report list hook generation; all other docs accurate. --- .../skills/metaobjects-authoring/SKILL.md | 2 +- docs/README.md | 2 +- .../Article.queries.ts | 88 ++++++++++ .../tmp-owned-skew-sqlite-uTNCWG/Article.ts | 159 ++++++++++++++++++ 4 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 server/typescript/packages/codegen-ts/test/tmp-owned-skew-sqlite-uTNCWG/Article.queries.ts create mode 100644 server/typescript/packages/codegen-ts/test/tmp-owned-skew-sqlite-uTNCWG/Article.ts diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index a7ed27bf9..1f0c1b64a 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -752,7 +752,7 @@ no `source.*` is checked at load and generates nothing. A report `@from` a TPH subtype is refused when its view is derived (the subtype shares its base's table): declare it `@from` the base with an `@filter` on the discriminator field. -What does not exist: no client hook, grid or form for a report yet, no way to narrow which +What does not exist: no grid or form for a report (TypeScript generates a list hook; no other port has a client tier), no way to narrow which derived fields are filterable, no `measure.derived` (arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and diff --git a/docs/README.md b/docs/README.md index 20d2bfd88..ea3574c14 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,7 +66,7 @@ this tree is documentation, not the source of truth. | Build on a metadata model another repository publishes (`dependencies`, `meta deps sync`, overlay/extend across the boundary) | [`features/metadata-dependencies.md`](features/metadata-dependencies.md) | | Adopt a design MetaObjects already ships — users/groups/roles, an LLM trace envelope — instead of authoring it (`libraries`, `meta eject `) | [`features/libraries.md`](features/libraries.md) | | Record what the system is supposed to do, and stop agents reviving retired features | [`features/requirements.md`](features/requirements.md) | -| Declare what a dashboard groups by and counts (`dimension`, `measure`, `segment`, `object.report`; load-time checked; a view-backed report becomes a SQL view served by a generated read-only list route; no client hook yet) | [`features/reporting.md`](features/reporting.md) | +| Declare what a dashboard groups by and counts (`dimension`, `measure`, `segment`, `object.report`; load-time checked; a view-backed report becomes a SQL view served by a generated read-only list route and a TypeScript list hook) | [`features/reporting.md`](features/reporting.md) | | Wire prompt construction (FR-004) | [`features/templates-and-payloads.md`](features/templates-and-payloads.md) | | Share a metadata shape across multiple instances (abstracts, `extends:`) | [`features/abstracts-and-inheritance.md`](features/abstracts-and-inheritance.md) | | Add a custom metamodel subtype or attribute to a downstream project | [`features/extending-with-providers.md`](features/extending-with-providers.md) + [`recipes/extending-metaobjects-with-providers.md`](recipes/extending-metaobjects-with-providers.md) | diff --git a/server/typescript/packages/codegen-ts/test/tmp-owned-skew-sqlite-uTNCWG/Article.queries.ts b/server/typescript/packages/codegen-ts/test/tmp-owned-skew-sqlite-uTNCWG/Article.queries.ts new file mode 100644 index 000000000..424fbd98c --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tmp-owned-skew-sqlite-uTNCWG/Article.queries.ts @@ -0,0 +1,88 @@ +// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT. +// Source metadata: Article (Article) +// Extend in your own module (e.g. Article.extra.ts) — nothing imports it for you. +import { eq } from "drizzle-orm"; +import type { z } from "zod"; + +import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; +type Db = BaseSQLiteDatabase< + "sync" | "async", + unknown, + Record +>; + +import { + type Article, + ArticleInsertPreservingSchema, + ArticleInsertSchema, + type ArticlePatch, + articles, + ArticleUpdateSchema, +} from "./Article"; +export async function findArticleById( + db: Db, + id: number, +): Promise
{ + const [article] = await db + .select() + .from(articles) + .where(eq(articles.id, id)) + .limit(1); + return article ?? null; +} +export async function listArticles( + db: Db, + opts?: { limit?: number; offset?: number }, +): Promise { + let q = db.select().from(articles).$dynamic(); + if (opts?.limit !== undefined) { + q = q.limit(opts.limit); + } + if (opts?.offset !== undefined) { + q = q.offset(opts.offset); + } + return q; +} +export async function createArticle( + db: Db, + data: z.input, +): Promise
{ + const validated = ArticleInsertSchema.parse(data); + const [article] = await db.insert(articles).values(validated).returning(); + return article!; +} +export async function insertPreservingArticle( + db: Db, + data: z.input, +): Promise
{ + const validated = ArticleInsertPreservingSchema.parse(data); + const [article] = await db.insert(articles).values(validated).returning(); + return article!; +} +export async function updateArticle( + db: Db, + id: number, + patch: ArticlePatch, +): Promise
{ + const validated = ArticleUpdateSchema.parse(patch); + // PATCH-5: an empty patch is a no-op — return the current row rather than let + // Drizzle throw on an empty SET clause. + if (Object.keys(validated).length === 0) { + return findArticleById(db, id); + } + const [article] = await db + .update(articles) + .set(validated) + .where(eq(articles.id, id)) + .returning(); + return article ?? null; +} +export async function deleteArticleById(db: Db, id: number): Promise { + // Use .returning() unconditionally — supported on SQLite ≥3.35 (covers D1, libsql/Turso) + // and Postgres. Result is an array of deleted rows; presence implies success. + const deleted = await db + .delete(articles) + .where(eq(articles.id, id)) + .returning(); + return deleted.length > 0; +} diff --git a/server/typescript/packages/codegen-ts/test/tmp-owned-skew-sqlite-uTNCWG/Article.ts b/server/typescript/packages/codegen-ts/test/tmp-owned-skew-sqlite-uTNCWG/Article.ts new file mode 100644 index 000000000..977c3f263 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tmp-owned-skew-sqlite-uTNCWG/Article.ts @@ -0,0 +1,159 @@ +// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts. +// Source metadata: Article (Article) +// Extend in your own module (e.g. Article.extra.ts) — nothing imports it for you. +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { z } from "zod"; + +export const articles = sqliteTable("articles", { + id: integer("id").primaryKey({ autoIncrement: true }), + title: text("title").notNull(), + createdAt: text("created_at").$defaultFn(() => new Date().toISOString()), + updatedAt: text("updated_at").$defaultFn(() => new Date().toISOString()), +}); +export type Article = InferSelectModel; +export type ArticleInsert = InferInsertModel; +export type ArticleUpdate = Partial; +/** A field.timestamp instant in UTC (`YYYY-MM-DDTHH:MM:SS[.fff]Z`): a value already spelled so is kept + * as sent, an offset is applied, a zoneless value is UTC and a date alone is midnight UTC. + * SQLite/D1 compare timestamps as text, so instants are stored in UTC. Generated. */ +function utcIsoTimestamp(v: string): string { + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(v)) { + return v; + } + const m = + /^(\d{4}-\d{2}-\d{2})(?:[Tt ](\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?(?:([Zz])|([+-]\d{2}):?(\d{2})?)?)?$/.exec( + v, + ); + if (m === null) { + return v; + } + const t = Date.parse( + `${m[1]}T${m[2] ?? "00:00"}:${m[3] ?? "00"}.${(m[4] ?? "").slice(0, 3).padEnd(3, "0")}${ + m[6] === undefined ? "Z" : `${m[6]}:${m[7] ?? "00"}` + }`, + ); + return Number.isNaN(t) + ? v + : new Date(t).toISOString().replace(/\.?0+Z$/, "Z"); +} + +export const ArticleInsertSchema = z.object({ + title: z.string().min(1).max(200), + createdAt: z + .string() + .optional() + .transform(() => new Date().toISOString()), + updatedAt: z + .string() + .optional() + .transform(() => new Date().toISOString()), +}); + +export const ArticleUpdateSchema = z.object({ + title: z.string().min(1).max(200).optional(), + updatedAt: z + .string() + .optional() + .transform(() => new Date().toISOString()), +}); + +/** Typed create shape for Article: the insert schema's INPUT (pre-transform) type. A + * renamed/dropped/misspelt field is a compile error at every `createArticle` call site; + * the schema still validates at runtime. */ +export type ArticleCreate = z.input; + +/** Typed patch shape for Article: every settable field, optional (FR-035 PATCH). A + * renamed/dropped field is a compile error at every `updateArticle` call site. */ +export type ArticlePatch = z.input; + +/** Insert-shape for import / restore / replication of Article: identical to + * ArticleInsertSchema, but the @autoSet timestamp columns are written VERBATIM + * (no create-time now() stamp) so the caller's original values are preserved. */ +export const ArticleInsertPreservingSchema = z.object({ + title: z.string().min(1).max(200), + createdAt: z + .string() + .regex( + /^(?:\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|02-(?:0[1-9]|1\d|2[0-8]))|(?:\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)(?:[Tt ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:[Zz]|[+-](?:[01]\d|2[0-3])(?::?[0-5]\d)?)?)?$/, + "must be an ISO 8601 timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])", + ) + .transform(utcIsoTimestamp) + .optional(), + updatedAt: z + .string() + .regex( + /^(?:\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|02-(?:0[1-9]|1\d|2[0-8]))|(?:\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)(?:[Tt ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:[Zz]|[+-](?:[01]\d|2[0-3])(?::?[0-5]\d)?)?)?$/, + "must be an ISO 8601 timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])", + ) + .transform(utcIsoTimestamp) + .optional(), +}); + +/** Typed input of `insertPreservingArticle` — ArticleInsertPreservingSchema's pre-transform shape. */ +export type ArticleCreatePreserving = z.input< + typeof ArticleInsertPreservingSchema +>; +/** + * Metadata constants for Article. + * + * Use these instead of magic strings so TS catches typos and refactors stay + * coherent. Each non-dollar-prefixed key is a per-field object carrying + * name, label, view, an htmlType where the field's view maps to one, and the + * RHF-shaped validation rules derived from the field's validator children. + * + * placeholder and helpText are NOT emitted — no provider registers an attribute + * for them, so nothing here could derive one. (htmlType is emitted, but derived + * from the view subtype; the @htmlType override was removed for the same reason.) + * useEntityForm still honours placeholder if you add it: this file is generated, + * hand edits inside it survive regeneration through the three-way merge, and the + * consumers read this const rather than the metadata. That is the intended way + * to set it. + * + * Typical usage with the metaobjects React form helper: + * + * import { useEntityForm } from '@metaobjectsdev/react'; + * const form = useEntityForm(Article, ArticleInsertSchema); + * + */ +export const Article = { + $entity: "Article", + $table: "articles", + $path: "/articles", + id: { name: "id", label: "Id", view: "number", htmlType: "number" }, + title: { + name: "title", + label: "Title", + view: "text", + htmlType: "text", + rules: { + required: "Title is required", + maxLength: { value: 200, message: "Must be 200 characters or fewer" }, + }, + }, + createdAt: { + name: "createdAt", + label: "Created At", + view: "date", + htmlType: "datetime-local", + }, + updatedAt: { + name: "updatedAt", + label: "Updated At", + view: "date", + htmlType: "datetime-local", + }, +} as const; +import type { FilterAllowlist } from "@metaobjectsdev/runtime-ts/drizzle-fastify"; + +export const ArticleFilterAllowlist = {} as const satisfies FilterAllowlist; +import type { SortAllowlist } from "@metaobjectsdev/runtime-ts/drizzle-fastify"; + +export const ArticleSortAllowlist = {} as const satisfies SortAllowlist; +export type ArticleFilter = { + limit?: number; + offset?: number; + sort?: string; + or?: ArticleFilter[]; + and?: ArticleFilter[]; +};