|
| 1 | +# R6 Plan 2b — `@dbColumnType` physical column-type attribute (cross-port) |
| 2 | + |
| 3 | +_Date: 2026-05-30. Status: **Design — approved, pending spec review.**_ |
| 4 | + |
| 5 | +> Part of R6 (RDB fidelity). Sibling of **Plan 2a (`field.uuid`)**. This sub-project |
| 6 | +> promotes the physical `@dbColumnType` attribute from Kotlin-only to a registered |
| 7 | +> cross-port `dbProvider` attribute. The two compose (`@dbColumnType: uuid` lets a |
| 8 | +> `field.string` use a native uuid column without the logical `field.uuid` binding) but |
| 9 | +> neither blocks the other. |
| 10 | +> |
| 11 | +> Governed by [ADR-0013](../../../spec/decisions/ADR-0013-logical-field-types-vs-physical-column-attributes.md) |
| 12 | +> (the logical/physical boundary — `@dbColumnType` is the canonical *physical* escape hatch), |
| 13 | +> [ADR-0007](../../../spec/decisions/ADR-0007-source-v2-paradigm.md) (physical RDB attributes |
| 14 | +> live on the `dbProvider`). Re-layers the deferred |
| 15 | +> `docs/superpowers/specs/2026-05-29-rdb-fidelity-field-type-additions-design.md`. |
| 16 | +
|
| 17 | +## Context |
| 18 | + |
| 19 | +ADR-0013 ratified the boundary: a logical field **subtype** fixes a value's *meaning and |
| 20 | +native type*; **how that value is stored in a specific database** is a *physical* concern that |
| 21 | +lives on a `dbProvider` attribute, never on the logical field. The canonical physical mechanism |
| 22 | +is `@dbColumnType`, which selects the DB column type while leaving the logical field type and its |
| 23 | +idiomatic native binding (ADR-0001) **untouched**. |
| 24 | + |
| 25 | +This absorbs the long tail of DB-specific column types (`jsonb`, `timestamptz`, native `uuid`, |
| 26 | +future `tsvector`/`inet`/`citext`) into **one bounded physical attribute** instead of growing the |
| 27 | +universal logical vocabulary per type. |
| 28 | + |
| 29 | +### What already exists (audit, 2026-05-30) |
| 30 | + |
| 31 | +`@dbColumnType` is **Kotlin-only and not even registered** — an ad-hoc string read in |
| 32 | +`KotlinTypeMapper` (`hasMetaAttr`/`getMetaAttr`), absent from every registry/constraint/ |
| 33 | +`dbProvider`. TS/C#/Java/Python have zero handling. Kotlin recognizes three values: |
| 34 | + |
| 35 | +| Value | Field subtype (Kotlin) | Kotlin emits today | Issue to reconcile | |
| 36 | +|---|---|---|---| |
| 37 | +| `uuid` | `StringField` | Exposed `uuid("col")` → PG `uuid`; property stays `String` | consistent target | |
| 38 | +| `jsonb` | `StringField` | Exposed **`text("col")`** | **emits a TEXT column, not JSONB** — must emit real `jsonb` | |
| 39 | +| `timestamp_with_tz` | `TimestampField` | `timestampWithTimeZone("col")` → `timestamptz` | consistent target | |
| 40 | + |
| 41 | +The 4 migrate ports already have the *target* `SqlType` variants (`Uuid`, `Json`, `Timestamp` |
| 42 | +with a `withTimezone` flag) that emit `UUID` / `JSONB` / `TIMESTAMP WITH TIME ZONE` for Postgres — |
| 43 | +but they are only reached via **introspection** (TS/C#) or via the logical timestamp path; nothing |
| 44 | +consults a `@dbColumnType` attribute because no port registers one. |
| 45 | + |
| 46 | +## Decision / Design |
| 47 | + |
| 48 | +### 1. Promote `@dbColumnType` to a registered `dbProvider` attribute (all 5 ports) |
| 49 | + |
| 50 | +Register `@dbColumnType` as a physical string attribute provided by the `dbProvider`/persistence |
| 51 | +layer (alongside `@column`/`@table`/`@kind`/`@schema`), not the core metamodel — so it is part of |
| 52 | +the schema, validated, and serialized consistently across ports. Constant |
| 53 | +`ATTR_DB_COLUMN_TYPE = "dbColumnType"` (promote Kotlin's existing constant to the shared form; |
| 54 | +add to TS constants first, then the parallel in each port). |
| 55 | + |
| 56 | +### 2. Enumerated values only (raw passthrough deferred) |
| 57 | + |
| 58 | +Plan 2b ships **three** validated, drift-safe, introspection-round-trippable values: |
| 59 | + |
| 60 | +| `@dbColumnType` | Legal logical subtype(s) | DB column (Postgres) | Logical binding (unchanged) | |
| 61 | +|---|---|---|---| |
| 62 | +| `uuid` | `field.string` | `uuid` | string | |
| 63 | +| `jsonb` | `field.string` | `jsonb` (genuinely-open JSON) | string (raw JSON text) | |
| 64 | +| `timestamp_with_tz` | `field.timestamp` | `timestamp with time zone` | the field's native timestamp type | |
| 65 | + |
| 66 | +A `<raw dialect type>` passthrough (`tsvector`/`inet`/`citext`/…) is **deferred** to a future |
| 67 | +extension — it needs a diff-engine "opaque column" story first (the engine can't validate or |
| 68 | +cleanly round-trip an arbitrary dialect string). ADR-0013 sanctions it as a *future* form; this |
| 69 | +sub-project ships the bounded enumerated set that covers the known adopter needs. |
| 70 | + |
| 71 | +**Typed jsonb stays preferred (ADR-0013 §5):** structured JSON is `field.object` + `@objectRef` |
| 72 | ++ `@storage: jsonb` (already shipped). `@dbColumnType: jsonb` is the *genuinely-open* escape |
| 73 | +hatch only — explicit and high-friction. |
| 74 | + |
| 75 | +### 3. Validation (consistency) |
| 76 | + |
| 77 | +The `dbProvider` validates the **(logical subtype × `@dbColumnType` value)** pairing and emits an |
| 78 | +actionable error for an illegal combination (e.g. `@dbColumnType: timestamp_with_tz` on a |
| 79 | +`field.string`, or an unrecognized value). Error code: reuse `ERR_BAD_ATTR_VALUE` (per the |
| 80 | +`field.enum` precedent), with a message naming the field, the value, and the legal set. This is |
| 81 | +own-only validation in every port's loader, identical across ports (metamodel-conformance-gated |
| 82 | +for the *error*, persistence-conformance-gated for the *effect*). |
| 83 | + |
| 84 | +### 4. Codegen + migrate routing (all 5 ports) |
| 85 | + |
| 86 | +Each port's metadata→schema builder, when a field carries `@dbColumnType`, selects the mapped |
| 87 | +`SqlType` (`Uuid` / `Json` / `Timestamp(withTimezone=true)`) **instead of** the subtype's default, |
| 88 | +while the entity-codegen native binding is unchanged. Specifically: |
| 89 | + |
| 90 | +- **TS** `expected-schema.ts` `subtypeToSqlType` + `emit/postgres.ts`. |
| 91 | +- **C#** `ExpectedSchema.SubtypeToSqlType` + `PostgresEmit`. |
| 92 | +- **Java** `ExpectedSchemaBuilder` / `SimpleMappingHandlerDB` + `PostgresDriver.pgType`. |
| 93 | +- **Python** `_subtype_to_sql_type` + `postgres_emit`. |
| 94 | +- **Kotlin** `KotlinTypeMapper.exposedColumnSpec`: keep `uuid("col")` and `timestampWithTimeZone("col")`; |
| 95 | + **change `jsonb` from `text("col")` to a real JSONB column** (Exposed `json`/custom column type |
| 96 | + that renders `JSONB` DDL) so Kotlin matches the other ports' `jsonb` emission. |
| 97 | + |
| 98 | +### 5. Introspection round-trip (the ADR-0013 litmus) |
| 99 | + |
| 100 | +A physical attribute must round-trip from the live DB. Each value's column must introspect back to |
| 101 | +the same `@dbColumnType`: |
| 102 | + |
| 103 | +- `timestamptz` → `timestamp_with_tz` (TS already via the `withTimezone` flag; Java |
| 104 | + `Types.TIMESTAMP_WITH_TIMEZONE` → `Timestamp(true)` already exists; C# already maps it). |
| 105 | +- `jsonb` and `uuid` → both are JDBC `Types.OTHER`; **Java introspection must disambiguate by |
| 106 | + type name** (`TYPE_NAME`/`udt_name` `uuid` vs `jsonb`) → `SqlType.Uuid` / `SqlType.Json`. (This |
| 107 | + extends the same Java `JdbcSqlTypes.fromJdbc` fix that Plan 2a starts for `uuid`.) |
| 108 | +- **Python has no introspection layer** — out of scope here (pre-existing gap; the persistence |
| 109 | + fixtures are bootstrap-from-empty + round-trip, which don't require introspection). |
| 110 | + |
| 111 | +### 6. Conformance fixtures |
| 112 | + |
| 113 | +- **Metamodel/negative:** a fixture asserting an illegal `(subtype × @dbColumnType)` pairing emits |
| 114 | + `ERR_BAD_ATTR_VALUE` (own-only, all 5 loaders). |
| 115 | +- **Persistence:** extend the corpus with columns exercising each value — a `@dbColumnType: uuid` |
| 116 | + string column, a `@dbColumnType: jsonb` open-JSON column, and a `@dbColumnType: timestamp_with_tz` |
| 117 | + column — asserting the bootstrap DDL emits `uuid` / `jsonb` / `timestamp with time zone` |
| 118 | + (type assertions, mirroring the R6 `REAL`/`DOUBLE PRECISION` pattern) and that values round-trip |
| 119 | + byte-identically across all 5 ports vs Testcontainers Postgres. |
| 120 | + |
| 121 | +## Per-port touch summary |
| 122 | + |
| 123 | +| Port | Register attr | Validate pairing | Route in schema builder | jsonb fix | Introspect | |
| 124 | +|---|---|---|---|---|---| |
| 125 | +| TS | add | add | add | n/a | exists ✓ | |
| 126 | +| C# | add | add | add | n/a | exists ✓ | |
| 127 | +| Java | add | add | add | n/a | **add `Types.OTHER` uuid/jsonb disambiguation** | |
| 128 | +| Python | add | add | add | n/a | n/a (no introspection) | |
| 129 | +| Kotlin | promote to registered | add | adjust | **text()→real JSONB** | n/a (Exposed) | |
| 130 | + |
| 131 | +## Testing |
| 132 | + |
| 133 | +- Metamodel conformance: the negative `@dbColumnType` pairing fixture (all 5 loaders). |
| 134 | +- Per-port unit tests for each value's schema routing + the validation errors. |
| 135 | +- Persistence conformance: the new uuid/jsonb/timestamptz columns, all 5 ports under |
| 136 | + Testcontainers Postgres via `scripts/integration-test.sh`. |
| 137 | +- TDD throughout; pre-merge gate = code review + simplify per unit before merge-forward. |
| 138 | + |
| 139 | +## Out of scope (explicit) |
| 140 | + |
| 141 | +- `<raw dialect type>` passthrough (deferred — needs a diff-engine opaque-column story). |
| 142 | +- Python introspection layer (pre-existing gap). |
| 143 | +- Typed jsonb (`field.object` + `@storage: jsonb`) — already shipped; `@dbColumnType: jsonb` is the |
| 144 | + open-JSON complement, not a replacement. |
| 145 | +- The logical `field.uuid` subtype — that is **Plan 2a**; `@dbColumnType: uuid` is the |
| 146 | + string-typed physical complement. |
0 commit comments