-
Notifications
You must be signed in to change notification settings - Fork 5
feat(postgres): per-request facade for serverless runtimes #421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 35 commits
9f61ae8
fd0a576
c9044d2
e79bc07
22bed6d
ebe1289
74bbbc0
730900e
8ab2b4f
b035113
a21d240
27981e0
e7b5e35
b1fc6b0
d549e6f
a91aa93
692dea9
73343c4
f470470
1a50697
f17d73b
de5d0dd
7e0e87b
2b4900b
78a1dd9
fa67a03
67b4ea2
b3f32c2
ecfa1d0
30d4c6e
48df22a
28039c3
0ef020a
922bb68
dfcefd9
1f90ff9
597f1ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # Local Postgres TCP URL for the Hyperdrive binding "HYPERDRIVE". | ||
| # Wrangler reads this from .env (NOT .dev.vars) for local dev. See: | ||
| # https://developers.cloudflare.com/hyperdrive/configuration/local-development | ||
| # | ||
| # Bring up the local container with `pnpm db:up` (Docker Postgres on port 5433), | ||
| # then copy this file to `.env` (gitignored). Schema/seed via: | ||
| # pnpm db:init && pnpm seed | ||
| WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://postgres:postgres@127.0.0.1:5433/prisma_next_cloudflare_worker" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| node_modules | ||
| dist | ||
| .wrangler | ||
| .env | ||
| .env.local | ||
| *.tsbuildinfo | ||
| .turbo |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| # prisma-next-cloudflare-worker | ||
|
|
||
| End-to-end example for the `@prisma-next/postgres/serverless` facade, running on a Cloudflare Worker against a Hyperdrive-fronted Postgres origin. | ||
|
|
||
| This example mirrors `examples/prisma-next-demo` (the Node demo), minus pgvector — the Worker example exists to exercise the per-request `postgresServerless` lifecycle, not vector search. | ||
|
|
||
| ## What this example demonstrates | ||
|
|
||
| - **Module-scope `db`** built once per isolate via `postgresServerless<Contract>({ contractJson, middleware })`. | ||
| - **Per-request `runtime`** via `await using runtime = await db.connect({ url: env.HYPERDRIVE.connectionString })`. The `[Symbol.asyncDispose]` ensures the underlying `pg.Client` is `end()`-ed when the `fetch` handler returns. | ||
| - **All three query surfaces** through `Runtime`: | ||
| - SQL DSL: `runtime.execute(db.sql.user.select(...).build())` | ||
| - ORM client: `createOrmClient(runtime).User.newestFirst().take(10).all()` | ||
| - Transactions: `withTransaction(runtime, async (tx) => …)` | ||
| - **Cursor early-break** over a streamed result set (`for await … break`), exercising the cursor path that `postgresServerless` enables by default. | ||
|
|
||
| Routes implemented in [`src/worker.ts`](src/worker.ts): | ||
|
|
||
| | Route | Surface | Notes | | ||
| | ------------------- | ----------------- | -------------------------------------------------------- | | ||
| | `GET /health` | — | DB-free liveness check | | ||
| | `GET /sql/users` | SQL DSL | `db.sql.user.select(...).limit(?)` | | ||
| | `GET /orm/users` | ORM client | `User.newestFirst().take(?)` | | ||
| | `GET /orm/posts` | ORM client | `Post.forUser(?).orderBy(...).take(?)` | | ||
| | `GET /tx/commit` | `withTransaction` | INSERT post + UPDATE user atomically | | ||
| | `GET /tx/rollback` | `withTransaction` | Throws inside the body; verifies ROLLBACK propagates | | ||
| | `GET /cursor/large` | Cursor stream | `for await … break` after N rows; cursor cancels cleanly | | ||
|
|
||
| ## Layout | ||
|
|
||
| ``` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add language identifiers to fenced code blocks. Both fences are missing a language tag ( 🛠️ Proposed fix-```
+```text
examples/prisma-next-cloudflare-worker/
...- Also applies to: 103-103 🧰 Tools🪛 markdownlint-cli2 (0.22.1)[warning] 31-31: Fenced code blocks should have a language specified (MD040, fenced-code-language) 🤖 Prompt for AI Agents |
||
| examples/prisma-next-cloudflare-worker/ | ||
| ├── prisma/schema.prisma # Demo schema minus pgvector | ||
| ├── src/ | ||
| │ ├── worker.ts # `fetch` handler — all routes | ||
| │ ├── prisma/db.ts # Module-scope postgresServerless client | ||
| │ ├── prisma/contract.{json,d.ts} # Emitted by `pnpm emit` | ||
| │ └── orm-client/ # ORM extensions (collections + factory) | ||
| ├── scripts/ | ||
| │ ├── setup-schema.ts # `prisma-next db init` | ||
| │ └── seed.ts # Insert sample users + posts | ||
| ├── test/ | ||
| │ ├── global-setup.ts # Connects to Docker Postgres, applies schema, seeds | ||
| │ ├── worker.integration.test.ts # vitest-pool-workers integration suite | ||
| │ └── cloudflare-test.d.ts # Pulls in `cloudflare:test` ambient types | ||
| ├── docker-compose.yml # Local Postgres origin (port 5433) | ||
| ├── wrangler.jsonc # Hyperdrive binding declaration | ||
| ├── prisma-next.config.ts # Contract emit config | ||
| ├── vitest.config.ts # cloudflareTest plugin + globalSetup | ||
| └── .env.example # Copy → .env (Hyperdrive local URL) | ||
| ``` | ||
|
|
||
| ## Setup (local development) | ||
|
|
||
| ### Prerequisites | ||
|
|
||
| - Node satisfying the root `package.json` `engines.node` (`>=24`). | ||
| - `pnpm`. Install workspace deps from the repo root with `pnpm install`. | ||
| - `docker` + `docker compose` (Docker Desktop, OrbStack, Colima, or Rancher Desktop). The local Postgres origin runs in a container — see [why not `prisma dev`](#why-not-prisma-dev) below. | ||
|
|
||
| ### One-time bootstrap | ||
|
|
||
| ```bash | ||
| cd examples/prisma-next-cloudflare-worker | ||
| pnpm emit # generate src/prisma/contract.{json,d.ts} | ||
| cp .env.example .env # gitignored | ||
| ``` | ||
|
|
||
| `.env` ships preset to the docker-compose URL (`postgres://postgres:postgres@127.0.0.1:5433/prisma_next_cloudflare_worker`). Wrangler reads `WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE` to populate the `HYPERDRIVE` binding's local connection string ([Cloudflare docs](https://developers.cloudflare.com/hyperdrive/configuration/local-development)). Note: this goes in **`.env`**, not `.dev.vars` — `.dev.vars` is for runtime worker secrets, not Wrangler configuration. The `WRANGLER_*` prefix is being deprecated in favour of `CLOUDFLARE_*` in newer Wrangler; either works as of `wrangler@4.87`. | ||
|
|
||
| ### Per-session: bring up Postgres, init schema, seed | ||
|
|
||
| ```bash | ||
| pnpm db:up # docker compose up -d --wait (postgres:16 on :5433) | ||
| pnpm db:init # prisma-next db init → CREATE TABLE … | ||
| pnpm seed # Insert Alice + Bob + 50 posts | ||
| ``` | ||
|
Comment on lines
+74
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix seeded post-count in setup docs. Line 76 says seed inserts 50 posts, but 🛠️ Proposed fix-pnpm seed # Insert Alice + Bob + 50 posts
+pnpm seed # Insert Alice + Bob + 8 posts🤖 Prompt for AI Agents |
||
|
|
||
| Tear down with `pnpm db:down` (drops the container + volume — data is `tmpfs`-backed for fast restarts), or `pnpm db:reset` to do everything in one command. | ||
|
|
||
| ### Run the Worker locally | ||
|
|
||
| ```bash | ||
| pnpm dev # wrangler dev → http://localhost:8787 | ||
| curl http://localhost:8787/health | ||
| curl http://localhost:8787/orm/users?limit=5 | ||
| ``` | ||
|
|
||
| ## Deploy | ||
|
|
||
| `wrangler.jsonc` carries a placeholder Hyperdrive `id` (`00000000…`). To deploy to a real Cloudflare account, provision a Hyperdrive config first: | ||
|
|
||
| ```bash | ||
| pnpm exec wrangler hyperdrive create my-hyperdrive --connection-string="postgres://…" | ||
| # Replace the "id" in wrangler.jsonc with the printed binding id. | ||
| pnpm run deploy | ||
| ``` | ||
|
|
||
| > Use `pnpm run deploy` (not `pnpm deploy`). The latter collides with pnpm's built-in `deploy` command and fails with `ERR_PNPM_INVALID_DEPLOY_TARGET`. | ||
|
|
||
| > If your origin sits behind Cloudflare Hyperdrive, also pass `cursor: { disabled: true }` to `postgresServerless({...})` in `src/prisma/db.ts`. Hyperdrive currently rejects the `Close portal` message that `pg-cursor` (the default streaming path) sends after an extended-query Execute, leaving the connection wedged. See the deployment guide's "Known limitations" for details. | ||
|
saevarb marked this conversation as resolved.
|
||
|
|
||
| ## Bundle size | ||
|
|
||
| `pnpm deploy:dry-run` (`wrangler deploy --dry-run --outdir dist`) reports: | ||
|
|
||
| ``` | ||
| Total Upload: 1289.96 KiB / gzip: 254.14 KiB | ||
| ``` | ||
|
|
||
| (254 KiB compressed, well under the 1 MB AC-19 budget.) | ||
|
|
||
| The bundle includes `pg`, `pg-protocol`, `pg-types`, `pg-cursor`, `pg-pool` (statically imported by `@prisma-next/driver-postgres` even though `postgresServerless` does not construct a `Pool` at runtime), `pg-cloudflare` (auto-pulled by `pg` when `navigator.userAgent === 'Cloudflare-Workers'`), and `@cloudflare/unenv-preset` polyfills. | ||
|
|
||
| ## Cold-start benchmark (AC-20 / TC-23) | ||
|
|
||
| Best-effort `wrangler dev` benchmark against the local Docker Postgres origin (`GET /orm/users?limit=10`): | ||
|
|
||
| | Run | Latency | | ||
| | ---------------------- | -------- | | ||
| | Cold start (run 0) | ~35 ms | | ||
| | Warm p50 (runs 1–5) | ~13 ms | | ||
|
|
||
| Both well inside the 200 ms ceiling in AC-20. Production cold-start over a real Hyperdrive will be slower (TLS handshake, region-vs-origin RTT) — re-measure during M4 deployment validation. | ||
|
|
||
| ## Integration tests (`vitest-pool-workers`) | ||
|
|
||
| The suite under `test/` boots the Worker under `workerd` via `vitest-pool-workers`, points the Hyperdrive binding at the local Docker Postgres, and exercises the SQL DSL, ORM, transactions, and cursor early-break paths. | ||
|
|
||
| ```bash | ||
| pnpm db:up # ensure container is running | ||
| pnpm test # vitest run --config vitest.config.ts | ||
| ``` | ||
|
|
||
| The test's `globalSetup` (`test/global-setup.ts`) reads `.env`, asserts the container is reachable, applies the schema (idempotent — uses the same `prisma-next db init` as the dev workflow), truncates and reseeds. There is no per-test isolation: the suite is read-mostly, the `/tx/commit` test mutates `Bob`'s display name and the next test's reseed restores it on the next `pnpm test`. | ||
|
|
||
| The canonical workspace invocation is `pnpm test:examples --filter prisma-next-cloudflare-worker` from the repo root (depends on the container being up — that's a local-dev precondition, not a CI one). | ||
|
|
||
| ### `pg` resolution under Vite 8 | ||
|
|
||
| `vitest.config.ts` includes a `test.deps.optimizer.ssr.{include, rolldownOptions.external}` workaround for [`cloudflare/workers-sdk#12984`](https://github.com/cloudflare/workers-sdk/issues/12984), which mis-resolves `pg`'s dual ESM/CJS exports under Vite 8 when loaded by `vitest-pool-workers`. Pre-bundling `pg`/`pg-protocol`/`pg-cursor`/`pg-cloudflare` and externalising Node built-ins keeps `workerd`'s loader on the right entries. | ||
|
|
||
| ### Why not `prisma dev`? | ||
|
|
||
| The first attempt at the local origin used `@prisma/dev` (PGlite-backed Postgres reachable over TCP) — same pattern as `examples/prisma-next-demo` for everything else. It hung in both `wrangler dev` and `vitest-pool-workers`: every DB-touching route would call `pg.Client.connect()` through miniflare's Hyperdrive emulator, the `pg-cloudflare` socket reported "Connection terminated unexpectedly", and the runtime never recovered. The hang reproduces in plain `wrangler dev`, so it's not a test-infra problem — it appears to be specific to PGlite's TCP shim interacting with `pg-cloudflare`'s socket layer in `workerd`. The third sub-issue in [`cloudflare/workers-sdk#12984`](https://github.com/cloudflare/workers-sdk/issues/12984) ("Cannot perform I/O on behalf of a different Durable Object") may be the same root cause; upstream PR #13062 covers the bundling regressions but not this one. | ||
|
|
||
| The M1 audit's "this works in `wrangler dev`" claim was empirically validated against a real Postgres on `localhost`, not against `prisma dev` — so the audit's conclusion still holds for real-Postgres origins. M3 uses Docker Postgres for that reason. The PPg-on-Workers story will pick back up in M4 against a real deployed Hyperdrive + PPg. | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| - **`pnpm db:up` fails with `Cannot connect to the Docker daemon`.** Start your container runtime (Docker Desktop, OrbStack, …) and retry. | ||
| - **`pnpm db:init` fails with a connection error.** Confirm `pnpm db:up` succeeded and the container is healthy: `docker compose ps`. Port 5433 (not 5432) — port collision with `examples/prisma-next-demo`'s Postgres.app would surface here. | ||
| - **`wrangler dev` boots but `/orm/users` returns `500 / connection error`.** The container probably stopped (or you forgot `pnpm db:up`). `pnpm db:reset` brings everything back from a clean slate. | ||
| - **Bundle includes `pg-cloudflare` even though I'm running on Node.** Expected — `pg` static-imports `pg-cloudflare` via `lib/stream.js`, and runtime detection (`navigator.userAgent === 'Cloudflare-Workers'`) picks the right socket implementation. | ||
|
|
||
| ## Known limitations | ||
|
|
||
| - **Transaction affinity** — every `withTransaction` body must run on the same `runtime` instance (the per-request one). Crossing `runtime` boundaries inside a transaction body is undefined. | ||
| - **Isolate memory** — large result sets bound through cursor by default (`postgresServerless` enables cursor unconditionally). For ORM `findMany`-style operations the result set is materialised; size your `take(...)` accordingly. | ||
| - **`pg.Pool` not used** — the serverless facade routes through `PostgresDirectDriverImpl` (`pgClient` binding kind). No connection pooling within the isolate; that's Hyperdrive's job in production. | ||
| - **Production `id`** — the committed `wrangler.jsonc` has a zero-stuffed Hyperdrive `id`. Deploy will fail until a real id is wired in (M4). | ||
| - **Class-table-inheritance ORM queries** — the schema declares `Bug` and `Feature` as `@@base(Task)` discriminator variants for parity with `examples/prisma-next-demo`, but `Task.take(...)` (and `Task.bugs()` / `Task.features()`) currently fail with `column "bug.id" does not exist` against the generated SQL. Pre-existing framework drift surfaced during M3 R2; not exercised by the integration test. The `User` and `Post` collections work normally. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", | ||
| "extends": "//" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Local Postgres origin for the Cloudflare Worker example. | ||
| # | ||
| # Port 5433 (not the default 5432) so this doesn't clash with | ||
| # `examples/prisma-next-demo`'s Postgres.app expectation on the standard port. | ||
| # | ||
| # Bring it up with `pnpm db:up`. Schema/seed via `pnpm db:init && pnpm seed`. | ||
| # The matching connection string for `.env` is in `.env.example`. | ||
| services: | ||
| postgres: | ||
| image: postgres:16 | ||
| container_name: prisma-next-cloudflare-worker-pg | ||
| environment: | ||
| POSTGRES_USER: postgres | ||
| POSTGRES_PASSWORD: postgres | ||
| POSTGRES_DB: prisma_next_cloudflare_worker | ||
| # pg_stat_statements lets the cursor integration test assert how many | ||
| # rows the server transmitted per logical statement — the only signal | ||
| # that distinguishes cursor-streamed from buffered execution from the | ||
| # test side. Loaded in shared_preload_libraries here so the extension | ||
| # can be CREATEd in test/global-setup.ts. | ||
| command: postgres -c shared_preload_libraries=pg_stat_statements | ||
| ports: | ||
| - '5433:5432' | ||
| healthcheck: | ||
| test: ['CMD-SHELL', 'pg_isready -U postgres -d prisma_next_cloudflare_worker'] | ||
| interval: 1s | ||
| timeout: 3s | ||
| retries: 30 | ||
| tmpfs: | ||
| # Keep data ephemeral (faster restarts; tests are seed-from-empty anyway). | ||
| - /var/lib/postgresql/data |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| { | ||
| "name": "prisma-next-cloudflare-worker", | ||
| "private": true, | ||
| "type": "module", | ||
| "engines": { | ||
| "node": ">=24" | ||
| }, | ||
| "scripts": { | ||
| "emit": "prisma-next contract emit", | ||
| "emit:check": "pnpm emit && git diff --exit-code src/prisma/contract.json src/prisma/contract.d.ts", | ||
| "db:up": "docker compose up -d --wait", | ||
| "db:down": "docker compose down -v", | ||
| "db:reset": "pnpm db:down && pnpm db:up && pnpm db:init && pnpm seed", | ||
| "db:init": "tsx scripts/setup-schema.ts", | ||
| "seed": "tsx scripts/seed.ts", | ||
| "dev": "wrangler dev", | ||
| "deploy": "wrangler deploy", | ||
| "deploy:dry-run": "wrangler deploy --dry-run --outdir dist", | ||
| "test": "vitest run --config vitest.config.ts", | ||
| "typecheck": "tsc --project tsconfig.json --noEmit", | ||
| "lint": "biome check . --error-on-warnings" | ||
| }, | ||
| "dependencies": { | ||
| "@prisma-next/adapter-postgres": "workspace:*", | ||
| "@prisma-next/contract": "workspace:*", | ||
| "@prisma-next/driver-postgres": "workspace:*", | ||
| "@prisma-next/family-sql": "workspace:*", | ||
| "@prisma-next/middleware-telemetry": "workspace:*", | ||
| "@prisma-next/postgres": "workspace:*", | ||
| "@prisma-next/sql-builder": "workspace:*", | ||
| "@prisma-next/sql-contract": "workspace:*", | ||
| "@prisma-next/sql-orm-client": "workspace:*", | ||
| "@prisma-next/sql-relational-core": "workspace:*", | ||
| "@prisma-next/sql-runtime": "workspace:*", | ||
| "@prisma-next/target-postgres": "workspace:*", | ||
| "arktype": "catalog:", | ||
| "pg": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@cloudflare/vitest-pool-workers": "0.15.2", | ||
| "@cloudflare/workers-types": "4.20260430.1", | ||
| "@prisma-next/cli": "workspace:*", | ||
| "@prisma-next/sql-contract-psl": "workspace:*", | ||
| "@prisma-next/test-utils": "workspace:*", | ||
| "@prisma-next/tsconfig": "workspace:*", | ||
| "@types/node": "catalog:", | ||
| "@types/pg": "catalog:", | ||
| "dotenv": "^16.4.5", | ||
| "tsx": "^4.19.2", | ||
| "typescript": "catalog:", | ||
| "vitest": "^4.1.0", | ||
| "wrangler": "4.87.0" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import 'dotenv/config'; | ||
| import postgresAdapter from '@prisma-next/adapter-postgres/control'; | ||
| import { defineConfig } from '@prisma-next/cli/config-types'; | ||
| import postgresDriver from '@prisma-next/driver-postgres/control'; | ||
| import sql from '@prisma-next/family-sql/control'; | ||
| import { prismaContract } from '@prisma-next/sql-contract-psl/provider'; | ||
| import postgres from '@prisma-next/target-postgres/control'; | ||
|
|
||
| export default defineConfig({ | ||
| family: sql, | ||
| target: postgres, | ||
| driver: postgresDriver, | ||
| adapter: postgresAdapter, | ||
| contract: prismaContract('./prisma/schema.prisma', { | ||
| output: 'src/prisma/contract.json', | ||
| target: postgres, | ||
| }), | ||
| db: { | ||
| // biome-ignore lint/style/noNonNullAssertion: loaded from .env | ||
| connection: process.env['DATABASE_URL']!, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove quote characters in
.env.examplevalueLine 8 includes quotes around the URL, which triggers the reported dotenv-linter
QuoteCharacterwarning. The value can be unquoted without changing behavior.Suggested patch
📝 Committable suggestion
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 8-8: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
🤖 Prompt for AI Agents