Skip to content

feat(app-cloud): pnPostgres — Prisma Next-typed postgres primitive (slice 1) - #44

Merged
wmadden-electric merged 13 commits into
mainfrom
claude/prisma-next-support-8a91c6
Jul 11, 2026
Merged

feat(app-cloud): pnPostgres — Prisma Next-typed postgres primitive (slice 1)#44
wmadden-electric merged 13 commits into
mainfrom
claude/prisma-next-support-8a91c6

Conversation

@wmadden-electric

Copy link
Copy Markdown
Contributor

What & why

Introduces pnPostgres, a Prisma Next-typed postgres primitive for @prisma/app-cloud — the first half of delivering the "data contracts are Prisma Next" promise the README/glossary already make but the code never implemented (today's bare postgres() binding is { url } and satisfies compares kind only).

A service that depends on pnPostgres(contract) gets a contract-typed Prisma Next client injected at load() — typed to the schema, built by the framework in hydrate. Bare postgres() is untouched: it stays the untyped escape hatch (the any of data deps).

This is slice 1 of 2. It lands the primitive + ADR-0021, proven live against real Postgres. Slice 2 adds the deploy-time migration lowering (control.ts).

Design record: ADR-0021. Tracking: TML-3009.

Design (settled with the operator)

  • Consume the contract; locate the config. The app build imports only the emitted contract (contract.json data + contract.d.ts types — lightweight, safe to bundle). It never imports prisma-next.config.ts — that would pull Prisma Next's CLI/migration machinery into the user's bundle. The config reaches the deploy lowering as a path (slice 2).
  • Binding is the typed clientpostgres<Contract>({ contractJson, url }) from @prisma-next/postgres/runtime, lazy pool. This amends ADR-0015: Prisma Next is framework-blessed like rpc, so the contract alone constructs the client.
  • No runtime schema check. Schema correctness is enforced at deploy (slice 2 migrates the DB to the contract's storageHash or fails). The runtime binding just builds the client — so a running service can never be crashed by a marker check.
  • Exact typing. The contract's branded storageHash literal makes wiring assignability version-exact; satisfies() mirrors it at Load with a hash comparison.
  • Opt-out is real at runtime. Ships behind a dedicated ./prisma-next subpath entry, never re-exported from the index — a service that doesn't import it never loads @prisma-next/* or pg.

What's in this PR

  • packages/app-cloud/src/prisma-next.ts — the prisma-next Contract kind, pnContract(), and the pnPostgres() resource ({ name, contract }) / dependency (pnPostgres(contract)) overloads, mirroring rpc.ts.
  • Package wiring: @prisma-next/postgres dep + the ./prisma-next subpath entry (tsdown).
  • Test fixtures (widget/gadget) built from a real prisma-next contract emit.
  • Unit + type tests (assignability exactness, satisfies incl. missing/malformed-hash paths, node shapes, lazy hydrate) and a live local-Postgres integration test — applies the fixture contract via Prisma Next's real control client (dbInit apply) and round-trips a query through the hydrated client; a branded Char<36> column makes the typed-result assertion enforced by the typecheck gate.
  • ADR-0021 + decisions index entry.

Proof

  • pnpm --filter @prisma/app-cloud typecheck / test (60 pass) / test:types (9 pass) — green.
  • Integration test executes live against a spawned ephemeral Postgres (honors STATE_TEST_DATABASE_URL, else initdb/pg_ctl); it throws on CI if no Postgres is available, so it can't silently pass.
  • Index isolation asserted: dist/index.mjs carries zero @prisma-next/pg tokens.

Deferred to slice 2

  • The control.ts deploy lowering: read the DB marker → migrate along the authored graph to the contract's hash → hard-fail on no-path / destructive-without-opt-in.
  • How the prisma-next.config.ts path rides on the (frozen) ResourceNode so the lowering can find the migrations dir.
  • Live proof on Prisma Cloud via an example conversion.

Notes

  • pnPostgres is a working name — Prisma Next → Prisma Data at GA will churn it.
  • Multi-contract / contract-space slices (shared DB, least-privilege) are a deliberate v1 non-goal; the extension path is recorded in ADR-0021 and the project design-notes.

🤖 Generated with Claude Code

…h entry

Adds @prisma-next/postgres (plus its @prisma-next/contract and
@prisma-next/sql-contract type-only deps) to @prisma/app-cloud, and gives
prisma-next.ts its own tsdown entry so the ./prisma-next subpath export is
generated automatically — the index entry never pulls in @prisma-next/* or
pg unless a caller explicitly imports the subpath (ADR-0021).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A second, parallel data primitive alongside bare postgres() (untouched):
pnPostgres(), overloaded like rpc()/postgres() into a resource end
({ name, config }, providing config.contract) and a dependency end
(pnPostgres(contract), binding to the typed Prisma Next client).

The prisma-next Contract kind's __cmp carries the deserialized contract data
plus a type-only _contract anchor pinning the emitted contract's branded
storageHash literal, so plain TypeScript assignability between two wrapped
contracts is exact-version equality; satisfies() mirrors it at Load with a
real storageHash comparison. Hydrate constructs the client via
@prisma-next/postgres/runtime with verifyMarker: 'onFirstUse', which is
natively warn-only (confirmed by reading sql-runtime's verifyMarker()
implementation, not just its .d.ts) — a marker mismatch logs and returns, it
never throws, so no wrapper is needed to satisfy "the app always attempts to
run" (ADR-0021).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…t emit

Two minimal, one-model contracts (Widget, Gadget), each with its TS-authored
source (defineContract() + prisma-next.config.ts) and real emitted artifacts
(contract.json + contract.d.ts) produced by running the installed
@prisma-next/cli's `contract emit` offline — no live database needed. Source
and emitted artifacts live in separate directories per fixture so a same-name
.ts/.d.ts pair in one directory can't shadow each other during module
resolution (this bit an earlier layout: an import of contract.d.ts silently
resolved to the sibling contract.ts's inferred, non-branded type instead).

Named widget-contract/gadget-contract rather than prisma-next(-other): a bare
`prisma-next/` name is caught by the repo's .gitignore (an external-checkout
ignore rule, not meant for this).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Type test (vitest --typecheck): a consumer requiring the emitted Widget
contract is assignable where the resource provides that same contract type
(including a second, independently-built wrap of it — the lever is the
type, not object identity); a different emitted contract (Gadget, a
different storageHash) is a type error, and so is a resource of a wholly
different protocol kind (bare postgresContract).

Unit test (bun test): satisfies() is true for equal storageHash and false
otherwise (including across kinds); the factory returns correctly branded
ResourceNode/DependencyEnd shapes; hydrate constructs a real Prisma Next
client from a fake url without connecting, and never throws regardless of
what a live marker would say.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Updates the entry-map invariant to expect the new subpath export, and adds
invariant 7: no module reachable from src/index.ts imports prisma-next.ts,
@prisma-next/*, or pg, and the built dist/index.mjs contains none of those
tokens — the DoD's "index import stays free of @prisma-next/*/pg" checked
directly against the built output, not just reasoned about.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…rop runtime marker check

Two locked design changes (ADR-0021, rewritten):

- The resource overload is now `pnPostgres({ name, contract })` — the
  consumed emitted contract, nothing else. Deletes `PnPostgresConfig` and its
  `connection?` field: the `prisma-next.config.ts` path the deploy migration
  step needs is slice-2 metadata that rides on the resource there, not a
  field the app build declares (the frozen ResourceNode has no metadata slot
  today; that mechanism is slice 2's call).
- Hydrate builds the client with no `verifyMarker`. Schema correctness is a
  build/deploy-time job: the deploy migrates the DB to the contract hash and
  keeps it there, so the runtime binding just builds the client. A running
  service can't be crashed by a marker check because there is none.

The dependency end (`pnPostgres(contract)`) and the `satisfies` storageHash
comparison are unchanged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…-hash satisfies

- Type + unit tests construct the resource via `pnPostgres({ name, contract })`;
  the removed `config.connection`-is-ignored case goes with the removed field.
- The hydrate test now asserts the design directly: hydrate does no schema
  verification and reads no database, so it can't throw regardless of any
  live marker (there is no `verifyMarker`).
- Adds the reviewer's missing-hash coverage for `satisfies`: a required
  contract with a malformed `__cmp` (no `contractJson`) is not satisfied, and
  a wrapper whose `contractJson` lacks `storage.storageHash` is unsatisfiable
  in both directions (and does not even satisfy itself) — exercising the
  `storageHashOf` undefined branches that were correct but untested.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Mirrors packages/alchemy/src/state/__tests__/harness.ts: honors
STATE_TEST_DATABASE_URL (the env var the CI test job already wires), else
spins an ephemeral initdb/pg_ctl cluster, else returns undefined so the
caller skips loudly on a dev machine without Postgres — and throws on CI so
the suite can never silently go unexecuted. Kept as a local copy rather than
a cross-package import: that harness is another package's private test file
with no export, and the workspace keeps a package's tests self-contained.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…al Postgres

The slice's live proof (D2): stand up a real Postgres, bring its schema to the
widget-contract fixture via Prisma Next's own control client
(createPostgresControlClient().dbInit({ mode: 'apply' }) — the machinery slice
2's deploy lowering will drive, so this exercises the real apply-and-sign path,
not hand-written SQL), construct the client exactly as a service would through
pnPostgres(contract)'s hydrate given only the DB url, and round-trip a real
query: ORM create() then where().first()/all(), asserting the row.

The result is contract-typed end to end — Widget.id is a char(36) column, so
its typed value is the branded Char<36>; a plain string does not compile as the
id, which is what makes the round-trip's type-check the typed-result proof (the
typecheck gate enforces it). Adds @prisma-next/target-postgres as a devDep for
that Char type (a direct import in the test).

Environment-gated via the harness: skips cleanly with no Postgres locally,
runs on CI against the wired postgres service. Verified live here against a
spawned ephemeral cluster.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Records the design for the pnPostgres primitive: a Prisma Next-typed
postgres resource/dependency whose binding is the contract-typed client,
alongside untouched bare postgres(). Consume the contract (emitted
artifact), locate the config by path (deploy-only); schema checks are
build/deploy-time, so the runtime binding does no verification. Amends
ADR-0015 (Prisma Next is framework-blessed like rpc). Includes the Drive
project artifacts for the two-slice delivery.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>

@wmadden wmadden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one small comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use PSL

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8738f19 — gadget is now authored in PSL (contract.prisma, same schema). It re-emits byte-for-byte identical contract.json/contract.d.ts and the same storageHash (the hash fingerprints the schema, not the authoring form), so no test changes were needed. I kept widget as TS-authored on purpose, so the two fixtures now cover both authoring modes ADR-0021 supports (PSL-first and TS no-emit). If you actually meant all fixtures should be PSL, say so and I will convert widget too.

Review ask on PR #44 ("use PSL"): the gadget contract is now authored as a
.prisma file instead of TS defineContract(), and its config points at the
.prisma source (same defineConfig({ contract: <path> }) shape — the provider
picks PSL from the extension). Widget stays TS-authored on purpose: widget
covers the TS defineContract() authoring mode, gadget covers PSL, so the two
fixtures exercise both modes ADR-0021 says to support. A short mode note is
added to each source.

The PSL re-expresses the identical schema (id char(36) uuid(4) default @id,
label text), with @@Map("Gadget") to keep the physical table capitalized —
PSL lowercases table names by default, and that was the only thing that
differed. Because storageHash is a deterministic fingerprint of the schema,
not the authoring form, the re-emitted emitted/ artifacts are byte-for-byte
identical (storageHash sha256:35caf6…) — so the widget-vs-gadget type test
and everything consuming emitted/ stay green with no test edits.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ias as

Two fixes so the CI Lint + cast-ratchet jobs pass on this branch:

- biome.jsonc: ignore `**/fixtures/**/emitted` — that dir holds only generated
  `prisma-next contract emit` output (contract.json plus the already-ignored
  contract.d.ts), which must stay byte-exact and must not be reformatted. Uses
  the folder form without a trailing `/**` (biome's own useBiomeIgnoreFolder
  rule rejects the `/**` suffix since 2.2.0).
- prisma-next.ts: the `no-bare-cast` grit rule counts every `as` token, so the
  `Contract as PnRawContract` import alias (a rename, not a cast) tripped the
  cast-ratchet as one net-new cast vs merge-base. Replaced the aliased import
  with an inline `import('@prisma-next/contract/types').Contract<…>` type
  reference — no `as`, no logic change. Ratchet is back to delta=0.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Brings in the schema-typed config-params breaking change (f69cb1f),
config()/load() split, target-owned serialization, the reusable-auth H3
work, and the hex-composition closeout.

Slice-1 adaptations:
- Params API: pnPostgres's dependency now declares its url param via the
  string() schema-builder (`string({ secret: true })`) instead of the removed
  `{ type: 'string', secret: true }` shape, matching the merged postgres.ts;
  dropped the now-stale explicit dependency<> generics. Unit assertion updated
  to `toEqual(string({ secret: true }))`.
- Conflicts: package.json (kept our ./prisma-next export + their ./testing),
  tsdown.config.ts (union of entries), invariants.test.ts (entry-map union
  of all three runtime entries; kept invariant 7), pnpm-lock.yaml regenerated.
- ADR renumber: main shipped its own ADR-0021 (params read through config),
  so our data-deps ADR renumbers to ADR-0022 (repo convention: the later
  branch takes the next free number). File renamed, header and all our-side
  references (code + tracking docs) updated; main's ADR-0021 references left
  untouched.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric merged commit 4ee0459 into main Jul 11, 2026
12 checks passed
wmadden-electric added a commit that referenced this pull request Jul 11, 2026
Integrates #44 (pnPostgres) / #43 (hex-composition close-out). Regenerates
pnpm-lock.yaml from the merged manifests — the auto-merge left it internally
inconsistent (a dangling snapshot ref) because my branch predated main's large
dependency change, which is what failed CI's frozen install on the PR merge.

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