Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1529,8 +1529,13 @@ until you regenerate.
five ports**: written as the canonical string, read from a string or a JSON number exactly.
The ports disagreed before (TypeScript refused a number, Python stored a float). A
`field.double` inside a value object stays a JSON number, and a fractional number that is
not a declared decimal stays a float in Python (it answered 500). A decimal inside a
`field.map` value object is not covered: it is not part of the rule and is not gated.
not a declared decimal stays a float in Python (it answered 500). The rule also holds
for a value object held by a `field.map` (applied to each map value; the keys are arbitrary
and never matched with member names), gated by the `jsonb-value-object-map-decimals`
scenario in every port. To get there, the generated Kotlin controller now creates, reads
and patches a `field.map` of value objects (it skipped that column before), and the
generated C# `DbContext` maps a value object that holds a map as one converted jsonb
property instead of an owned type (EF Core 8 cannot own a dictionary inside JSON).
- **A `field.decimal` array works in C#, Java and Kotlin.** Java binds a Postgres `numeric[]`
as exact `BigDecimal` elements, a non-required C# decimal array is nullable, and each
element follows the scalar decimal rule (a bad element is `400 validation`).
Expand Down
5 changes: 3 additions & 2 deletions agent-context/skills/metaobjects-codegen/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ in order and every change that needs action.
- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min`
or `@max` is refused (write an integer bound).
- **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a
plain `gen` does not refresh it. `meta eject --list` (`dotnet meta eject`, `metaobjects eject`,
`mvn metaobjects:eject`) reports each copy that differs from the reference;
plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no
`eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin)
reports each copy that differs from the reference;
`eject <name> --force` takes the new reference for a copy you never edited, and a copy you
edited is three-way merged instead. Steps for 1.0.x: https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md.

