From ecd0abbe8649b414a48689ebd1a6eb6f7e890fd2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 20:21:04 -0400 Subject: [PATCH 1/4] fix(reporting): report list routes return all rows up to a uniform cap, refuse bad bounds Report list routes in all five ports now return every row up to a generator-configurable cap (default 10000) instead of a silent 50-row page. Above the cap, or with an explicit limit above it, the route answers 400 report_too_large with maxRows. limit<=0, negative offset and non-integer bounds are refused with the existing 400 envelope (C# used to ignore them). Pinned by new api-contract report scenarios. --- CHANGELOG.md | 18 ++- docs/features/api-contract.md | 14 +- docs/features/reporting.md | 28 +++- .../codegen/generators/routes.ts | 12 +- .../runtime/drizzle-fastify/filter-parser.ts | 2 +- .../runtime/drizzle-fastify/list-params.ts | 43 ++++++ .../drizzle-fastify/mount-read-only.ts | 35 ++++- .../showcase/codegen/generators/routes.ts | 12 +- .../runtime/drizzle-fastify/filter-parser.ts | 2 +- .../runtime/drizzle-fastify/list-params.ts | 43 ++++++ .../drizzle-fastify/mount-read-only.ts | 35 ++++- .../api-contract-conformance/report/README.md | 18 ++- .../api-contract-conformance/report/meta.json | 18 +++ .../report/scenarios/bad-page-bound.yaml | 41 ++++++ .../report/scenarios/cap-all-rows.yaml | 31 +++++ .../report/scenarios/cap-refused.yaml | 38 ++++++ .../report/schema.mysql.sql | 12 ++ .../report/schema.postgres.sql | 14 ++ .../report/schema.sqlite.sql | 12 ++ .../api-contract-conformance/report/seed.json | 126 ++++++++++++++++++ .../Generators/RoutesGenerator.cs | 64 ++++++++- .../Api/ReportGeneratedServerFactory.cs | 8 +- .../kotlin/KotlinSpringControllerGenerator.kt | 77 ++++++++++- .../spring/SpringControllerGenerator.java | 97 +++++++++++++- ...portGeneratedApiContractConformanceTest.kt | 4 +- .../GeneratedReportControllerHarness.kt | 7 +- .../integration/api/ReportCorpus.java | 2 +- .../api/ReportOmdbApiContractLane.java | 2 +- .../GeneratedReportControllerHarness.java | 5 + .../codegen/generators/router_generator.py | 94 ++++++++++++- .../tests/integration/generated_report_app.py | 9 +- .../integration/test_api_contract_report.py | 2 +- .../src/generators/routes-file-hono.ts | 9 +- .../codegen-ts/src/generators/routes-file.ts | 9 +- .../codegen-ts/src/reference/routes-hono.ts | 12 +- .../codegen-ts/src/reference/routes.ts | 12 +- .../src/templates/routes-file-hono.ts | 3 + .../codegen-ts/src/templates/routes-file.ts | 3 + .../api-contract-report-generated-server.ts | 15 ++- .../src/api-contract-report-mysql-schema.ts | 4 + .../src/api-contract-report-mysql-server.ts | 11 +- .../src/api-contract-report-sqlite-server.ts | 12 +- .../test/api-contract-report-corpus.test.ts | 20 +-- .../src/drizzle-fastify/filter-parser.ts | 2 +- .../src/drizzle-fastify/list-params.ts | 43 ++++++ .../src/drizzle-fastify/mount-read-only.ts | 35 ++++- .../runtime-ts/src/hono/mount-read-only.ts | 34 ++++- .../drizzle-fastify/mount-read-only.test.ts | 74 ++++++++++ .../test-generators/src/generators/routes.ts | 12 +- 49 files changed, 1148 insertions(+), 87 deletions(-) create mode 100644 fixtures/api-contract-conformance/report/scenarios/bad-page-bound.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/cap-all-rows.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/cap-refused.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 371c3b641..8b8ca9ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ here.** ### Changed +- **A report list route answers every row up to one cap, and is refused above it, in all five + ports.** With no `limit` a served `object.report` returns all of its rows; one row past the + cap, or an explicit `limit` above it, is `400 { "error": "report_too_large", "maxRows": N }`, + before any body is written. The cap is 10,000 by default and a generator option: + `routesFile({ reportMaxRows })` / `routesFileHono` (TypeScript), `RoutesGenerator.ReportMaxRows` + (C#), the `reportMaxRows` generator arg (Java, Kotlin), `router_generator(report_max_rows=)` + (Python). **This changes served output in Java, Kotlin and Python**, whose report lists used to + stop at 50 rows with no limit; and in every port a report longer than the cap that used to answer + now answers `report_too_large`. Entity and projection lists keep their paging. A report page + bound that is not usable (`limit` below 1, a negative `offset`, not an integer) is + `400 pagination.invalid_value` in every port. Gated by the new `ReadingLog` report and the + `cap-all-rows`, `cap-refused` and `bad-page-bound` scenarios of the api-contract `report/` corpus. + - **Requirements guidance: a feature commit updates its requirement.** The agent context (the authoring and verify skills and the always-on file `meta init` writes) now tells an agent that a commit adding, changing or removing behaviour updates the ledger in the same @@ -31,7 +44,10 @@ here.** the cast changes the operator and still reads as drift, as does a changed pattern or any other cast. Schema comparison is TypeScript-owned, so no other port carries this comparison. - +- **A report list in Java, Kotlin and Python no longer silently drops rows past the 50th.** A + 90-day daily report showed 50 days with no error when `limit` was omitted. C# also ignored a + `limit` of 0 or below on a report instead of refusing it. See the report row cap under + Changed. - **The api reference now follows a routes generator's `filter` as well as its `expose`.** `routesFile({ filter })` and `routesFileHono({ filter })` mount nothing for an entity the filter rejects, but the API pages still listed all five verbs for it. The pages now document the diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index c949f314a..9010dc2a3 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -206,7 +206,7 @@ feature demand (it is a consistency/safety divergence, not a capability). The TypeScript mount helpers (`@metaobjectsdev/runtime-ts/drizzle-fastify`, `/fastify` and `/hono`) pin five responses the contract leaves open — HTTP 5xx is implementation-defined below, and no corpus scenario sends a malformed body, a write the database rejects, a -malformed page bound, or a value only the generated schema's format check refuses. All five use the contract's +malformed page bound to an entity, or a value only the generated schema's format check refuses. All five use the contract's `{ "error": "" }` envelope, and all five are scoped to the routes the helpers mount: an adopter's own routes, and a Fastify `setErrorHandler` or Hono `onError` the adopter installed, answer exactly as they did before. @@ -214,7 +214,7 @@ or Hono `onError` the adopter installed, answer exactly as they did before. | Response | When | |---|---| | malformed JSON body | a `POST`/`PATCH`/`PUT` body that does not parse as JSON (an empty body sent as `application/json` included) → HTTP 400 `{ "error": "invalid_json" }`. An EMPTY body on a method that takes none (`GET`, `DELETE`, `HEAD`) is accepted even when the request declares `content-type: application/json`, as many clients do on every request; a non-empty `DELETE` body is still parsed. Before, Fastify answered its own `{ "statusCode": 400, "code": "FST_ERR_CTP_INVALID_JSON_BODY", … }` and Hono a Zod `validation` error about a missing object | -| malformed page bound | a `?limit=` or `?offset=` that is not a non-negative integer (`abc`, `-5`, `1.5`, an empty value) → HTTP 400 `{ "error": "pagination.invalid_value", "param": "limit", "value": "abc", "expected": "a non-negative integer (0 or more)" }`. Before, a non-numeric bound was dropped (every row came back) and a negative or fractional one reached SQL. There is no page-size maximum on an entity or a projection with declared columns; an opaque view (no declared columns, served by raw SQL) pages to at most 1000 rows, so there a `limit` above 1000 is refused too, with `"expected": "an integer from 0 to 1000"` (it used to be clamped silently). The same answer comes from the Drizzle mounts and the ObjectManager Fastify mount. No corpus scenario sends a malformed bound; the other ports answer it as their framework does | +| malformed page bound | a `?limit=` or `?offset=` that is not a non-negative integer (`abc`, `-5`, `1.5`, an empty value) → HTTP 400 `{ "error": "pagination.invalid_value", "param": "limit", "value": "abc", "expected": "a non-negative integer (0 or more)" }`. Before, a non-numeric bound was dropped (every row came back) and a negative or fractional one reached SQL. There is no page-size maximum on an entity or a projection with declared columns; an opaque view (no declared columns, served by raw SQL) pages to at most 1000 rows, so there a `limit` above 1000 is refused too, with `"expected": "an integer from 0 to 1000"` (it used to be clamped silently). The same answer comes from the Drizzle mounts and the ObjectManager Fastify mount. The corpus sends a malformed bound only to a report (`report/bad-page-bound`), where every port answers this same envelope and also refuses `limit=0`; on an entity or a projection the other ports answer it as their framework does | | database constraint violation | a write the database itself rejects → `{ "error": "constraint_violation", "constraint": "" }`, HTTP **409** for `unique` and `foreign_key` (a conflict with another row) and HTTP **400** for `check` and `not_null` (the request's own value), per the status rule under "Error response". The body names the constraint KIND only — never the SQL, the table or the bound values. A `CHECK` the model derives (an enum, a `validator.numeric`/`length`/`regex` range) is normally refused earlier by the route's own validation, as the corpus's `{ "error": "validation" }` 400; this row is the database's backstop | | schema validation failure | a body that fails the insert/update schema → HTTP 400 `{ "error": "validation", "issues": [...] }`, each issue as Zod reports it **minus `pattern`**: Zod 4 attaches a failed `.regex()` check's source there, which for a generated `field.date` / `field.time` / `field.timestamp` check is a ~300-character calendar regex (and an authored `validator.regex` pattern is the server's to keep). The generated temporal checks carry a short `message` naming the expected format instead (`"must be an ISO date (YYYY-MM-DD)"`) | | unexpected server error | anything that is not a filter, validation, not-found or constraint answer — a query against a column the database no longer has, a driver failure → HTTP 500 `{ "error": "internal" }`, the code the cross-port reference servers already use. The body names no SQL, table, column or bound parameter; the full error goes to the server log (`console.error`). Before, Fastify's default handler echoed the driver message, which for Drizzle is the query text and its parameter values | @@ -250,7 +250,13 @@ each handler they register instead; an `HTTPException` is rethrown to your - `limit=N` — page size. - `offset=N` — page offset. Both are non-negative integers; the TS mounts refuse anything else with a 400 (see "TS-only error responses"). -- **No page-size maximum, and the default is per port.** The contract pins what +- **A report is the exception: one cap in every port.** A served `object.report` list returns every + row when `limit` is omitted, up to 10,000 (a generator option), and answers + `400 { "error": "report_too_large", "maxRows": N }` for one row more, or for a `limit` above the cap. + A `limit` below 1, a negative `offset` or a value that is not an integer is + `400 { "error": "pagination.invalid_value", "param": "limit" | "offset", "value", "expected" }` on + a report in every port. See [reporting.md](reporting.md#how-a-report-is-served). +- **No page-size maximum on an entity, and the default is per port.** The contract pins what an explicit `limit`/`offset` returns (`list-with-pagination`); it does not pin a default or a ceiling, and the ports differ. With `limit` **omitted**, the TypeScript mounts and the C# routes return **every** matching row, while the @@ -528,7 +534,7 @@ the empty rows, `isNull=true` matches nothing, and a sort on it orders the same engine. Gated by [`fixtures/api-contract-conformance/report/`](../../fixtures/api-contract-conformance/report/) -(27 scenarios), **generated lane only, on all five ports**, for the reason `projection/` +(30 scenarios), **generated lane only, on all five ports**, for the reason `projection/` gives. The columns, their types and the per-port generated files are in [reporting.md](reporting.md#how-a-report-is-served). diff --git a/docs/features/reporting.md b/docs/features/reporting.md index dbe294334..18e578145 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -556,8 +556,26 @@ rule every object uses ([api-contract.md](api-contract.md)). | a filter or sort error | The four field-naming envelopes of [api-contract.md](api-contract.md), unchanged. | The route lists the compiled view. No request parameter picks dimensions, measures or a -grain. The default page size is the port's own, as for any list route: TypeScript and C# -return every row when `limit` is omitted, Java, Kotlin and Python the first 50. +grain. + +**Paging and the row cap.** A report is a whole answer, so its list route has no page default. +With no `limit` it returns **every row**, in all five ports, up to one cap: **10,000 rows**. One +row past the cap, or an explicit `limit` above it, is refused before any body is written with +`400 { "error": "report_too_large", "maxRows": N }`; the route never truncates silently. Narrow +the report with a filter, or page it with `limit` and `offset`. The cap is a generator option, the +same default everywhere, set where each port sets its other generator options: + +| Port | Set the cap | +|---|---| +| TypeScript | `routesFile({ reportMaxRows: 500 })` (and `routesFileHono`), or `reportMaxRows` on `mountReadOnlyCrudRoutes` in an owned routes file | +| C# | `new RoutesGenerator { ReportMaxRows = 500 }` | +| Java, Kotlin | the `reportMaxRows` generator arg (`500` in the plugin's ``) | +| Python | `router_generator(report_max_rows=500)` | + +A page bound that is not usable is refused, never ignored: `limit` below 1, a negative `offset` or +a value that is not an integer answers `400 { "error": "pagination.invalid_value", "param": ... }`, +the envelope the TypeScript mounts use for a malformed bound. Entity and projection lists keep +their own paging ([api-contract.md](api-contract.md#sort--pagination)). **What a caller may filter and sort on.** Every derived field whose type has filter operators (string, enum, uuid, int, long, double, float, decimal, currency, date, time, timestamp, @@ -716,7 +734,7 @@ compares the canonical string. D1 has no separate corpus: it is SQLite behind Cl binding, so TypeScript runs the same scenarios and the same REST scenarios through Miniflare's local D1 (`meta migrate --dialect d1`), with no cloud account. -The REST surface is gated by twenty-seven scenarios under +The REST surface is gated by thirty scenarios under [`fixtures/api-contract-conformance/report/`](../../fixtures/api-contract-conformance/report/), run in the **generated lane on all five ports**: list (a dimension with a segment-scoped sum that is null for one group), a time dimension at a grain (`YYYY-MM-DD`), a no-dimension @@ -727,7 +745,9 @@ sale keeps its row, with a filter and a sort on its defaulted measure, a dimensi two references, one time dimension listed at two grains, a filter on a string (`like`), a boolean and a uuid dimension, a filter on a measure that carries its own `@filter`, and a dimension over an array and one over a map, which refuse a filter (`invalid_filter_field`) and a sort -(`invalid_sort`). The corpus model carries one sourceless report, so a port that serves every +(`invalid_sort`), and the row cap: a report of 61 rows served with a cap of 60 returns 60 rows past +the old 50-row default, refuses the whole report, an explicit limit above the cap and a bad page +bound. The corpus model carries one sourceless report, so a port that serves every report it finds fails. Every port runs the scenarios through its generated routes against the real views on Postgres, SQLite and MySQL (`report/schema.sqlite.sql`, `report/schema.mysql.sql`), and TypeScript also through local D1. A report's decimal columns are asserted as canonical diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 934358a69..1412d3964 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -107,6 +107,8 @@ function renderRoutes( // which emits no `expose` key at all and so keeps output byte-identical for projects // that do not use it. expose?: readonly CrudVerb[], + // Row cap of a served report (`routesFile({ reportMaxRows })`). undefined emits no key. + reportMaxRows?: number, ): string { // FR-017 Tier 2 — a TPH discriminator base mounts polymorphic list/get at the // base path plus a full per-subtype CRUD route set scoped to each @@ -160,6 +162,7 @@ function renderRoutes( : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + + (report && reportMaxRows !== undefined ? `\n${indent}reportMaxRows: ${reportMaxRows},` : "") + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); @@ -705,6 +708,13 @@ export interface RoutesFileOpts { * your host file. Off by default, so a project that does not ask gets no new file. */ registerAll?: boolean; + /** + * The row cap of a report list route (default 10000, the same in every port). With no + * `limit` a report answers every row up to the cap and is refused above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Absent emits no key, so the output stays byte-identical. + */ + reportMaxRows?: number; /** * Where the emitted routes import their mount helpers from. Absent: the adapter copy * `meta eject routes` placed in `codegen/runtime/`, by a path computed from the target's @@ -728,7 +738,7 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }; return { path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`), - content: await formatTs(renderRoutes(entity, renderContext, resolveExpose(entity, opts?.expose))), + content: await formatTs(renderRoutes(entity, renderContext, resolveExpose(entity, opts?.expose), opts?.reportMaxRows)), }; }); const generator: Generator = { diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts index 76abece6c..6373e966e 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts @@ -10,7 +10,7 @@ import { FilterParseError, parsePageBound, SORT_EXPECTED } from "./list-params.j // Re-exported so existing importers keep one door; the definitions live in the // drizzle-free list-params module, which the ObjectManager Fastify mount also uses. -export { FilterParseError, parsePageBound, PAGINATION_EXPECTED, RAW_VIEW_MAX_LIMIT, SORT_EXPECTED } from "./list-params.js"; +export { FilterParseError, parsePageBound, PAGINATION_EXPECTED, RAW_VIEW_MAX_LIMIT, REPORT_MAX_ROWS, ReportTooLargeError, parseReportPage, SORT_EXPECTED } from "./list-params.js"; import { ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, PLUS_OFFSET_HINT, looksLikeUnencodedPlusOffset, matchesAnyTemporal, matchesFormat, utcIsoIfZoned, utcIsoInstant, diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/list-params.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/list-params.ts index 89401ceff..f435709d0 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/list-params.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/list-params.ts @@ -15,6 +15,11 @@ export class FilterParseError extends Error { * no allowlist to page through and returns at most this many rows. */ export const RAW_VIEW_MAX_LIMIT = 1000; +/** The most rows a report list answers, whether or not the caller sends a `limit`. A report + * with no `limit` returns every row up to this cap and is refused above it (never + * truncated); the same default in all five ports. */ +export const REPORT_MAX_ROWS = 10_000; + /** The `expected` member of a `pagination.invalid_value` answer with no upper bound. */ export const PAGINATION_EXPECTED = "a non-negative integer (0 or more)"; @@ -48,3 +53,41 @@ export function parsePageBound( /** The `expected` member of an `invalid_sort` answer — the one spelling `?sort` takes. */ export const SORT_EXPECTED = "sort=:asc|desc"; + +/** The page a report list reads: `limit` is how many rows to FETCH, which is one past the + * cap when the caller sent none, so the caller can tell exactly the cap apart from more than it. */ +export interface ReportPage { + readonly limit: number; + readonly offset: number; + /** True when the caller sent no `limit`, so more than `maxRows` rows means refuse. */ + readonly unbounded: boolean; +} + +/** A report answered more rows than `maxRows` allows. The wire answer is + * `400 { "error": "report_too_large", "maxRows": N }`. */ +export class ReportTooLargeError extends Error { + constructor(public readonly maxRows: number) { + super(`A report answers at most ${maxRows} rows; narrow it with a filter or page it with ?limit=.`); + this.name = "ReportTooLargeError"; + } +} + +/** + * Read a report list's `?limit=` / `?offset=`. A limit is an integer of at least 1 (a + * report with `limit=0` is a bad bound, not an empty answer) and an offset is a + * non-negative integer; anything else is `pagination.invalid_value`. A limit above + * `maxRows` throws {@link ReportTooLargeError}. + */ +export function parseReportPage(query: Record, maxRows: number): ReportPage { + const limit = parsePageBound(query, "limit"); + if (limit === 0) { + throw new FilterParseError( + "pagination.invalid_value", + `?limit= must be an integer from 1 to ${maxRows}, got "0".`, + { param: "limit", value: "0", expected: `an integer from 1 to ${maxRows}` }, + ); + } + if (limit !== undefined && limit > maxRows) throw new ReportTooLargeError(maxRows); + const offset = parsePageBound(query, "offset") ?? 0; + return { limit: limit ?? maxRows + 1, offset, unbounded: limit === undefined }; +} 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 2451dfebb..254b69cae 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 @@ -2,7 +2,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify"; import { quoteIdent, type SqlDialect } from "../sql-dialect.js"; import { sql, eq, and, count } from "drizzle-orm"; import qs from "qs"; -import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "./filter-parser.js"; +import { parseFilterParams, parsePageBound, parseReportPage, RAW_VIEW_MAX_LIMIT, REPORT_MAX_ROWS, ReportTooLargeError, FilterParseError } from "./filter-parser.js"; import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; @@ -50,6 +50,13 @@ export interface MountReadOnlyOptions extends WireOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; + /** + * A report list's row cap (`resource: "report"` only; ignored otherwise). With no `limit` + * the route answers every row up to this many and refuses above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Default 10000, the same in every port. + */ + readonly reportMaxRows?: number; } const rejectMutation = (resource: string) => async ( @@ -147,6 +154,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const ro = withContractErrorHandler(opts.routeOptions); const reject = rejectMutation(opts.resource ?? "projection"); const itemRoutes = opts.itemRoutes !== false; + // A report answers every row up to a cap and refuses above it; every other mount pages as asked. + const isReport = opts.resource === "report"; + const reportMaxRows = opts.reportMaxRows ?? REPORT_MAX_ROWS; const viewName = resolveViewName(view); const useRawSql = isEmptyColumnView(view) && !!viewName; @@ -167,11 +177,13 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : ""; const parsed = qs.parse(queryString) as Record; // A bound that is not an integer in range is a 400, not silently clamped. - const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT; - const offsetVal = parsePageBound(parsed, "offset") ?? 0; + const reportPage = isReport ? parseReportPage(parsed, reportMaxRows) : undefined; + const limitVal = reportPage?.limit ?? parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT; + const offsetVal = reportPage?.offset ?? parsePageBound(parsed, "offset") ?? 0; const withCount = isTruthyFlag(parsed["withCount"]); // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[]; + if (reportPage?.unbounded && rows.length > reportMaxRows) throw new ReportTooLargeError(reportMaxRows); const camelRows = rows.map((r: Record) => camelizeRow(r)); if (!withCount) return camelRows; // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result @@ -185,6 +197,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : ""; const parsed = qs.parse(queryString) as Record; const withCount = isTruthyFlag(parsed["withCount"]); + const reportPage = isReport ? parseReportPage(parsed, reportMaxRows) : undefined; const result = parseFilterParams({ query: parsed, table: view, @@ -198,13 +211,19 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { let q = db.select().from(view); if (combinedWhere) q = q.where(combinedWhere); if (result.orderBy) q = q.orderBy(...result.orderBy); - if (result.limit !== undefined) q = q.limit(result.limit); - if (result.offset !== undefined) q = q.offset(result.offset); + // A report fetches one row past the cap when no limit was sent, to tell exactly the + // cap apart from more than the cap before any body is written. + const limit = reportPage?.limit ?? result.limit; + const offset = reportPage?.offset ?? result.offset; + if (limit !== undefined) q = q.limit(limit); + if (offset !== undefined) q = q.offset(offset); // Await directly — `.all()` is libsql/better-sqlite3-only; the // node-postgres builder is thenable. (The useRawSql branch above keeps // db.all(sql.raw(...)) because that is the libsql raw-exec API, only // reachable for `.existing()` empty-column views which are sqlite-only.) - const rows = (await q as unknown[]).map(toWire); + const fetched = await q as unknown[]; + if (reportPage?.unbounded && fetched.length > reportMaxRows) throw new ReportTooLargeError(reportMaxRows); + const rows = fetched.map(toWire); if (!withCount) return rows; @@ -215,6 +234,10 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const total = countRow?.c ?? 0; return { rows, total }; } catch (err) { + if (err instanceof ReportTooLargeError) { + reply.code(400).send({ error: "report_too_large", maxRows: err.maxRows }); + return; + } if (err instanceof FilterParseError) { // Spread the details so the cross-port-required `field` reaches the wire on a // projection route too (F20) — the writable mount already does this. diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index 934358a69..1412d3964 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -107,6 +107,8 @@ function renderRoutes( // which emits no `expose` key at all and so keeps output byte-identical for projects // that do not use it. expose?: readonly CrudVerb[], + // Row cap of a served report (`routesFile({ reportMaxRows })`). undefined emits no key. + reportMaxRows?: number, ): string { // FR-017 Tier 2 — a TPH discriminator base mounts polymorphic list/get at the // base path plus a full per-subtype CRUD route set scoped to each @@ -160,6 +162,7 @@ function renderRoutes( : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + + (report && reportMaxRows !== undefined ? `\n${indent}reportMaxRows: ${reportMaxRows},` : "") + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); @@ -705,6 +708,13 @@ export interface RoutesFileOpts { * your host file. Off by default, so a project that does not ask gets no new file. */ registerAll?: boolean; + /** + * The row cap of a report list route (default 10000, the same in every port). With no + * `limit` a report answers every row up to the cap and is refused above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Absent emits no key, so the output stays byte-identical. + */ + reportMaxRows?: number; /** * Where the emitted routes import their mount helpers from. Absent: the adapter copy * `meta eject routes` placed in `codegen/runtime/`, by a path computed from the target's @@ -728,7 +738,7 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }; return { path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`), - content: await formatTs(renderRoutes(entity, renderContext, resolveExpose(entity, opts?.expose))), + content: await formatTs(renderRoutes(entity, renderContext, resolveExpose(entity, opts?.expose), opts?.reportMaxRows)), }; }); const generator: Generator = { diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts b/examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts index 76abece6c..6373e966e 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts @@ -10,7 +10,7 @@ import { FilterParseError, parsePageBound, SORT_EXPECTED } from "./list-params.j // Re-exported so existing importers keep one door; the definitions live in the // drizzle-free list-params module, which the ObjectManager Fastify mount also uses. -export { FilterParseError, parsePageBound, PAGINATION_EXPECTED, RAW_VIEW_MAX_LIMIT, SORT_EXPECTED } from "./list-params.js"; +export { FilterParseError, parsePageBound, PAGINATION_EXPECTED, RAW_VIEW_MAX_LIMIT, REPORT_MAX_ROWS, ReportTooLargeError, parseReportPage, SORT_EXPECTED } from "./list-params.js"; import { ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, PLUS_OFFSET_HINT, looksLikeUnencodedPlusOffset, matchesAnyTemporal, matchesFormat, utcIsoIfZoned, utcIsoInstant, diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/list-params.ts b/examples/showcase/codegen/runtime/drizzle-fastify/list-params.ts index 89401ceff..f435709d0 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/list-params.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/list-params.ts @@ -15,6 +15,11 @@ export class FilterParseError extends Error { * no allowlist to page through and returns at most this many rows. */ export const RAW_VIEW_MAX_LIMIT = 1000; +/** The most rows a report list answers, whether or not the caller sends a `limit`. A report + * with no `limit` returns every row up to this cap and is refused above it (never + * truncated); the same default in all five ports. */ +export const REPORT_MAX_ROWS = 10_000; + /** The `expected` member of a `pagination.invalid_value` answer with no upper bound. */ export const PAGINATION_EXPECTED = "a non-negative integer (0 or more)"; @@ -48,3 +53,41 @@ export function parsePageBound( /** The `expected` member of an `invalid_sort` answer — the one spelling `?sort` takes. */ export const SORT_EXPECTED = "sort=:asc|desc"; + +/** The page a report list reads: `limit` is how many rows to FETCH, which is one past the + * cap when the caller sent none, so the caller can tell exactly the cap apart from more than it. */ +export interface ReportPage { + readonly limit: number; + readonly offset: number; + /** True when the caller sent no `limit`, so more than `maxRows` rows means refuse. */ + readonly unbounded: boolean; +} + +/** A report answered more rows than `maxRows` allows. The wire answer is + * `400 { "error": "report_too_large", "maxRows": N }`. */ +export class ReportTooLargeError extends Error { + constructor(public readonly maxRows: number) { + super(`A report answers at most ${maxRows} rows; narrow it with a filter or page it with ?limit=.`); + this.name = "ReportTooLargeError"; + } +} + +/** + * Read a report list's `?limit=` / `?offset=`. A limit is an integer of at least 1 (a + * report with `limit=0` is a bad bound, not an empty answer) and an offset is a + * non-negative integer; anything else is `pagination.invalid_value`. A limit above + * `maxRows` throws {@link ReportTooLargeError}. + */ +export function parseReportPage(query: Record, maxRows: number): ReportPage { + const limit = parsePageBound(query, "limit"); + if (limit === 0) { + throw new FilterParseError( + "pagination.invalid_value", + `?limit= must be an integer from 1 to ${maxRows}, got "0".`, + { param: "limit", value: "0", expected: `an integer from 1 to ${maxRows}` }, + ); + } + if (limit !== undefined && limit > maxRows) throw new ReportTooLargeError(maxRows); + const offset = parsePageBound(query, "offset") ?? 0; + return { limit: limit ?? maxRows + 1, offset, unbounded: limit === undefined }; +} 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 2451dfebb..254b69cae 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify"; import { quoteIdent, type SqlDialect } from "../sql-dialect.js"; import { sql, eq, and, count } from "drizzle-orm"; import qs from "qs"; -import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "./filter-parser.js"; +import { parseFilterParams, parsePageBound, parseReportPage, RAW_VIEW_MAX_LIMIT, REPORT_MAX_ROWS, ReportTooLargeError, FilterParseError } from "./filter-parser.js"; import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; @@ -50,6 +50,13 @@ export interface MountReadOnlyOptions extends WireOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; + /** + * A report list's row cap (`resource: "report"` only; ignored otherwise). With no `limit` + * the route answers every row up to this many and refuses above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Default 10000, the same in every port. + */ + readonly reportMaxRows?: number; } const rejectMutation = (resource: string) => async ( @@ -147,6 +154,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const ro = withContractErrorHandler(opts.routeOptions); const reject = rejectMutation(opts.resource ?? "projection"); const itemRoutes = opts.itemRoutes !== false; + // A report answers every row up to a cap and refuses above it; every other mount pages as asked. + const isReport = opts.resource === "report"; + const reportMaxRows = opts.reportMaxRows ?? REPORT_MAX_ROWS; const viewName = resolveViewName(view); const useRawSql = isEmptyColumnView(view) && !!viewName; @@ -167,11 +177,13 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : ""; const parsed = qs.parse(queryString) as Record; // A bound that is not an integer in range is a 400, not silently clamped. - const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT; - const offsetVal = parsePageBound(parsed, "offset") ?? 0; + const reportPage = isReport ? parseReportPage(parsed, reportMaxRows) : undefined; + const limitVal = reportPage?.limit ?? parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT; + const offsetVal = reportPage?.offset ?? parsePageBound(parsed, "offset") ?? 0; const withCount = isTruthyFlag(parsed["withCount"]); // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[]; + if (reportPage?.unbounded && rows.length > reportMaxRows) throw new ReportTooLargeError(reportMaxRows); const camelRows = rows.map((r: Record) => camelizeRow(r)); if (!withCount) return camelRows; // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result @@ -185,6 +197,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : ""; const parsed = qs.parse(queryString) as Record; const withCount = isTruthyFlag(parsed["withCount"]); + const reportPage = isReport ? parseReportPage(parsed, reportMaxRows) : undefined; const result = parseFilterParams({ query: parsed, table: view, @@ -198,13 +211,19 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { let q = db.select().from(view); if (combinedWhere) q = q.where(combinedWhere); if (result.orderBy) q = q.orderBy(...result.orderBy); - if (result.limit !== undefined) q = q.limit(result.limit); - if (result.offset !== undefined) q = q.offset(result.offset); + // A report fetches one row past the cap when no limit was sent, to tell exactly the + // cap apart from more than the cap before any body is written. + const limit = reportPage?.limit ?? result.limit; + const offset = reportPage?.offset ?? result.offset; + if (limit !== undefined) q = q.limit(limit); + if (offset !== undefined) q = q.offset(offset); // Await directly — `.all()` is libsql/better-sqlite3-only; the // node-postgres builder is thenable. (The useRawSql branch above keeps // db.all(sql.raw(...)) because that is the libsql raw-exec API, only // reachable for `.existing()` empty-column views which are sqlite-only.) - const rows = (await q as unknown[]).map(toWire); + const fetched = await q as unknown[]; + if (reportPage?.unbounded && fetched.length > reportMaxRows) throw new ReportTooLargeError(reportMaxRows); + const rows = fetched.map(toWire); if (!withCount) return rows; @@ -215,6 +234,10 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const total = countRow?.c ?? 0; return { rows, total }; } catch (err) { + if (err instanceof ReportTooLargeError) { + reply.code(400).send({ error: "report_too_large", maxRows: err.maxRows }); + return; + } if (err instanceof FilterParseError) { // Spread the details so the cross-port-required `field` reaches the wire on a // projection route too (F20) — the writable mount already does this. diff --git a/fixtures/api-contract-conformance/report/README.md b/fixtures/api-contract-conformance/report/README.md index c93860a05..a1ed96c45 100644 --- a/fixtures/api-contract-conformance/report/README.md +++ b/fixtures/api-contract-conformance/report/README.md @@ -54,8 +54,14 @@ object uses (`InvoicesByMonth` is `/api/invoices_by_months`). - **A ratio is a decimal**, so `paidShare` reaches the wire as the canonical string (`"0.4"` for 2/5) in every port and on every engine (ADR-0019 Amendment 1). Exactness beyond that is the `decimal/` sub-corpus's job. -- 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 +- **A report answers every row up to one cap, and is refused above it** (`cap-all-rows`, + `cap-refused`, `bad-page-bound`). There is no page default: with no `limit` all rows come + back, in every port, and `400 {"error": "report_too_large", "maxRows": N}` answers a + report longer than the cap or a `limit` above it. The cap is a generator option with a + default of 10,000; **every lane builds its routes with a cap of 60**, and `ReadingLog` has + 61 rows (`Reading.seq` 1..61), so one view tells "exactly the cap" (60, past the old + 50-row default) from "one past it". A bound that is not usable (`limit` 0 or below, + negative `offset`, not an integer) is `400 pagination.invalid_value`. The other scenarios that page pass `limit` explicitly. ## The model @@ -153,11 +159,11 @@ hand-rolled reference server would answer every scenario by construction. | Port | Generated lane | Note | |---|---|---| -| TypeScript | wired | `server/typescript/packages/integration-tests/test/api-contract-report.test.ts` (27 scenarios + a seed-vs-view check). Full stack: generated Fastify routes over the real views on Testcontainers Postgres. `api-contract-report-sqlite.test.ts` runs the same 27 on SQLite, `api-contract-report-mysql.test.ts` on MySQL 8.4, and the SQLite test file also runs them through local D1 (Miniflare) | +| TypeScript | wired | `server/typescript/packages/integration-tests/test/api-contract-report.test.ts` (30 scenarios + a seed-vs-view check). Full stack: generated Fastify routes over the real views on Testcontainers Postgres. `api-contract-report-sqlite.test.ts` runs the same 30 on SQLite, `api-contract-report-mysql.test.ts` on MySQL 8.4, and the SQLite test file also runs them through local D1 (Miniflare) | | C# | wired | `server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs`. Full stack: generated routes and EF Core over `schema.postgres.sql` on Testcontainers Postgres, over `schema.sqlite.sql` on SQLite, and over `schema.mysql.sql` on MySQL 8.4 | -| Java | wired | `server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdb{Postgres,Sqlite,MySql}ApiContractConformanceTest.java` (27 scenarios + a scenario-count check, on each engine). Full stack: generated controllers over OMDB reading the real views | -| Kotlin | wired | `server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt` (27 scenarios, the count check, and a check that the sourceless report generated nothing). Full stack: generated controllers over the generated Exposed table objects, reading the real views on Postgres, SQLite and MySQL | -| Python | wired | `server/python/tests/integration/test_api_contract_report.py` (27 scenarios, a check that exactly the served reports are generated, a views-vs-seed check, and `/api/invoice_days` is `404`). Full stack: generated FastAPI routers over `ObjectManager`, reading the real views on Postgres, SQLite and MySQL | +| Java | wired | `server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdb{Postgres,Sqlite,MySql}ApiContractConformanceTest.java` (30 scenarios + a scenario-count check, on each engine). Full stack: generated controllers over OMDB reading the real views | +| Kotlin | wired | `server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt` (30 scenarios, the count check, and a check that the sourceless report generated nothing). Full stack: generated controllers over the generated Exposed table objects, reading the real views on Postgres, SQLite and MySQL | +| Python | wired | `server/python/tests/integration/test_api_contract_report.py` (30 scenarios, a check that exactly the served reports are generated, a views-vs-seed check, and `/api/invoice_days` is `404`). Full stack: generated FastAPI routers over `ObjectManager`, reading the real views on Postgres, SQLite and MySQL | The scenarios use only assertion keys every runner already has (`equals`, `length`, `envelope`, `error` with `field`, and a status with no `body`). diff --git a/fixtures/api-contract-conformance/report/meta.json b/fixtures/api-contract-conformance/report/meta.json index 51c12e83c..9e046413d 100644 --- a/fixtures/api-contract-conformance/report/meta.json +++ b/fixtures/api-contract-conformance/report/meta.json @@ -158,6 +158,24 @@ "@dimensions": ["tags", "attrs"], "@measures": ["tickets"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_tickets_by_tags" } } ] + }}, + { "object.entity": { + "name": "Reading", + "children": [ + { "source.rdb": { "@table": "readings" } }, + { "field.long": { "name": "id" } }, + { "field.int": { "name": "seq", "@required": true } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { "name": "seq", "@of": "Reading.seq" } }, + { "measure.aggregate": { "name": "readings", "@agg": "count", "@of": "Reading.id" } } + ] + }}, + { "object.report": { + "name": "ReadingLog", + "@from": "Reading", + "@dimensions": ["seq"], + "@measures": ["readings"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_reading_log" } } ] }} ] } diff --git a/fixtures/api-contract-conformance/report/scenarios/bad-page-bound.yaml b/fixtures/api-contract-conformance/report/scenarios/bad-page-bound.yaml new file mode 100644 index 000000000..210fb8a5b --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/bad-page-bound.yaml @@ -0,0 +1,41 @@ +name: report-bad-page-bound +description: > + A page bound that is not usable is refused, never ignored: a limit of 0 or below, a + negative offset, and a value that is not an integer all answer 400 + pagination.invalid_value (the TypeScript mounts' existing envelope for a malformed bound). +requests: + - id: r1 + method: GET + path: /api/reading_logs?limit=0 + expect: + status: 400 + body: + error: "pagination.invalid_value" + - id: r2 + method: GET + path: /api/reading_logs?limit=-1 + expect: + status: 400 + body: + error: "pagination.invalid_value" + - id: r3 + method: GET + path: /api/reading_logs?limit=abc + expect: + status: 400 + body: + error: "pagination.invalid_value" + - id: r4 + method: GET + path: /api/reading_logs?limit=2&offset=-1 + expect: + status: 400 + body: + error: "pagination.invalid_value" + - id: r5 + method: GET + path: /api/reading_logs?limit=2&offset=abc + expect: + status: 400 + body: + error: "pagination.invalid_value" diff --git a/fixtures/api-contract-conformance/report/scenarios/cap-all-rows.yaml b/fixtures/api-contract-conformance/report/scenarios/cap-all-rows.yaml new file mode 100644 index 000000000..1846d659b --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/cap-all-rows.yaml @@ -0,0 +1,31 @@ +name: report-cap-all-rows +description: > + With no limit a report answers EVERY row, not a page: 60 rows, more than the old + 50-row default of Java, Kotlin and Python, and exactly the row cap every lane runs this + corpus with (60). The filter trims the 61-row report to 60. An explicit limit at the cap + is honoured as well. +requests: + - id: r1 + method: GET + path: /api/reading_logs?filter[seq][lte]=60&sort=seq:asc + expect: + status: 200 + body: + length: 60 + - id: r2 + method: GET + path: /api/reading_logs?limit=60&sort=seq:asc + expect: + status: 200 + body: + length: 60 + - id: r3 + method: GET + path: /api/reading_logs?filter[seq][gt]=55&sort=seq:asc&limit=3&offset=2 + expect: + status: 200 + body: + equals: + - { seq: 58, readings: 1 } + - { seq: 59, readings: 1 } + - { seq: 60, readings: 1 } diff --git a/fixtures/api-contract-conformance/report/scenarios/cap-refused.yaml b/fixtures/api-contract-conformance/report/scenarios/cap-refused.yaml new file mode 100644 index 000000000..c46a764b0 --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/cap-refused.yaml @@ -0,0 +1,38 @@ +name: report-cap-refused +description: > + A report is never truncated. The ReadingLog view has 61 rows and every lane runs this + corpus with a row cap of 60, so a request that would answer more than the cap is + refused before any body is written with 400 report_too_large, naming the cap. That holds + with no limit (the route fetches cap + 1 rows and sees the extra one), with withCount, and + with an explicit limit above the cap. A limit within the cap still pages. +requests: + - id: r1 + method: GET + path: /api/reading_logs + expect: + status: 400 + body: + equals: { error: "report_too_large", maxRows: 60 } + - id: r2 + method: GET + path: /api/reading_logs?withCount=1 + expect: + status: 400 + body: + equals: { error: "report_too_large", maxRows: 60 } + - id: r3 + method: GET + path: /api/reading_logs?limit=61 + expect: + status: 400 + body: + equals: { error: "report_too_large", maxRows: 60 } + - id: r4 + method: GET + path: /api/reading_logs?sort=seq:asc&limit=2&offset=59 + expect: + status: 200 + body: + equals: + - { seq: 60, readings: 1 } + - { seq: 61, readings: 1 } diff --git a/fixtures/api-contract-conformance/report/schema.mysql.sql b/fixtures/api-contract-conformance/report/schema.mysql.sql index d4636f871..318dfa773 100644 --- a/fixtures/api-contract-conformance/report/schema.mysql.sql +++ b/fixtures/api-contract-conformance/report/schema.mysql.sql @@ -40,6 +40,11 @@ CREATE TABLE `tickets` ( `attrs` JSON ); +CREATE TABLE `readings` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `seq` INT NOT NULL +); + CREATE VIEW `v_invoice_status_totals` AS SELECT i.`status` AS `status`, @@ -131,3 +136,10 @@ CREATE VIEW `v_tickets_by_tags` AS COUNT(t.`id`) AS `tickets` FROM `tickets` t GROUP BY t.`tags`, t.`attrs`; + +CREATE VIEW `v_reading_log` AS + SELECT + r.`seq` AS `seq`, + COUNT(r.`id`) AS `readings` + FROM `readings` r + GROUP BY r.`seq`; diff --git a/fixtures/api-contract-conformance/report/schema.postgres.sql b/fixtures/api-contract-conformance/report/schema.postgres.sql index 24e914329..02c94979c 100644 --- a/fixtures/api-contract-conformance/report/schema.postgres.sql +++ b/fixtures/api-contract-conformance/report/schema.postgres.sql @@ -43,6 +43,12 @@ CREATE TABLE "tickets" ( CONSTRAINT "tickets_pkey" PRIMARY KEY ("id") ); +CREATE TABLE "readings" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "seq" INTEGER NOT NULL, + CONSTRAINT "readings_pkey" PRIMARY KEY ("id") +); + ALTER TABLE "products" ADD CONSTRAINT "products_categoryId_fk" FOREIGN KEY ("categoryId") REFERENCES "categories" ("id"); ALTER TABLE "sales" ADD CONSTRAINT "sales_productId_fk" FOREIGN KEY ("productId") REFERENCES "products" ("id"); @@ -148,3 +154,11 @@ CREATE VIEW "v_tickets_by_tags" AS FROM "tickets" t GROUP BY t."tags", t."attrs"; COMMENT ON VIEW "v_tickets_by_tags" IS 'metaobjects:v1:sha256:38b4020ab33a2857d8cdf5eb860216342aa43fa8c4a914d330dbdd72646f8793'; + +CREATE VIEW "v_reading_log" AS + SELECT + r."seq" AS "seq", + COUNT(r."id") AS "readings" + FROM "readings" r + GROUP BY r."seq"; +COMMENT ON VIEW "v_reading_log" IS 'metaobjects:v1:sha256:eb0bf93a21445f65232e7cb99c47c412aa5d352d56e11d5f3fdf594965ea6eb8'; diff --git a/fixtures/api-contract-conformance/report/schema.sqlite.sql b/fixtures/api-contract-conformance/report/schema.sqlite.sql index f4193b971..1109fa4d2 100644 --- a/fixtures/api-contract-conformance/report/schema.sqlite.sql +++ b/fixtures/api-contract-conformance/report/schema.sqlite.sql @@ -40,6 +40,11 @@ CREATE TABLE "tickets" ( "attrs" TEXT ); +CREATE TABLE "readings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "seq" INTEGER NOT NULL +); + CREATE VIEW "v_invoice_status_totals" AS SELECT i."status" AS "status", @@ -131,3 +136,10 @@ CREATE VIEW "v_tickets_by_tags" AS COUNT(t."id") AS "tickets" FROM "tickets" t GROUP BY t."tags", t."attrs"; + +CREATE VIEW "v_reading_log" AS + SELECT + r."seq" AS "seq", + COUNT(r."id") AS "readings" + FROM "readings" r + GROUP BY r."seq"; diff --git a/fixtures/api-contract-conformance/report/seed.json b/fixtures/api-contract-conformance/report/seed.json index b9d83cf72..4617c9308 100644 --- a/fixtures/api-contract-conformance/report/seed.json +++ b/fixtures/api-contract-conformance/report/seed.json @@ -20,6 +20,69 @@ { "id": 2, "productId": 1, "amountCents": 18000, "refunded": true }, { "id": 3, "productId": 2, "amountCents": 4500, "refunded": false } ], + "readings": [ + { "id": 1, "seq": 1 }, + { "id": 2, "seq": 2 }, + { "id": 3, "seq": 3 }, + { "id": 4, "seq": 4 }, + { "id": 5, "seq": 5 }, + { "id": 6, "seq": 6 }, + { "id": 7, "seq": 7 }, + { "id": 8, "seq": 8 }, + { "id": 9, "seq": 9 }, + { "id": 10, "seq": 10 }, + { "id": 11, "seq": 11 }, + { "id": 12, "seq": 12 }, + { "id": 13, "seq": 13 }, + { "id": 14, "seq": 14 }, + { "id": 15, "seq": 15 }, + { "id": 16, "seq": 16 }, + { "id": 17, "seq": 17 }, + { "id": 18, "seq": 18 }, + { "id": 19, "seq": 19 }, + { "id": 20, "seq": 20 }, + { "id": 21, "seq": 21 }, + { "id": 22, "seq": 22 }, + { "id": 23, "seq": 23 }, + { "id": 24, "seq": 24 }, + { "id": 25, "seq": 25 }, + { "id": 26, "seq": 26 }, + { "id": 27, "seq": 27 }, + { "id": 28, "seq": 28 }, + { "id": 29, "seq": 29 }, + { "id": 30, "seq": 30 }, + { "id": 31, "seq": 31 }, + { "id": 32, "seq": 32 }, + { "id": 33, "seq": 33 }, + { "id": 34, "seq": 34 }, + { "id": 35, "seq": 35 }, + { "id": 36, "seq": 36 }, + { "id": 37, "seq": 37 }, + { "id": 38, "seq": 38 }, + { "id": 39, "seq": 39 }, + { "id": 40, "seq": 40 }, + { "id": 41, "seq": 41 }, + { "id": 42, "seq": 42 }, + { "id": 43, "seq": 43 }, + { "id": 44, "seq": 44 }, + { "id": 45, "seq": 45 }, + { "id": 46, "seq": 46 }, + { "id": 47, "seq": 47 }, + { "id": 48, "seq": 48 }, + { "id": 49, "seq": 49 }, + { "id": 50, "seq": 50 }, + { "id": 51, "seq": 51 }, + { "id": 52, "seq": 52 }, + { "id": 53, "seq": 53 }, + { "id": 54, "seq": 54 }, + { "id": 55, "seq": 55 }, + { "id": 56, "seq": 56 }, + { "id": 57, "seq": 57 }, + { "id": 58, "seq": 58 }, + { "id": 59, "seq": 59 }, + { "id": 60, "seq": 60 }, + { "id": 61, "seq": 61 } + ], "reports": { "InvoiceStatusTotals": [ { "status": "OPEN", "invoices": 2, "totalCents": 130500, "paidCents": null }, @@ -62,6 +125,69 @@ { "productId": 1, "productName": "Atlas", "sales": 2, "hasRefund": true, "hasRefundOrFalse": true }, { "productId": 2, "productName": "Beacon", "sales": 1, "hasRefund": false, "hasRefundOrFalse": false }, { "productId": 3, "productName": "Cinder", "sales": 0, "hasRefund": null, "hasRefundOrFalse": false } + ], + "ReadingLog": [ + { "seq": 1, "readings": 1 }, + { "seq": 2, "readings": 1 }, + { "seq": 3, "readings": 1 }, + { "seq": 4, "readings": 1 }, + { "seq": 5, "readings": 1 }, + { "seq": 6, "readings": 1 }, + { "seq": 7, "readings": 1 }, + { "seq": 8, "readings": 1 }, + { "seq": 9, "readings": 1 }, + { "seq": 10, "readings": 1 }, + { "seq": 11, "readings": 1 }, + { "seq": 12, "readings": 1 }, + { "seq": 13, "readings": 1 }, + { "seq": 14, "readings": 1 }, + { "seq": 15, "readings": 1 }, + { "seq": 16, "readings": 1 }, + { "seq": 17, "readings": 1 }, + { "seq": 18, "readings": 1 }, + { "seq": 19, "readings": 1 }, + { "seq": 20, "readings": 1 }, + { "seq": 21, "readings": 1 }, + { "seq": 22, "readings": 1 }, + { "seq": 23, "readings": 1 }, + { "seq": 24, "readings": 1 }, + { "seq": 25, "readings": 1 }, + { "seq": 26, "readings": 1 }, + { "seq": 27, "readings": 1 }, + { "seq": 28, "readings": 1 }, + { "seq": 29, "readings": 1 }, + { "seq": 30, "readings": 1 }, + { "seq": 31, "readings": 1 }, + { "seq": 32, "readings": 1 }, + { "seq": 33, "readings": 1 }, + { "seq": 34, "readings": 1 }, + { "seq": 35, "readings": 1 }, + { "seq": 36, "readings": 1 }, + { "seq": 37, "readings": 1 }, + { "seq": 38, "readings": 1 }, + { "seq": 39, "readings": 1 }, + { "seq": 40, "readings": 1 }, + { "seq": 41, "readings": 1 }, + { "seq": 42, "readings": 1 }, + { "seq": 43, "readings": 1 }, + { "seq": 44, "readings": 1 }, + { "seq": 45, "readings": 1 }, + { "seq": 46, "readings": 1 }, + { "seq": 47, "readings": 1 }, + { "seq": 48, "readings": 1 }, + { "seq": 49, "readings": 1 }, + { "seq": 50, "readings": 1 }, + { "seq": 51, "readings": 1 }, + { "seq": 52, "readings": 1 }, + { "seq": 53, "readings": 1 }, + { "seq": 54, "readings": 1 }, + { "seq": 55, "readings": 1 }, + { "seq": 56, "readings": 1 }, + { "seq": 57, "readings": 1 }, + { "seq": 58, "readings": 1 }, + { "seq": 59, "readings": 1 }, + { "seq": 60, "readings": 1 }, + { "seq": 61, "readings": 1 } ] } } diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index 3587cf869..9ed873982 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -106,6 +106,51 @@ private static void AppendUnauthenticatedRoutesDoc(StringBuilder sb, string cls, sb.AppendLine(" /// "); } + /// + /// The most rows a report list answers when its generator sets no cap, the same default in + /// every port. With no limit a report answers every row up to the cap and is refused + /// above it, never truncated. + /// + public const int DefaultReportMaxRows = 10_000; + + /// + /// The row cap of a report list route: new RoutesGenerator { ReportMaxRows = 500 }. + /// With no limit the route answers every row up to the cap; one more row, or a + /// limit above the cap, is 400 { "error": "report_too_large", "maxRows": N }. + /// Reports only: an entity's list keeps its paging. + /// + public int ReportMaxRows { get; init; } = DefaultReportMaxRows; + + // A report answers all of its rows or refuses: no silent page default and no truncation. A + // bound that is not usable (limit below 1, negative offset, not an integer) is refused with + // the envelope the TypeScript mounts use, rather than ignored. The query fetches one row past + // the cap when no limit was sent, so the refusal comes before any body is written. + private void AppendReportPaging(StringBuilder sb) + { + var max = ReportMaxRows.ToString(System.Globalization.CultureInfo.InvariantCulture); + sb.AppendLine(" // Pagination (report): every row up to the cap, refused above it."); + sb.AppendLine($" const int maxRows = {max};"); + sb.AppendLine(" long? limit = null;"); + sb.AppendLine(" long offset = 0;"); + sb.AppendLine(" if (qs.TryGetValue(\"limit\", out var limitRaw))"); + sb.AppendLine(" {"); + sb.AppendLine(" if (!long.TryParse(limitRaw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedLimit) || parsedLimit < 1)"); + sb.AppendLine(" return Results.BadRequest(new { error = \"pagination.invalid_value\", param = \"limit\", value = limitRaw.ToString(), expected = $\"an integer from 1 to {maxRows}\" });"); + sb.AppendLine(" if (parsedLimit > maxRows)"); + sb.AppendLine(" return Results.BadRequest(new { error = \"report_too_large\", maxRows });"); + sb.AppendLine(" limit = parsedLimit;"); + sb.AppendLine(" }"); + sb.AppendLine(" if (qs.TryGetValue(\"offset\", out var offsetRaw)"); + sb.AppendLine(" && (!long.TryParse(offsetRaw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out offset) || offset < 0))"); + sb.AppendLine(" return Results.BadRequest(new { error = \"pagination.invalid_value\", param = \"offset\", value = offsetRaw.ToString(), expected = \"a non-negative integer (0 or more)\" });"); + sb.AppendLine(" if (offset > 0) q = q.Skip((int)System.Math.Min(offset, int.MaxValue));"); + sb.AppendLine(" q = q.Take((int)(limit ?? maxRows + 1L));"); + sb.AppendLine(); + sb.AppendLine(" var rows = await q.ToListAsync();"); + sb.AppendLine(" if (limit is null && rows.Count > maxRows)"); + sb.AppendLine(" return Results.BadRequest(new { error = \"report_too_large\", maxRows });"); + } + protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenContext ctx) { var cls = CSharpNaming.Pascal(entity.Name); @@ -254,13 +299,18 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte : $" q = ApplySort{cls}(q, resolved, desc);"); sb.AppendLine(" }"); sb.AppendLine(); - sb.AppendLine(" // Pagination: ?offset=N first (skip), then ?limit=N (take)."); - sb.AppendLine(" if (qs.TryGetValue(\"offset\", out var offsetRaw) && int.TryParse(offsetRaw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var offset) && offset > 0)"); - sb.AppendLine(" q = q.Skip(offset);"); - sb.AppendLine(" if (qs.TryGetValue(\"limit\", out var limitRaw) && int.TryParse(limitRaw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var limit) && limit > 0)"); - sb.AppendLine(" q = q.Take(limit);"); - sb.AppendLine(); - sb.AppendLine(" var rows = await q.ToListAsync();"); + if (isReport) + AppendReportPaging(sb); + else + { + sb.AppendLine(" // Pagination: ?offset=N first (skip), then ?limit=N (take)."); + sb.AppendLine(" if (qs.TryGetValue(\"offset\", out var offsetRaw) && int.TryParse(offsetRaw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var offset) && offset > 0)"); + sb.AppendLine(" q = q.Skip(offset);"); + sb.AppendLine(" if (qs.TryGetValue(\"limit\", out var limitRaw) && int.TryParse(limitRaw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var limit) && limit > 0)"); + sb.AppendLine(" q = q.Take(limit);"); + sb.AppendLine(); + sb.AppendLine(" var rows = await q.ToListAsync();"); + } sb.AppendLine(); sb.AppendLine(" // withCount=1 → { rows, total } envelope; otherwise bare row array."); sb.AppendLine(" var withCount = qs.TryGetValue(\"withCount\", out var wc) && (wc == \"1\" || string.Equals(wc, \"true\", System.StringComparison.OrdinalIgnoreCase));"); diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs index 097fcbcc1..81ca29376 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs @@ -44,9 +44,13 @@ internal sealed class ReportGeneratedServerFactory : IAsyncDisposable // Ordinal order. InvoiceDays is in the model and declares no source. private static readonly string[] ExpectedRoutedNames = ["Category", "Invoice", "InvoiceCalendar", "InvoiceDisputes", "InvoiceStatusTotals", "InvoiceTotals", - "InvoicesByMonth", "Product", "ProductRefunds", "ProductRevenue", "Sale", "SalesByCategory", "SalesBySku", "Ticket", "TicketsByTags"]; + "InvoicesByMonth", "Product", "ProductRefunds", "ProductRevenue", "Reading", "ReadingLog", "Sale", "SalesByCategory", "SalesBySku", "Ticket", "TicketsByTags"]; private const string SourcelessReport = "InvoiceDays"; + // The row cap the report routes are built with: ReadingLog has one row more, so the cap + // scenarios tell "exactly the cap" from "past it" (report/README.md). + private const int ReportCorpusMaxRows = 60; + private readonly ScenarioEngine _engine; private readonly string _connectionString; private readonly WebApplication _app; @@ -163,7 +167,7 @@ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGen var files = new EntityGenerator().Generate(ctx) .Concat(new DbContextGenerator().Generate(ctx)) .Concat(new FilterAllowlistGenerator().Generate(ctx)) - .Concat(new RoutesGenerator().Generate(ctx)) + .Concat(new RoutesGenerator { ReportMaxRows = ReportCorpusMaxRows }.Generate(ctx)) .Concat(new NamesGenerator().Generate(ctx)) .ToList(); diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index a0a04d003..fa94f862c 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -45,6 +45,7 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import org.slf4j.LoggerFactory +import com.metaobjects.generator.util.GeneratorUtil import com.metaobjects.generator.util.RestSurfaceGate import com.metaobjects.generator.util.GeneratedFileWriter @@ -108,6 +109,14 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase, maxRows: Int + ) = with(sb) { + val api = exposedApi() + append(" @GetMapping\n") + append(" fun list(\n") + append(" @RequestParam(required = false) limit: String?,\n") + append(" @RequestParam(required = false) offset: String?,\n") + append(" @RequestParam(required = false) sort: String?,\n") + append(" @RequestParam(required = false, name = \"withCount\") withCount: Int?,\n") + append(" request: HttpServletRequest,\n") + append(" ): ResponseEntity = transaction {\n") + append(" val maxRows = $maxRows\n") + append(" val tooLarge = ResponseEntity.badRequest().body(mapOf(\"error\" to \"report_too_large\", \"maxRows\" to maxRows) as Any)\n") + append(" var requestedLimit: Int? = null\n") + append(" if (limit != null) {\n") + append(" val n = limit.toLongOrNull()?.takeIf { it >= 1 }\n") + append(" ?: return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"pagination.invalid_value\", \"param\" to \"limit\", \"value\" to limit, \"expected\" to \"an integer from 1 to \$maxRows\") as Any)\n") + append(" if (n > maxRows) return@transaction tooLarge\n") + append(" requestedLimit = n.toInt()\n") + append(" }\n") + append(" val effectiveOffset: Long = if (offset == null) 0L else offset.toLongOrNull()?.takeIf { it >= 0 }\n") + append(" ?: return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"pagination.invalid_value\", \"param\" to \"offset\", \"value\" to offset, \"expected\" to \"a non-negative integer (0 or more)\") as Any)\n") + append(" val filterResult = parse${shortName}Filter(request.queryString)\n") + append(" if (filterResult.error != null) {\n") + append(" return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to filterResult.error, \"field\" to filterResult.field) as Any)\n") + append(" }\n") + append(" val whereOp = ${shortName}WhereOp(filterResult.predicates)\n") + append(" var q = if (whereOp != null) ${readObj}.selectAll().where { whereOp } else ${readObj}.selectAll()\n") + append(" if (sort != null) {\n") + append(" val parsed = parse${shortName}Sort(sort)\n") + append(" ?: return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"invalid_sort\", \"field\" to sort.substringBefore(':')) as Any)\n") + append(" val (field, dir) = parsed\n") + append(" q = q.orderBy(${sortColumnExpr(readObj, shortName, sortFields, api)} to dir)\n") + append(" }\n") + append(" val total: Long = if (withCount == 1) q.count() else -1L\n") + append(" val effectiveLimit = requestedLimit ?: (maxRows + 1)\n") + if (api == ExposedApi.V1) { + append(" val rows = q.limit(effectiveLimit).offset(effectiveOffset).map { rowTo${shortName}(it) }\n") + } else { + append(" val rows = q.limit(effectiveLimit, effectiveOffset).map { rowTo${shortName}(it) }\n") + } + append(" if (requestedLimit == null && rows.size > maxRows) return@transaction tooLarge\n") + append(" if (withCount == 1) ResponseEntity.ok(mapOf(\"rows\" to rows, \"total\" to total) as Any)\n") + append(" else ResponseEntity.ok(rows as Any)\n") + append(" }\n\n") + } + /** GET /{id} — single row by primary key, 404 envelope on a miss. */ private fun appendGetHandler( sb: StringBuilder, shortName: String, readObj: String, pkFieldName: String, pkParamType: String @@ -2269,6 +2338,12 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase getFilterClass() { public void execute(MetaDataLoader loader) { parseArgs(); runtimePackage = getArg(ARG_RUNTIME_PACKAGE, RUNTIME_PACKAGE); + reportMaxRows = Integer.parseInt(getArg(ARG_REPORT_MAX_ROWS, String.valueOf(DEFAULT_REPORT_MAX_ROWS))); this.loader = loader; Path outRoot = Paths.get(outDir.getAbsolutePath()); for (MetaObject declared : loader.getMetaObjects()) { @@ -625,7 +640,11 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) { src.append(" this.repository = repository;\n"); src.append(" }\n\n"); - appendListHandler(src, shortName, dtoName, repoName); + if (com.metaobjects.generator.util.GeneratorUtil.isReport(entity)) { + appendReportListHandler(src, shortName, dtoName, repoName, reportMaxRows); + } else { + appendListHandler(src, shortName, dtoName, repoName); + } if (hasItem) { appendGetByIdHandler(src, pkType); } @@ -770,6 +789,82 @@ private static void appendListHandler(StringBuilder src, String shortName, Strin src.append(" }\n\n"); } + /** + * GET (list) of a report: every row up to the cap, refused above it. There is no page + * default and nothing is truncated; a request that would answer more than {@code maxRows} + * rows is {@code 400 {"error": "report_too_large", "maxRows": N}} before any body is + * written (the repository is asked for one row past the cap when no {@code limit} is sent). + * A bound that is not usable (limit below 1, negative offset, not an integer) is refused with + * {@code pagination.invalid_value}, the envelope the TypeScript mounts use, instead of being + * ignored or answered by the framework's own conversion error. The bounds arrive as text so + * this handler, not Spring, decides what a bad one answers. + */ + private static void appendReportListHandler(StringBuilder src, String shortName, String dtoName, + String repoName, int maxRows) { + String allowlistName = SpringNaming.filterAllowlistName(shortName); + src.append(" @GetMapping\n"); + src.append(" public ResponseEntity list(\n"); + src.append(" @RequestParam(required = false) String limit,\n"); + src.append(" @RequestParam(required = false) String offset,\n"); + src.append(" @RequestParam(required = false) String sort,\n"); + src.append(" @RequestParam(required = false, name = \"withCount\") Integer withCount,\n"); + src.append(" HttpServletRequest request) {\n"); + src.append(" final int maxRows = ").append(maxRows).append(";\n"); + src.append(" Integer requestedLimit = null;\n"); + src.append(" if (limit != null) {\n"); + src.append(" Long parsed = parseBound(limit, 1);\n"); + src.append(" if (parsed == null) {\n"); + src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"pagination.invalid_value\", \"param\", \"limit\", \"value\", limit, \"expected\", \"an integer from 1 to \" + maxRows));\n"); + src.append(" }\n"); + src.append(" if (parsed > maxRows) {\n"); + src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"report_too_large\", \"maxRows\", maxRows));\n"); + src.append(" }\n"); + src.append(" requestedLimit = parsed.intValue();\n"); + src.append(" }\n"); + src.append(" int actualOffset = 0;\n"); + src.append(" if (offset != null) {\n"); + src.append(" Long parsed = parseBound(offset, 0);\n"); + src.append(" if (parsed == null) {\n"); + src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"pagination.invalid_value\", \"param\", \"offset\", \"value\", offset, \"expected\", \"a non-negative integer (0 or more)\"));\n"); + src.append(" }\n"); + src.append(" actualOffset = (int) Math.min(parsed, Integer.MAX_VALUE);\n"); + src.append(" }\n"); + src.append(" ").append(repoName).append(".SortClause sortClause = null;\n"); + src.append(" if (sort != null) {\n"); + src.append(" sortClause = parseSort(sort);\n"); + src.append(" if (sortClause == null) {\n"); + src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"invalid_sort\", \"field\", sort.split(\":\", 2)[0]));\n"); + src.append(" }\n"); + src.append(" }\n"); + src.append(" FilterParseResult filter = FilterParser.parse(\n"); + src.append(" request.getQueryString(), ").append(allowlistName).append(".FIELDS, ") + .append(allowlistName).append(".OPS_BY_FIELD, ").append(allowlistName).append(".DECIMAL_FIELDS);\n"); + src.append(" if (filter.error() != null) {\n"); + src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", filter.error(), \"field\", filter.field()));\n"); + src.append(" }\n"); + src.append(" List filters = filter.predicates();\n"); + src.append(" List<").append(dtoName) + .append("> rows = repository.list(requestedLimit != null ? requestedLimit : maxRows + 1, actualOffset, sortClause, filters);\n"); + src.append(" if (requestedLimit == null && rows.size() > maxRows) {\n"); + src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"report_too_large\", \"maxRows\", maxRows));\n"); + src.append(" }\n"); + src.append(" if (withCount != null && withCount == 1) {\n"); + src.append(" long total = repository.count(filters);\n"); + src.append(" return ResponseEntity.ok(Map.of(\"rows\", rows, \"total\", total));\n"); + src.append(" }\n"); + src.append(" return ResponseEntity.ok(rows);\n"); + src.append(" }\n\n"); + src.append(" /** A page bound as an integer of at least {@code min}, or null when it is not one. */\n"); + src.append(" private static Long parseBound(String raw, long min) {\n"); + src.append(" try {\n"); + src.append(" long n = Long.parseLong(raw);\n"); + src.append(" return n >= min ? n : null;\n"); + src.append(" } catch (NumberFormatException e) {\n"); + src.append(" return null;\n"); + src.append(" }\n"); + src.append(" }\n\n"); + } + /** GET /{id} — single by primary key, 404 envelope on a miss. */ private static void appendGetByIdHandler(StringBuilder src, String pkType) { src.append(" @GetMapping(\"/{id}\")\n"); diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt index d3dbddc52..2d69b6da9 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt @@ -67,9 +67,9 @@ internal class ReportGeneratedApiContractConformanceTest { } @Test - fun `the corpus has its twenty-seven scenarios`() { + fun `the corpus has its thirty scenarios`() { // A lane that silently ran fewer would still be green. - assertEquals(27, scenarios().count()) + assertEquals(30, scenarios().count()) } @Test diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt index 14792ae2c..dbbb132b9 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt @@ -94,7 +94,7 @@ class GeneratedReportControllerHarness( KotlinFilterAllowlistGenerator(), KotlinSpringControllerGenerator(), )) { - g.setArgs(mapOf("outputDir" to srcDir.toString(), "useNames" to "true")) + g.setArgs(mapOf("outputDir" to srcDir.toString(), "useNames" to "true", "reportMaxRows" to MAX_ROWS.toString())) g.execute(loader) } @@ -232,9 +232,12 @@ class GeneratedReportControllerHarness( val SERVED = listOf( "InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags", + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags", "ReadingLog", ) + /** The row cap every lane builds its report routes with; `ReadingLog` has one row more. */ + const val MAX_ROWS = 60 + /** * A seed value as a SQL literal. A date (`"2026-04-30"`) is a quoted string the engine * converts to the column's own type on insert. diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java index d13aa53b7..9b4bd4f18 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java @@ -30,7 +30,7 @@ private ReportCorpus() {} * references; {@code ProductRefunds} adds boolean {@code @agg any} measures. */ static final List SERVED = List.of("InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags"); + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags", "ReadingLog"); /** The report that declares no view and must generate nothing. */ static final String SOURCELESS = "InvoiceDays"; diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdbApiContractLane.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdbApiContractLane.java index a05790587..d4769147d 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdbApiContractLane.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdbApiContractLane.java @@ -113,7 +113,7 @@ static Stream scenarios() { @Test void theCorpusCarriesItsTwentySevenScenarios() { // A scenarios directory that resolved to nothing would leave this lane green and empty. - assertEquals(27, SCENARIOS.size()); + assertEquals(30, SCENARIOS.size()); } @ParameterizedTest(name = "{0}") diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedReportControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedReportControllerHarness.java index 01449ce10..52c569cef 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedReportControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedReportControllerHarness.java @@ -205,9 +205,14 @@ private static MetaDataLoader loadCorpus(Path metaJson) { return loader; } + /** The row cap every lane builds its report routes with; {@code ReadingLog} has one row more. */ + public static final int MAX_ROWS = 60; + private static void runGenerator(Object generator, MetaDataLoader loader, Path outDir) { Map args = new HashMap<>(); args.put("outputDir", outDir.toString()); + // The row cap of the corpus (report/README.md): ReadingLog has one row more. + args.put("reportMaxRows", String.valueOf(MAX_ROWS)); ((com.metaobjects.generator.direct.MultiFileDirectGeneratorBase) generator).setArgs(args); ((com.metaobjects.generator.direct.MultiFileDirectGeneratorBase) generator).execute(loader); } diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index 770efd3b8..ef64df575 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -658,6 +658,12 @@ def _auth_docstring_paragraph(*, read_only: bool) -> str: ) +#: The most rows a report list answers when no cap is configured, the same default in every +#: port. With no ``limit`` a report answers every row up to the cap and is refused above it, +#: never truncated. +DEFAULT_REPORT_MAX_ROWS = 10_000 + + class RouterGenerator: """One ``_router.py`` per routed object (FastAPI ``APIRouter``): full CRUD for a writable source, read-only for a view-backed one. @@ -689,6 +695,13 @@ class RouterGenerator: name = "router-generator" + def __init__(self, report_max_rows: int = DEFAULT_REPORT_MAX_ROWS) -> None: + """*report_max_rows* is the row cap of a served report's list route: with no ``limit`` + the route answers every row up to it, and one more row (or a ``limit`` above it) is + ``400 {"error": "report_too_large", "maxRows": N}``. Reports only; an entity's list + keeps its paging.""" + self.report_max_rows = report_max_rows + def _emit_repository_protocol( self, repo_class: str, m2m: list[M2mDescriptor], pk_type: str ) -> list[str]: @@ -758,12 +771,81 @@ def _emit_route_handler( create_autoset: list[str] = (), update_autoset: list[str] = (), frozen_strip: list[str] = (), + report_max_rows: int | None = None, ) -> list[str]: """One CRUD route handler block, dispatched by *name* (``list`` / ``get`` / ``create`` / ``update`` / ``delete``). Override to change a handler's body / decorators / response shape. *model_name* / *patch_model* are the generated create + all-optional PATCH Pydantic models - used to run field constraints on POST / PATCH (FR-036).""" + used to run field constraints on POST / PATCH (FR-036). *report_max_rows* is + passed only for a served report's ``list``: every row up to that cap, refused above it.""" + if name == "list" and report_max_rows is not None: + return [ + '@router.get("")', + f"def list_{plural}(", + " request: Request,", + f" repo: Annotated[{repo_class}, Depends(get_repository)],", + " limit: str | None = Query(None),", + " offset: str | None = Query(None),", + " sort: str | None = Query(None),", + ' with_count: int | None = Query(None, alias="withCount"),', + ") -> Any:", + f" max_rows = {report_max_rows}", + " too_large = JSONResponse(", + ' status_code=400, content={"error": "report_too_large", "maxRows": max_rows}', + " )", + " requested_limit: int | None = None", + " if limit is not None:", + " if not (limit.isascii() and limit.isdigit()) or int(limit) < 1:", + " return JSONResponse(", + " status_code=400,", + " content={", + ' "error": "pagination.invalid_value",', + ' "param": "limit",', + ' "value": limit,', + ' "expected": f"an integer from 1 to {max_rows}",', + " },", + " )", + " requested_limit = int(limit)", + " if requested_limit > max_rows:", + " return too_large", + " actual_offset = 0", + " if offset is not None:", + " if not (offset.isascii() and offset.isdigit()):", + " return JSONResponse(", + " status_code=400,", + " content={", + ' "error": "pagination.invalid_value",', + ' "param": "offset",', + ' "value": offset,', + ' "expected": "a non-negative integer (0 or more)",', + " },", + " )", + " actual_offset = int(offset)", + " sort_clause: _SortClause | None = None", + " if sort is not None:", + " sort_clause = _parse_sort(sort)", + " if sort_clause is None:", + ' return JSONResponse(status_code=400, content={"error": "invalid_sort", "field": sort.split(":", 1)[0]})', + f" filter_result = parse_filter(request.query_params, {fields_const}, {ops_const})", + " if filter_result.error_envelope is not None:", + " return JSONResponse(status_code=400, content=filter_result.error_envelope)", + " predicates, decimal_error = coerce_decimal_predicates(", + " filter_result.predicates, _DECIMAL_FIELDS", + " )", + " if decimal_error is not None:", + " return JSONResponse(status_code=400, content=decimal_error)", + " # With no limit the repository is asked for one row past the cap, so the", + " # refusal comes before any body is written.", + " fetch_limit = requested_limit if requested_limit is not None else max_rows + 1", + " rows = repo.list(fetch_limit, actual_offset, sort_clause, predicates)", + " if requested_limit is None and len(rows) > max_rows:", + " return too_large", + " if with_count == 1:", + " total = repo.count(predicates)", + ' return {"rows": render_wire(rows, _WIRE), "total": total}', + " return render_wire(rows, _WIRE)", + ] if name == "list": return [ '@router.get("")', @@ -1803,6 +1885,9 @@ def _render_readonly_router( # handlers do not read these; they are required by the shared signature. model_name="", patch_model="", + # Only a report is capped; passed for it alone so an adopter's `_emit_route_handler` + # override that does not take the keyword keeps working for a projection. + **({"report_max_rows": self.report_max_rows} if is_report else {}), ) for i, hname in enumerate(("list", "get") if item_route else ("list",)): if i > 0: @@ -1856,11 +1941,12 @@ def render_router( return RouterGenerator().render_router(entity, object_index, column_naming) -def router_generator() -> Generator: +def router_generator(report_max_rows: int = DEFAULT_REPORT_MAX_ROWS) -> Generator: """Generator factory: one ``_router.py`` per routed object — - full CRUD for a writable source, read-only for a view-backed one. + full CRUD for a writable source, read-only for a view-backed one. *report_max_rows* + is the row cap of a served report's list route (default 10000). Returns a :class:`RouterGenerator` (subclassable extension seam). Skips objects without a ``source.rdb`` child and the non-collection read-only kinds (storedProc / tableFunction).""" - return RouterGenerator() + return RouterGenerator(report_max_rows) diff --git a/server/python/tests/integration/generated_report_app.py b/server/python/tests/integration/generated_report_app.py index 4c952339b..41e57b87c 100644 --- a/server/python/tests/integration/generated_report_app.py +++ b/server/python/tests/integration/generated_report_app.py @@ -42,6 +42,7 @@ from .corpus_config import attach_corpus_wire from metaobjects.codegen.config import GenConfig from metaobjects.codegen.generator_registry import GeneratorBuildContext, list_generators +from metaobjects.codegen.generators.router_generator import router_generator from metaobjects.codegen.runner import run_gen from metaobjects.codegen.runtime.filter_parser import FilterPredicate from metaobjects.naming import attach_naming @@ -60,7 +61,11 @@ "SalesBySku": "sales_by_sku", "ProductRefunds": "product_refunds", "TicketsByTags": "tickets_by_tags", + "ReadingLog": "reading_log", } +#: The row cap every lane builds its report routers with: ``ReadingLog`` has one row more, so the +#: cap scenarios tell "exactly the cap" from "past it" (``report/README.md``). +REPORT_CORPUS_MAX_ROWS = 60 #: The sourceless report: generated nowhere, mounted nowhere. UNSERVED_REPORT = "InvoiceDays" @@ -121,7 +126,9 @@ def generate_reports(corpus_root: Path, model: LaneModel = REPORT_MODEL) -> Gene templates = tmp / "templates" templates.mkdir() generators = [ - entry.factory(GeneratorBuildContext(template_root=str(templates))) + router_generator(REPORT_CORPUS_MAX_ROWS) + if entry.name == "routes" + else entry.factory(GeneratorBuildContext(template_root=str(templates))) for entry in list_generators() if entry.name in _GENERATORS ] diff --git a/server/python/tests/integration/test_api_contract_report.py b/server/python/tests/integration/test_api_contract_report.py index f4959a276..7938f1426 100644 --- a/server/python/tests/integration/test_api_contract_report.py +++ b/server/python/tests/integration/test_api_contract_report.py @@ -102,7 +102,7 @@ def test_exactly_the_served_reports_are_generated() -> None: allowlist and router (and the names module is not a route concern, so it is not asked for here: only the three generators the lane runs).""" emitted = generate_reports(_CORPUS).emitted - assert len(SERVED_REPORTS) == 10 + assert len(SERVED_REPORTS) == 11 for report, stem in SERVED_REPORTS.items(): assert f"{report}.py" in emitted assert f"{stem}_filter_allowlist.py" in emitted diff --git a/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts b/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts index a237fe875..85b32fd52 100644 --- a/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts +++ b/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts @@ -27,6 +27,13 @@ export interface RoutesFileHonoOpts { * your host file. Off by default, so a project that does not ask gets no new file. */ registerAll?: boolean; + /** + * The row cap of a report list route (default 10000, the same in every port). With no + * `limit` a report answers every row up to the cap and is refused above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Absent emits no key, so the output stays byte-identical. + */ + reportMaxRows?: number; target?: string; } @@ -111,7 +118,7 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts) effectivePackage(entity), `${entity.name}.routes.hono.ts`, ), - content: await formatTs(renderRoutesFileHono(entity, ctx.renderContext, resolveExpose(entity, opts?.expose))), + content: await formatTs(renderRoutesFileHono(entity, ctx.renderContext, resolveExpose(entity, opts?.expose), opts?.reportMaxRows)), }; }); if (opts?.target) { diff --git a/server/typescript/packages/codegen-ts/src/generators/routes-file.ts b/server/typescript/packages/codegen-ts/src/generators/routes-file.ts index e47667d82..f2f9b34b6 100644 --- a/server/typescript/packages/codegen-ts/src/generators/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/routes-file.ts @@ -27,6 +27,13 @@ export interface RoutesFileOpts { * your host file. Off by default, so a project that does not ask gets no new file. */ registerAll?: boolean; + /** + * The row cap of a report list route (default 10000, the same in every port). With no + * `limit` a report answers every row up to the cap and is refused above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Absent emits no key, so the output stays byte-identical. + */ + reportMaxRows?: number; target?: string; } @@ -54,7 +61,7 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator } return { path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`), - content: await formatTs(renderRoutesFile(entity, ctx.renderContext, resolveExpose(entity, opts?.expose))), + content: await formatTs(renderRoutesFile(entity, ctx.renderContext, resolveExpose(entity, opts?.expose), opts?.reportMaxRows)), }; }); const generator: Generator = { 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 1101d71b0..bd03e71f1 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts @@ -90,6 +90,8 @@ function renderRoutesHono( // mountReadOnlyCrudRoutes, a different helper that is read-only by construction and // takes no `expose` — there is no writable surface there to narrow. expose?: readonly CrudVerb[], + // Row cap of a served report (`routesFile({ reportMaxRows })`). undefined emits no key. + reportMaxRows?: number, ): string { const entityName = entity.name; const handlerName = `register${entityName}Routes`; @@ -142,6 +144,7 @@ function renderRoutesHono( : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + + (report && reportMaxRows !== undefined ? `\n${indent}reportMaxRows: ${reportMaxRows},` : "") + decimalColumnsLine(entity, indent, ctx.loadedRoot); const HonoSym = imp("t:Hono@hono"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); @@ -256,6 +259,13 @@ export interface RoutesFileHonoOpts { * your host file. Off by default, so a project that does not ask gets no new file. */ registerAll?: boolean; + /** + * The row cap of a report list route (default 10000, the same in every port). With no + * `limit` a report answers every row up to the cap and is refused above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Absent emits no key, so the output stays byte-identical. + */ + reportMaxRows?: number; /** * Where the emitted routes import their mount helpers from. Absent: the adapter copy * `meta eject routes-hono` placed in `codegen/runtime/`, by a path computed from the @@ -331,7 +341,7 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts) effectivePackage(entity), `${entity.name}.routes.hono.ts`, ), - content: await formatTs(renderRoutesHono(entity, renderContext, resolveExpose(entity, opts?.expose))), + content: await formatTs(renderRoutesHono(entity, renderContext, resolveExpose(entity, opts?.expose), opts?.reportMaxRows)), }; }); if (opts?.target) { diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index 934358a69..1412d3964 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -107,6 +107,8 @@ function renderRoutes( // which emits no `expose` key at all and so keeps output byte-identical for projects // that do not use it. expose?: readonly CrudVerb[], + // Row cap of a served report (`routesFile({ reportMaxRows })`). undefined emits no key. + reportMaxRows?: number, ): string { // FR-017 Tier 2 — a TPH discriminator base mounts polymorphic list/get at the // base path plus a full per-subtype CRUD route set scoped to each @@ -160,6 +162,7 @@ function renderRoutes( : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + + (report && reportMaxRows !== undefined ? `\n${indent}reportMaxRows: ${reportMaxRows},` : "") + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); @@ -705,6 +708,13 @@ export interface RoutesFileOpts { * your host file. Off by default, so a project that does not ask gets no new file. */ registerAll?: boolean; + /** + * The row cap of a report list route (default 10000, the same in every port). With no + * `limit` a report answers every row up to the cap and is refused above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Absent emits no key, so the output stays byte-identical. + */ + reportMaxRows?: number; /** * Where the emitted routes import their mount helpers from. Absent: the adapter copy * `meta eject routes` placed in `codegen/runtime/`, by a path computed from the target's @@ -728,7 +738,7 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }; return { path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`), - content: await formatTs(renderRoutes(entity, renderContext, resolveExpose(entity, opts?.expose))), + content: await formatTs(renderRoutes(entity, renderContext, resolveExpose(entity, opts?.expose), opts?.reportMaxRows)), }; }); const generator: Generator = { 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 0e7c07a69..c9ed27652 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 @@ -47,6 +47,8 @@ export function renderRoutesFileHono( // mountReadOnlyCrudRoutes, a different helper that is read-only by construction and // takes no `expose` — there is no writable surface there to narrow. expose?: readonly CrudVerb[], + // Row cap of a served report (`routesFile({ reportMaxRows })`). undefined emits no key. + reportMaxRows?: number, ): string { const entityName = entity.name; const handlerName = `register${entityName}Routes`; @@ -100,6 +102,7 @@ export function renderRoutesFileHono( : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + + (report && reportMaxRows !== undefined ? `\n${indent}reportMaxRows: ${reportMaxRows},` : "") + decimalColumnsLine(entity, indent, ctx.loadedRoot); 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 32194e23d..9c04ababe 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts @@ -45,6 +45,8 @@ export function renderRoutesFile( // which emits no `expose` key at all and so keeps output byte-identical for projects // that do not use it. expose?: readonly CrudVerb[], + // Row cap of a served report (`routesFile({ reportMaxRows })`). undefined emits no key. + reportMaxRows?: number, ): string { // FR-017 Tier 2 — a TPH discriminator base mounts polymorphic list/get at the // base path plus a full per-subtype CRUD route set scoped to each @@ -99,6 +101,7 @@ export function renderRoutesFile( : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + + (report && reportMaxRows !== undefined ? `\n${indent}reportMaxRows: ${reportMaxRows},` : "") + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts index 10ed3dc8b..1ba5aea3b 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts @@ -147,6 +147,14 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ "attrs" jsonb );`, }, + { + name: "readings", + ddl: ` + CREATE TABLE IF NOT EXISTS "readings" ( + "id" bigserial PRIMARY KEY, + "seq" integer NOT NULL + );`, + }, ]; export interface GeneratedReportServerHandle { @@ -168,8 +176,13 @@ export const SERVED_REPORTS = [ { name: "SalesBySku", registrar: "salesBySkuRoutes" }, { name: "ProductRefunds", registrar: "productRefundsRoutes" }, { name: "TicketsByTags", registrar: "ticketsByTagsRoutes" }, + { name: "ReadingLog", registrar: "readingLogRoutes" }, ] as const; +/** The row cap every lane builds its report routes with: ReadingLog has one row more than + * this, so the cap scenarios can tell "exactly the cap" from "past it". */ +export const REPORT_CORPUS_MAX_ROWS = 60; + export async function startGeneratedReportServer( connectionUri: string, metaPath: string, @@ -192,7 +205,7 @@ export async function startGeneratedReportServer( dbImport: "./db", dialect: "postgres", apiPrefix: "/api", - generators: [entityFile(), routesFile()], + generators: [entityFile(), routesFile({ reportMaxRows: REPORT_CORPUS_MAX_ROWS })], }), metadata: root, }); diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-mysql-schema.ts b/server/typescript/packages/integration-tests/src/api-contract-report-mysql-schema.ts index 375145f35..dbf814b74 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-report-mysql-schema.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-report-mysql-schema.ts @@ -52,6 +52,10 @@ const TABLES = [ \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, \`tags\` JSON, \`attrs\` JSON +)`, + `CREATE TABLE \`readings\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`seq\` INT NOT NULL )`, ]; diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-mysql-server.ts b/server/typescript/packages/integration-tests/src/api-contract-report-mysql-server.ts index 9741a3b7e..9bda3e556 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-report-mysql-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-report-mysql-server.ts @@ -14,7 +14,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { runGen, defineConfig, buildReportViews } from "@metaobjectsdev/codegen-ts"; import { entityFile, routesFile } from "@metaobjectsdev/test-generators"; import mysql from "mysql2/promise"; -import { seedInserts, SERVED_REPORTS, type ReportSeed } from "./api-contract-report-generated-server.ts"; +import { REPORT_CORPUS_MAX_ROWS, seedInserts, SERVED_REPORTS, type ReportSeed } from "./api-contract-report-generated-server.ts"; import { DEFAULT_PHYSICAL_NAMING } from "@metaobjectsdev/metadata"; import { loadMetadataFile } from "./load-metadata.ts"; @@ -72,6 +72,13 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ \`attrs\` JSON )`, }, + { + name: "readings", + ddl: `CREATE TABLE \`readings\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`seq\` INT NOT NULL + )`, + }, ]; export async function startMysqlReportServer(mysqlUrl: string, metaPath: string): Promise { @@ -92,7 +99,7 @@ export async function startMysqlReportServer(mysqlUrl: string, metaPath: string) dbImport: "./db", dialect: "mysql", apiPrefix: "/api", - generators: [entityFile(), routesFile()], + generators: [entityFile(), routesFile({ reportMaxRows: REPORT_CORPUS_MAX_ROWS })], }), metadata: root, }); 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 index 9ba53fb6e..b2f407779 100644 --- 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 @@ -20,7 +20,7 @@ import { runGen, defineConfig, buildReportViews } from "@metaobjectsdev/codegen- import { DEFAULT_PHYSICAL_NAMING } from "@metaobjectsdev/metadata"; import { entityFile, routesFile } from "@metaobjectsdev/test-generators"; import { loadMetadataFile } from "./load-metadata.ts"; -import { seedInserts, SERVED_REPORTS, type ReportSeed } from "./api-contract-report-generated-server.ts"; +import { REPORT_CORPUS_MAX_ROWS, seedInserts, SERVED_REPORTS, type ReportSeed } from "./api-contract-report-generated-server.ts"; import { startLocalD1 } from "./query-scenario-d1.ts"; /** The SQLite-family engines this server runs the emitted routes on. */ @@ -89,6 +89,14 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ "attrs" TEXT )`, }, + { + name: "readings", + ddl: ` + CREATE TABLE "readings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "seq" INTEGER NOT NULL + )`, + }, ]; export async function startSqliteReportServer( @@ -112,7 +120,7 @@ export async function startSqliteReportServer( dbImport: "./db", dialect: "sqlite", // codegen has no d1 dialect: D1 is SQLite to Drizzle (only the db module differs) apiPrefix: "/api", - generators: [entityFile(), routesFile()], + generators: [entityFile(), routesFile({ reportMaxRows: REPORT_CORPUS_MAX_ROWS })], }), metadata: root, }); diff --git a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts index 62330dab5..14e919d57 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts @@ -26,20 +26,20 @@ describe("api-contract report corpus", () => { const reports = root.objects().filter((o) => o.subType === OBJECT_SUBTYPE_REPORT); expect(reports.map((r) => r.name)).toEqual( ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", "InvoiceDays", - "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags"]); + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags", "ReadingLog"]); expect(reports.filter((r) => reportReadSource(r) !== undefined).map((r) => r.name)).toEqual( ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags"]); + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags", "ReadingLog"]); }); test("the route segments are the ones the scenarios call", async () => { const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); const segment = (name: string): string => pluralize(toSnakeCase(name)); expect(["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags"].map(segment)).toEqual( + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", "TicketsByTags", "ReadingLog"].map(segment)).toEqual( ["invoice_status_totals", "invoices_by_months", "invoice_totals", "invoice_calendars", "invoice_disputes", - "product_revenues", "sales_by_categories", "sales_by_skus", "product_refunds", "tickets_by_tags"]); - expect(root.objects().length).toBe(16); + "product_revenues", "sales_by_categories", "sales_by_skus", "product_refunds", "tickets_by_tags", "reading_logs"]); + expect(root.objects().length).toBe(18); }); test("each seeded report row has exactly the report's derived fields", async () => { @@ -52,7 +52,7 @@ describe("api-contract report corpus", () => { } expect(Object.keys(seedReports())).toEqual( ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "SalesByCategory", "SalesBySku", "ProductRevenue", "ProductRefunds"]); + "SalesByCategory", "SalesBySku", "ProductRevenue", "ProductRefunds", "ReadingLog"]); }); // A lane inserts every top-level key but `reports` in file order, so the order is the @@ -60,9 +60,9 @@ describe("api-contract report corpus", () => { test("the base tables of seed.json are the model's tables, listed parents first", async () => { const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); const tables = Object.keys(seed).filter((k) => k !== SEED_REPORTS_KEY); - expect(tables).toEqual(["invoices", "categories", "products", "sales"]); + expect(tables).toEqual(["invoices", "categories", "products", "sales", "readings"]); // Every key is an entity's table and every row key one of its fields (seedInserts throws otherwise). - expect(seedInserts(root, seed).length).toBe(5 + 2 + 3 + 3); + expect(seedInserts(root, seed).length).toBe(5 + 2 + 3 + 3 + 61); for (const [i, table] of tables.entries()) { const entity = root.objects().find((o) => o.isEntity() && resolveTableName(o) === table); if (entity === undefined) throw new Error(`no entity has table ${table}`); @@ -77,9 +77,9 @@ describe("api-contract report corpus", () => { } }); - test("every scenario parses, and there are twenty-seven", () => { + test("every scenario parses, and there are thirty", () => { const scenarios = loadScenarios(API_CONTRACT_REPORT_SCENARIOS_DIR); - expect(scenarios.length).toBe(27); + expect(scenarios.length).toBe(30); for (const s of scenarios) expect(s.requests.length).toBeGreaterThan(0); }); diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts index 76abece6c..6373e966e 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts @@ -10,7 +10,7 @@ import { FilterParseError, parsePageBound, SORT_EXPECTED } from "./list-params.j // Re-exported so existing importers keep one door; the definitions live in the // drizzle-free list-params module, which the ObjectManager Fastify mount also uses. -export { FilterParseError, parsePageBound, PAGINATION_EXPECTED, RAW_VIEW_MAX_LIMIT, SORT_EXPECTED } from "./list-params.js"; +export { FilterParseError, parsePageBound, PAGINATION_EXPECTED, RAW_VIEW_MAX_LIMIT, REPORT_MAX_ROWS, ReportTooLargeError, parseReportPage, SORT_EXPECTED } from "./list-params.js"; import { ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, PLUS_OFFSET_HINT, looksLikeUnencodedPlusOffset, matchesAnyTemporal, matchesFormat, utcIsoIfZoned, utcIsoInstant, diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/list-params.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/list-params.ts index 89401ceff..f435709d0 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/list-params.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/list-params.ts @@ -15,6 +15,11 @@ export class FilterParseError extends Error { * no allowlist to page through and returns at most this many rows. */ export const RAW_VIEW_MAX_LIMIT = 1000; +/** The most rows a report list answers, whether or not the caller sends a `limit`. A report + * with no `limit` returns every row up to this cap and is refused above it (never + * truncated); the same default in all five ports. */ +export const REPORT_MAX_ROWS = 10_000; + /** The `expected` member of a `pagination.invalid_value` answer with no upper bound. */ export const PAGINATION_EXPECTED = "a non-negative integer (0 or more)"; @@ -48,3 +53,41 @@ export function parsePageBound( /** The `expected` member of an `invalid_sort` answer — the one spelling `?sort` takes. */ export const SORT_EXPECTED = "sort=:asc|desc"; + +/** The page a report list reads: `limit` is how many rows to FETCH, which is one past the + * cap when the caller sent none, so the caller can tell exactly the cap apart from more than it. */ +export interface ReportPage { + readonly limit: number; + readonly offset: number; + /** True when the caller sent no `limit`, so more than `maxRows` rows means refuse. */ + readonly unbounded: boolean; +} + +/** A report answered more rows than `maxRows` allows. The wire answer is + * `400 { "error": "report_too_large", "maxRows": N }`. */ +export class ReportTooLargeError extends Error { + constructor(public readonly maxRows: number) { + super(`A report answers at most ${maxRows} rows; narrow it with a filter or page it with ?limit=.`); + this.name = "ReportTooLargeError"; + } +} + +/** + * Read a report list's `?limit=` / `?offset=`. A limit is an integer of at least 1 (a + * report with `limit=0` is a bad bound, not an empty answer) and an offset is a + * non-negative integer; anything else is `pagination.invalid_value`. A limit above + * `maxRows` throws {@link ReportTooLargeError}. + */ +export function parseReportPage(query: Record, maxRows: number): ReportPage { + const limit = parsePageBound(query, "limit"); + if (limit === 0) { + throw new FilterParseError( + "pagination.invalid_value", + `?limit= must be an integer from 1 to ${maxRows}, got "0".`, + { param: "limit", value: "0", expected: `an integer from 1 to ${maxRows}` }, + ); + } + if (limit !== undefined && limit > maxRows) throw new ReportTooLargeError(maxRows); + const offset = parsePageBound(query, "offset") ?? 0; + return { limit: limit ?? maxRows + 1, offset, unbounded: limit === undefined }; +} 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 2451dfebb..254b69cae 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 @@ -2,7 +2,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify"; import { quoteIdent, type SqlDialect } from "../sql-dialect.js"; import { sql, eq, and, count } from "drizzle-orm"; import qs from "qs"; -import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "./filter-parser.js"; +import { parseFilterParams, parsePageBound, parseReportPage, RAW_VIEW_MAX_LIMIT, REPORT_MAX_ROWS, ReportTooLargeError, FilterParseError } from "./filter-parser.js"; import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; @@ -50,6 +50,13 @@ export interface MountReadOnlyOptions extends WireOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; + /** + * A report list's row cap (`resource: "report"` only; ignored otherwise). With no `limit` + * the route answers every row up to this many and refuses above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Default 10000, the same in every port. + */ + readonly reportMaxRows?: number; } const rejectMutation = (resource: string) => async ( @@ -147,6 +154,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const ro = withContractErrorHandler(opts.routeOptions); const reject = rejectMutation(opts.resource ?? "projection"); const itemRoutes = opts.itemRoutes !== false; + // A report answers every row up to a cap and refuses above it; every other mount pages as asked. + const isReport = opts.resource === "report"; + const reportMaxRows = opts.reportMaxRows ?? REPORT_MAX_ROWS; const viewName = resolveViewName(view); const useRawSql = isEmptyColumnView(view) && !!viewName; @@ -167,11 +177,13 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : ""; const parsed = qs.parse(queryString) as Record; // A bound that is not an integer in range is a 400, not silently clamped. - const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT; - const offsetVal = parsePageBound(parsed, "offset") ?? 0; + const reportPage = isReport ? parseReportPage(parsed, reportMaxRows) : undefined; + const limitVal = reportPage?.limit ?? parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT; + const offsetVal = reportPage?.offset ?? parsePageBound(parsed, "offset") ?? 0; const withCount = isTruthyFlag(parsed["withCount"]); // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[]; + if (reportPage?.unbounded && rows.length > reportMaxRows) throw new ReportTooLargeError(reportMaxRows); const camelRows = rows.map((r: Record) => camelizeRow(r)); if (!withCount) return camelRows; // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result @@ -185,6 +197,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : ""; const parsed = qs.parse(queryString) as Record; const withCount = isTruthyFlag(parsed["withCount"]); + const reportPage = isReport ? parseReportPage(parsed, reportMaxRows) : undefined; const result = parseFilterParams({ query: parsed, table: view, @@ -198,13 +211,19 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { let q = db.select().from(view); if (combinedWhere) q = q.where(combinedWhere); if (result.orderBy) q = q.orderBy(...result.orderBy); - if (result.limit !== undefined) q = q.limit(result.limit); - if (result.offset !== undefined) q = q.offset(result.offset); + // A report fetches one row past the cap when no limit was sent, to tell exactly the + // cap apart from more than the cap before any body is written. + const limit = reportPage?.limit ?? result.limit; + const offset = reportPage?.offset ?? result.offset; + if (limit !== undefined) q = q.limit(limit); + if (offset !== undefined) q = q.offset(offset); // Await directly — `.all()` is libsql/better-sqlite3-only; the // node-postgres builder is thenable. (The useRawSql branch above keeps // db.all(sql.raw(...)) because that is the libsql raw-exec API, only // reachable for `.existing()` empty-column views which are sqlite-only.) - const rows = (await q as unknown[]).map(toWire); + const fetched = await q as unknown[]; + if (reportPage?.unbounded && fetched.length > reportMaxRows) throw new ReportTooLargeError(reportMaxRows); + const rows = fetched.map(toWire); if (!withCount) return rows; @@ -215,6 +234,10 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const total = countRow?.c ?? 0; return { rows, total }; } catch (err) { + if (err instanceof ReportTooLargeError) { + reply.code(400).send({ error: "report_too_large", maxRows: err.maxRows }); + return; + } if (err instanceof FilterParseError) { // Spread the details so the cross-port-required `field` reaches the wire on a // projection route too (F20) — the writable mount already does this. 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 41e49daa6..35c45ac42 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 @@ -6,7 +6,7 @@ import { quoteIdent, type SqlDialect } from "../sql-dialect.js"; import type { Hono } from "hono"; import { sql, eq, and, count } from "drizzle-orm"; import qs from "qs"; -import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "../drizzle-fastify/filter-parser.js"; +import { parseFilterParams, parsePageBound, parseReportPage, RAW_VIEW_MAX_LIMIT, REPORT_MAX_ROWS, ReportTooLargeError, FilterParseError } from "../drizzle-fastify/filter-parser.js"; import type { FilterAllowlist, SortAllowlist, @@ -49,6 +49,13 @@ export interface MountReadOnlyOptions extends WireOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; + /** + * A report list's row cap (`resource: "report"` only; ignored otherwise). With no `limit` + * the route answers every row up to this many and refuses above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Default 10000, the same in every port. + */ + readonly reportMaxRows?: number; } function resolveViewName(view: AnyView): string | undefined { @@ -126,6 +133,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const { app, path, db, view, filterAllowlist, sortAllowlist, dialect } = opts; const idCol = opts.idColumn ?? "id"; const itemRoutes = opts.itemRoutes !== false; + // A report answers every row up to a cap and refuses above it; every other mount pages as asked. + const isReport = opts.resource === "report"; + const reportMaxRows = opts.reportMaxRows ?? REPORT_MAX_ROWS; const viewName = resolveViewName(view); const useRawSql = isEmptyColumnView(view) && !!viewName; @@ -142,11 +152,13 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : ""; const parsed = qs.parse(queryString) as Record; // A bound that is not an integer in range is a 400, not silently clamped. - const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT; - const offsetVal = parsePageBound(parsed, "offset") ?? 0; + const reportPage = isReport ? parseReportPage(parsed, reportMaxRows) : undefined; + const limitVal = reportPage?.limit ?? parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT; + const offsetVal = reportPage?.offset ?? parsePageBound(parsed, "offset") ?? 0; const withCount = isTruthyFlag(parsed["withCount"]); // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} LIMIT ${limitVal} OFFSET ${offsetVal}`))) as any[]; + if (reportPage?.unbounded && rows.length > reportMaxRows) throw new ReportTooLargeError(reportMaxRows); const camelRows = rows.map((r: Record) => camelizeRow(r)); if (!withCount) return c.json(camelRows); // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result @@ -160,6 +172,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : ""; const parsed = qs.parse(queryString) as Record; const withCount = isTruthyFlag(parsed["withCount"]); + const reportPage = isReport ? parseReportPage(parsed, reportMaxRows) : undefined; const result = parseFilterParams({ query: parsed, table: view, @@ -173,14 +186,20 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { let q = db.select().from(view); if (combinedWhere) q = q.where(combinedWhere); if (result.orderBy) q = q.orderBy(...result.orderBy); - if (result.limit !== undefined) q = q.limit(result.limit); - if (result.offset !== undefined) q = q.offset(result.offset); + // A report fetches one row past the cap when no limit was sent, to tell exactly the + // cap apart from more than the cap before any body is written. + const limit = reportPage?.limit ?? result.limit; + const offset = reportPage?.offset ?? result.offset; + if (limit !== undefined) q = q.limit(limit); + if (offset !== undefined) q = q.offset(offset); // Await the query directly rather than calling `.all()`: the drizzle-orm // node-postgres builder is thenable but has no `.all()` (a libsql / // better-sqlite3-only API). Awaiting works on BOTH dialects — this is what // makes the Hono helpers genuinely Postgres-capable, matching the Fastify // adapter, which carried this fix while Hono did not (#286). - const rows = (await q as unknown[]).map(toWire); + const fetched = await q as unknown[]; + if (reportPage?.unbounded && fetched.length > reportMaxRows) throw new ReportTooLargeError(reportMaxRows); + const rows = fetched.map(toWire); if (!withCount) return c.json(rows); @@ -190,6 +209,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const total = countRow?.c ?? 0; return c.json({ rows, total }); } catch (err) { + if (err instanceof ReportTooLargeError) { + return c.json({ error: "report_too_large", maxRows: err.maxRows }, 400); + } if (err instanceof FilterParseError) { // Wire code + the details (so the cross-port-required `field` is present), // matching the Fastify projection mount. diff --git a/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts b/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts index 200622ad5..464e1de69 100644 --- a/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts +++ b/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts @@ -333,3 +333,77 @@ describe("mountReadOnlyCrudRoutes — GET :id addresses idColumn", () => { } }); }); + +// A report answers every row up to a cap and refuses above it: never a silent page default, +// never a truncated body. Both the column-bearing and the opaque (raw-SQL) view are covered. +describe("mountReadOnlyCrudRoutes — report row cap", () => { + let client: ReturnType; + const apps: FastifyInstance[] = []; + + beforeAll(async () => { + client = createClient({ url: ":memory:" }); + await client.execute(`CREATE TABLE days (id INTEGER PRIMARY KEY, n INTEGER NOT NULL)`); + await client.execute(`CREATE VIEW v_days AS SELECT id, n FROM days`); + const values = Array.from({ length: 60 }, (_, i) => `(${i + 1}, ${i + 1})`).join(","); + await client.execute(`INSERT INTO days (id, n) VALUES ${values}`); + }); + + afterAll(async () => { + for (const a of apps) await a.close(); + client.close(); + }); + + const mountReport = async (opaque: boolean, reportMaxRows?: number): Promise => { + const view = opaque + ? sqliteView("v_days", {}).existing() + : sqliteView("v_days", { id: integer("id").notNull(), n: integer("n").notNull() }).existing(); + const app = Fastify(); + mountReadOnlyCrudRoutes({ + fastify: app, path: "/days", db: drizzle(client), view, + filterAllowlist: { n: { ops: ["eq"], subType: "int" } }, + sortAllowlist: { id: { defaultOrder: "asc" } }, + dialect: "sqlite", itemRoutes: false, resource: "report", + ...(reportMaxRows !== undefined && { reportMaxRows }), + }); + await app.ready(); + apps.push(app); + return app; + }; + + for (const opaque of [false, true]) { + const kind = opaque ? "opaque view" : "declared columns"; + test(`${kind}: no limit returns every row, past 50`, async () => { + const app = await mountReport(opaque); + const res = await app.inject({ method: "GET", url: "/days" }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toHaveLength(60); + }); + + test(`${kind}: exactly the cap is answered, one more is refused`, async () => { + const exact = await mountReport(opaque, 60); + expect(JSON.parse((await exact.inject({ method: "GET", url: "/days" })).body)).toHaveLength(60); + const over = await mountReport(opaque, 59); + const res = await over.inject({ method: "GET", url: "/days" }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body)).toEqual({ error: "report_too_large", maxRows: 59 }); + }); + + test(`${kind}: a limit above the cap is refused, one within it pages`, async () => { + const app = await mountReport(opaque, 10); + const over = await app.inject({ method: "GET", url: "/days?limit=11" }); + expect(over.statusCode).toBe(400); + expect(JSON.parse(over.body)).toEqual({ error: "report_too_large", maxRows: 10 }); + const ok = await app.inject({ method: "GET", url: "/days?limit=10&offset=5" }); + expect(JSON.parse(ok.body)).toHaveLength(10); + }); + + for (const url of ["/days?limit=0", "/days?limit=-1", "/days?limit=abc", "/days?offset=-1", "/days?offset=1.5"]) { + test(`${kind}: ${url} → 400 pagination.invalid_value`, async () => { + const app = await mountReport(opaque); + const res = await app.inject({ method: "GET", url }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toBe("pagination.invalid_value"); + }); + } + } +}); diff --git a/server/typescript/packages/test-generators/src/generators/routes.ts b/server/typescript/packages/test-generators/src/generators/routes.ts index 934358a69..1412d3964 100644 --- a/server/typescript/packages/test-generators/src/generators/routes.ts +++ b/server/typescript/packages/test-generators/src/generators/routes.ts @@ -107,6 +107,8 @@ function renderRoutes( // which emits no `expose` key at all and so keeps output byte-identical for projects // that do not use it. expose?: readonly CrudVerb[], + // Row cap of a served report (`routesFile({ reportMaxRows })`). undefined emits no key. + reportMaxRows?: number, ): string { // FR-017 Tier 2 — a TPH discriminator base mounts polymorphic list/get at the // base path plus a full per-subtype CRUD route set scoped to each @@ -160,6 +162,7 @@ function renderRoutes( : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + + (report && reportMaxRows !== undefined ? `\n${indent}reportMaxRows: ${reportMaxRows},` : "") + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); @@ -705,6 +708,13 @@ export interface RoutesFileOpts { * your host file. Off by default, so a project that does not ask gets no new file. */ registerAll?: boolean; + /** + * The row cap of a report list route (default 10000, the same in every port). With no + * `limit` a report answers every row up to the cap and is refused above it with + * `400 { "error": "report_too_large", "maxRows": N }`; an explicit `limit` above it is + * refused the same way. Absent emits no key, so the output stays byte-identical. + */ + reportMaxRows?: number; /** * Where the emitted routes import their mount helpers from. Absent: the adapter copy * `meta eject routes` placed in `codegen/runtime/`, by a path computed from the target's @@ -728,7 +738,7 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }; return { path: entityOutputPath(ctx.config.outputLayout ?? "flat", effectivePackage(entity), `${entity.name}.routes.ts`), - content: await formatTs(renderRoutes(entity, renderContext, resolveExpose(entity, opts?.expose))), + content: await formatTs(renderRoutes(entity, renderContext, resolveExpose(entity, opts?.expose), opts?.reportMaxRows)), }; }); const generator: Generator = { From 1c9907f0a0a6719c82b390fbe160083b172cc471 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 20:26:19 -0400 Subject: [PATCH 2/4] no-mistakes(review): Fix TS report limit error text; rename Java test --- .../runtime/drizzle-fastify/list-params.ts | 16 +++++++++++----- .../runtime/drizzle-fastify/list-params.ts | 16 +++++++++++----- .../api/ReportOmdbApiContractLane.java | 2 +- .../src/drizzle-fastify/list-params.ts | 16 +++++++++++----- .../test/drizzle-fastify/mount-read-only.test.ts | 8 ++++++++ 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/list-params.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/list-params.ts index f435709d0..8915b2f6e 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/list-params.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/list-params.ts @@ -79,14 +79,20 @@ export class ReportTooLargeError extends Error { * `maxRows` throws {@link ReportTooLargeError}. */ export function parseReportPage(query: Record, maxRows: number): ReportPage { - const limit = parsePageBound(query, "limit"); - if (limit === 0) { - throw new FilterParseError( + const badLimit = (value: string) => + new FilterParseError( "pagination.invalid_value", - `?limit= must be an integer from 1 to ${maxRows}, got "0".`, - { param: "limit", value: "0", expected: `an integer from 1 to ${maxRows}` }, + `?limit= must be an integer from 1 to ${maxRows}, got "${value}".`, + { param: "limit", value, expected: `an integer from 1 to ${maxRows}` }, ); + let limit: number | undefined; + try { + limit = parsePageBound(query, "limit"); + } catch (e) { + if (e instanceof FilterParseError) throw badLimit(String(query.limit)); + throw e; } + if (limit === 0) throw badLimit("0"); if (limit !== undefined && limit > maxRows) throw new ReportTooLargeError(maxRows); const offset = parsePageBound(query, "offset") ?? 0; return { limit: limit ?? maxRows + 1, offset, unbounded: limit === undefined }; diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/list-params.ts b/examples/showcase/codegen/runtime/drizzle-fastify/list-params.ts index f435709d0..8915b2f6e 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/list-params.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/list-params.ts @@ -79,14 +79,20 @@ export class ReportTooLargeError extends Error { * `maxRows` throws {@link ReportTooLargeError}. */ export function parseReportPage(query: Record, maxRows: number): ReportPage { - const limit = parsePageBound(query, "limit"); - if (limit === 0) { - throw new FilterParseError( + const badLimit = (value: string) => + new FilterParseError( "pagination.invalid_value", - `?limit= must be an integer from 1 to ${maxRows}, got "0".`, - { param: "limit", value: "0", expected: `an integer from 1 to ${maxRows}` }, + `?limit= must be an integer from 1 to ${maxRows}, got "${value}".`, + { param: "limit", value, expected: `an integer from 1 to ${maxRows}` }, ); + let limit: number | undefined; + try { + limit = parsePageBound(query, "limit"); + } catch (e) { + if (e instanceof FilterParseError) throw badLimit(String(query.limit)); + throw e; } + if (limit === 0) throw badLimit("0"); if (limit !== undefined && limit > maxRows) throw new ReportTooLargeError(maxRows); const offset = parsePageBound(query, "offset") ?? 0; return { limit: limit ?? maxRows + 1, offset, unbounded: limit === undefined }; diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdbApiContractLane.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdbApiContractLane.java index d4769147d..eeb5ef323 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdbApiContractLane.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportOmdbApiContractLane.java @@ -111,7 +111,7 @@ static Stream scenarios() { } @Test - void theCorpusCarriesItsTwentySevenScenarios() { + void theCorpusCarriesItsThirtyScenarios() { // A scenarios directory that resolved to nothing would leave this lane green and empty. assertEquals(30, SCENARIOS.size()); } diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/list-params.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/list-params.ts index f435709d0..8915b2f6e 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/list-params.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/list-params.ts @@ -79,14 +79,20 @@ export class ReportTooLargeError extends Error { * `maxRows` throws {@link ReportTooLargeError}. */ export function parseReportPage(query: Record, maxRows: number): ReportPage { - const limit = parsePageBound(query, "limit"); - if (limit === 0) { - throw new FilterParseError( + const badLimit = (value: string) => + new FilterParseError( "pagination.invalid_value", - `?limit= must be an integer from 1 to ${maxRows}, got "0".`, - { param: "limit", value: "0", expected: `an integer from 1 to ${maxRows}` }, + `?limit= must be an integer from 1 to ${maxRows}, got "${value}".`, + { param: "limit", value, expected: `an integer from 1 to ${maxRows}` }, ); + let limit: number | undefined; + try { + limit = parsePageBound(query, "limit"); + } catch (e) { + if (e instanceof FilterParseError) throw badLimit(String(query.limit)); + throw e; } + if (limit === 0) throw badLimit("0"); if (limit !== undefined && limit > maxRows) throw new ReportTooLargeError(maxRows); const offset = parsePageBound(query, "offset") ?? 0; return { limit: limit ?? maxRows + 1, offset, unbounded: limit === undefined }; diff --git a/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts b/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts index 464e1de69..52a28d28e 100644 --- a/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts +++ b/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts @@ -405,5 +405,13 @@ describe("mountReadOnlyCrudRoutes — report row cap", () => { expect(JSON.parse(res.body).error).toBe("pagination.invalid_value"); }); } + + for (const bad of ["0", "-1", "abc"]) { + test(`${kind}: ?limit=${bad} says a report limit starts at 1`, async () => { + const app = await mountReport(opaque, 10); + const res = await app.inject({ method: "GET", url: `/days?limit=${bad}` }); + expect(JSON.parse(res.body).expected).toBe("an integer from 1 to 10"); + }); + } } }); From cdd9835fdc4e73952fcc47341a8a23cee7d11258 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 22:48:54 -0400 Subject: [PATCH 3/4] no-mistakes(test): Fix report cap test filterAllowlist type to typecheck --- .../runtime-ts/test/drizzle-fastify/mount-read-only.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts b/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts index 52a28d28e..db40fd8c8 100644 --- a/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts +++ b/server/typescript/packages/runtime-ts/test/drizzle-fastify/mount-read-only.test.ts @@ -360,7 +360,7 @@ describe("mountReadOnlyCrudRoutes — report row cap", () => { const app = Fastify(); mountReadOnlyCrudRoutes({ fastify: app, path: "/days", db: drizzle(client), view, - filterAllowlist: { n: { ops: ["eq"], subType: "int" } }, + filterAllowlist: { n: { ops: ["eq"], subType: "number", leadingWildcard: false } }, sortAllowlist: { id: { defaultOrder: "asc" } }, dialect: "sqlite", itemRoutes: false, resource: "report", ...(reportMaxRows !== undefined && { reportMaxRows }), From f0eb56db1dda3a151a6050ae137d1e77bc3c5249 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 26 Oct 2026 02:33:40 -0400 Subject: [PATCH 4/4] no-mistakes(document): Docs checked against report row cap change. CHANGELOG, api-contract.md and reporting.md already updated by the change and match code (cap 10,000, reportMaxRows option names, report_too_large and pagination.invalid_value envelopes, 30-scenario count). Fixed one formatting nit: missing blank line between Fixed entries in CHANGELOG [Unreleased]. docs/superpowers/plans/2026-10-04-fr-044-plan-3 still describes the old 50-row default, but it is a dated plan record, so left as is. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8ca9ee7..c3e304676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ here.** the cast changes the operator and still reads as drift, as does a changed pattern or any other cast. Schema comparison is TypeScript-owned, so no other port carries this comparison. + - **A report list in Java, Kotlin and Python no longer silently drops rows past the 50th.** A 90-day daily report showed 50 days with no error when `limit` was omitted. C# also ignored a `limit` of 0 or below on a report instead of refusing it. See the report row cap under