Skip to content

Commit 00b7fed

Browse files
authored
feat(api-contract): identical projection and report read routes in all five ports (#408)
* feat(api-contract): identical projection and report read routes in all five ports - A projection with no declared primary identity has no /{id} route in any port, even with a field named id (TypeScript and Python change). - Java and Kotlin api docs list a read-only projection's reads only. - New projection/ cases: keyless-no-item-route, keyed-by-non-id-field, filter-decimal, filter-float, plus docs-routes.json run by a docs test per port. - New report/ case sort-enum-dimension; Invoice.status is an enum there. - Harness fixes: C# host serializes enums as strings; Java seam coerces enum operands. * no-mistakes(document): Fixed stale scenario list in reporting.md: added enum dimension to documented report conformance tests.
1 parent 50e0f57 commit 00b7fed

55 files changed

Lines changed: 1299 additions & 510 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ it until 1.1 ships._
107107
with a derived field over a `field.object`. In C# a report's enum dimension is sortable (an
108108
entity's enum field still is not). A decimal column (`avg`, a ratio, a `sum` of a decimal)
109109
has no cross-port JSON spelling: each port sends its own, and TypeScript sends a string.
110-
Gated by a new api-contract sub-corpus, `fixtures/api-contract-conformance/report/` (12
111-
scenarios, generated lane, all five ports; the corpus goes from 61 scenarios to 73), which
110+
Gated by a new api-contract sub-corpus, `fixtures/api-contract-conformance/report/` (13
111+
scenarios, generated lane, all five ports; the corpus goes from 61 scenarios to 78, the
112+
`projection/` sub-corpus gaining four), which
112113
is also the first to assert a `field.date` literally. Anyone who declared a view-sourced
113114
report under the unreleased 1.1 vocabulary will see these files on the next `gen`. See
114115
[docs/features/reporting.md](docs/features/reporting.md#how-a-report-is-served).
@@ -144,28 +145,32 @@ it until 1.1 ships._
144145

145146
### Changed
146147

147-
Seven corrections that shipped with report serving and reach models that declare no report.
148+
Eight corrections that shipped with report serving and reach models that declare no report.
148149
Each one changes generated code on the next `gen`, or generated docs on the next `meta docs`
149150
or Python api-docs build, as its entry says. A drift gate that covers that output reports it
150151
until you regenerate.
151152

152-
- **TypeScript and Python: a read-only projection with no declared identity and no field named
153-
`id` loses its item surface.** It no longer gets `GET /{id}` or the three item-verb refusals,
154-
its by-id query (`find<Name>ById` in TypeScript, `find_by_id` on the Python repository
155-
Protocol) or, in TypeScript, its detail hook and `detail` query keys. That surface could not
156-
address a row: TypeScript built the query with no `WHERE` and answered the view's first row,
157-
and Python bound an `id: int` to nothing. **Unchanged by this entry:** a projection with a
158-
declared identity (a composite one still binds its first field; the next entry covers one
159-
keyed on a field not named `id`), and a projection with an `id` field and no declared
160-
identity. C#, Java and Kotlin are unchanged and remain stricter: they mount
161-
`/{id}` only for a declared single-column identity, so a projection with an `id` field and no
162-
declared identity has item routes in TypeScript and Python and none in the other three. The
163-
TypeScript mounts (`mountReadOnlyCrudRoutes`, Fastify and Hono) take a new `itemRoutes: false`
164-
option for this; it defaults to mounting them. **Upgrading:** an owned `routes` or
165-
`routes-hono` generator ejected before this release keeps mounting the item routes for this
166-
shape until you re-eject it or add `itemRoutes: false` to its read-only mount options, and
167-
an owned `mount-read-only.ts` needs the re-sync described under the served-report entry
168-
above.
153+
- **TypeScript and Python: a read-only projection with no declared identity loses its item
154+
surface, even when it has a field named `id`.** It no longer gets `GET /{id}` or the three
155+
item-verb refusals, its by-id query (`find<Name>ById` in TypeScript, `find_by_id` on the Python
156+
repository Protocol) or, in TypeScript, its detail hook and `detail` query keys. This is a
157+
**named behaviour change for an adopter whose view-only projection has an `id` field and no
158+
`identity.primary`**: that shape kept working in these two ports (TypeScript answered the row
159+
whose `id` matched), and now answers the framework's `404` for `/{id}`. To keep the item
160+
route, declare the identity: `identity.primary` extending the base entity's, with a
161+
pass-through field for its key. A field named `id` is a convention, not a key, and C#, Java
162+
and Kotlin never mounted item routes for it, so all five ports now mount `/{id}` for exactly
163+
the projections that declare an identity. Without any `id` field the same surface was already
164+
unaddressable: TypeScript built the by-id query with no `WHERE` and answered the view's first
165+
row, and Python bound an `id: int` to nothing. **Unchanged by this entry:** a projection with
166+
a declared identity (a composite one still binds its first field in these two ports; the next
167+
entry covers one keyed on a field not named `id`). The TypeScript mounts
168+
(`mountReadOnlyCrudRoutes`, Fastify and Hono) take a new `itemRoutes: false` option for this;
169+
it defaults to mounting them. **Upgrading:** an owned `routes` or `routes-hono` generator
170+
ejected before this release keeps mounting the item routes for this shape until you re-eject
171+
it or add `itemRoutes: false` to its read-only mount options, and an owned
172+
`mount-read-only.ts` needs the re-sync described under the served-report entry above. Gated
173+
in all five ports by `projection/keyless-no-item-route.yaml`.
169174
- **TypeScript: a read-only projection whose identity is on a field not named `id` now
170175
addresses that field.** The shape: a view-only `object.projection` whose
171176
`identity.primary` names a single field such as `code`, or a composite identity whose first
@@ -197,6 +202,13 @@ until you regenerate.
197202
`GET <path>/{id}` only when the projection has an item route (the rule above), and the
198203
filter allowlist. No write verb is listed, since the router answers those with `405`.
199204
Entities and write-through objects are unchanged. Regenerate the docs to pick the pages up.
205+
- **Java and Kotlin API docs: a read-only projection's page lists the reads only.** The
206+
api-docs builders listed `POST`, `PATCH`, `PUT` and `DELETE` on a read-only projection, each
207+
described as a `405` refusal, beside `GET`. The other three ports never documented a write
208+
verb on one, and a refusal is not an operation a caller can use, so the page now lists `GET
209+
<path>` and, when the projection declares an identity, `GET <path>/{id}`. The generated
210+
controllers are unchanged. Regenerate the docs to pick it up. Gated in all five ports by
211+
`projection/docs-routes.json`.
200212
- **Java: a filter allowlist with more than ten filterable fields now compiles.**
201213
`SpringFilterAllowlistGenerator` spelled `OPS_BY_FIELD` with `Map.of`, which has no overload
202214
past ten pairs, so an entity or projection with eleven or more `@filterable` fields generated

‎agent-context/skills/metaobjects-codegen/references/python.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,9 @@ Its REST surface is generated and READ-ONLY (F22): GET list + GET by id, the sam
118118
projection's OWN declared field set, and `POST` / `PATCH` / `PUT` / `DELETE` each
119119
answering `405 {"error": "method_not_allowed"}` — 405 and not 404 because the same
120120
path answers GET. A KEYLESS projection mounts no `/{id}` route at all, so it refuses only
121-
the collection verb, and its repository Protocol has no `find_by_id`. In Python keyless
122-
means no `identity.primary` AND no field named `id`: a projection with an `id` field and
123-
no declared identity keeps its item routes (C#, Java and Kotlin are stricter and need a
124-
declared single-column identity).
121+
the collection verb, and its repository Protocol has no `find_by_id`. Keyless means no
122+
declared `identity.primary`: a field that is merely named `id` is a convention, not a key, so
123+
a projection with an `id` field and no declared identity is keyless too (in every port).
125124
The read-only router is a separate assembly from the writable one, sharing only the
126125
emitters they genuinely have in common; `router_generator` and
127126
`filter_allowlist_generator` ask one shared `emits_router()` predicate.

‎agent-context/skills/metaobjects-codegen/references/typescript.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,9 @@ Its REST surface is generated and READ-ONLY (F22): GET list + GET by id, the sam
154154
projection's OWN declared field set, and `POST` / `PATCH` / `PUT` / `DELETE` each
155155
answering `405 {"error": "method_not_allowed"}` — 405 and not 404 because the same
156156
path answers GET. A KEYLESS projection mounts no `/:id` route at all, so it refuses only
157-
the collection verb, and it gets no `find…ById` query and no detail hook. In TypeScript
158-
keyless means no `identity.primary` AND no field named `id`: a projection with an `id`
159-
field and no declared identity keeps its item routes (C#, Java and Kotlin are stricter and
160-
need a declared single-column identity).
157+
the collection verb, and it gets no `find…ById` query and no detail hook. Keyless means no
158+
declared `identity.primary`: a field that is merely named `id` is a convention, not a key, so
159+
a projection with an `id` field and no declared identity is keyless too (in every port).
161160
`routesFile()` mounts it through `mountReadOnlyCrudRoutes` from the drizzle-fastify
162161
adapter (your `codegen/runtime/` copy once ejected), which is where the refusals live.
163162
A `field.decimal` in a view's read schema is `z.string()`: the driver reads `numeric` as

‎docs/CONFORMANCE.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
3434
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
3535
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ |
3636
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 39 (33 query + 6 migration) | all 39 | 33 query (migrations TS-only, ADR-0015) | 33 query (via Exposed) | 33 query | 33 query |
37-
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 73 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 7 projection + 12 report) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) |
37+
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 78 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 11 projection + 13 report) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) |
3838
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
3939
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
4040
| [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -257,9 +257,9 @@ All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-d
257257
- `migrations/*` (6) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section)
258258
- `queries/*` (33) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`)
259259

260-
### `fixtures/api-contract-conformance/` (73)
260+
### `fixtures/api-contract-conformance/` (78)
261261

262-
All 73 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
262+
All 78 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
263263
REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD
264264
routes answer identically over HTTP — list / get / create / patch+put / delete,
265265
plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1`
@@ -274,8 +274,10 @@ scenarios the corpus carries six sub-corpora — `tph/` (10, single-table
274274
inheritance), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination
275275
each corpus alone could not reach, and 1 pinning the collection-URL spelling),
276276
`jsonb/` (2, typed value-object columns),
277-
`write-through/` (2, table-write + view-read entities), `projection/` (7, a
278-
read-only view answers reads and refuses writes with 405) and `report/` (12,
277+
`write-through/` (2, table-write + view-read entities), `projection/` (11, a
278+
read-only view answers reads and refuses writes with 405; a projection with no declared
279+
identity has no item route; a projection keyed on a field not named `id` is addressed by
280+
it; decimal and float fields filter) and `report/` (13,
279281
FR-044: a view-backed `object.report` is listed, filtered, sorted and paged on
280282
its derived fields, answers `POST` with 405 and mounts no `/{id}`). All 5 ports — TS, Java,
281283
Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and
@@ -405,7 +407,7 @@ own those two functions), and
405407
## Orphaned fixtures (tested but not yet documented)
406408

