Skip to content

feat(prisma-cloud): pnPostgres dependencies hydrate to { url, client } with a lazy client (ADR-0040)#154

Merged
wmadden-electric merged 5 commits into
mainfrom
claude/lazy-pn-binding
Jul 22, 2026
Merged

feat(prisma-cloud): pnPostgres dependencies hydrate to { url, client } with a lazy client (ADR-0040)#154
wmadden-electric merged 5 commits into
mainfrom
claude/lazy-pn-binding

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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 —

const service = compute({
  deps: { db: pnPostgres(appContract) },  // contract-checked, deploy-migrated
  /* … */
});

const { db } = service.load();
const pool = new pg.Pool({ connectionString: db.url }); // its own client

— while a service that wants the framework-built client reads the other field:

const products = await db.client.orm.public.Product.all();  // was: db.orm.…

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 plain postgres()'s { url }. The compatibility check is untouched — the dependency still names the contract, and satisfies still compares storage hashes.

Laziness is the second half. The Prisma Next runtime validates contractJson when 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 whole load() with an error naming no input. Construction now happens inside the first client access: 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: url passed through, client a memoized getter over the existing buildClient. New exported PnPostgresBinding<Ct>; the dependency overload returns DependencyEnd<PnPostgresBinding<C>, C>.
  • Consumers updated mechanically (db.orm.…db.client.orm.…): examples/pn-widgets, examples/store catalog + orders, and the two doc lines quoting the expression.
  • Tests: shape tests assert the new Hydrated<> surface; unit tests prove url passthrough, frozen binding, memoization, and — by hydrating a contract the runtime rejects, no spies — that reading url succeeds while only the first client access throws. The integration test queries through db.client.orm against live Postgres.
  • Docs: ADR-0040 + index entry; 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 typecheck across the target package and touched examples: green.
  • Target package tests: 253 pass (incl. live PN integration); type-tests: 9 pass, no type errors; cast ratchet at delta 0.
  • Proving consumer — ran, green: prisma/open-chat#1 (commit 5aa700a) switched from plain postgres() + by-hand schema application to pnPostgres. 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 only db.url; the lazy client is never constructed.

Alternatives considered

  • Cross-kind 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 loses sql, transaction, close, and future client surface for no gain. Rejected.
  • Eager client + url beside 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 inside hydrate) to { url, client }:

  • url — the raw connection string, exactly what plain postgres() delivers.
  • client — the typed Prisma Next client, built lazily and memoized on
    first 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) provision pnPostgres
(framework-run migrations at deploy, ADR-0022) and read db.url, never
paying for client construction; typed consumers read db.client.orm.….

Chosen design

  • In packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts, the
    dependency end's hydrate: ({ url }) => buildClient(contract, url) becomes
    a hydrate returning a frozen binding object with a plain url field and a
    memoizing client getter that calls the existing buildClient on first
    access. New exported binding type (e.g. PnPostgresBinding<C> =
    { readonly url: string; readonly client: Client<C> }); the dependency
    overload's return becomes DependencyEnd<PnPostgresBinding<C>, C>.
  • Laziness is the point, not a nicety: pnPostgresRuntime() deserializes and
    structurally validates contractJson at construction (its pool is already
    lazy, 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 in
    open-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 client access and attribute to the db input.
  • The contract remains the compatibility interface (ADR-0022 unchanged):
    required: contract, satisfies hash check, and deploy-side PnMigration
    are untouched. The binding widens; the edge does not.
  • Examples (pn-widgets, store/catalog, store/orders) update
    mechanically: db.orm.…db.client.orm.….
  • ADR-0040 argues the principle fit: "data contracts are the interface for
    data resources" governs whether a resource plugs in (the hash check),
    not which client reads it; plain postgres() already treats the raw URL
    as 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:

  • ADR-0040.
  • The binding change + exported type in prisma-next.ts.
  • Example updates (pn-widgets, store), type-shape tests
    (prisma-next-shapes.test-d.ts), unit/integration tests covering: url
    present and equal to the wire value; client not constructed when only url
    is touched; client memoized; validation failure surfaces at first client
    access (not at load()), and does not prevent reading url or sibling
    inputs.
  • Docs: docs/guides/building-an-app.md §pnPostgres() snippet.

Deliberately out:

  • The open-chat flip (separate repo, lands on Run open-chat as a native Prisma Composer app open-chat#1 as the
    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).
  • Cross-kind contract satisfaction (a 'prisma-next' resource satisfying a
    'postgres' dependency) — superseded by this design for the motivating
    case; not needed.
  • Any change to PnMigration / deploy lowering.

Pre-investigated edge cases

Case Knowledge source
Contract validation is eager inside pnPostgresRuntime() even though the pool is lazy — deferral must wrap the whole buildClient, not just connection open-chat FRICTION #2 stack trace (deserializeContract … at postgres … at hydrateSync)
A version-skewed contract (0.13-emitted vs 0.15 runtime) is the real-world failure the lazy path must attribute usefully open-chat FRICTION #2
hydrateSync hydrates all inputs in one pass; today one throw hides which input failed open-chat FRICTION #2 "compounding effect"

Slice-specific done conditions

  • A consumer touching only binding.url never 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 orm alone, so sql/transaction/close
stay reachable).

References

🤖 Generated with Claude Code

wmadden-electric and others added 2 commits July 22, 2026 15:57
…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>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@wmadden-electric, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 807b666f-dd2c-44f0-b586-bdfea7c40c03

📥 Commits

Reviewing files that changed from the base of the PR and between c26200b and e364380.

📒 Files selected for processing (15)
  • docs/design/90-decisions/ADR-0040-the-pn-binding-carries-the-url-and-a-lazy-client.md
  • docs/design/90-decisions/README.md
  • docs/guides/building-an-app.md
  • examples/pn-widgets/src/server.ts
  • examples/pn-widgets/src/service.ts
  • examples/store/DEMO.md
  • examples/store/README.md
  • examples/store/modules/catalog/src/server.ts
  • examples/store/modules/catalog/src/service.ts
  • examples/store/modules/orders/src/server.ts
  • examples/store/modules/orders/src/service.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next-shapes.test-d.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next.integration.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/lazy-pn-binding
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/lazy-pn-binding

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@154
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@154

commit: e364380

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>
@wmadden-electric wmadden-electric changed the title feat(prisma-cloud): the pn dependency binding carries the url and a lazy client (ADR-0040) feat(prisma-cloud): pnPostgres dependencies hydrate to { url, client } with a lazy client (ADR-0040) Jul 22, 2026
@wmadden-electric
wmadden-electric merged commit d0f3f07 into main Jul 22, 2026
13 checks passed
@wmadden-electric
wmadden-electric deleted the claude/lazy-pn-binding branch July 22, 2026 14:19
wmadden-electric added a commit to prisma/open-chat that referenced this pull request Jul 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants