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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<VO>`), 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<List<VO>>` 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.<subType>` 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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion agent-context/skills/metaobjects-audit/references/kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
5 changes: 3 additions & 2 deletions agent-context/skills/metaobjects-codegen/references/kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,7 +95,7 @@ All live in `metaobjects-codegen-kotlin` under

| Generator | Output |
|---|---|
| `KotlinEntityGenerator` | `<Entity>.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` | `<Entity>.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` | `<Entity>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` | `<Entity>Relations.kt` — extension fns for `@cardinality="many"` query helpers |
| `KotlinSpringControllerGenerator` | `<Entity>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 /<base>(+/{id})` plus a per-subtype CRUD set at `/<base>/<discriminatorValue lowercased>` — 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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Entity>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
Expand All @@ -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,
Expand Down
9 changes: 4 additions & 5 deletions docs/features/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion docs/features/field-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions docs/ports/csharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ The codegen emits:

- `Author.cs` — record per entity.
- `AppDbContext.cs` — `DbSet<Author>`, projection `.ToView()`, `@storage` owned
types via `OwnsOne`, enum-as-string via `HasConversion<string>()`.
types via `OwnsOne` (single) / `OwnsMany(...).ToJson(...)` (`@isArray` array-of-VO),
enum-as-string via `HasConversion<string>()`.
- `Author.routes.cs` — CRUD minimal-API endpoints.

## Use
Expand Down Expand Up @@ -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`) |
Expand Down
25 changes: 22 additions & 3 deletions docs/ports/kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -39,6 +39,26 @@ Two modules:
<artifactId>exposed-core</artifactId>
<version>${exposed.version}</version>
</dependency>
<!-- Generated typed `field.object @storage:jsonb` / `field.map` columns serialize through a
generated per-package `MetaJsonbMapper.kt` Jackson `ObjectMapper` (no kotlinx-serialization
compiler plugin required). -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- FR-006 output parser + prompt-payload lane; also backs the open-bag
`field.string @dbColumnType:jsonb` → kotlinx `JsonElement` path. -->
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
Expand All @@ -53,7 +73,7 @@ The 9 generators in `codegen-kotlin`:

| Generator | Output | Per |
|---|---|---|
| `KotlinEntityGenerator` | `<Entity>.kt` — `@Serializable data class` | every `object.entity` + `object.value` |
| `KotlinEntityGenerator` | `<Entity>.kt` — Kotlin `data class` (Jackson-compatible; no `@Serializable`) | every `object.entity` + `object.value` |
| `KotlinExposedTableGenerator` | `<Entity>Table.kt` — Exposed `Table` object with PK + FK + `@storage` columns | entities with `source.rdb` |
| `KotlinRelationsGenerator` | `<Entity>Relations.kt` — extension fns for `cardinality=many` query helpers | entities with to-many relationships |
| `KotlinPayloadGenerator` | `<Template>Payload.kt` — `@Serializable` payload from `@payloadRef` view-object | every `template.prompt` / `template.output` |
Expand Down Expand Up @@ -139,7 +159,6 @@ emits:

```kotlin
// generated/acme/blog/Author.kt
@Serializable
data class Author(
val id: Long,
val name: String,
Expand Down
8 changes: 6 additions & 2 deletions fixtures/persistence-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,12 @@ native-uuid-write.
- The `AllTypes` entity (`all_types` table) in the canonical model exists to cover
**every persistable field subtype** in one row — string / int / long / double /
float / decimal / boolean / date / time / timestamp / timestamp(tz) / currency /
enum / uuid / object(`@objectRef`, jsonb storage). No read scenario references
it, so it is inert for the read runners (it just adds a table to the schema).
enum / uuid / object(`@objectRef`, jsonb storage) — plus an **array-of-VO**
`field.object @isArray @storage:jsonb` column (`labels`) written as a 2-element,
empty-`[]`, and single-element array across the three roundtrip rows, exercising
each port's array-of-value-object write+read jsonb codec (the gap that once let a
non-compiling / wrong array serializer ship). No read scenario references it, so
it is inert for the read runners (it just adds a table to the schema).
- **Full int64 fidelity:** one roundtrip query writes the BIGINT max
(`9223372036854775807`, > 2^53) to `field.long` AND `field.currency` as a
numeric **string** — the write contract for those two subtypes accepts a base-10
Expand Down
8 changes: 8 additions & 0 deletions fixtures/persistence-conformance/canonical/meta.fitness.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@
{ "field.int": { "name": "retries" } }
]
}},
{ "object.value": {
"name": "Label",
"children": [
{ "field.string": { "name": "key" } },
{ "field.int": { "name": "weight" } }
]
}},
{ "object.entity": {
"name": "AllTypes",
"children": [
Expand All @@ -239,6 +246,7 @@
{ "field.inet": { "name": "inetVal", "@required": true } },
{ "field.inet": { "name": "inet6Val", "@required": true } },
{ "field.object": { "name": "settings", "@objectRef": "Settings", "@storage": "jsonb" } },
{ "field.object": { "name": "labels", "@objectRef": "Label", "@storage": "jsonb", "isArray": true } },
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } }
]
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ CREATE TABLE "all_types" (
"inetVal" INET NOT NULL,
"inet6Val" INET NOT NULL,
"settings" JSONB,
"labels" JSONB,
CONSTRAINT "all_types_pkey" PRIMARY KEY ("id"),
CONSTRAINT "all_types_enumVal_chk" CHECK ("enumVal" IN ('LOW', 'MEDIUM', 'HIGH'))
);
Expand Down
Loading
Loading