Skip to content

Commit f8e89b3

Browse files
wmaddenclaude
andcommitted
docs(enums): shape enums-as-domain-concept project
Reframe enums from a storage-plane native Postgres type to a domain-plane concept: a codec is the type, an enum is a named valueSet restriction. Storage realizes it as a named value-set + check constraint; native enum machinery is removed (restorable as a different storage shape). Adds the project spec, plan, and design-notes; renames the prior postgres-enum-finishing workspace. Refs TML-2850, TML-2851, TML-2852, TML-2853. Signed-off-by: Will Madden <madden@prisma.io> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d869089 commit f8e89b3

4 files changed

Lines changed: 721 additions & 203 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Design notes: enums-as-domain-concept
2+
3+
> Synthesized design document for `enums-as-domain-concept`. Read this to understand
4+
> **what the design is**, **what principles it serves**, and **what alternatives were
5+
> considered and rejected**. It captures the settled design, standing independently of
6+
> the discussion that produced it. The spec (`./spec.md`) is the authoritative,
7+
> requirement-mapped statement; this document is the rationale behind it.
8+
9+
## Principles this design serves
10+
11+
- **A codec is a type; an enum is a restriction on it.** A column's type is its codec
12+
(the set of assignable values). An enum does not replace the codec — it narrows the
13+
permitted values to a named subset. Every field/column keeps its codec, always.
14+
- **Domain concept vs storage projection (ADR 172).** The application's enum (named,
15+
ordered members) is a domain concept; the permitted physical values are a storage
16+
concept. Each lives in its own plane, referenced within that plane.
17+
- **Single source, emitted projections.** The domain enum is the one authored source.
18+
Storage and runtime copies are emitted from it, so they cannot drift — the same
19+
redundancy ADR 172 already accepts for nullability and native types.
20+
- **Structure carries strategy (no markers).** As with polymorphism and ownership, the
21+
persistence strategy is implied by the shape (text column + value-set + check), not a
22+
separate flag. Changing the strategy is a visible structural diff.
23+
- **One reference rule everywhere (ADR 221 / PR #745).** References use the full
24+
space-aware entity coordinate, never bare names — uniform with relations and FKs.
25+
- **Delete native enums; keep the seam.** Native `CREATE TYPE … AS ENUM` carries
26+
operational pain (no value removal without rebuild, transaction caveats, text-only).
27+
It is removed now and, because the strategy is structural, can return later as a
28+
different storage shape under the same unchanged domain enum.
29+
30+
## The model
31+
32+
An enum is an ordered map from a member **name** (a code identifier) to a member
33+
**value** (the runtime value the column stores). The two are independent; the **value**
34+
is the runtime identity used in the ORM, the query builder, raw SQL, and the wire.
35+
36+
### Domain plane — the concept
37+
38+
`domain.namespaces[ns].enum[Name]` carries an explicit `codecId` and ordered
39+
`members: [{ name, value }, …]`. The codec is required (declared, never inferred) and
40+
its input type constrains the member value type. A field that uses the enum keeps its
41+
always-present `codecId` and adds a `valueSet` restriction referencing the enum.
42+
43+
### Storage plane — the physical projection
44+
45+
`storage.namespaces[ns].valueSet[Name]` carries ordered `values: […]` — a bare, named
46+
set of permitted physical values (no member names, no application semantics; a
47+
storage-legitimate concept). A column keeps its `codecId` + `nativeType` and adds a
48+
`valueSet` restriction referencing the storage value-set. The value-set is referenced,
49+
not inlined, so the values live once per plane.
50+
51+
### Restriction and enforcement are separate jobs
52+
53+
- The column's **`valueSet` property** is the *notional* restriction — read the column
54+
in isolation and you know its value space. This types the client (ORM from the domain
55+
field, query builder from the storage column), present whether or not the database
56+
enforces anything.
57+
- The **check constraint** (`StorageTable.checks[]`, referencing the same value-set) is
58+
the *server-side* enforcement. A column may carry the restriction with or without the
59+
check.
60+
61+
### References
62+
63+
`valueSet` (and the `enumMember` default) carry a discriminated, space-aware coordinate:
64+
`kind` (the source entity-kind) + `namespaceId` (admitting the `__unbound__` sentinel) +
65+
`name`, plus an optional `spaceId` whose presence is the cross-space discriminator — the
66+
TML-2500 / PR #745 carrier convention. Domain → domain and storage → storage references
67+
are intra-plane; the `enumMember` default is storage → domain, permitted by ADR 221's
68+
directional invariant.
69+
70+
### Typing and surface
71+
72+
Read/write types are the codec's `Output`/`Input` narrowed to the value-set's values
73+
(`string``'user' | 'admin'`). `db.enums.<Name>` exposes the ordered, literal-typed
74+
value tuple and member accessors. `ORDER BY` follows declaration order, rendered per
75+
target from the ordered values.
76+
77+
## Alternatives considered
78+
79+
- **Enum as a storage-plane entity (the original approach).** Attractive: it was already
80+
half-built (`PostgresEnumType`). **Rejected because:** it puts the source of truth in
81+
the wrong plane, forcing every application-facing feature to reach down into storage
82+
for the values — the breakage this project removes.
83+
- **Native `CREATE TYPE … AS ENUM` as the storage realization.** Attractive: a real,
84+
shared, introspectable type. **Rejected because:** Postgres-only, no value removal
85+
without rebuild, transaction caveats, text-only. Value-set + check works on every SQL
86+
target with ordinary `ALTER TABLE`s; native can return later as a different structure.
87+
- **Field/column type as a `codec | enum` union.** Attractive: explicit. **Rejected
88+
because:** it breaks the "every field/column has a codec, always" invariant — a
89+
foundational change that ripples everywhere. A codec *is* the type; the enum is
90+
additive.
91+
- **A named enum entity in the storage plane.** Attractive: symmetry with the domain
92+
enum. **Rejected because:** with native types gone there is no physical object to name;
93+
a storage "enum" would be a domain concept in a plane meant for concrete artifacts. The
94+
bare value-set is the storage-legitimate version.
95+
- **Inlining permitted values on each column/check.** Attractive: storage fully
96+
self-contained for DDL. **Rejected because:** it duplicates the list per site. The
97+
named value-set, referenced intra-plane, keeps values once and storage still resolves
98+
without leaving its plane.
99+
- **A literal default instead of an `enumMember` variant.** Attractive: no new
100+
`ColumnDefault` shape. **Rejected because:** a column now openly carries an enum
101+
restriction, so a member-referenced default is the natural corollary and records
102+
intent; the fixture cost is small.
103+
- **An explicit persistence-strategy marker.** **Rejected because:** the structure
104+
declares the strategy (as in polymorphism/ownership); a marker would be a second source
105+
of truth.
106+
- **Bare-name references.** **Rejected because:** names collide and need lexical context;
107+
the full space-aware coordinate is the uniform rule.
108+
- **Authoring as a `Map` / bare object / array of pairs.** **Rejected because:** a `Map`
109+
erases literals; an object reorders integer-like keys and collides the accessor with
110+
type properties; pairs are unergonomic. The `member()` variadic preserves order and
111+
literals.
112+
- **A per-enum runtime validator (e.g. arktype).** **Rejected because:** the compile-time
113+
union and the database check already enforce membership; a third check is redundant
114+
defense.
115+
- **An ecosystem enum library** (Zod / Effect / enumify / …). **Rejected because:** each
116+
either collapses name into value or uses runtime classes (against no-runtime-codegen);
117+
none gives ordered + independent name/value + literal inference. The ~30-line
118+
`enumType` is hand-rolled.
119+
120+
## Open questions
121+
122+
- **Realization layer** — implement value-set + check at the SQL-family layer
123+
(MySQL/SQLite inherit) or Postgres-only now? **Working position:** family-layer; the
124+
structured check is dialect-agnostic.
125+
- **PSL surface** for declaring an enum's codec and per-member values. **Working
126+
position:** an explicit codec annotation on the `enum` block + per-member `@map` for
127+
the value; exact syntax settled at slice-plan time.
128+
- **`db.enums` scope** — local to this project or the first instance of a broader
129+
domain-client surface for IR-modelled entities? **Working position:** ship it here,
130+
shaped so a later generalization is non-breaking.
131+
- **Reference-carrier coupling** — the `valueSet`/default refs track TML-2500 / PR #745;
132+
if that convention shifts before this lands, these refs shift with it. **Working
133+
position:** conform to the merged M1 carrier; local refs need no `spaceId`.
134+
135+
## References
136+
137+
- Project spec: [`./spec.md`](./spec.md)
138+
- Project plan: [`./plan.md`](./plan.md)
139+
- [ADR 172 — Contract domain-storage separation](../../docs/architecture%20docs/adrs/ADR%20172%20-%20Contract%20domain-storage%20separation.md)
140+
- [ADR 221 — Contract IR two planes, uniform entity coordinate, pack-contributed kinds](../../docs/architecture%20docs/adrs/ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md)
141+
- TML-2500 / PR #745 — cross-contract-space FK reference carrier (the reference coordinate convention)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# enums-as-domain-concept — Plan
2+
3+
**Spec:** `projects/enums-as-domain-concept/spec.md`
4+
**Linear Project:** [Enums as a domain concept](https://linear.app/prisma-company/project/enums-as-a-domain-concept-696d6b36cb89) (team Terminal)
5+
6+
## At a glance
7+
8+
Four slices in a substrate-then-parallel-realization-then-cleanup shape: a contract
9+
substrate slice lands the two-plane enum shape; two independent realization slices then
10+
run in parallel — one delivering the Postgres server-side realization (checks +
11+
defaults + verification), the other the application read surface (typing + `db.enums` +
12+
ordering); a final cleanup slice deletes the native enum machinery. One stack thread
13+
(substrate → cleanup) with a parallel pair in the middle.
14+
15+
## Composition
16+
17+
### Stack (deliver in order)
18+
19+
1. **Slice `enum-contract-substrate`** — Linear: [TML-2850](https://linear.app/prisma-company/issue/TML-2850)
20+
- **Outcome:** An enum declared in PSL or the TS DSL emits a `domain…enum` entity
21+
(explicit codec + ordered name→value members) and a `storage…valueSet` entity
22+
(ordered permitted values), with the using field and column each keeping their
23+
always-present `codecId` and additionally carrying a `valueSet` restriction
24+
reference in the space-aware coordinate shape. The contract round-trips through the
25+
serializer and passes validation.
26+
- **Builds on:** None. (Soft external: the `valueSet` reference shape tracks the
27+
TML-2500 / PR #745 carrier — see § Dependencies. Local refs carry no `spaceId`, so
28+
this is not blocking.)
29+
- **Hands to:** The two-plane contract shape — `domain…enum`, `storage…valueSet`, and
30+
the `valueSet` property + reference coordinate — that slices 2 and 3 consume.
31+
- **Focus:** the `enumType` / `member` authoring API; the two new IR entity kinds and
32+
the `valueSet` property on domain field + storage column; PSL and TS-DSL lowering
33+
into both planes; serializer, validator, round-trip. The new path uses the ordinary
34+
scalar codec — no bespoke enum codec. Deliberately out of scope: server-side
35+
enforcement (slice 2), client typing / defaults (slice 3), and removing the existing
36+
native enum path (slice 4), which stays untouched alongside.
37+
38+
2. **Slice `delete-native-enum-machinery`** — Linear: [TML-2853](https://linear.app/prisma-company/issue/TML-2853)
39+
- **Outcome:** The native Postgres enum machinery (spec § What this replaces) is
40+
gone; enums are realized only as `valueSet` + check; build, type-checks, and
41+
`fixtures:check` pass; no `postgres-enum` discriminator or `PostgresEnumType`
42+
remains; the no-bare-cast ratchet is clean.
43+
- **Builds on:** Slice `check-constraint-realization`'s value-set + check realization
44+
(which makes the native emission/migration/verification path redundant). Sequenced
45+
after the parallel pair so fixtures regenerate once, not twice.
46+
- **Hands to:** A single enum path — the project's end state.
47+
- **Focus:** delete the enumerated native machinery; migrate canonical fixtures to the
48+
`valueSet` + check form; confirm `fixtures:check` and the cast ratchet. Pure
49+
subtraction + fixture regeneration; no new behavior.
50+
51+
### Parallel group A — Postgres realization (independent of group B; builds on slice 1)
52+
53+
- **Slice `check-constraint-realization`** — Linear: [TML-2851](https://linear.app/prisma-company/issue/TML-2851)
54+
- **Outcome:** A `storage…valueSet` is enforced server-side by a check constraint, and
55+
member defaults render to DDL. `CheckConstraint` IR exists in a table-level `checks`
56+
array (the `uniques` / `indexes` / `foreignKeys` precedent); migrations add/remove
57+
permitted values by dropping and recreating the check (no type rebuild); the
58+
`enumMember` `ColumnDefault` variant renders `DEFAULT '<value>'`; schema verification
59+
compares the contract's expected check against the live database and reports drift.
60+
- **Builds on:** Slice 1's `storage…valueSet` + `domain…enum`.
61+
- **Hands to:** An enforced, migratable, default-capable Postgres realization of the
62+
value-set, replacing the deleted native ops/verification (consumed by slice 4).
63+
- **Focus:** `CheckConstraint` IR + `StorageTable.checks`; Postgres check DDL
64+
(create / add / remove); the `enumMember` default variant, its PSL/TS lowering, and
65+
its DDL rendering; check-based verification replacing `verifyEnumType`. Out of scope:
66+
client-side typing (slice 3). Touches the Postgres migration/planner surface and the
67+
`CheckConstraint` / `ColumnDefault` contract IR.
68+
69+
### Parallel group B — application read surface (independent of group A; builds on slice 1)
70+
71+
- **Slice `application-read-surface`** — Linear: [TML-2852](https://linear.app/prisma-company/issue/TML-2852)
72+
- **Outcome:** Reads and writes of an enum-typed field/column are statically the value
73+
union (not `string`) in both the ORM and the query-builder lanes; `db.enums.<Name>`
74+
exposes the ordered, literal-typed value tuple and member accessors at runtime;
75+
`ORDER BY` on an enum column sorts by declaration order.
76+
- **Builds on:** Slice 1's `domain…enum` + the field/column `valueSet` property.
77+
- **Hands to:** Enums usable idiomatically in application code — typed I/O, runtime
78+
introspection, declaration-order sort.
79+
- **Focus:** codec-`Output`-narrowed-by-`valueSet` typing in the ORM and query-builder
80+
lanes (R4 / R5); the `db.enums` runtime surface (R6); declaration-order `ORDER BY`
81+
rendering (R8). Touches the SQL lanes (`packages/2-sql/4-lanes/**`) and the runtime
82+
client — disjoint from group A's migration/planner surface. Out of scope: server-side
83+
enforcement and defaults (slice 2).
84+
85+
## Dependencies (external)
86+
87+
- [ ] **TML-2500 / PR #745 — cross-contract-space reference carrier.** The `valueSet`
88+
and `enumMember` default reference shapes follow this carrier's coordinate convention
89+
(`namespaceId` with the `__unbound__` sentinel; optional `spaceId` whose presence is
90+
the cross-space discriminator). **Status:** M1 (the storage-plane carrier + aggregate-
91+
load checks) merged to `main`; authoring surface and planner/verifier wiring are
92+
M2/M3. **Not blocking:** slice 1's local enum references carry no `spaceId` and use the
93+
landed carrier shape; if the convention shifts before this project lands, the `valueSet`
94+
refs shift with it (spec § Deferred to plan).
95+
96+
## Sequencing rationale
97+
98+
- **Slice 1 first** because it lands the two-plane contract shape that every other slice
99+
reads. Nothing downstream can be specced against an unsettled substrate.
100+
- **Slices 2 and 3 parallelise** because both build only on slice 1 and touch disjoint
101+
surfaces — slice 2 the Postgres migration/planner plus the `CheckConstraint` /
102+
`ColumnDefault` contract IR; slice 3 the SQL lanes plus the runtime client. The
103+
migration DDL path and the query/typing path do not collide, so the
104+
"different-surface slices parallelise; same-adapter slices serialise" heuristic applies
105+
in favour of parallel.
106+
- **Slice 4 last (build-before-delete)** because removing the native emission, migration,
107+
verification, and codec only becomes safe once slice 2's realization covers those cases
108+
in the new shape. It is sequenced after the parallel pair so the canonical fixtures are
109+
regenerated a single time rather than churned by both realization slices.

0 commit comments

Comments
 (0)