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
1 change: 1 addition & 0 deletions .claude/rules/cross-language-porting.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Preserve the following contracts exactly across all language ports:
**Wire format:**
- Currency: integer minor units on the wire always. Float arithmetic for money is forbidden.
- Pagination: `?limit=N&offset=N` — identical across all generated endpoints.
- **Output form is a project setting, and the default is each language's own** (ADR-0019 Amendment 2): `wire` in `.metaobjects/config.json` (`decimals`: `native`|`string`; `nullMembers`: `native`|`emit`|`omit`), read by the same config reader as `naming` and carried on the loaded root. `native` means what that port's standard serializer writes. Never make a port's default match another port or a corpus. The corpora pin `{"decimals": "string", "nullMembers": "omit"}` in their own config, and a harness reads it from there. Input rules (W2, W3) hold in every mode. Shared cases: `fixtures/wire-conformance/`.
- **Runtime return types**: a port's runtime `ObjectManager` returns **native in-process language types** (`field.decimal`→`BigDecimal`/`decimal`/`Decimal`, TS `string`; temporal→native; jsonb→native map). Wire canonicalization (the bullets above + `normalization.md`) is applied at the **serialization boundary**, never inside the runtime query path. See [ADR-0019](spec/decisions/ADR-0019-runtime-return-type-contract.md).

**Grammar:**
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.

