feat(prisma-cloud): pnPostgres dependencies hydrate to { url, client } with a lazy client (ADR-0040)#154
Conversation
…ient
The prisma-next dependency binding becomes { url, client }: the raw
connection string beside the typed client, with the client constructed
lazily and memoized on first access — hydrate builds nothing. The contract
stays the compatibility interface (hash check and deploy-time migration
unchanged); the binding becomes a strict superset of plain postgres()'s
{ url }, so an app that owns its database client still gets framework-run
migrations. Validation cost and failure move from load() — where one bad
input poisoned every input, unattributed — to the first client access.
Motivated by the open-chat port: one pg.Pool shared with a non-Prisma-Next
consumer, framework migrations wanted, "migrations + my own client"
previously inexpressible. Cross-kind satisfaction rejected in its favor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…azy client
pnPostgres(contract) now hydrates to a frozen { url, client } binding
(ADR-0040): url is the wire value passed through; client is the typed
Prisma Next client, constructed by the existing buildClient on first
access and memoized in the getter's closure — hydrate builds nothing.
New exported PnPostgresBinding<Ct>; the dependency overload returns
DependencyEnd<PnPostgresBinding<C>, C>.
The contract remains the compatibility interface: satisfies, the hash
check, and deploy-time migration are untouched. The binding becomes a
strict superset of plain postgres()'s { url }, so an app that owns its
database client gets framework-run migrations without adopting the
framework-built client.
The PN runtime validates contractJson eagerly at construction, so the
old eager hydrate charged URL-only consumers for validation they never
needed and let one bad contract poison the whole load() with an error
naming no input. Both now surface at the first client access instead:
tested by hydrating a contract the runtime rejects — url reads fine,
only client throws. Consumers updated mechanically (db.orm.… →
db.client.orm.…): pn-widgets, store catalog + orders, shape/unit/
integration tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
|
Warning Review limit reached
Next review available in: 41 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (15)
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
commit: |
Open with the consumer situation (own pg.Pool, shared with a non-PN library) and a before/after-shaped snippet rather than the bare accessor pair; rebuild the reasoning to walk binding-was-the-client → who that excluded → why widening is safe → why lazy; replace references to a specific port and a one-time incident with the timeless facts they instantiate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…6 (D6)
Flip the database edge from plain postgres() to the contract-carrying
pnPostgres: the resource is pnPostgres({ name: "database", contract,
config }) and the service consumes pnPostgres(chatData), so the deploy
migrates the freshly provisioned database itself (a PrismaNext.Migration
resource, [database-migrate] in the plan) before the chat service starts.
The launcher reads only db.url from the { url, client } binding
(prisma/composer#154, ADR-0040) — open-chat keeps its own pg.Pool shared
with Better Auth, and the framework-built typed client is never
constructed. FRICTION.md #12's by-hand schema step is gone.
The app toolchain moves to @prisma-next 0.15 (the version the framework
bundles): contract re-emitted, migrations store regenerated as
migrations/app/20260722T1415_init/ (from: null to the re-emitted
contract's hash; the 0.13-vintage refs are deleted). Two mechanical
consequences in app code: regenerated src/prisma/contract.{json,d.ts},
and the 0.15 runtime's schema-namespaced ORM paths (db.orm.<Model> →
db.orm.public.<Model>, 32 call sites, no behavioral change).
Composer packages pin npm 0.2.0-dev.6. The @effect/platform-* dev
dependencies added as a deploy workaround are removed —
@prisma/composer-prisma-cloud carries them itself since
prisma/composer#151 (FRICTION.md #11). New FRICTION.md #13 records a bun
resolver failure when two pkg.pr.new previews reference each other,
hit while proving against the PR preview and moot on npm pins.
Deployed fresh (destroy + deploy, zero manual steps) and verified end to
end on the deploy-reported URL: [database-migrate] ran in the deploy;
shell + client asset 200; guest sign-in 200 against the brand-new
database with no schema step ever run; chat create 201 with the row in
Postgres; SSE tail ready + 4 heartbeats over ~22s; Stripe webhook 400
unsigned / 400 bad-signature / 405 GET; message generation stops at
OpenRouter's auth boundary with the placeholder key. Local dev loop
re-verified on the npm pin (shell + sign-in 200).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A
pnPostgres(contract)dependency now hydrates to{ url, client }— the raw connection string beside the typed client — and the client is constructed on first access instead of during hydration. Recorded as ADR-0040.What it looks like: a service that builds its own
pg.Pool(for example to share it with an auth library that is not a Prisma Next consumer) can now declare the contract-carrying dependency and read only the URL —— while a service that wants the framework-built client reads the other field:
Why
Until now the binding was the typed client, so the only way to consume a contract-carrying database was through the framework-built client. An app that owns its client had to fall back to plain
postgres()— which loses framework-run migrations entirely: the schema has to be applied by hand after every fresh deploy, against a URL the deploy resolves internally but never surfaces. "Contract-checked and deploy-migrated" and "framework-built client" are separate wants, and the binding can serve both: the PN binding becomes a strict superset of plainpostgres()'s{ url }. The compatibility check is untouched — the dependency still names the contract, andsatisfiesstill compares storage hashes.Laziness is the second half. The Prisma Next runtime validates
contractJsonwhen the client is constructed, and hydration resolves every dependency in one synchronous pass — so a URL-only consumer paid validation it never needed, and one contract the runtime's validator rejects (e.g. emitted by a different toolchain version) failed the wholeload()with an error naming no input. Construction now happens inside the firstclientaccess: the cost disappears for URL-only consumers, and a validation failure surfaces at the access that wanted the client.What changed
packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts— the dependency end hydrates to a frozen binding:urlpassed through,clienta memoized getter over the existingbuildClient. New exportedPnPostgresBinding<Ct>; the dependency overload returnsDependencyEnd<PnPostgresBinding<C>, C>.db.orm.…→db.client.orm.…):examples/pn-widgets,examples/storecatalog + orders, and the two doc lines quoting the expression.Hydrated<>surface; unit tests prove url passthrough, frozen binding, memoization, and — by hydrating a contract the runtime rejects, no spies — that readingurlsucceeds while only the firstclientaccess throws. The integration test queries throughdb.client.ormagainst live Postgres.building-an-app.md§pnPostgres()shows the new shape. ADR-0022's snippet is left as a historical record; ADR-0040 records the amendment.Verification
turbo typecheckacross the target package and touched examples: green.postgres()+ by-hand schema application topnPostgres. On a fresh cloud stack the deploy ran the migration itself ([database-migrate]in the plan) and guest sign-in returned 200 against the brand-new database with zero manual steps; full end-to-end suite green (chat -> Postgres, SSE heartbeats, Stripe webhook rejections, OpenRouter auth boundary). The launcher reads onlydb.url; the lazy client is never constructed.Alternatives considered
satisfies(a'prisma-next'resource satisfying a plain'postgres'dependency): touches core's kind model to express a binding concern, and a naive rule would let a PN resource satisfy unrelated kinds. Rejected.{ url, orm }— exposing only the ORM losessql,transaction,close, and future client surface for no gain. Rejected.urlbeside it — keeps the validation tax and the all-inputs failure mode for URL-only consumers. Rejected.Full reasoning in the ADR.
Slice contract
Slice spec (drive-process artifact)
Slice spec — lazy PN binding carries the raw URL
At a glance
pnPostgres(contract)'s dependency binding changes from the bare typed client(
Client<C>, built eagerly insidehydrate) to{ url, client }:url— the raw connection string, exactly what plainpostgres()delivers.client— the typed Prisma Next client, built lazily and memoized onfirst property access, not inside
hydrate.ADR-0040 records the decision. Consumers that own their database client (a
shared
pg.Pool, an ORM the framework didn't build) provisionpnPostgres(framework-run migrations at deploy, ADR-0022) and read
db.url, neverpaying for client construction; typed consumers read
db.client.orm.….Chosen design
packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts, thedependency end's
hydrate: ({ url }) => buildClient(contract, url)becomesa hydrate returning a frozen binding object with a plain
urlfield and amemoizing
clientgetter that calls the existingbuildClienton firstaccess. New exported binding type (e.g.
PnPostgresBinding<C>={ readonly url: string; readonly client: Client<C> }); the dependencyoverload's return becomes
DependencyEnd<PnPostgresBinding<C>, C>.pnPostgresRuntime()deserializes andstructurally validates
contractJsonat construction (its pool is alreadylazy, its validation is not). Today that validation runs inside
hydrateSync, so (a) a consumer that only wants the URL still pays it, and(b) one bad input poisons the whole
load()call — both recorded inopen-chat FRICTION.md MVP example-app: design refinements + Compute provider + workspace/Auth (Slices 1–2) #2. After this slice, validation cost and validation
failure both move to first
clientaccess and attribute to thedbinput.required: contract,satisfieshash check, and deploy-sidePnMigrationare untouched. The binding widens; the edge does not.
pn-widgets,store/catalog,store/orders) updatemechanically:
db.orm.…→db.client.orm.….data resources" governs whether a resource plugs in (the hash check),
not which client reads it; plain
postgres()already treats the raw URLas a first-class binding value.
Coherence rationale
One reviewer sitting: one binding shape change in one file, its type export,
an ADR, and mechanical example/test updates. Rollback is one revert.
Scope
In:
prisma-next.ts.pn-widgets,store), type-shape tests(
prisma-next-shapes.test-d.ts), unit/integration tests covering: urlpresent and equal to the wire value; client not constructed when only
urlis touched; client memoized; validation failure surfaces at first
clientaccess (not at
load()), and does not prevent readingurlor siblinginputs.
docs/guides/building-an-app.md§pnPostgres()snippet.Deliberately out:
proving consumer once a build carrying this slice exists; includes contract
re-emit with the aligned prisma-next CLI and migrations-store update — per
operator decision 2026-07-22).
'prisma-next'resource satisfying a'postgres'dependency) — superseded by this design for the motivatingcase; not needed.
PnMigration/ deploy lowering.Pre-investigated edge cases
pnPostgresRuntime()even though the pool is lazy — deferral must wrap the wholebuildClient, not just connectiondeserializeContract … at postgres … at hydrateSync)hydrateSynchydrates all inputs in one pass; today one throw hides which input failedSlice-specific done conditions
binding.urlnever constructs the PN client(proven by test, not asserted in prose).
Open questions
None — design settled with operator 2026-07-22 (binding shape
{ url, client }with full client rather than
ormalone, sosql/transaction/closestay reachable).
References
params), architectural principle "Data contracts are the interface for
data resources".
consumer.
🤖 Generated with Claude Code