Skip to content

Commit 48a571d

Browse files
dmealingclaude
andcommitted
docs(spec): R6 Plan 2b — @dbColumnType physical attribute design
Promote @dbColumnType from Kotlin-only/unregistered to a registered cross-port dbProvider attribute. Enumerated values uuid/jsonb/timestamp_with_tz (raw dialect passthrough deferred); (subtype × value) validation; route in each port's schema builder; reconcile Kotlin's jsonb (text→real JSONB); Java introspection Types.OTHER uuid/jsonb disambiguation; persistence + negative conformance fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a695050 commit 48a571d

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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

Comments
 (0)