Expand Down
10 changes: 5 additions & 5 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) |
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 105 (31 core + 10 tph + 9 m2m + 3 jsonb + 2 write-through + 12 projection + 27 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (HttpListener + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL |
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 106 (31 core + 10 tph + 9 m2m + 4 jsonb + 2 write-through + 12 projection + 27 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (HttpListener + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL |
| [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) |
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
Expand Down Expand Up @@ -347,9 +347,9 @@ REAL, a MySQL `DECIMAL` keeps its scale, a MySQL `DATETIME` is the UTC wall cloc
`report-relative-date` carries a per-engine seed (`seed-data-engine`). `op: relate` and the
write ops stay Postgres-only. See [features/reporting.md](features/reporting.md#what-the-corpus-gates).

### `fixtures/api-contract-conformance/` (105)
### `fixtures/api-contract-conformance/` (106)

All 105 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
All 106 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD
routes answer identically over HTTP — list / get / create / patch+put / delete,
plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1`
Expand All @@ -363,7 +363,7 @@ URL grammar `?filter[<field>][<op>]=<value>` (FR-009). On top of the 31 core
scenarios the corpus carries seven sub-corpora — `tph/` (10, single-table
inheritance; a subtype decimal filters and refuses extra digits), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination
each corpus alone could not reach, and 1 pinning the collection-URL spelling),
`jsonb/` (3, typed value-object columns; numbers inside them: a double stays a JSON number, a decimal is the canonical string),
`jsonb/` (4, typed value-object columns; numbers inside them: a double stays a JSON number, a decimal is the canonical string),
`write-through/` (2, table-write + view-read entities), `projection/` (12, a
read-only view answers reads and refuses writes with 405; a projection with no declared
identity has no item route; a projection keyed on a field not named `id` is addressed by
Expand Down Expand Up @@ -554,7 +554,7 @@ files into a real Cube.
## Orphaned fixtures (tested but not yet documented)

The fixtures in the nine corpora mapped above (metamodel 410 + yaml 16 + verify 37
+ render 16 + persistence 59 + api-contract 105 + source-resolution 25 + scope 10 +
+ render 16 + persistence 59 + api-contract 106 + source-resolution 25 + scope 10 +
dependency 23) each map to a feature doc, and so do the two requirement corpora and the
`cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining
corpora in the totals table gate tooling contracts (registry manifests, provider
Expand Down
21 changes: 10 additions & 11 deletions docs/features/field-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,16 @@ Four rows need a footnote:
> added a boundary check of your own while this was open, it is now redundant rather than
> load-bearing.

**The RUNTIME tier is not there yet, and no conformance corpus covers it.** No
persistence- or api-contract-conformance fixture exercises `field.map` on any port; it is
loader- and codegen-gated only. Of the runtime persistence layers, only Python's
`ObjectManager` encodes a map (its jsonb write codec names `FIELD_SUBTYPE_MAP`
alongside `FIELD_SUBTYPE_OBJECT`). TypeScript's `runtime-ts`, Java's OMDB and the Kotlin
Exposed persistence lane carry no map handling at all — OMDB's jsonb path in particular
is gated on the `@storage` attr, which a map does not have, and serializes through a
per-`MetaObject` Gson adapter that has no map-of-value-object binding. Closing that is a
cross-port runtime workstream, not a codegen change, and it is the prerequisite for the
shared `op: roundtrip` persistence scenario that would gate this subtype the way every
other persistable subtype is gated.
**The RUNTIME tier is partial, and no persistence conformance corpus covers it.** No
persistence-conformance fixture exercises `field.map`; the api-contract
`jsonb/scenarios/jsonb-value-object-map-decimals.yaml` gates a map of value objects through
each port's generated API. Of the runtime persistence layers, Python's `ObjectManager` encodes
a map (its jsonb write codec names `FIELD_SUBTYPE_MAP` alongside `FIELD_SUBTYPE_OBJECT`), and
Java's OMDB maps a `field.map @objectRef` to a jsonb column whose values are the value object
(`GenericSQLDriver`). TypeScript's `runtime-ts` and the Kotlin Exposed persistence lane carry
no map-of-value-object handling. Closing that gap across ports is a cross-port runtime
workstream, not a codegen change, and it is the prerequisite for the shared `op: roundtrip`
persistence scenario that would gate this subtype the way every other persistable subtype is gated.

So: for data you intend to READ BACK THROUGH A PORT RUNTIME today, a stable key set is
still better declared as an `object.value` behind `field.object`, which every runtime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ in order and every change that needs action.
- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min`
or `@max` is refused (write an integer bound).
- **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a
plain `gen` does not refresh it. `meta eject --list` (`dotnet meta eject`, `metaobjects eject`,
`mvn metaobjects:eject`) reports each copy that differs from the reference;
plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no
`eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin)
reports each copy that differs from the reference;
`eject <name> --force` takes the new reference for a copy you never edited, and a copy you
edited is three-way merged instead. Steps for 1.0.x: https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ in order and every change that needs action.
- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min`
or `@max` is refused (write an integer bound).
- **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a
plain `gen` does not refresh it. `meta eject --list` (`dotnet meta eject`, `metaobjects eject`,
`mvn metaobjects:eject`) reports each copy that differs from the reference;
plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no
`eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin)
reports each copy that differs from the reference;
`eject <name> --force` takes the new reference for a copy you never edited, and a copy you
edited is three-way merged instead. Steps for 1.0.x: https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ in order and every change that needs action.
- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min`
or `@max` is refused (write an integer bound).
- **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a
plain `gen` does not refresh it. `meta eject --list` (`dotnet meta eject`, `metaobjects eject`,
`mvn metaobjects:eject`) reports each copy that differs from the reference;
plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no
`eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin)
reports each copy that differs from the reference;
`eject <name> --force` takes the new reference for a copy you never edited, and a copy you
edited is three-way merged instead. Steps for 1.0.x: https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ in order and every change that needs action.
- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min`
or `@max` is refused (write an integer bound).
- **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a
plain `gen` does not refresh it. `meta eject --list` (`dotnet meta eject`, `metaobjects eject`,
`mvn metaobjects:eject`) reports each copy that differs from the reference;
plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no
`eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin)
reports each copy that differs from the reference;
`eject <name> --force` takes the new reference for a copy you never edited, and a copy you
edited is three-way merged instead. Steps for 1.0.x: https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ in order and every change that needs action.
- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min`
or `@max` is refused (write an integer bound).
- **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a
plain `gen` does not refresh it. `meta eject --list` (`dotnet meta eject`, `metaobjects eject`,
`mvn metaobjects:eject`) reports each copy that differs from the reference;
plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no
`eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin)
reports each copy that differs from the reference;
`eject <name> --force` takes the new reference for a copy you never edited, and a copy you
edited is three-way merged instead. Steps for 1.0.x: https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md.

Expand Down
4 changes: 2 additions & 2 deletions fixtures/api-contract-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ fixtures/api-contract-conformance/
```

Beside the 31 core scenarios above sit seven **sub-corpora**, each a directory with its
own `meta.json`, `seed.json`, `scenarios/` and `README.md` (74 scenarios in all, 105 with the
own `meta.json`, `seed.json`, `scenarios/` and `README.md` (75 scenarios in all, 106 with the
core):

| Directory | Scenarios | Gates | Lanes |
|---|---|---|---|
| `tph/` | 10 | single-table inheritance | both |
| `m2m/` | 9 | M:N traversal, and TPH x M:N together | both |
| `jsonb/` | 3 | typed value-object columns, and the numbers inside them (a double stays a JSON number; a decimal is a canonical string, option A of the 1.1.0 review) | both |
| `jsonb/` | 4 | typed value-object columns, and the numbers inside them (a double stays a JSON number; a decimal is a canonical string, option A of the 1.1.0 review) | both |
| `write-through/` | 2 | an entity that writes a table and reads a view | generated only |
| `projection/` | 12 | a read-only `object.projection`: reads served, every write `405`; no declared identity means no `/{id}` route; a key not named `id` addresses the row; decimal and float filters. Its `docs-routes.json` pins the routes each port documents | generated only |
| `report/` | 27 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default; a dimension over an array or a map is neither filterable nor sortable. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only |
Expand Down
1 change: 1 addition & 0 deletions fixtures/api-contract-conformance/jsonb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jsonb/
└── scenarios/
├── jsonb-open-bag-roundtrip.yaml # open-bag parsed-value contract
├── jsonb-value-object-patch.yaml # Program D — cross-port VO-column PATCH/POST
├── jsonb-value-object-map-decimals.yaml # decimals in a field.map of value objects, per map value
└── jsonb-value-object-numbers.yaml # doubles and decimals inside a value object
```

Expand Down
14 changes: 14 additions & 0 deletions fixtures/api-contract-conformance/jsonb/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@
{ "field.decimal": { "name": "amount", "@precision": 12, "@scale": 4 } }
]
}},
{ "object.value": {
"name": "Tier",
"children": [
{ "field.decimal": { "name": "amount", "@precision": 12, "@scale": 4 } }
]
}},
{ "object.value": {
"name": "Pricing",
"children": [
{ "field.map": { "name": "tiers", "@objectRef": "Tier" } }
]
}},
{ "object.entity": {
"name": "Document",
"children": [
Expand All @@ -29,6 +41,8 @@
{ "field.object": { "name": "markers", "@objectRef": "Marker", "@storage": "jsonb", "isArray": true } },
{ "field.object": { "name": "measure", "@objectRef": "Measure", "@storage": "jsonb" } },
{ "field.object": { "name": "measures", "@objectRef": "Measure", "@storage": "jsonb", "isArray": true } },
{ "field.map": { "name": "tiers", "@objectRef": "Tier" } },
{ "field.object": { "name": "pricing", "@objectRef": "Pricing", "@storage": "jsonb" } },
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }
]
}}
Expand Down
Loading
Loading