From b82ce07a6f8ee949119a261ef0900c93c8fc6718 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 29 Jun 2026 08:22:00 -0400 Subject: [PATCH 1/2] fix(codegen-kotlin): expose field.string @dbColumnType:jsonb as a parsed JSON value in REST payloads (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `field.string @dbColumnType=jsonb` open JSON bag had its physical column emitted as JSONB but was typed `String` everywhere, forcing REST clients to double-encode (`"{\"k\":\"v\"}"` instead of `{"k":"v"}`). Per the maintainer decision on #98, expose the bag at the serialization/REST payload boundary as a parsed JSON value, uniformly (matching TS `z.unknown()` #97, Python `Any` #99). Two layers, treated differently: - Persistence (unchanged): the Exposed column stays the identity-codec `jsonb(name, { it }, { it })` and the entity data-class property stays `String` — the JDBC driver returns jsonb as raw text. `KotlinTypeMapper.kotlinTypeName` / `exposedColumnSpec` are untouched (existing `KotlinTypeMapperTest` stays green). - REST payload (fixed): add `KotlinTypeMapper.payloadTypeName` — identical to `kotlinTypeName` except the jsonb open bag maps to kotlinx `JsonElement` (the `@Serializable` payload substrate this port already uses). `KotlinPayloadGenerator` now resolves field types through it; the lenient->strict extract mapper bridges `String -> JsonElement` via `Json.parseToJsonElement`; api-docs documents the payload field as the parsed value it actually emits. The type mapper is shared between persistence and the payload surface, so the split is made by a dedicated `payloadTypeName` path keyed on the new `isJsonbOpenBag` predicate (single source of truth for payload + extractor). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky --- .../kotlin/KotlinExtractorGenerator.kt | 15 +++++++ .../kotlin/KotlinPayloadGenerator.kt | 31 ++++++++------- .../generator/kotlin/KotlinTypeMapper.kt | 39 +++++++++++++++++++ .../kotlin/apidocs/KotlinApiModelBuilder.kt | 16 ++++++-- .../kotlin/KotlinPayloadGeneratorTest.kt | 38 ++++++++++++++++++ .../generator/kotlin/KotlinTypeMapperTest.kt | 31 +++++++++++++++ 6 files changed, 152 insertions(+), 18 deletions(-) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt index 2f97dcd2e..3c82ad4ea 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt @@ -311,6 +311,14 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase() else "m.$name!!.filterNotNull().map { $conv }" } + // jsonb open bag (`field.string @dbColumnType=jsonb`): the strict payload types this as a + // parsed JSON value (kotlinx `JsonElement`, via KotlinTypeMapper.payloadTypeName — issue #98) + // while the lenient mirror leaf stays `String` (the LLM emits text). Bridge String → JsonElement + // by parsing. FQN-qualified so the emitted code resolves without an import (as elsewhere here). + if (KotlinTypeMapper.isJsonbOpenBag(field)) { + return "kotlinx.serialization.json.Json.parseToJsonElement(m.$name!!)" + } + // Scalar (single): mirror is T?, strict is T — null-assert. return "m.$name!!" } @@ -331,6 +339,13 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase() * FAILS LOUD at codegen time ([GeneratorException]) rather than emitting non-compiling code.

*/ private fun scalarArrayElementConversion(field: MetaField<*>): String? { + // jsonb open bag element (`field.string @dbColumnType=jsonb` + isArray): the strict payload + // element is a parsed JSON value (kotlinx `JsonElement`, via payloadTypeName — issue #98); + // the mirror element stays `String`. Parse each element. Checked first because the generic + // dispatch below keys on kotlinTypeName, which (correctly, for persistence) reports `String`. + if (KotlinTypeMapper.isJsonbOpenBag(field)) { + return "kotlinx.serialization.json.Json.parseToJsonElement(it)" + } // Same path the payload generator wraps in List<…> for a scalar-array element. val elementType = KotlinTypeMapper.kotlinTypeName(field) return when (elementType) { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt index f014bb683..90ef9215e 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt @@ -43,7 +43,9 @@ import java.nio.file.Paths * (@agg sum/min/max) — type of the referenced `@of` field. *
  • {@code origin.collection} (@via "Parent.rel") — {@code List}, and the * nested payload class is recursively emitted alongside (deduped per execute() run).
  • - *
  • No origin child — fall back to {@link KotlinTypeMapper#kotlinTypeName(MetaField)}.
  • + *
  • No origin child — fall back to {@link KotlinTypeMapper#payloadTypeName(MetaField)} + * (parsed JSON value for a `field.string @dbColumnType=jsonb` open bag; otherwise the + * same mapping as {@code kotlinTypeName}).
  • * */ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { @@ -133,8 +135,9 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { /** * Resolve the Kotlin TypeName of a single payload-VO field, honoring any - * `origin.*` child. Falls back to [KotlinTypeMapper.kotlinTypeName] when no - * origin is present. + * `origin.*` child. Falls back to [KotlinTypeMapper.payloadTypeName] when no + * origin is present (parsed JSON value for a `field.string @dbColumnType=jsonb` open + * bag, otherwise identical to `kotlinTypeName`). */ protected open fun resolveFieldType( field: MetaField<*>, @@ -154,7 +157,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { is CollectionOrigin -> resolveCollectionType( origin, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns, field ) - else -> KotlinTypeMapper.kotlinTypeName(field) + else -> KotlinTypeMapper.payloadTypeName(field) } } @@ -185,7 +188,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { // Scalar array (`isArray: true` on a non-object field): model as List in the // strict payload (matching the cross-port payload shape). Without this, `kotlinTypeName` // returns the bare element type and the array semantics are lost. - val scalarType = KotlinTypeMapper.kotlinTypeName(field) + val scalarType = KotlinTypeMapper.payloadTypeName(field) if (field.isArrayType()) { return ClassName("kotlin.collections", "List").parameterizedBy(scalarType) } @@ -207,7 +210,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { emittedNestedFqns: MutableSet, emittedEnumFqns: MutableSet, ): TypeName { - val fallbackType = { KotlinTypeMapper.kotlinTypeName(field) } + val fallbackType = { KotlinTypeMapper.payloadTypeName(field) } val target = try { field.objectRef } catch (e: RuntimeException) { @@ -248,10 +251,10 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { loader: MetaDataLoader, fallbackField: MetaField<*>, ): TypeName { - val from = origin.from ?: return KotlinTypeMapper.kotlinTypeName(fallbackField) + val from = origin.from ?: return KotlinTypeMapper.payloadTypeName(fallbackField) val sourceField = resolveDottedFieldRef(loader, from) - ?: return KotlinTypeMapper.kotlinTypeName(fallbackField) - return KotlinTypeMapper.kotlinTypeName(sourceField) + ?: return KotlinTypeMapper.payloadTypeName(fallbackField) + return KotlinTypeMapper.payloadTypeName(sourceField) } /** @@ -269,12 +272,12 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { MetaOrigin.AGG_COUNT -> LONG MetaOrigin.AGG_AVG -> DOUBLE MetaOrigin.AGG_SUM, MetaOrigin.AGG_MIN, MetaOrigin.AGG_MAX -> { - val of = origin.of ?: return KotlinTypeMapper.kotlinTypeName(fallbackField) + val of = origin.of ?: return KotlinTypeMapper.payloadTypeName(fallbackField) val sourceField = resolveDottedFieldRef(loader, of) - ?: return KotlinTypeMapper.kotlinTypeName(fallbackField) - KotlinTypeMapper.kotlinTypeName(sourceField) + ?: return KotlinTypeMapper.payloadTypeName(fallbackField) + KotlinTypeMapper.payloadTypeName(sourceField) } - else -> KotlinTypeMapper.kotlinTypeName(fallbackField) + else -> KotlinTypeMapper.payloadTypeName(fallbackField) } } @@ -293,7 +296,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { emittedEnumFqns: MutableSet, fallbackField: MetaField<*>, ): TypeName { - val fallbackType = { KotlinTypeMapper.kotlinTypeName(fallbackField) } + val fallbackType = { KotlinTypeMapper.payloadTypeName(fallbackField) } val via = origin.via ?: return fallbackType() val (parentName, relName) = KotlinGenUtil.splitDottedRef(via) ?: return fallbackType() val parent = KotlinGenUtil.resolveObjectByShortOrFqn(loader, parentName) ?: return fallbackType() diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt index 391531d03..254cd4c1b 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt @@ -118,6 +118,18 @@ object KotlinTypeMapper { /** FQN of the Exposed `jsonb` extension function (raw-string open-JSON path). */ private const val EXPOSED_JSONB_IMPORT = "org.jetbrains.exposed.sql.json.jsonb" + /** + * The kotlinx-serialization JSON-value type a `field.string @dbColumnType=jsonb` open-bag is + * exposed as in the REST/serialization PAYLOAD (issue #98). `JsonElement` is the idiomatic + * "any JSON value" type for the `@Serializable` payload data classes [KotlinPayloadGenerator] + * emits (kotlinx is already this port's payload substrate — the same `Json` used by the + * `jsonb(...)` column codec for `field.map`), so a client sends/receives a real JSON object + * rather than a double-encoded string. The PERSISTENCE side is deliberately unaffected: the + * entity data-class property ([kotlinTypeName]) stays `String` (raw text from the JDBC driver) + * and the Exposed column ([exposedColumnSpec]) stays the identity-codec `jsonb(name,{it},{it})`. + */ + private val PAYLOAD_JSON_VALUE_TYPE = ClassName("kotlinx.serialization.json", "JsonElement") + /** * Name of the generated, file-local Exposed extension function emitted for a * `@dbColumnType=timestamp_with_tz` [TimestampField]. It returns a @@ -250,6 +262,33 @@ object KotlinTypeMapper { ) } + /** + * True iff [field] is the `field.string @dbColumnType=jsonb` open JSON bag — the sanctioned + * "arbitrary JSON value" pattern whose physical column is JSONB while its logical subtype stays + * `string`. The single source of truth both the payload type ([payloadTypeName]) and the + * extract-mapper bridge ([KotlinExtractorGenerator]) dispatch on, so the two sites stay in + * lockstep. Resolved THROUGH the `extends` chain (same as [dbColumnType]) so a projection field + * that binds a base-entity jsonb column via `extends:` inherits the open-bag treatment. + */ + fun isJsonbOpenBag(field: MetaField<*>): Boolean = + field is StringField && dbColumnType(field) == DB_COLUMN_TYPE_JSONB + + /** + * Map a MetaField to its KotlinPoet TypeName for a REST/serialization **payload** property + * (the `@Serializable` projection data classes emitted by [KotlinPayloadGenerator]). + * + * Identical to [kotlinTypeName] for every subtype EXCEPT the `field.string @dbColumnType=jsonb` + * open bag, which becomes the parsed JSON value [PAYLOAD_JSON_VALUE_TYPE] instead of `String` + * (issue #98). This is the deliberate payload/persistence split: the persistence holder + * ([kotlinTypeName]) and the Exposed column ([exposedColumnSpec]) keep the raw-text `String` + * contract; only the API/wire boundary exposes the bag as a real JSON value (matching the + * TS `unknown` / Python `Any` ports). Generators that drive PERSISTENCE (entity data class, + * Exposed table, the entity-CRUD controller DTO, which reuses the entity data class) must keep + * calling [kotlinTypeName], not this. + */ + fun payloadTypeName(field: MetaField<*>): TypeName = + if (isJsonbOpenBag(field)) PAYLOAD_JSON_VALUE_TYPE else kotlinTypeName(field) + /** * The Kotlin value TypeName for a scalar-valued [MapField] (the type named by its * `@valueType` attr — string/int/long/double/float/decimal/boolean/date/time/ diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt index 21eb3ffec..dc8d17aeb 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt @@ -381,18 +381,26 @@ class KotlinApiModelBuilder { val rows = mutableListOf() for (f in vo.metaFields) { if (f is ObjectField) continue - rows.add(FieldShape(f.name, kotlinTypeLabel(f, vo), optional = !KotlinGenUtil.isRequiredField(f))) + rows.add(FieldShape(f.name, kotlinTypeLabel(f, vo, forPayload = true), optional = !KotlinGenUtil.isRequiredField(f))) } return rows } - /** The simple Kotlin type label for a documented field (enum → generated enum class name). */ - private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject): String { + /** + * The simple Kotlin type label for a documented field (enum → generated enum class name). + * + * [forPayload] selects the payload type path ([KotlinTypeMapper.payloadTypeName]) so a + * `field.string @dbColumnType=jsonb` open bag is documented as the parsed JSON value + * (`JsonElement`) the payload data class actually emits (issue #98); the entity model field + * (forPayload = false) stays `String`, matching its data-class property. Keeps the documented + * type == the generated type on both surfaces. + */ + private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject, forPayload: Boolean = false): String { KotlinTypeMapper.enumTypeName(field, owner)?.let { enumType -> val simple = enumType.simpleName return if (field.isArrayType) "List<$simple>" else simple } - val tn = KotlinTypeMapper.kotlinTypeName(field) + val tn = if (forPayload) KotlinTypeMapper.payloadTypeName(field) else KotlinTypeMapper.kotlinTypeName(field) val simple = (tn as? com.squareup.kotlinpoet.ClassName)?.simpleName ?: tn.toString() return if (field.isArrayType) "List<$simple>" else simple } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt index 288135701..d9bdbb202 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt @@ -40,6 +40,44 @@ class KotlinPayloadGeneratorTest { } } + @Test fun `jsonb open-bag field is a parsed JSON value in the payload, plain string stays String`() { + // Issue #98: a `field.string @dbColumnType=jsonb` payload property is exposed as a parsed + // JSON value (kotlinx `JsonElement`), NOT a (double-encoded) String. A sibling plain + // `field.string` on the same VO stays `String`, proving the divergence is scoped to the + // jsonb open-bag. The persistence side (Exposed column / entity data class) is unaffected — + // KotlinPayloadGenerator emits no table/row code. + val fx = """{ + "metadata.root": { "package": "acme::demo", "children": [ + { "object.value": { "name": "Settings", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "config", "@dbColumnType": "jsonb" } }, + { "field.string": { "name": "label" } } + ] } }, + { "template.prompt": { "name": "SettingsPrompt", + "@payloadRef": "Settings", "@textRef": "demo/settings" } } + ] } + }""".trimIndent() + + val outDir = Files.createTempDirectory("kpay-jsonb-") + try { + val gen = KotlinPayloadGenerator() + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loadString("test-jsonb", fx)) + + val emitted = outDir.resolve("acme/demo/prompts/SettingsPromptPayload.kt") + assertTrue(Files.exists(emitted), + "expected $emitted; files=${Files.walk(outDir).toList()}") + val src = Files.readString(emitted) + // The open-bag field → parsed JSON value. + assertTrue("val config: JsonElement" in src, src) + assertTrue("import kotlinx.serialization.json.JsonElement" in src, src) + // The plain string field → String (no double-encoding). + assertTrue("val label: String" in src, src) + } finally { + outDir.toFile().deleteRecursively() + } + } + // ----------------------------------------------------------------------- // origin.* coverage — FR-004 payload-VO field-value provenance // ----------------------------------------------------------------------- diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt index b1de208bf..91ef04105 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt @@ -231,6 +231,37 @@ class KotlinTypeMapperTest { assertEquals("org.jetbrains.exposed.sql.json.jsonb", KotlinTypeMapper.exposedColumnImport(f)) } + @Test fun `string field with dbColumnType=jsonb exposes a parsed JSON value in the REST payload`() { + // Issue #98 (cross-port jsonb open-bag REST contract). A `field.string @dbColumnType=jsonb` + // is the sanctioned "open JSON bag": the physical column is JSONB but the LOGICAL subtype + // stays `string`. The two layers are deliberately split: + // - PERSISTENCE (kotlinTypeName / exposedColumnSpec): the Exposed column stays the + // identity-codec `jsonb(name, { it }, { it })` and the entity data-class property stays + // `String` — the JDBC driver returns jsonb as raw text, so String is correct here. + // - REST PAYLOAD (payloadTypeName): the typed projection exposes the bag as a PARSED JSON + // value (kotlinx `JsonElement`) so a client sends/receives a real JSON object, never a + // double-encoded string. Matches TS `z.unknown()` (#97) and Python `Any` (#99). + val f = StringField("rubricWeights") + f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb")) + + // REST payload → parsed JSON value (the fix). + assertEquals( + ClassName("kotlinx.serialization.json", "JsonElement"), + KotlinTypeMapper.payloadTypeName(f), + ) + // Persistence stays String — the entity data-class property is the raw-JSON holder for the + // identity-codec jsonb column. (This MUST NOT regress; the column assertion above gates it.) + assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f)) + assertEquals("jsonb(\"rubric_weights\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f)) + } + + @Test fun `plain string field payload stays String`() { + // payloadTypeName only diverges from kotlinTypeName for the jsonb open-bag; a plain + // `field.string` payload property is still `String` (no double-encoding concern). + val f = StringField("name") + assertEquals(STRING, KotlinTypeMapper.payloadTypeName(f)) + } + @Test fun `string field with dbColumnType=jsonb is case-insensitive`() { // `@dbColumnType` lookup case-folds (see `KotlinTypeMapper.dbColumnType`); both // `"JSONB"` and `"jsonb"` route to the JSONB branch. From a0247c4bf599d3a16057da03a22bf7ef84f7c808 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 29 Jun 2026 09:21:34 -0400 Subject: [PATCH 2/2] fix(codegen-kotlin): parse jsonb open-bag to JsonElement at persistence+CRUD, not just payload (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #104 exposed a `field.string @dbColumnType=jsonb` open bag as a parsed kotlinx `JsonElement` only in the REST *payload* surface; the derived entity-CRUD controller reuses the Exposed entity data class as its DTO, and that property was still `String` (the Exposed column used an identity codec `jsonb(col, { it }, { it })` keeping raw JSON text). So the entity-CRUD contract still exposed a jsonb bag as a double-encoded `String`, unlike TS (`z.unknown()`), Python (`Any`), Java, and C# (`JsonDocument`). Per the maintainer decision on #98 — uniform parsed value at the API boundary, all ports — this does the Kotlin analogue of the C# `jsonb <-> JsonDocument` change: - `KotlinTypeMapper.kotlinTypeName` now maps the jsonb open bag to `JsonElement` (keyed on the existing `isJsonbOpenBag` predicate from #104), so the entity data class — and thus the reused entity-CRUD DTO — is a parsed JSON value. - `KotlinTypeMapper.exposedColumnSpec` changes the jsonb codec from the identity passthrough to a real `JsonElement` codec: encode `{ it.toString() }` (a JsonElement's toString is canonical JSON), decode `{ Json.parseToJsonElement(it) }` (concrete return anchors the column generic to `Column`). It is STILL a real `jsonb(...)` column — only the codec/type changes, not that it is jsonb. - `KotlinExposedTableGenerator` emits the `kotlinx.serialization.json.Json` import for tables carrying a jsonb open bag (direct or flattened sub-field), without double-emitting the `jsonb` extension import. Net: column / entity data class (CRUD DTO) / payload all expose `JsonElement` for a jsonb bag — fully uniform. A plain `field.string` stays `String`; `@dbColumnType:uuid` stays `String`. The wire/serialized form is byte-identical to the other ports (`{...}`); only the in-process Kotlin type changes String -> JsonElement. Tests (TDD, RED->GREEN): - KotlinTypeMapperTest: the jsonb case now asserts `kotlinTypeName == JsonElement` and the new column codec, while still asserting the column is a real `jsonb(...)`. - KotlinEntityGeneratorTest: new test proving the entity data class types the jsonb field as `JsonElement` (the entity-CRUD path). - integration-tests-kotlin: the hand-written reference `AssetTable.payload` and the `QueryScenarioRunner` read/write coercion + `RuntimeReturnTypeTest` updated to the parsed `JsonElement` read-back (wire form unchanged). Full module green (90/90) against Testcontainers Postgres. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky --- .../kotlin/KotlinExposedTableGenerator.kt | 21 ++++++- .../generator/kotlin/KotlinTypeMapper.kt | 52 +++++++++++------- .../kotlin/apidocs/KotlinApiModelBuilder.kt | 10 ++-- .../kotlin/KotlinEntityGeneratorTest.kt | 36 ++++++++++++ .../generator/kotlin/KotlinTypeMapperTest.kt | 55 +++++++++++-------- .../integration/kotlin/QueryScenarioRunner.kt | 20 ++++--- .../kotlin/RuntimeReturnTypeTest.kt | 21 ++++--- .../integration/kotlin/tables/AssetTable.kt | 14 +++-- 8 files changed, 158 insertions(+), 71 deletions(-) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index 1497c2f38..5b4f59547 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -210,7 +210,20 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase + f is ObjectField && readStorage(f) == STORAGE_FLATTENED && + (readObjectRef(f)?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + ?.metaFields?.any { KotlinTypeMapper.isJsonbOpenBag(it) } ?: false) + } + val needsJsonbImport = + objectColumns.any { it.kind == ObjectColumnKind.JSONB } || hasStringJsonbOpenBag val needsRefOptForDecor = refDecorations.values.any { it.hasReferenceOption } // Does any column on this table use the TZ-aware `@dbColumnType=timestamp_with_tz` @@ -252,6 +265,10 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase): TypeName = when (field) { // `@dbColumnType=uuid_array/text_array` makes a field.string a native SQL array, - // so the property is a List / List. Plain strings stay String. + // so the property is a List / List. `@dbColumnType=jsonb` is the open + // JSON bag — a parsed JSON value (kotlinx `JsonElement`, issue #98), uniform with the + // payload + Exposed column codec so the entity-CRUD DTO is never a double-encoded String. + // Plain strings stay String. is StringField -> when (dbColumnType(field)) { DB_COLUMN_TYPE_UUID_ARRAY -> LIST.parameterizedBy(ClassName("java.util", "UUID")) DB_COLUMN_TYPE_TEXT_ARRAY -> LIST.parameterizedBy(STRING) + DB_COLUMN_TYPE_JSONB -> JSON_VALUE_TYPE else -> STRING } is IntegerField -> INT @@ -277,17 +285,16 @@ object KotlinTypeMapper { * Map a MetaField to its KotlinPoet TypeName for a REST/serialization **payload** property * (the `@Serializable` projection data classes emitted by [KotlinPayloadGenerator]). * - * Identical to [kotlinTypeName] for every subtype EXCEPT the `field.string @dbColumnType=jsonb` - * open bag, which becomes the parsed JSON value [PAYLOAD_JSON_VALUE_TYPE] instead of `String` - * (issue #98). This is the deliberate payload/persistence split: the persistence holder - * ([kotlinTypeName]) and the Exposed column ([exposedColumnSpec]) keep the raw-text `String` - * contract; only the API/wire boundary exposes the bag as a real JSON value (matching the - * TS `unknown` / Python `Any` ports). Generators that drive PERSISTENCE (entity data class, - * Exposed table, the entity-CRUD controller DTO, which reuses the entity data class) must keep - * calling [kotlinTypeName], not this. + * For the `field.string @dbColumnType=jsonb` open bag this is the parsed JSON value + * [JSON_VALUE_TYPE] (issue #98). As of the #98 uniform-parsed-value cutover this now AGREES + * with [kotlinTypeName] at every subtype — the persistence holder (entity data class reused as + * the entity-CRUD DTO) and the Exposed column ([exposedColumnSpec]) ALSO expose the bag as a + * `JsonElement`, so there is no longer a payload/persistence split. Retained as a distinct, + * intention-revealing entry point for the payload generators (and to keep them robust if the + * two surfaces ever diverge again). */ fun payloadTypeName(field: MetaField<*>): TypeName = - if (isJsonbOpenBag(field)) PAYLOAD_JSON_VALUE_TYPE else kotlinTypeName(field) + if (isJsonbOpenBag(field)) JSON_VALUE_TYPE else kotlinTypeName(field) /** * The Kotlin value TypeName for a scalar-valued [MapField] (the type named by its @@ -391,7 +398,14 @@ object KotlinTypeMapper { // would never round-trip to JSONB through the introspection corpus. when (dbColumnType(field)) { DB_COLUMN_TYPE_UUID -> "uuid(\"$colName\")" - DB_COLUMN_TYPE_JSONB -> "jsonb(\"$colName\", { it }, { it })" + // `@dbColumnType=jsonb` open bag (#98): decode the JSONB text to a kotlinx + // `JsonElement` (so the data-class property + CRUD DTO is a parsed JSON value, not + // a double-encoded String) and encode it back via `toString()` (a JsonElement's + // toString is canonical JSON). `Json.parseToJsonElement` returns a concrete + // `JsonElement`, which ANCHORS the Exposed column's generic to `Column` + // (the `{ it }` identity codec would have left it `Column`). The table file + // imports `kotlinx.serialization.json.Json` (see KotlinExposedTableGenerator). + DB_COLUMN_TYPE_JSONB -> "jsonb(\"$colName\", { it.toString() }, { Json.parseToJsonElement(it) })" // Native SQL array columns via Exposed's `array("col", columnType)` Table // member. The element ColumnType is explicit (UUIDColumnType / TextColumnType) // so emission never depends on Exposed's reified resolveColumnType picking diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt index dc8d17aeb..777f6df31 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt @@ -389,11 +389,11 @@ class KotlinApiModelBuilder { /** * The simple Kotlin type label for a documented field (enum → generated enum class name). * - * [forPayload] selects the payload type path ([KotlinTypeMapper.payloadTypeName]) so a - * `field.string @dbColumnType=jsonb` open bag is documented as the parsed JSON value - * (`JsonElement`) the payload data class actually emits (issue #98); the entity model field - * (forPayload = false) stays `String`, matching its data-class property. Keeps the documented - * type == the generated type on both surfaces. + * [forPayload] selects the payload type path ([KotlinTypeMapper.payloadTypeName]); the entity + * model field path uses [KotlinTypeMapper.kotlinTypeName]. As of the #98 uniform-parsed-value + * cutover BOTH surfaces document a `field.string @dbColumnType=jsonb` open bag as the parsed + * JSON value (`JsonElement`) — the payload data class and the entity data class (the reused + * entity-CRUD DTO) now agree. Keeps the documented type == the generated type on both surfaces. */ private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject, forPayload: Boolean = false): String { KotlinTypeMapper.enumTypeName(field, owner)?.let { enumType -> diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt index d87f0ec1f..40befc08f 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt @@ -126,6 +126,42 @@ class KotlinEntityGeneratorTest { } } + @Test fun jsonbOpenBagEntityPropertyIsParsedJsonValue() { + // Issue #98: the `field.string @dbColumnType=jsonb` open bag is a PARSED JSON value + // (kotlinx `JsonElement`) on the entity data class too — not a (double-encoded) raw-JSON + // `String`. The entity data class is REUSED as the derived entity-CRUD request/response DTO, + // so this is the entity-CRUD REST contract reaching full parity with the payload surface + // (and with TS/Python/Java/C#). A sibling plain `field.string` stays `String`. + val fx = """{ + "metadata.root": { "package": "acme::demo", "children": [ + { "object.entity": { "name": "Rubric", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "weights", "@dbColumnType": "jsonb" } }, + { "field.string": { "name": "label" } } + ] } } + ] } + }""".trimIndent() + + val outDir = Files.createTempDirectory("kgen-jsonb-") + try { + val gen = KotlinEntityGenerator() + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loadString("jsonb", fx)) + + val src = Files.readString(outDir.resolve("acme/demo/Rubric.kt")) + // jsonb open bag → parsed JSON value, imported. + assertTrue("val weights: JsonElement" in src, "expected `val weights: JsonElement` in:\n$src") + assertTrue("import kotlinx.serialization.json.JsonElement" in src, + "expected JsonElement import in:\n$src") + // plain string stays String (no double-encoding concern). + assertTrue("val label: String" in src, "expected `val label: String` in:\n$src") + // must NOT regress to a raw-JSON String holder for the bag. + assertFalse("val weights: String" in src, "jsonb open bag must not be String:\n$src") + } finally { + outDir.toFile().deleteRecursively() + } + } + // === field.enum coverage =============================================== private val enumFixture = """{ diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt index 91ef04105..2774b182b 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt @@ -219,40 +219,45 @@ class KotlinTypeMapperTest { @Test fun `string field with dbColumnType=jsonb emits real jsonb column not text`() { // R6 Plan 2b: `@dbColumnType=jsonb` on a `field.string` selects a native Postgres - // JSONB column (matching the other 4 ports). The Exposed column is the - // `jsonb(name, encoder, decoder)` extension with identity String functions (the - // property stays a raw-JSON `String`), NOT the old `text(...)` — a TEXT column + // JSONB column (matching the other 4 ports). NOT the old `text(...)` — a TEXT column // never round-trips to JSONB in the introspection corpus. + // + // Issue #98: the column codec now PARSES the JSONB text to a kotlinx `JsonElement` + // (decode `Json.parseToJsonElement`, encode `it.toString()`) instead of the old + // identity passthrough `{ it }, { it }`, so the entity data-class property is a parsed + // JSON value, not a raw-JSON `String`. The decode anchors the Exposed column's generic + // to `JsonElement`. It is STILL a real `jsonb(...)` column — only the codec/type changes. val f = StringField("rubricWeights") f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb")) - assertEquals("jsonb(\"rubric_weights\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f)) - assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f)) + assertEquals( + "jsonb(\"rubric_weights\", { it.toString() }, { Json.parseToJsonElement(it) })", + KotlinTypeMapper.exposedColumnSpec(f), + ) + assertEquals(ClassName("kotlinx.serialization.json", "JsonElement"), KotlinTypeMapper.kotlinTypeName(f)) // The column needs the exposed-json `jsonb` extension import. assertEquals("org.jetbrains.exposed.sql.json.jsonb", KotlinTypeMapper.exposedColumnImport(f)) } - @Test fun `string field with dbColumnType=jsonb exposes a parsed JSON value in the REST payload`() { + @Test fun `string field with dbColumnType=jsonb exposes a parsed JSON value at every layer`() { // Issue #98 (cross-port jsonb open-bag REST contract). A `field.string @dbColumnType=jsonb` // is the sanctioned "open JSON bag": the physical column is JSONB but the LOGICAL subtype - // stays `string`. The two layers are deliberately split: - // - PERSISTENCE (kotlinTypeName / exposedColumnSpec): the Exposed column stays the - // identity-codec `jsonb(name, { it }, { it })` and the entity data-class property stays - // `String` — the JDBC driver returns jsonb as raw text, so String is correct here. - // - REST PAYLOAD (payloadTypeName): the typed projection exposes the bag as a PARSED JSON - // value (kotlinx `JsonElement`) so a client sends/receives a real JSON object, never a - // double-encoded string. Matches TS `z.unknown()` (#97) and Python `Any` (#99). + // stays `string`. Per the maintainer decision the bag is a PARSED JSON value (kotlinx + // `JsonElement`) UNIFORMLY at every layer — payload, entity data class (the reused CRUD DTO), + // and the Exposed column codec — so a client sends/receives a real JSON object, never a + // double-encoded string. Matches TS `z.unknown()` (#97), Python `Any` (#99), C# `JsonDocument`. val f = StringField("rubricWeights") f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb")) - // REST payload → parsed JSON value (the fix). + val jsonElement = ClassName("kotlinx.serialization.json", "JsonElement") + // REST payload → parsed JSON value. + assertEquals(jsonElement, KotlinTypeMapper.payloadTypeName(f)) + // Entity data class / entity-CRUD DTO → parsed JSON value (now uniform with payload). + assertEquals(jsonElement, KotlinTypeMapper.kotlinTypeName(f)) + // The Exposed column stays a real jsonb column but parses to JsonElement. assertEquals( - ClassName("kotlinx.serialization.json", "JsonElement"), - KotlinTypeMapper.payloadTypeName(f), + "jsonb(\"rubric_weights\", { it.toString() }, { Json.parseToJsonElement(it) })", + KotlinTypeMapper.exposedColumnSpec(f), ) - // Persistence stays String — the entity data-class property is the raw-JSON holder for the - // identity-codec jsonb column. (This MUST NOT regress; the column assertion above gates it.) - assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f)) - assertEquals("jsonb(\"rubric_weights\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f)) } @Test fun `plain string field payload stays String`() { @@ -267,7 +272,10 @@ class KotlinTypeMapperTest { // `"JSONB"` and `"jsonb"` route to the JSONB branch. val f = StringField("featureFlags") f.addMetaAttr(StringAttribute.create("dbColumnType", "JSONB")) - assertEquals("jsonb(\"feature_flags\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f)) + assertEquals( + "jsonb(\"feature_flags\", { it.toString() }, { Json.parseToJsonElement(it) })", + KotlinTypeMapper.exposedColumnSpec(f), + ) } @Test fun `string field with dbColumnType=jsonb ignores maxLength`() { @@ -276,7 +284,10 @@ class KotlinTypeMapperTest { val f = StringField("blob") f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb")) f.addMetaAttr(IntAttribute.create("maxLength", 64)) - assertEquals("jsonb(\"blob\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f)) + assertEquals( + "jsonb(\"blob\", { it.toString() }, { Json.parseToJsonElement(it) })", + KotlinTypeMapper.exposedColumnSpec(f), + ) } // === Currency / Enum / UUID coverage === diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt index 67efb39a9..bcc4c93b4 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt @@ -438,8 +438,11 @@ object QueryScenarioRunner { } type == "date" -> LocalDate.parse(raw.toString()) type.contains("time") -> LocalTime.parse(raw.toString()) - // jsonb raw-String column: serialize the authoring Map to a JSON string so the column - // writes a real Postgres JSONB value (a bare String bind would be rejected by jsonb). + // jsonb column: bind the authoring Map as a JSON string. Both jsonb codec styles in this + // module accept it via type erasure — the open-bag `Column` encoder + // (`{ it.toString() }`) and the object/map `Column` identity encoder (`{ it }`) + // both receive the String and write a real Postgres JSONB value (a bare String bind + // would be rejected by jsonb). Read-back (#98) parses it back per-column. type.contains("jsonb") -> if (raw is String) raw else JSON.writeValueAsString(raw) else -> raw } @@ -641,12 +644,13 @@ object QueryScenarioRunner { // LocalDateTime so it lands on the no-`Z` branch. Instant (the TZ-aware shape) is left // as-is for Normalization's Instant branch. if (v is Timestamp) v = v.toLocalDateTime() - // `@dbColumnType:jsonb` open-JSON column round-trips as a raw JSON String - // (identity decode). Parse it to a Map so Normalization sorts the keys and - // the `expect` block (a YAML object) compares byte-equal. Detected by the - // column's SQL type (`jsonb`) so it stays generic across jsonb columns. - if (v is String && col.columnType.sqlType().lowercase().contains("jsonb")) { - v = JSON.readValue(v, Map::class.java) + // `@dbColumnType:jsonb` open-JSON column (#98) reads back as a parsed kotlinx + // JsonElement (or, for an object/map jsonb column, a raw JSON String). Convert + // whatever it is to a Map (via its JSON text — JsonElement.toString() is canonical + // JSON) so Normalization sorts the keys and the `expect` block (a YAML object) compares + // byte-equal. Detected by the column's SQL type (`jsonb`) so it stays generic. + if (v != null && col.columnType.sqlType().lowercase().contains("jsonb")) { + v = JSON.readValue(v.toString(), Map::class.java) } out[col.name] = v } diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt index e238a4ff2..8b81916d1 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt @@ -27,9 +27,9 @@ import kotlin.test.assertTrue * - `Measurement.preciseKg` (NUMERIC) → [BigDecimal] (exact native decimal). * - `Asset.recordedAt` (TIMESTAMPTZ) → [Instant] (native temporal, NOT a String — * the metaobjects `instantWithTimeZone` Column path matches the `Instant` data class). - * - `Asset.payload` (jsonb) → [String] (Exposed surfaces the open-JSON - * column via identity decode; the parse-to-Map step is a harness concern. We assert what - * the runtime genuinely returns — raw JSON text, not a pre-canonicalized/key-sorted string). + * - `Asset.payload` (jsonb) → [kotlinx.serialization.json.JsonElement] (#98: the + * open-JSON column decodes to a parsed JSON value, uniform with the entity data class + REST + * payload — NOT a raw-JSON String; the parse-to-Map key-sorting step is a harness concern). * * Per-port gate (native types differ per language), not a byte-identical * cross-port corpus. Catches the Python-outlier class of regression: a runtime @@ -98,17 +98,16 @@ class RuntimeReturnTypeTest { assertTrue(recordedAt is Temporal, "Asset.recordedAt must be a java.time temporal") assertTrue(recordedAt !is String, "Asset.recordedAt must not be a wire-string") - // jsonb: Exposed surfaces the open-JSON column via identity decode → raw JSON - // text (String). The parse-to-Map (key-sorting) step is a harness concern - // (QueryScenarioRunner.rowToMap), NOT baked into the runtime. We assert the - // runtime's genuine native return and document that canonicalization happens - // at the boundary. + // jsonb (#98): the open-JSON column decodes to a parsed kotlinx `JsonElement` + // (NOT a raw-JSON String), uniform with the generated entity data-class property + // and the REST payload. The key-sorting parse-to-Map step is a harness concern + // (QueryScenarioRunner.rowToMap); here we assert the runtime's genuine native return. val payload = a[AssetTable.payload] assertNotNull(payload, "Asset.payload should be present") assertTrue( - payload is String, - "Asset.payload (jsonb) is surfaced via Exposed identity decode as raw JSON " + - "text; got: ${payload::class}", + payload is kotlinx.serialization.json.JsonElement, + "Asset.payload (jsonb) is surfaced as a parsed JsonElement (#98); " + + "got: ${payload::class}", ) } } diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetTable.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetTable.kt index abb563a59..eebac2936 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetTable.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetTable.kt @@ -6,6 +6,7 @@ import org.jetbrains.exposed.sql.UUIDColumnType import org.jetbrains.exposed.sql.javatime.date import org.jetbrains.exposed.sql.javatime.datetime import org.jetbrains.exposed.sql.json.jsonb +import kotlinx.serialization.json.Json /** * Hand-written reference Exposed Table mirroring `Asset` from @@ -16,7 +17,7 @@ import org.jetbrains.exposed.sql.json.jsonb * - `field.uuid` PK + `@generation:uuid` → `uuid("id")` + gen_random_uuid() DEFAULT * - `field.uuid` (non-key, @required) → `uuid("ownerId")` (Postgres native uuid) * - `field.string` + `@dbColumnType:uuid` → `uuid("externalId")` (native uuid column; generated DATA-CLASS property stays String) - * - `field.string` + `@dbColumnType:jsonb` → `jsonb("payload", …)` (real Postgres JSONB) + * - `field.string` + `@dbColumnType:jsonb` → `jsonb("payload", …)` (real Postgres JSONB; parsed to kotlinx JsonElement, issue #98) * - `field.timestamp` + `@dbColumnType:timestamp_with_tz` → `instantWithTimeZone("recordedAt")` * (a `Column` whose DDL is `TIMESTAMP WITH TIME ZONE` — matches the * `Instant` data-class property with zero coercion; see [instantWithTimeZone]) @@ -33,10 +34,13 @@ object AssetTable : Table("assets") { // `@dbColumnType:uuid` on a field.string → native uuid column. Exposed surfaces this as // a java.util.UUID at the SQL boundary; the normalizer lowercases it canonically. val externalId = uuid("externalId") - // `@dbColumnType:jsonb` open-JSON column. Identity encode/decode keeps the raw JSON text - // (the property is String); the runner parses it to a Map before normalization so the - // jsonb re-serializes with sorted keys per the normalization contract. - val payload = jsonb("payload", { it }, { it }) + // `@dbColumnType:jsonb` open-JSON column (#98). The codec PARSES the JSONB text to a kotlinx + // `JsonElement` (decode `Json.parseToJsonElement`, encode `it.toString()`), so the column is + // `Column` and a read returns a parsed JSON value (uniform with the generated + // entity data-class property + REST payload). The runner converts the JsonElement to a Map + // before normalization so the jsonb re-serializes with sorted keys per the normalization + // contract. Byte-for-byte the codec KotlinExposedTableGenerator now emits for this column. + val payload = jsonb("payload", { it.toString() }, { Json.parseToJsonElement(it) }) // `recordedAt` is a TIMESTAMPTZ column surfaced as java.time.Instant (the metaobjects // `instantWithTimeZone` Column path — matches the `Instant` data class with no // OffsetDateTime coercion). The instant is already UTC, so Normalization renders it at