Skip to content

Commit f35545b

Browse files
wmadden-electricwmaddenclaude
authored
TML-2887: namespace entries becomes an open, kind-keyed dictionary (ADR 224/225) (#812)
**Decision:** a storage namespace stores its entities in one open dictionary — keyed by entity kind, then by entity name — and that path is the *only* way the framework addresses, validates, and hydrates them. This PR makes the runtime types and the validation/hydration machinery match that model (ADR 224/225). Nothing persisted changes: the wire format was already this shape, byte for byte. Here is a namespace exactly as it sits in a committed `contract.json` today (unchanged by this PR): ```jsonc // storage.namespaces.public { "id": "public", "entries": { "table": { "user": { /* StorageTable */ }, "post": { /* … */ } }, "type": { "user_type": { "kind": "postgres-enum", "values": ["admin", "user"] } }, "valueSet": { "Priority": { "kind": "valueSet", "values": ["low", "high"] } } } } ``` Every entity has a coordinate — `(plane, namespaceId, entityKind, entityName)` — and one expression resolves any coordinate, for any entity kind, including kinds a target or extension pack contributes that the framework has never heard of: ```ts entityAt(storage, { namespaceId, entityKind, entityName }) // ≡ storage.namespaces[namespaceId].entries[entityKind][entityName] ``` ## The problem The persisted JSON was already open, but the runtime types were not. Each namespace class declared a closed object with one named field per kind it knew about: ```ts // before — PostgresSchema readonly entries: { readonly table: Readonly<Record<string, StorageTable>>; readonly type: Readonly<Record<string, PostgresEnumType>>; readonly valueSet?: Readonly<Record<string, StorageValueSet>>; }; ``` A closed type forces every consumer to know every kind. The validator hardcoded `table?`/`type?`/`valueSet?` schema fields; the postgres serializer hydrated the `type` map through a lookup keyed by the node tag `'postgres-enum'`; generic code that received a coordinate had to switch on the kind to pick a property. Adding a new entity kind (the RLS branch needs policies and roles) meant editing framework and family code — or smuggling the kind past the closed types, which is what that branch currently does. ## The change **The entity kind is the `entries` key** — `table`, `type`, `valueSet`, `collection` — exactly the strings already persisted. Everything dispatches on that one string. **Open types that keep typed property access.** `entries` is typed as the open dictionary *intersected* with its known kinds as optional keys: ```ts // after — the SQL family shape (Postgres adds `type?`, Mongo has `collection?`) readonly entries: Readonly<Record<string, Readonly<Record<string, unknown>>>> & { readonly table?: Readonly<Record<string, StorageTable>>; readonly valueSet?: Readonly<Record<string, StorageValueSet>>; }; ``` Unknown kinds remain valid (walkers iterate `Object.entries` as before), but `ns.entries.table` is ordinary typed property access — no helper layer, no per-call-site casts. Class instances additionally expose non-enumerable per-kind getters (`schema.type`, `db.collection`), so `JSON.stringify` emits only `id` + `entries`. Repo-wide there are exactly two read styles: generic walkers use `entries[kind][name]`; typed code uses property access or the getters. ~50 production and ~190 test call sites moved. **Construction is permissive-carry; the JSON boundary fails closed.** This split is the heart of the open model, so be explicit about where each rule applies: - **In-memory construction (builders/constructors) is permissive.** A builder constructs the kinds it owns a factory for (`table`, `collection`, …) into IR instances and **freezes-and-carries any other kind's map untouched**. It does *not* enforce a closed kind list. This is required by the open model: a pack-contributed kind (an RLS policy, a Postgres role) must pass through `buildSqlNamespace` / `buildMongoNamespace` without the family layer knowing its name — if construction threw on unknown kinds, pack kinds would be un-authorable, which is the closed-list behavior this PR exists to remove. The carry tests that assert a synthetic unknown kind survives construction (frozen, present in `JSON.stringify`, yielded by `elementCoordinates`) are pinning this promise on purpose. - **The JSON boundary (validation + hydration) fails closed.** When a contract is *deserialized*, the kind→schema registry validates every kind and the hydration dispatch constructs every kind; an unregistered kind is rejected with an error naming the kind and the namespace id. This is where corruption and typos are caught — at the point a contract enters from disk, not at every in-memory construction. The accepted trade-off: a typo'd kind in hand-authored TypeScript surfaces at emit-time boundary validation rather than at the `build*` call. Construction also accepts *input literals only* — the `Instance | Input` unions and their `v instanceof X ? v : new X(v)` normalization are gone (hydration constructs from validated JSON; authoring passes literals; the one caller that fed constructed instances was migrated). **Validation dispatches through one kind→schema registry.** This *is* the fail-closed boundary above. Validation walks `Object.entries(entries)` and validates each inner map against the schema registered for that kind. The SQL core registers `table` and `valueSet` into the same registry the postgres pack registers `type` into — one tier, no privileged built-in fallback — and the postgres pack owns `PostgresEnumTypeSchema` outright, so the family layer contains zero target-kind knowledge. A kind nobody registered fails validation with an error naming the kind and namespace id. Hydration is also kind-dispatched and fail-closed (the `hydrateEntriesKind` hook, which a target overrides per kind), and namespace **construction** carries unknown kinds and builds the kinds it owns — but both of those still use per-kind code (a `hydrateEntriesKind` hook and an `if (kind === …)` construction switch) rather than the validation registry. **Unifying construction and hydration onto one shared kind→factory registry is the explicit follow-up [TML-2890](https://linear.app/prisma-company/issue/TML-2890); this PR delivers the registry on the validation path only.** **One canonical lookup.** `entityAt(storage, coordinate)` lives beside `elementCoordinates` in the framework; the hand-rolled two-step lookups (emitter FK validation and siblings) are gone. **Docs follow the code**, and one ADR bug is fixed on the way: ADR 221 §115 stated the cross-plane reference invariant backwards. The correct direction — a domain entity may reference a storage entity, never the reverse, because the migration planner/runner must consume the storage plane in isolation — already misled one project design; the parenthetical now matches reality. ## One breaking change for downstream code The family-generic `SqlContractSerializer` no longer knows the postgres `type` kind (per ADR 225, the family carries no target knowledge), so it rejects postgres contracts with a clear error naming the kind. The fix is one line, and contract deserialization is now properly generic — no cast: ```ts // before const contract = new SqlContractSerializer(…).deserializeContract(json) as Contract; // after const contract = new PostgresContractSerializer(…).deserializeContract<Contract>(json); ``` SQLite and table-only contracts are unaffected. Upgrade instructions are recorded via the repo's upgrade-instructions process; the examples in this PR show the migration. The `as Contract` casts are swept from the demo, retail-store, and mongo-demo apps (three sites in `multi-extension-monorepo` are a recorded follow-up). ## What deliberately does not change - **Every persisted byte.** `storageHash` is content-addressed over the entries subtree, so the persisted shape — keys, key order, presence semantics — is preserved exactly. `pnpm fixtures:check` passes with zero committed-artifact diffs (and earned its keep: it caught the one hash-drift regression introduced mid-review and forced the correct fix). - **Node-body `kind` tags**, including the two that don't match their entries key (`'postgres-enum'` under `type`, `'mongo-collection'` under `collection`). They sit inside hash-covered node bodies, and nothing dispatches on them anymore — see Alternatives. - **`elementCoordinates`** — the framework's coordinate walker already read `entries` structurally; it is the consumer this refactor had to keep working unchanged, and it did. ## Verification All acceptance criteria carry committed tests: exact-shape serialization per concretion (getters absent from JSON), deep-freeze, unknown-kind rejection at construction/validation/hydration, round-trip unchanged, and a coordinate-resolution test asserting every `elementCoordinates` tuple resolves through `entries[entityKind][entityName]` for representative postgres, sqlite, and mongo contracts. Local gates: build, typecheck (138/138), lint (79/79), `lint:deps`, `fixtures:check` (clean tree, zero contract diffs); cast ratchet net negative vs merge-base. Integration suites run on this PR's CI. ## Known follow-ups (tracked, deliberately not in this PR) Review surfaced three refinements that are real but are each a distinct, higher-blast-radius change; bundling them into this already-large branch is what produced the one regression this PR hit, so they ship as focused PRs: - **[TML-2890](https://linear.app/prisma-company/issue/TML-2890) — Uniform kind dispatch in construction & hydration.** One shared kind→factory registry replacing the per-kind `if (kind === …)` switches in the 8 construction/hydration files, so dispatch is uniform everywhere, not just in validation. (RLS adds its `policy` construction as a registration rather than a switch branch once this lands — it is not blocked on it.) - **[TML-2891](https://linear.app/prisma-company/issue/TML-2891) — Eliminate the SQL family placeholder concretion.** Delete `SqlBoundNamespace`/`buildSqlNamespace`/`SqlNamespaceTablesInput`; SQL namespaces become always-target concretions; supabase emits `postgres-schema`. This is a wire-format change (committed contracts regenerate) and must land in isolation; the reverted item-G `instanceof` guard resolves here. - **[TML-2892](https://linear.app/prisma-company/issue/TML-2892) — Migration-author accessor.** A `Contract`/`Storage` accessor for collections/tables by name, so migrations stop reaching through `namespaces.__unbound__.entries.collection`. ## Out of scope (deliberate) RLS (its branch rebases onto this and deletes its workarounds); the migration planner; `ValueSetRef` (settled in #805); the domain plane's flat `models`/`valueObjects` shape and the namespace-kind serializer strings (recorded follow-ups); the ADR 225/126 rewrite batch. ## Alternatives considered - **A helper-function layer instead of intersection typing.** An earlier round of this PR shipped `namespaceTables()`/`namespaceValueSets()`/`namespaceCollections()` to give typed reads over a fully-widened `entries`. Rejected in review and deleted: the helpers were a parallel per-kind API duplicating the getters, and they existed only because the type had been over-widened — the intersection keeps the dictionary open while making plain property access type-safe. - **Keep the `Instance | Input` construction unions.** Also shipped early, also deleted: the `instanceof`-normalization they force is unreadable and hides which paths actually carry what. One shape per path needs no normalization. - **Rename the legacy node tags to match their entries key** (`'postgres-enum'` → `'type'`, `'mongo-collection'` → `'collection'`) so the kind is literally one string everywhere. Rejected: the tags are persisted inside hash-covered node bodies, so the rename would churn every committed contract and `storageHash` — and the postgres-enum machinery is deleted by the enums cutover (TML-2853) regardless. New kinds (e.g. `valueSet`, post-#805) use one string across coordinate, entries key, node tag, and registration from day one. - **Rename the entries keys to match the node tags.** Rejected: the keys are also persisted — strictly worse churn for the same goal. - **Keep the closed types and teach each consumer the kind list.** Rejected: that moves the kind→property mapping into every consumer and keeps pack-contributed kinds unreachable — the exact model ADR 224 rejects. - **Translate between kind and key at the serializer boundary, or carry a mapping field on contributions** (the `entrySlotName` approach the RLS branch had to invent). Rejected: both are the same translation table in disguise; ADR 224 explicitly rejects boundary reshaping, and the open dictionary makes the mapping unnecessary. **Linear:** TML-2887 --------- Signed-off-by: Will Madden <madden@prisma.io> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Co-authored-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 00eeeaf commit f35545b

143 files changed

Lines changed: 2831 additions & 1075 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/architecture docs/adrs/ADR 221 - Contract IR two planes with uniform entity coordinate and pack-contributed entity kinds.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ The coordinate `(plane, namespaceId, entityKind, entityName)` is the payoff. Wit
112112

113113
The `kind` is part of the address, not derived from the entity instance's runtime type. This matters in two places: consumers that diff raw JSON envelopes (without rehydrating to classes) can still dispatch, and two packs that happen to use overlapping name conventions for different kinds stay distinct.
114114

115-
The `plane` axis rides on the coordinate rather than being split across two separate per-plane coordinate types. This lets a cross-plane consumer address either side through one tuple type — most importantly the directional reference invariant (a storage entity may reference a domain entity, but not the reverse). That invariant is enforced by a dedicated validator; the coordinate simply carries the axis the validator reads.
115+
The `plane` axis rides on the coordinate rather than being split across two separate per-plane coordinate types. This lets a cross-plane consumer address either side through one tuple type — most importantly the directional reference invariant (a domain entity may reference a storage entity, but not the reverse — the storage plane must remain independently consumable by the migration planner/runner). That invariant is enforced by a dedicated validator; the coordinate simply carries the axis the validator reads.
116116

117117
### The walk is a free function, not an interface method
118118

docs/architecture docs/subsystems/1. Data Contract.md

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -144,27 +144,30 @@ At a high level. The contract encodes what exists (models, storage), how it can
144144
"storageHash": "sha256:…",
145145
"namespaces": {
146146
"public": {
147-
"tables": {
148-
"user": {
149-
"columns": {
150-
"id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false },
151-
"email": { "nativeType": "text", "codecId": "pg/text@1", "nullable": false },
152-
"active": { "nativeType": "bool", "codecId": "pg/bool@1", "nullable": false }
147+
"id": "public",
148+
"entries": {
149+
"table": {
150+
"user": {
151+
"columns": {
152+
"id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false },
153+
"email": { "nativeType": "text", "codecId": "pg/text@1", "nullable": false },
154+
"active": { "nativeType": "bool", "codecId": "pg/bool@1", "nullable": false }
155+
},
156+
"primaryKey": { "columns": ["id"], "name": "user_pkey" },
157+
"uniques": [ { "columns": ["email"], "name": "user_email_key" } ],
158+
"indexes": [],
159+
"foreignKeys": []
153160
},
154-
"primaryKey": { "columns": ["id"], "name": "user_pkey" },
155-
"uniques": [ { "columns": ["email"], "name": "user_email_key" } ],
156-
"indexes": [],
157-
"foreignKeys": []
158-
},
159-
"post": {
160-
"columns": {
161-
"id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false },
162-
"user_id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false }
163-
},
164-
"primaryKey": { "columns": ["id"], "name": "post_pkey" },
165-
"foreignKeys": [ { "columns": ["user_id"], "references": { "namespaceId": "public", "tableName": "user", "columns": ["id"] }, "name": "post_user_id_fkey" } ],
166-
"indexes": [ { "columns": ["user_id"], "name": "post_user_id_idx" } ],
167-
"uniques": []
161+
"post": {
162+
"columns": {
163+
"id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false },
164+
"user_id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false }
165+
},
166+
"primaryKey": { "columns": ["id"], "name": "post_pkey" },
167+
"foreignKeys": [ { "columns": ["user_id"], "references": { "namespaceId": "public", "tableName": "user", "columns": ["id"] }, "name": "post_user_id_fkey" } ],
168+
"indexes": [ { "columns": ["user_id"], "name": "post_user_id_idx" } ],
169+
"uniques": []
170+
}
168171
}
169172
}
170173
}
@@ -178,7 +181,7 @@ At a high level. The contract encodes what exists (models, storage), how it can
178181

179182
- **roots** map ORM accessor names to a model coordinate — an object pair `{ namespace, model }` (e.g., `"users": { "namespace": "public", "model": "User" }`)
180183
- **domain** holds the application concepts the user defines, under `domain.namespaces.<namespaceId>.{ models, valueObjects }`. A model carries fields with `{ nullable, codecId }`, relations whose `to` is an object pair `{ namespace, model }`, and a `storage` bridge to the physical layer. The framework domain plane has **no** `types` member — codec aliases live on the SQL storage plane.
181-
- **storage** holds the family-owned persistence projection, under `storage.namespaces.<namespaceId>.{ tables | collections | <pack-contributed kind> }`, plus plane-level metadata (`storageHash`; on the SQL family only, a doc-scoped `types` map). Cross-namespace foreign-key references are object pairs `{ namespaceId, tableName, columns }`.
184+
- **storage** holds the family-owned persistence projection. Each namespace carries an open `entries` dictionary keyed by entity kind — `storage.namespaces.<namespaceId>.entries[entityKind][entityName]`plus plane-level metadata (`storageHash`). Built-in entity kinds are `table` (SQL family), `valueSet` (SQL family), `collection` (Mongo family); pack-contributed kinds (e.g. `type` for Postgres enum types) register via the serializer registry. Cross-namespace foreign-key references are object pairs `{ namespaceId, tableName, columns }`.
182185
- **capabilities** surface target features that affect compilation/lowering (e.g., lateral)
183186
- **Types (.d.ts)** expose branded TS types for contract type identifiers; at runtime, codecs provided by packs/adapters handle encode/decode ([ADR 114 — Extension codecs & branded types](../adrs/ADR%20114%20-%20Extension%20codecs%20&%20branded%20types.md))
184187
- Authoring provenance (paths/source IDs/spans) is **diagnostics-only**; it is not embedded in canonical artifacts
@@ -205,7 +208,7 @@ The on-disk JSON shape above is canonical for persistence, identity, and hashing
205208

206209
Per the [three-layer polymorphic IR](../patterns/three-layer-polymorphic-ir.md) and [frozen-class AST](../patterns/frozen-class-ast.md) patterns, class fields are plain readonly properties (JSON-clean by construction); concrete classes call `freezeNode(this)` in their constructor. The on-disk JSON envelope is reached through the per-target `ContractSerializer.serializeContract(contract): JsonObject` SPI method — runtime-only class fields (e.g. `MongoTargetStorage.namespaces`) stay enumerable on the in-memory instance and the serializer elides them on the way out. The inverse direction — `descriptor.contractSerializer.deserializeContract(json)` — is the typed hydration path that replaces the previous standalone `validateContract(json)` usage in framework-internal hydration call sites (the supported public surface remains the serializer API on the execution context).
207210

208-
**Polymorphic `storage.types`.** `Contract.storage.types[name]` is the inflection point where target-specific IR kinds and family-shared codec-typed entries coexist. Enum entries (the first concrete consumer) carry a literal `kind: 'sql-enum-type'` and hydrate into target subclasses (`PostgresEnumType`); codec-typed entries (pgvector, decimal, varchar parameterized types) inherit the family base's non-enumerable `kind` and continue through the existing codec-typed `StorageTypeInstance` path unchanged. The hydration walker dispatches on shape/kind. Adding the next target-specific IR kind — Postgres RLS policies, MongoDB Atlas-only collection options — follows the same recipe without touching the framework or family layers.
211+
**Pack-contributed entity kinds in `entries`.** The `entries` dictionary is open: built-in kinds (`table`, `valueSet`, `collection`) are registered by the family serializer base; target packs add extra kinds under their own key (e.g. the Postgres pack registers `type``PostgresEnumType`, persisted as `kind: 'postgres-enum'`). Validation and hydration dispatch through a registry keyed by the entries key — an unregistered kind is a validation error that names the kind. Codec-typed named storage types (`storage.types`) were a prior representation; they are superseded by the `entries` dictionary and the pack-registration mechanism. Adding a new target-specific entity kind follows the same registry-registration recipe without touching the framework or family layers.
209212

210213
### Cross-family contract design
211214

docs/architecture docs/subsystems/2. Contract Emitter & Types.md

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -94,32 +94,39 @@ Emitted contract.json (excerpt):
9494
}
9595
},
9696
"storage": {
97-
"tables": {
98-
"user": {
99-
"columns": {
100-
"id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false },
101-
"email": { "nativeType": "text", "codecId": "pg/text@1", "nullable": false },
102-
"active": { "nativeType": "bool", "codecId": "pg/bool@1", "nullable": false },
103-
"created_at": { "nativeType": "timestamptz", "codecId": "pg/timestamptz@1", "nullable": false }
104-
},
105-
"primaryKey": { "columns": ["id"], "name": "user_pkey" },
106-
"uniques": [ { "columns": ["email"], "name": "user_email_key" } ],
107-
"indexes": [],
108-
"foreignKeys": []
109-
},
110-
"post": {
111-
"columns": {
112-
"id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false },
113-
"title": { "nativeType": "text", "codecId": "pg/text@1", "nullable": false },
114-
"user_id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false }
115-
},
116-
"primaryKey": { "columns": ["id"], "name": "post_pkey" },
117-
"foreignKeys": [ { "columns": ["user_id"], "references": { "table": "user", "columns": ["id"] }, "name": "post_user_id_fkey" } ],
118-
"indexes": [ { "columns": ["user_id"], "name": "post_user_id_idx" } ],
119-
"uniques": []
97+
"storageHash": "sha256:…",
98+
"namespaces": {
99+
"__unbound__": {
100+
"id": "__unbound__",
101+
"entries": {
102+
"table": {
103+
"user": {
104+
"columns": {
105+
"id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false },
106+
"email": { "nativeType": "text", "codecId": "pg/text@1", "nullable": false },
107+
"active": { "nativeType": "bool", "codecId": "pg/bool@1", "nullable": false },
108+
"created_at": { "nativeType": "timestamptz", "codecId": "pg/timestamptz@1", "nullable": false }
109+
},
110+
"primaryKey": { "columns": ["id"], "name": "user_pkey" },
111+
"uniques": [ { "columns": ["email"], "name": "user_email_key" } ],
112+
"indexes": [],
113+
"foreignKeys": []
114+
},
115+
"post": {
116+
"columns": {
117+
"id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false },
118+
"title": { "nativeType": "text", "codecId": "pg/text@1", "nullable": false },
119+
"user_id": { "nativeType": "int4", "codecId": "pg/int4@1", "nullable": false }
120+
},
121+
"primaryKey": { "columns": ["id"], "name": "post_pkey" },
122+
"foreignKeys": [ { "columns": ["user_id"], "references": { "namespaceId": "__unbound__", "tableName": "user", "columns": ["id"] }, "name": "post_user_id_fkey" } ],
123+
"indexes": [ { "columns": ["user_id"], "name": "post_user_id_idx" } ],
124+
"uniques": []
125+
}
126+
}
127+
}
120128
}
121-
},
122-
"storageHash": "sha256:…"
129+
}
123130
},
124131
"profileHash": "sha256:…"
125132
}
@@ -246,7 +253,7 @@ export type Namespaces = Contract['storage']['namespaces'];
246253
export type Models = ContractModelDefinitions<Contract>;
247254
```
248255

249-
Both planes are namespaced: storage tables live under `storage.namespaces.<ns>.tables` and models under `domain.namespaces.<ns>.models` (see [Data Contract § Two planes, namespaced](1.%20Data%20Contract.md#two-planes-namespaced) and [ADR 221](../adrs/ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md)). The convenience aliases the emitter writes are **single-namespace projections**: `Models = ContractModelDefinitions<Contract>` resolves the contract's sole domain namespace's models, and the emitter guards this with `assertSingleDomainNamespaceForEmission` — it **throws** on a multi-namespace contract rather than silently projecting one namespace. Per-namespace `contract.d.ts` emission for multi-namespace contracts is co-designed with the explicit namespace-aware DSL ([TML-2550](https://linear.app/prisma-company/issue/TML-2550)); until then, extension authors with multiple namespaces target explicit namespace paths in hand-authored types. See [ADR 223 — Target-owned default namespace](../adrs/ADR%20223%20-%20Target-owned%20default%20namespace.md) for the runtime/emitter fail-loud split.
256+
Both planes are namespaced: storage entities live under `storage.namespaces.<ns>.entries[entityKind][entityName]` (e.g. `entries['table']['user']`) and models under `domain.namespaces.<ns>.models` (see [Data Contract § Two planes, namespaced](1.%20Data%20Contract.md#two-planes-namespaced) and [ADR 221](../adrs/ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md)). The convenience aliases the emitter writes are **single-namespace projections**: `Models = ContractModelDefinitions<Contract>` resolves the contract's sole domain namespace's models, and the emitter guards this with `assertSingleDomainNamespaceForEmission` — it **throws** on a multi-namespace contract rather than silently projecting one namespace. Per-namespace `contract.d.ts` emission for multi-namespace contracts is co-designed with the explicit namespace-aware DSL ([TML-2550](https://linear.app/prisma-company/issue/TML-2550)); until then, extension authors with multiple namespaces target explicit namespace paths in hand-authored types. See [ADR 223 — Target-owned default namespace](../adrs/ADR%20223%20-%20Target-owned%20default%20namespace.md) for the runtime/emitter fail-loud split.
250257

251258
### Codec type map emission (types‑only)
252259

@@ -271,13 +278,13 @@ Notes:
271278

272279
Parameterized codecs (e.g. `pg/vector@1`, `arktype/json@1`) ship a `CodecDescriptor<P>` with `paramsSchema: StandardSchemaV1<P>`, optional `renderOutputType: (params: P) => string`, and a curried `factory: (params: P) => (ctx: CodecInstanceContext) => Codec`. The descriptor unifies what previous iterations split across the codec object's optional `paramsSchema?` / `init?` / `renderOutputType?` slots and per-codec hand-rolled column-helper factories. Non-parameterized codecs are the degenerate `P = void` case authored as `class extends CodecDescriptorImpl<void>` with a constant factory that returns the same shared codec instance for every column (see [ADR 208](../adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) for the unified authoring pattern). The descriptor map is the single read source for codec-id-keyed metadata across both shapes.
273280

274-
The emit path consults `renderOutputType` via the framework's `CodecLookup`. Inline `field.type.typeParams` always wins when present; the framework only consults the resolver as a fallback for `typeRef`-backed columns whose inline `typeParams` is absent. The family-specific emitter (e.g. SQL) implements `EmissionSpi.resolveFieldTypeParams(modelName, fieldName, model, contract)` to walk `model.storage.fields → storage.namespaces.<ns>.tables → storage.types` (the doc-scoped `types` map is plane-level) and return the named instance's `typeParams`, so typeRef-backed columns render with the same fidelity as inline-`typeParams` columns. Mongo and other families that don't use named storage types simply don't implement the optional hook.
281+
The emit path consults `renderOutputType` via the framework's `CodecLookup`. Inline `field.type.typeParams` always wins when present; the framework only consults the resolver as a fallback for `typeRef`-backed columns whose inline `typeParams` is absent. The family-specific emitter (e.g. SQL) implements `EmissionSpi.resolveFieldTypeParams(modelName, fieldName, model, contract)` to walk `model.storage.fields → storage.namespaces.<ns>.entries['table'][tableName]` and return the named instance's `typeParams`, so typeRef-backed columns render with the same fidelity as inline-`typeParams` columns. Mongo and other families that don't use named storage types simply don't implement the optional hook.
275282

276283
### Rehydration via the per-target `ContractSerializer` SPI
277284

278285
In-memory contracts are class hierarchies (see [Data Contract § In-memory representation](1.%20Data%20Contract.md#in-memory-representation-polymorphic-ir-class-hierarchy)). Hydration from JSON and serialization back to JSON both go through the per-target `ContractSerializer<TContract>` SPI exposed on the target descriptor:
279286

280-
- `descriptor.contractSerializer.deserializeContract(json: unknown): TContract` — replaces the previous standalone `validateContract(json)` function at every framework-internal call site. Family-shared arktype validation lives on `SqlContractSerializerBase` / `MongoContractSerializerBase`; per-target subclasses construct the concrete class instances (e.g. `PostgresEnumType` for `kind: 'sql-enum-type'` entries in `storage.types`) via protected hooks.
287+
- `descriptor.contractSerializer.deserializeContract(json: unknown): TContract` — replaces the previous standalone `validateContract(json)` function at every framework-internal call site. Family-shared arktype validation lives on `SqlContractSerializerBase` / `MongoContractSerializerBase`; per-target subclasses register additional entity kinds in the entries registry and construct the concrete class instances (e.g. `PostgresEnumType` for `entries['type']` entries carrying `kind: 'postgres-enum'`) via protected hooks.
281288
- `descriptor.contractSerializer.serializeContract(contract: TContract): JsonObject` — owns the on-disk JSON envelope shape. Runtime-only class fields (fields that exist on the in-memory IR for behaviour but should not appear on disk; current example: `MongoTargetStorage.namespaces`) stay enumerable on instances and the serializer elides them.
282289

283290
The framework's `canonicalizeContractToObject` accepts the target's `serializeContract` as an optional hook (threaded from the descriptor at the CLI) and uses it to convert in-memory class instances to a plain `JsonObject` before applying the family-agnostic key-ordering / default-omission / sort steps. Targets that gain runtime-only IR fields in future work override `serializeContract` to construct the persisted shape; they do not reach for non-enumerable property tricks on the class layer.

0 commit comments

Comments
 (0)