407409
The fixtures in the nine corpora mapped above (metamodel 364 + yaml 16 + verify 31
408-
+ render 15 + persistence 39 + api-contract 73 + source-resolution 25 + scope 10 +
410+
+ render 15 + persistence 39 + api-contract 78 + source-resolution 25 + scope 10 +
409411
dependency 23) each map to a feature doc. None are orphaned today. The remaining
410412
corpora in the totals table gate tooling contracts (registry manifests, provider
411413
composition, agent context, docs emit) rather than user-facing metamodel behaviour,

‎docs/features/api-contract.md‎

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -425,21 +425,23 @@ The surface:
425425
on the collection, `PATCH` / `PUT` / `DELETE` on the item. 405 rather than 404
426426
because the resource plainly exists: the same path answers `GET`. `message` is free
427427
prose and is not part of the contract.
428-
- A **keyless** projection mounts no `/{id}` route at all, so it refuses only the
429-
collection verb — refusing an item verb would advertise an address the port never
430-
serves. What counts as keyless differs by port, and the corpus does not gate it:
431-
432-
| Port | Item routes are mounted when |
433-
|---|---|
434-
| C#, Java, Kotlin | the projection declares or inherits a **single-column** `identity.primary` |
435-
| TypeScript, Python | it declares or inherits an `identity.primary` (a composite one binds its first field), **or** it declares none and has a field named `id` |
436-
437-
So a projection with an `id` field and no declared identity has item routes in
438-
TypeScript and Python and none in the other three. Until FR-044, TypeScript and Python
439-
mounted the item routes for **every** read-only projection, including one with no
440-
identity and no `id` field, where they could not address a row (TypeScript answered the
441-
view's first row). That one shape lost its `/{id}` routes, its by-id query and, in
442-
TypeScript, its detail hook; every other projection is unchanged.
428+
- A **keyless** projection, one that declares and inherits no `identity.primary`, mounts no
429+
`/{id}` route of any verb in any port, so it refuses only the collection verb —
430+
refusing an item verb would advertise an address the port never serves. A field that
431+
happens to be named `id` is a convention, not a key: a projection with an `id` field and
432+
no declared identity is keyless. `GET /<plural>/1` on one answers the framework's own
433+
`404`, never a row. A projection with a declared identity addresses a row by the field
434+
that identity names, which need not be called `id` and which the view's column must
435+
match: `GET /<plural>/{key}` answers that row, or the `404 {"error": "not_found"}`
436+
envelope. One difference remains, and the corpus does not gate it: C#, Java and Kotlin
437+
mount item routes only for a **single-column** identity, while TypeScript and Python
438+
bind a composite one to its first field.
439+
- Filters apply to a projection's `field.decimal` and `field.float` fields as they do to
440+
an entity's. A decimal's wire spelling is port-specific and is not asserted.
441+
- **The api docs list the same routes.** A projection's api page documents `GET <path>`,
442+
and `GET <path>/{id}` only when the item route exists. The `405` refusals are not
443+
operations a caller can use, so no write verb is documented. Pinned in every port by
444+
`projection/docs-routes.json`.
443445

444446
Every port mounts those refusals **explicitly**. Left to the framework, ASP.NET and
445447
Spring each answer an unmatched method on a matched path with an empty-bodied 405 and
@@ -472,7 +474,7 @@ entity's. No request parameter picks dimensions, measures or a grain. No port ge
472474
client hook, grid or form for a report yet.
473475

474476
Gated by [`fixtures/api-contract-conformance/report/`](../../fixtures/api-contract-conformance/report/)
475-
(12 scenarios), **generated lane only, on all five ports**, for the reason `projection/`
477+
(13 scenarios), **generated lane only, on all five ports**, for the reason `projection/`
476478
gives. The columns, their types and the per-port generated files are in
477479
[reporting.md](reporting.md#how-a-report-is-served).
478480

0 commit comments

Comments
 (0)