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/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/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/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/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/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[]; +}; 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..28d48b542 --- /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: T[]): T[] => [...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) as Array<{ kind: string; n: number; share: number }>)).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 —