Commit f35545b
TML-2887: namespace
**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>entries becomes an open, kind-keyed dictionary (ADR 224/225) (#812)1 parent 00eeeaf commit f35545b
143 files changed
Lines changed: 2831 additions & 1075 deletions
File tree
- docs
- architecture docs
- adrs
- subsystems
- reference
- examples
- mongo-demo/test
- prisma-next-demo
- migrations/app/20260422T0748_migration
- src/app
- test
- retail-store
- migrations/app
- 20260513T0505_initial
- 20260513T0508_backfill_product_status
- test
- supabase/test
- packages
- 1-framework/1-core/framework-components
- src
- exports
- ir
- test
- 2-mongo-family
- 1-foundation/mongo-contract
- src
- exports
- ir
- test
- 2-authoring
- contract-psl/src
- contract-ts
- src
- test
- 3-tooling/emitter/src
- 9-family
- src/core
- ir
- test
- 2-sql
- 1-core/contract
- src
- exports
- ir
- test
- 2-authoring
- contract-psl/test
- contract-ts
- src
- test
- 3-tooling/emitter
- src
- test
- 4-lanes/sql-builder/src
- runtime
- types
- 5-runtime
- src
- codecs
- test
- 9-family
- src/core
- ir
- migrations
- schema-verify
- test
- 3-extensions
- pgvector/test
- postgres/test
- sql-orm-client
- src
- test
- supabase/test
- 3-mongo-target/1-mongo-target
- src/core
- test
- 3-targets
- 3-targets
- postgres
- src
- core
- migrations
- exports
- test
- sqlite
- src/core
- migrations
- test
- 6-adapters/postgres
- src/core
- test
- skills
- extension-author/prisma-next-extension-upgrade/upgrades/0.13-to-0.14
- upgrade/prisma-next-upgrade/upgrades/0.13-to-0.14
- test/integration/test
- authoring
- contract-space-fixture-mongo
- contract-space-fixture
- cross-package
- mongo
- sql-builder
- utils
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
115 | | - | |
| 115 | + | |
116 | 116 | | |
117 | 117 | | |
118 | 118 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
144 | 144 | | |
145 | 145 | | |
146 | 146 | | |
147 | | - | |
148 | | - | |
149 | | - | |
150 | | - | |
151 | | - | |
152 | | - | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
153 | 160 | | |
154 | | - | |
155 | | - | |
156 | | - | |
157 | | - | |
158 | | - | |
159 | | - | |
160 | | - | |
161 | | - | |
162 | | - | |
163 | | - | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
168 | 171 | | |
169 | 172 | | |
170 | 173 | | |
| |||
178 | 181 | | |
179 | 182 | | |
180 | 183 | | |
181 | | - | |
| 184 | + | |
182 | 185 | | |
183 | 186 | | |
184 | 187 | | |
| |||
205 | 208 | | |
206 | 209 | | |
207 | 210 | | |
208 | | - | |
| 211 | + | |
209 | 212 | | |
210 | 213 | | |
211 | 214 | | |
| |||
Lines changed: 35 additions & 28 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
99 | | - | |
100 | | - | |
101 | | - | |
102 | | - | |
103 | | - | |
104 | | - | |
105 | | - | |
106 | | - | |
107 | | - | |
108 | | - | |
109 | | - | |
110 | | - | |
111 | | - | |
112 | | - | |
113 | | - | |
114 | | - | |
115 | | - | |
116 | | - | |
117 | | - | |
118 | | - | |
119 | | - | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
120 | 128 | | |
121 | | - | |
122 | | - | |
| 129 | + | |
123 | 130 | | |
124 | 131 | | |
125 | 132 | | |
| |||
246 | 253 | | |
247 | 254 | | |
248 | 255 | | |
249 | | - | |
| 256 | + | |
250 | 257 | | |
251 | 258 | | |
252 | 259 | | |
| |||
271 | 278 | | |
272 | 279 | | |
273 | 280 | | |
274 | | - | |
| 281 | + | |
275 | 282 | | |
276 | 283 | | |
277 | 284 | | |
278 | 285 | | |
279 | 286 | | |
280 | | - | |
| 287 | + | |
281 | 288 | | |
282 | 289 | | |
283 | 290 | | |
| |||
0 commit comments