diff --git a/CHANGELOG.md b/CHANGELOG.md index 52761022d..7a565b40c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,13 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added - **`@convert` on `origin.passthrough` — acknowledge a deliberate passthrough type change (#185).** A new optional boolean attr. Absent/false (the default), a passthrough is type-preserving (see Changed, below); `@convert: true` opts a field out of the type-equality check when its type intentionally differs from its `@from` source. It is an **acknowledgement only — it does NOT generate a cast**; the value flows through unchanged and the consumer owns any coercion. Real type-converting projections remain `origin.expression`'s job (#159). Registered on `origin.passthrough` in all five ports (cross-port registry-conformance gated). +- **Typed value-object jsonb columns work end-to-end across all persistence ports (single + array-of-VO).** A `field.object @storage:jsonb` column — a single value-object OR an `@isArray` array-of-VO — now round-trips through every port's runtime/ORM write+read codec (TS Kysely, C# EF Core, Java OMDB/Gson, Kotlin Exposed, Python pg8000). Gated by a new array-of-VO dimension in the persistence-conformance `AllTypes` `op: roundtrip` scenario: a `labels` column (`field.object @isArray @storage:jsonb`) written as a 2-element, empty-`[]` (≠ `null`), and single-element array across three rows — the gap that had let a non-compiling / wrong array serializer ship in three ports. The single-VO jsonb path was already cross-port green; this closes the array-of-VO half. + +### Fixed +- **C# / Java / Python array-of-VO jsonb write+read codecs.** C# EF Core model finalization threw `'ICollection must be a non-interface reference type'` on an `@isArray` `field.object @storage:jsonb` column — `DbContextGenerator` now emits `.OwnsMany(...).ToJson(...)` when `field.ResolvedIsArray()` (the `EntityGenerator` emits `ICollection`), with a coupled empty-`[]`-vs-`null` nullability fix. Java OMDB threw `Expected BEGIN_OBJECT but was BEGIN_ARRAY` — `GenericSQLDriver.deserializeJsonb` now branches on `isArray` (target `TypeToken.getParameterized(List.class, VO)` via `jsonbTargetType`) and the read path stores the resulting `List` through `setObjectArray` (not the scalar `setObject`). Python's `_coerce_write_value` now `json.dumps`s both dict and list jsonb-storage `field.object`/`field.map` values to a JSON text string — pg8000 binds a native `dict` to jsonb fine, but adapts a native `list` as a Postgres ARRAY literal (`{...,...}`) which the JSONB column rejects with `22P02`. ### Changed +- **Kotlin codegen moved to Jackson for typed jsonb columns; entity/value/projection classes dropped `@Serializable`.** `KotlinExposedTableGenerator` now emits a per-package `MetaJsonbMapper.kt` — a `com.fasterxml.jackson.databind.ObjectMapper` (`kotlinModule()` + `JavaTimeModule()`, `WRITE_DATES_AS_TIMESTAMPS` disabled) — that the generated `jsonb()` column codecs read/write through (a `TypeReference>` captures the array-of-VO generic). Jackson (not kotlinx) is the codec precisely so generated entity/value/projection data classes carry **NO `@Serializable`** and need **NO `kotlin("plugin.serialization")` compiler plugin**: a kotlinx `VO.serializer()` would require the plugin, and once it is on, every VO carrying a `java.util.UUID` / `java.time.*` / `java.math.BigDecimal` / `java.net.*` field fails to compile (kotlinx has no serializer for those `java.*` types). `@Serializable` is **kept** only on prompt payloads + enums (genuinely kotlinx-decoded by the FR-006 output parser). Consumers generating any typed jsonb/`field.map` column add `jackson-databind` + `jackson-module-kotlin` + `jackson-datatype-jsr310` (documented in `docs/ports/kotlin.md` + `codegen-kotlin/KNOWN_GAPS.md`); the open-bag `field.string @dbColumnType:jsonb` column stays on the kotlinx `JsonElement` lane. New gate: compile-WITHOUT-the-serialization-plugin + Testcontainers-PG roundtrip of a GENERATED typed-jsonb table (`GeneratedTypedJsonbRoundTripTest`). - **BREAKING — `origin.passthrough` is now type-preserving: a passthrough field must match its `@from` source's type (#185).** A field carrying `origin.passthrough @from: "Entity.field"` forwards another field's value unchanged, so its declared `field.` and array-ness must be identical to the resolved source field. A divergence now fails load with `ERR_PASSTHROUGH_TYPE_MISMATCH` (e.g. a `field.uuid` source surfaced by a projection as `field.string` — the exact mismodeling that forces hand-written `String↔UUID` bridging and defeats a UUID migration). The check compares **subType and array-ness only — nullability is deliberately not judged** (a view over an outer join legitimately widens `NOT NULL` → nullable). Opt out of a deliberate type change with `@convert: true` (see Added). This **generalizes and retires** the narrow, stored-proc-parameter-ref-only `ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH` (FR-015) into one host-agnostic invariant covering projections, entities, values, and parameter refs alike. Enforced in the loader/verify in all five ports (TS / Python / C# / Java / Kotlin), cross-port conformance-gated. **Migration:** if load newly fails, the error names both the declared type and the source type — declare the source type (usually the fix — the projection was wrong), or add `@convert: true` if the divergence is intentional. ## [0.15.15] — 2026-07-07 diff --git a/CLAUDE.md b/CLAUDE.md index 89256374f..3bc0e501d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ _Last refreshed 2026-07-04._ **Cross-port conformance corpora** (every port runs the shared corpus): - Metamodel: `fixtures/conformance/` (~90 fixtures). 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 **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 — so every subtype write+read 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".) +- 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 **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 20/20 — 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. - YAML / verify corpora green across the ports that ship those layers. diff --git a/README.md b/README.md index d34265f0e..bd686107f 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ building blocks are complete in all five ports; MCP exposure of declared prompts/tools is the one remaining roadmap item: 1. **Codegen** — emit idiomatic per-language code (Drizzle/Zod + Fastify for TS, - POJO + OMDB for Java, `@Serializable data class` + Exposed for Kotlin, EF Core + POJO + OMDB for Java, `data class` + Exposed for Kotlin, EF Core record + ASP.NET routes for C#, `@dataclass` for Python). Hand-edit-preserving regen via three-way merge. 2. **Runtime metadata** — load metadata at runtime, drive behavior dynamically diff --git a/agent-context/skills/metaobjects-audit/references/kotlin.md b/agent-context/skills/metaobjects-audit/references/kotlin.md index ec774cb68..2aca79e50 100644 --- a/agent-context/skills/metaobjects-audit/references/kotlin.md +++ b/agent-context/skills/metaobjects-audit/references/kotlin.md @@ -2,7 +2,8 @@ The Kotlin port is a **codegen tier built on top of the Java port** — the loader, render engine, and Maven plugin are all Java; `metaobjects-codegen-kotlin` emits -idiomatic Kotlin (KotlinPoet: `@Serializable data class`, Exposed `Table` objects, +idiomatic Kotlin (KotlinPoet: `data class` (no `@Serializable` — entities are +Jackson-compatible, not kotlinx-serialized), Exposed `Table` objects, Spring `@RestController`). Codegen runs as the same Maven plugin goal the Java port uses (`mvn metaobjects:generate`). Schema migration and live-DB drift are **Node-`meta`-only** (ADR-0015). The runtime persistence tier is **JetBrains Exposed** diff --git a/agent-context/skills/metaobjects-codegen/references/kotlin.md b/agent-context/skills/metaobjects-codegen/references/kotlin.md index 953f5f0ae..3be6588d3 100644 --- a/agent-context/skills/metaobjects-codegen/references/kotlin.md +++ b/agent-context/skills/metaobjects-codegen/references/kotlin.md @@ -2,7 +2,8 @@ The Kotlin port is a **codegen tier built on top of the Java port**. The loader, render engine, and Maven plugin are all Java; `codegen-kotlin` emits idiomatic -Kotlin (`@Serializable data class`, Exposed `Table` objects, Spring +Kotlin (`data class` (no `@Serializable` — entities are Jackson-compatible, not +kotlinx-serialized), Exposed `Table` objects, Spring `@RestController`/`@Configuration`) via KotlinPoet. Codegen runs as the same build-time Maven plugin goal the Java port uses — there is no standalone `meta` binary on the JVM side (the Node `meta` is for schema migrations only; see the @@ -94,7 +95,7 @@ All live in `metaobjects-codegen-kotlin` under | Generator | Output | |---|---| -| `KotlinEntityGenerator` | `.kt` — `@Serializable data class` per `object.entity` / `object.value`. A TPH `@discriminator` base's data class is the **union** of every subtype's columns (each folded nullable, validation dropped) so one wire shape backs the polymorphic + per-subtype endpoints. | +| `KotlinEntityGenerator` | `.kt` — `data class` (no `@Serializable`; Jackson-compatible) per `object.entity` / `object.value`. A TPH `@discriminator` base's data class is the **union** of every subtype's columns (each folded nullable, validation dropped) so one wire shape backs the polymorphic + per-subtype endpoints. | | `KotlinExposedTableGenerator` | `Table.kt` — Exposed `Table` object (PK + FK + `@storage` columns) for entities with `source.rdb`. A TPH `@discriminator` base emits ONE `Table` for the whole hierarchy — every subtype-only column folded in `.nullable()` (a row of another subtype stores null there) — single-table inheritance; subtype entities emit no table of their own. | | `KotlinRelationsGenerator` | `Relations.kt` — extension fns for `@cardinality="many"` query helpers | | `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController`, five CRUD endpoints on the cross-port REST contract, for writable entities (`source.rdb` `@kind="table"`). A TPH `@discriminator` base emits ONE controller: polymorphic `GET /(+/{id})` plus a per-subtype CRUD set at `//` — create injects the discriminator from the URL (never the body); get/update/delete are scoped to the subtype (cross-subtype → 404); the discriminator is immutable. | diff --git a/agent-context/skills/metaobjects-runtime-ui/references/kotlin.md b/agent-context/skills/metaobjects-runtime-ui/references/kotlin.md index fb13e2c3a..208217866 100644 --- a/agent-context/skills/metaobjects-runtime-ui/references/kotlin.md +++ b/agent-context/skills/metaobjects-runtime-ui/references/kotlin.md @@ -4,7 +4,7 @@ The Kotlin port runtime is **generated Exposed `Table` objects plus your own JetBrains Exposed transactions** — there is no Kotlin-specific persistence engine. `KotlinExposedTableGenerator` emits one `Table.kt` per entity with a `source.rdb`; you hand-write the (trivial) transaction bodies, since the table -column definitions and the `@Serializable` entity data class are both generated +column definitions and the generated `data class` entity are both generated from the same metadata. (If you want a fully metadata-driven engine instead of hand-written Exposed, the @@ -20,7 +20,6 @@ emit a data class and an Exposed `Table`: ```kotlin // generated/acme/blog/Author.kt -@Serializable data class Author( val id: Long, val name: String, diff --git a/docs/features/entities.md b/docs/features/entities.md index 4a317ac73..2ff5b9c1d 100644 --- a/docs/features/entities.md +++ b/docs/features/entities.md @@ -169,16 +169,15 @@ public class Author { ### Kotlin -`metaobjects-codegen-kotlin` (`KotlinEntityGenerator`) emits a `@Serializable -data class` and (with `KotlinExposedTableGenerator`) an Exposed `Table` object. +`metaobjects-codegen-kotlin` (`KotlinEntityGenerator`) emits a plain +`data class` (no `@Serializable` — entities are Jackson-compatible, not +kotlinx-serialized) and (with `KotlinExposedTableGenerator`) an Exposed +`Table` object. ```kotlin // generated/acme/blog/Author.kt package acme.blog -import kotlinx.serialization.Serializable - -@Serializable data class Author( val id: Long, val name: String, diff --git a/docs/features/field-types.md b/docs/features/field-types.md index cbd8cb49f..bf4c6790f 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -141,7 +141,6 @@ public enum Status { DRAFT, PUBLISHED, ARCHIVED } ```kotlin // generated/acme/blog/Author.kt -@Serializable data class Author( val id: Long, val name: String, diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index 8fdb4f37a..aff2fcfe4 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -94,7 +94,8 @@ The codegen emits: - `Author.cs` — record per entity. - `AppDbContext.cs` — `DbSet`, projection `.ToView()`, `@storage` owned - types via `OwnsOne`, enum-as-string via `HasConversion()`. + types via `OwnsOne` (single) / `OwnsMany(...).ToJson(...)` (`@isArray` array-of-VO), + enum-as-string via `HasConversion()`. - `Author.routes.cs` — CRUD minimal-API endpoints. ## Use @@ -270,7 +271,7 @@ are not yet generated — see | Entities + fields | Yes | | Relationships + FK | Yes (EF Core + Postgres FK clause) | | Source kinds (table / view / storedProc) | `table` + `view` fully shipped; `storedProc` / `tableFunction` / `materializedView` partial | -| `field.currency` / `field.enum` / `field.object` + `@storage` | Yes (incl. EF Core `OwnsOne` for `flattened`) | +| `field.currency` / `field.enum` / `field.object` + `@storage` | Yes (incl. EF Core `OwnsOne` for `flattened`; `OwnsMany(...).ToJson(...)` for `@isArray` array-of-VO jsonb) | | Templates + render (FR-004) | Yes (`MetaObjects.Render`) | | Output parser codegen (FR-006) | Yes (`OutputParserGenerator` — `Parse`/`TryParse` BCL pattern) | | Payload-VO codegen | Yes (`MetaObjects.Codegen`) | diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 6a3cce77a..b08937969 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -3,7 +3,7 @@ Idiomatic Kotlin codegen target for Spring-Boot-Kotlin consumers on Exposed + Flyway. The Kotlin port is a **codegen tier built on top of the Java port** — the loader, OMDB persistence engine, render engine, Maven plugin, and conformance -runners are all Java; Kotlin emits idiomatic Kotlin (`@Serializable data class`, +runners are all Java; Kotlin emits idiomatic Kotlin (`data class`, Exposed `Table` objects, extension-fn relationship helpers, Spring `@Configuration` wiring) via KotlinPoet. @@ -39,6 +39,26 @@ Two modules: exposed-core ${exposed.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + org.jetbrains.kotlinx kotlinx-serialization-json @@ -53,7 +73,7 @@ The 9 generators in `codegen-kotlin`: | Generator | Output | Per | |---|---|---| -| `KotlinEntityGenerator` | `.kt` — `@Serializable data class` | every `object.entity` + `object.value` | +| `KotlinEntityGenerator` | `.kt` — Kotlin `data class` (Jackson-compatible; no `@Serializable`) | every `object.entity` + `object.value` | | `KotlinExposedTableGenerator` | `Table.kt` — Exposed `Table` object with PK + FK + `@storage` columns | entities with `source.rdb` | | `KotlinRelationsGenerator` | `Relations.kt` — extension fns for `cardinality=many` query helpers | entities with to-many relationships | | `KotlinPayloadGenerator` | `