**Cross-port conformance corpora** (every port runs the shared corpus):
- Metamodel: `fixtures/conformance/` (410 fixtures; 30 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
- Metamodel: `fixtures/conformance/` (410 fixtures; 31 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived.
Expand Down Expand Up @@ -389,6 +389,8 @@ These are the load-bearing principles that have emerged through implementation.

- **Expanding the metamodel vocabulary follows ONE decision procedure (ADR-0037) — driven by semantic behavior, not surface storage.** Don't ask "is X a string/number/date?"; ask "what does X *do*?" Ordered test: (0) **derivable** from existing subtype + attrs (`isArray`/`@maxLength`) + structure? → derive in codegen, add nothing; (1) **physical-only** (native type/meaning unchanged)? → the `@dbColumnType` escape hatch, not first-class vocab; (2) logical — **does X have its own native type, behavior, or attributes** (a *thing* that owns custom logic)? → **subtype** (the extension point: `field.uuid`, `field.uri`, `field.inet`); a **structural variant *within* a subtype** (changes generated shape, shares native type)? → **`@kind`** (the one chartered structural-variant axis: source table/view, uri url/urn — never a catch-all, never on a plain string); otherwise X just **modifies/validates/configures** an existing type → **attribute** (boolean flag `@localTime`; validation `@stringFormat` email/hostname; config `@maxLength`). The "string formats" set splits by behavior: url/uri→`field.uri`, ip→`field.inet` (native types + behavior), only email/hostname→`@stringFormat` (plain validated strings); uuid is already `field.uuid`. Self-documentation over economy; no same-name overloads (hence `@stringFormat`, not a third `@format`). Consult it every time. See [ADR-0037](spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md).

- **Each port defaults to its own language's standard behaviour; identical cross-port output is opt-in.** Generated code writes what that language's standard serializer writes: C# decimals as JSON numbers, a value object's null member as `null`. Do not standardize the ports for the convenience of our own conformance tests. A cross-port form, such as `"wire": { "decimals": "string", "nullMembers": "omit" }`, is a project setting that adopters choose. The corpora pin it explicitly in their own config and never decide a default. 1.1.0 broke this rule by making the decimal string and null omission the only output, and 1.1.2 put it back. See [ADR-0019 Amendment 2](spec/decisions/ADR-0019-runtime-return-type-contract.md).

- **Pattern-derivable from metadata = codegen, never hand-code.** This is the metaobjects raison d'être. If you find yourself proposing that users hand-write something the metadata fully describes (FK references, basic CRUD, validator chains, type-safe finders, relations() blocks), stop. Codegen it. The only exception is what metadata genuinely cannot express (custom SQL views, regex patterns from outside metadata, business logic). When in doubt, generate.

- **Study reference implementations for subtle pipeline behavior; don't re-derive from spec.** For complex orchestration (loader, parser, super resolution, overlay/override merging, registry lifecycle), the spec describes WHAT but the implementation captures HOW — including edge cases and error handling. When porting, read existing implementations first. First-principles reasoning produces subtly wrong behavior that breaks cross-language interop.
Expand Down
45 changes: 44 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,53 @@ here.**

## [1.1.2] — unreleased

_Fixes #455. A bug-fix release: no vocabulary or `metamodelVersion` change._
_Fixes #454 and #455. A bug-fix release: no vocabulary or `metamodelVersion` change._

### Fixed

- **Each port writes its language's standard JSON again by default, and the cross-port form is
a setting (#454).** 1.1.0 changed what every generated API writes, with no way back. Decimals
became canonical strings (`12.50` was sent as `"12.5"`), and C#, Java and Kotlin stopped
writing a value object's null members. On an upgrade within 1.x, that broke clients of live
APIs. The output form is now the project setting `wire` in `.metaobjects/config.json`, read by
every port's codegen the way `naming` is (ADR-0019 Amendment 2):

```json
{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } }
```

Both keys default to `native`, meaning what the language's standard serializer writes. **C#,
Java and Kotlin** write a decimal as a JSON number again (`12.50`), and a value object's null
member as `null` with its key present: the 1.0.x output. **Python and TypeScript** write a
decimal as their standard string, which keeps its scale (`"12.50"`). That is a change from
1.1.1's `"12.5"`. **Python adopters coming from 1.0.x:** 1.0.x routers returned plain dicts,
and FastAPI's encoder wrote a `Decimal` as a float (`12.5`), losing digits past a double's
precision. From 1.1.2 a decimal is Pydantic's string, `str(Decimal)` (`"12.50"`), so a client
that read a number now reads a string. There is no float option, by decision: a float that
loses precision is not the Python-normal form, and Pydantic's string is.
**To keep 1.1.0's output** in every port, declare
`"wire": { "decimals": "string", "nullMembers": "omit" }` and regenerate. `nullMembers` also
takes `"emit"`, which writes every declared member of a value object, `null` when unset,
including members the stored value lacks (Python and TypeScript; in C#, Java and Kotlin
`emit` is what `native` already writes). Input is unchanged in every mode: a decimal is
accepted as a string or a number, and garbage or a value past `@scale` or `@precision` is
`400 validation`, never rounded. An unknown key or value is refused when the config is read.
Every port's codegen drift gate says which `wire` block reproduces committed output generated
under a different setting, instead of telling you to regenerate. The conformance
corpora pin the portable form in their own config. The per-port table is in
`docs/features/api-contract.md`, "The wire setting".

**What to expect when you regenerate with no setting.** C# entity and value-object properties
carry `[DecimalWire.Number]` in place of the string converters, and lose
`[JsonIgnore(WhenWritingNull)]`. A non-required array member of a value object starts as an
empty list again. Java DTOs and records, and Kotlin data classes, lose the decimal serializer
annotation and `@JsonInclude(NON_NULL)`. Python and TypeScript routes stop canonicalizing
decimals on output.
- **C#: two namespaces no longer collide on `DecimalWire.g.cs` and `MetaNetValidation.g.cs`.**
When the model's packages bound to more than one namespace, each namespace's copy of a helper
was named `DecimalWire.g.cs`, and `gen` refused the duplicate output path (`emitted by both
"entity" and "entity"`). With more than one namespace, each copy is now named after its
namespace (`DecimalWire.Acme.Billing.g.cs`). A single-namespace project keeps the plain name.
- **C#: `MetaDataLoader.FromDirectory` (every overload, the registry overload included) now applies
the project's `naming`**, as the JVM `fromDirectory` does. A host that composed its own registry
and loaded with `FromDirectory(dir, registry, strict: true)` got `PhysicalNaming.Default`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ A concrete report whose read source is `@kind: view` is served by every port's g
- `<segment>` is the report name, snake_cased then pluralized, like any object: `InvoicesByMonth` is `/invoices_by_months`, `StoreTotals` is `/store_totals`. There is no report-specific spelling.
- **Every derived field with filter operators is filterable and sortable**, dimension and measure alike, with its type's own operators. You cannot narrow that set: a report has no field to put `@filterable` on. A field of the `@from` entity that the report does not expose is refused with a `400`.
- A time dimension at `day` or coarser is `YYYY-MM-DD`; a null measure is `null` with the key present (a measure with `@default` is never null); `currency` is integer minor units.
- **A decimal (`avg`, a ratio, a `sum` of a decimal) is one canonical string in every port** (ADR-0019 Amendment 1: `12.5`, never `12.50` or an exponent). Display formatting is a view's job. Do not parse it as a float to compare.
- **A decimal (`avg`, a ratio, a `sum` of a decimal) is written per the project's `wire` setting**: by default, the port's language-standard form (a JSON number in C#, Java and Kotlin; a string in Python and TypeScript), and under `"wire": { "decimals": "string" }` one canonical string in every port (`12.5`, never `12.50` or an exponent). Do not parse it as a float to compare.
- `meta gen` writes a model page for every report (`docsFile()`; a sourceless one is marked "Not served") and an API page for a served one (`apiDocsFile()`) — each an ordinary entry in `generators`.

The files each port generates are in the `metaobjects-codegen` skill's reference for that language ("Reports").
Expand Down
17 changes: 15 additions & 2 deletions agent-context/skills/metaobjects-codegen/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ exact config, generator names and commands for this project; read it. The other
| `references/choosing-layers.md` | the generated API does not fit, you need joined or derived data, or you are adopting onto existing code |
| `references/template-or-code.md` | choosing between a code generator and a Mustache template |
| `references/physical-names.md` | before you write a table, column or index name as a string |
| `references/wire-format.md` | a generated API's JSON for decimals or null value-object members is not what a client expects, or you are about to edit a JSON converter or annotation |
| `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects |
| `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator |
| `references/typescript-mysql.md` | a TypeScript project on a MySQL database |
Expand All @@ -60,8 +61,11 @@ in order and every change that needs action.
args are removed.
- Documentation is a generator in the project's list, and the docs commands are removed. C#
and Python also need a generator list (`dotnet meta init`; `metaobjects.config.yaml`).
- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min`
or `@max` is refused (write an integer bound).
- Upgrade to 1.1.2 or later. With no `wire` setting, each port writes its language's standard
JSON (C#, Java and Kotlin keep their 1.0.x decimals and null members). A project whose clients
expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string",
"nullMembers": "omit" }`; see `references/wire-format.md`. 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` (TypeScript; `metaobjects gen --list` in Python, which has no
`eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin)
Expand Down Expand Up @@ -389,6 +393,15 @@ names artifact (`<Entity>.names.ts`, `<Entity>Names.java`, …) — **but only w
migration scripts and external mappings; prefer the ORM's typed column handle where one exists.
Details: `references/physical-names.md`.

## The JSON your API writes is a project setting

Each port writes what its language's standard serializer writes: a C# decimal is a JSON
number, a Python one `str(Decimal)`. Identical output across ports is opt-in, through `wire` in
`.metaobjects/config.json` (`"decimals": "string"`, `"nullMembers": "omit"` or `"emit"`). Never
change one port to match another or to match a conformance corpus, and never hand-edit a
generated converter or annotation: change the setting and regenerate. Input rules hold in every
mode. Details and the per-port table: `references/wire-format.md`.

## Dialects

Generated DB schema/DDL targets a SQL **dialect**:
Expand Down
13 changes: 13 additions & 0 deletions agent-context/skills/metaobjects-codegen/references/csharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ is **schema-migrations only** on the C# side (ADR-0015): `meta migrate` /
- `MetaObjects.Codegen` generators
- Docs — one more entry in `codegen/Generators.cs`
- Persistence + routes are the deployed artifact
- The JSON the API writes
- Write your own generator
- Scoping a generator to some objects
- Extending the generators (open-for-extension, ADR-0002)
Expand Down Expand Up @@ -207,6 +208,18 @@ persistence layer (EF Core), and the minimal-API routes mount on your `WebApplic
There is no runtime "ObjectManager" layer to wire — the generated EF Core code is
what runs. (The other ports leave a repository seam; C# does not.)

## The JSON the API writes

With no `wire` setting, the generated API writes what System.Text.Json writes: a decimal is a
JSON number with its scale (`12.50`, through `[DecimalWire.Number]`), and a value object's null
member is `null` with its key present. That is the 1.0.x output. `"wire": { "decimals":
"string", "nullMembers": "omit" }` in `.metaobjects/config.json` gives the cross-port form
(`"12.5"`, null members left out). The input checks (a string or a number; `400` past `@scale`
or `@precision`) hold in every mode. Never strip the attributes or delete `DecimalWire.g.cs` to
change the output: set `wire` and regenerate. `dotnet meta verify --codegen` names the `wire`
block that reproduces committed output generated under another setting. See
`references/wire-format.md`.

## Write your own generator

For any output the model describes and no reference emits, write an `IGenerator` in the
Expand Down
13 changes: 13 additions & 0 deletions agent-context/skills/metaobjects-codegen/references/java.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Maven plugin goal — there is no standalone `meta` binary on the Java side (the
- When a generator's output is wrong — own the generator
- Docs — the `api-docs` generator
- `codegen-spring` generators
- The JSON the API writes
- Discriminator inheritance (TPH)
- Serializing generated objects

Expand Down Expand Up @@ -307,6 +308,18 @@ Metadata lives wherever the plugin's `<loader><sourceDir>` points (the examples
ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed
physical column.

## The JSON the API writes

With no `wire` setting, the generated records write what the host's Jackson `ObjectMapper` writes: a
decimal is a JSON number (`12.50`; `1E-7` under Spring Boot's defaults), and a value object's
null member is `null` with its key present. `"wire": { "decimals": "string", "nullMembers":
"omit" }` in `.metaobjects/config.json` gives the cross-port form (`"12.5"`, null members left
out): `string` adds the canonical decimal serializer, and `omit` adds `@JsonInclude(NON_NULL)` to
each value-object type. The input checks (a string or a number; `400` past `@scale` or
`@precision`) hold in every mode. Never strip those annotations from generated code to change
the output: set `wire` and regenerate. `mvn metaobjects:verify` names the `wire` block that
reproduces committed output generated under another setting. See `references/wire-format.md`.

## Discriminator inheritance (TPH)

`codegen-spring` fully supports **table-per-hierarchy (TPH) inheritance**. `TphPlan`
Expand Down
Loading
Loading